|
| 1 | +'use strict'; |
| 2 | + |
| 3 | +/** |
| 4 | + * End-to-end coverage for routed reasoning efforts across the routing boundary: |
| 5 | + * catalogue → candidate pool → router selection → published selection → |
| 6 | + * /reflect state → per-request routing observation. |
| 7 | + * |
| 8 | + * Guards the full Copilot effort set, in particular explicit `none` and `max`, |
| 9 | + * so a future change cannot silently drop or alter them before the selected |
| 10 | + * request reaches the provider. |
| 11 | + */ |
| 12 | + |
| 13 | +const { EventEmitter } = require('events'); |
| 14 | +const fs = require('fs'); |
| 15 | +const os = require('os'); |
| 16 | +const path = require('path'); |
| 17 | + |
| 18 | +const { createRoutingController } = require('./routing-controller'); |
| 19 | +const { createProductionRoutingSession } = require('./routing-runtime'); |
| 20 | + |
| 21 | +const CONVERSATION = [{ role: 'user', parts: [{ text: 'add a test' }] }]; |
| 22 | + |
| 23 | +const DEFAULT_MODELS = [ |
| 24 | + { id: 'gpt-test', efforts: ['none', 'low', 'max'], protocols: ['responses'] }, |
| 25 | + { id: 'chat-test', efforts: [], protocols: ['chat-completions'] }, |
| 26 | +]; |
| 27 | + |
| 28 | +function capabilities() { |
| 29 | + return { |
| 30 | + name: 'gh-aw-router', |
| 31 | + version: '1.0.0', |
| 32 | + routing_profiles: [{ goal: 'cost', mode: 'balanced' }], |
| 33 | + execution_catalogue: { models: [] }, |
| 34 | + }; |
| 35 | +} |
| 36 | + |
| 37 | +function copyChoice(choice) { |
| 38 | + const copy = { id: choice.id, model: choice.model }; |
| 39 | + if (Object.hasOwn(choice, 'effort')) copy.effort = choice.effort; |
| 40 | + return copy; |
| 41 | +} |
| 42 | + |
| 43 | +class FakeResponse extends EventEmitter { |
| 44 | + constructor() { |
| 45 | + super(); |
| 46 | + this.statusCode = 200; |
| 47 | + this.writableFinished = false; |
| 48 | + } |
| 49 | + |
| 50 | + write() { |
| 51 | + return true; |
| 52 | + } |
| 53 | + |
| 54 | + end() { |
| 55 | + this.writableFinished = true; |
| 56 | + this.emit('finish'); |
| 57 | + return this; |
| 58 | + } |
| 59 | +} |
| 60 | + |
| 61 | +/** |
| 62 | + * Run a routed session with a real controller and candidate pool. The router |
| 63 | + * picks whichever offered choice `pick` returns, or `pick` may return a |
| 64 | + * fabricated choice to exercise contract rejection. |
| 65 | + */ |
| 66 | +async function runRoutedSession({ models = DEFAULT_MODELS, pick }) { |
| 67 | + const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-routing-efforts-')); |
| 68 | + const records = []; |
| 69 | + const routed = []; |
| 70 | + const observer = { record: record => records.push(record) }; |
| 71 | + const createController = () => createRoutingController({ |
| 72 | + config: { |
| 73 | + provider: 'copilot', |
| 74 | + objective: { goal: 'cost', mode: 'balanced' }, |
| 75 | + task: { conversationFile: '/tmp/conversation.json' }, |
| 76 | + }, |
| 77 | + planner: { |
| 78 | + health: async () => 204, |
| 79 | + capabilities: async () => capabilities(), |
| 80 | + classify: async request => ({ |
| 81 | + system_prompt: 'classify the task', |
| 82 | + prompt: 'add a test', |
| 83 | + ranked_choices: request.models.map(copyChoice), |
| 84 | + }), |
| 85 | + route: async request => { |
| 86 | + routed.push(request.models.map(copyChoice)); |
| 87 | + return { ranked_choices: [pick(request.models.map(copyChoice))] }; |
| 88 | + }, |
| 89 | + }, |
| 90 | + catalogue: { |
| 91 | + getSnapshot: async () => ({ provider: 'copilot', configured: true, discovery: 'complete', models }), |
| 92 | + }, |
| 93 | + loadConversation: async () => JSON.parse(JSON.stringify(CONVERSATION)), |
| 94 | + executor: { execute: jest.fn(), checkBeforePrimary: async () => undefined }, |
| 95 | + observer, |
| 96 | + }); |
| 97 | + const session = createProductionRoutingSession({ rawConfig: '{}', outputDir, createController, observer }); |
| 98 | + const result = await session.start(); |
| 99 | + return { session, result, records, routed, outputDir }; |
| 100 | +} |
| 101 | + |
| 102 | +function readResult(outputDir, name) { |
| 103 | + return JSON.parse(fs.readFileSync(path.join(outputDir, name), 'utf8')); |
| 104 | +} |
| 105 | + |
| 106 | +function sendRequest(session, records, url, payload) { |
| 107 | + const req = { method: 'POST', url, headers: {} }; |
| 108 | + session.observeRequest(req, new FakeResponse(), { name: 'copilot' }); |
| 109 | + expect(req.awfRouting.bodyTransform(Buffer.from(JSON.stringify(payload), 'utf8'))).toBeNull(); |
| 110 | + return records.filter(record => record.stage === 'request').at(-1); |
| 111 | +} |
| 112 | + |
| 113 | +const byEffort = effort => choices => choices.find(choice => |
| 114 | + effort === undefined ? !Object.hasOwn(choice, 'effort') : choice.effort === effort); |
| 115 | + |
| 116 | +describe('routed reasoning efforts', () => { |
| 117 | + it('offers explicit none and max, and keeps an omitted effort distinct from none', async () => { |
| 118 | + const { routed } = await runRoutedSession({ pick: byEffort('none') }); |
| 119 | + expect(routed[0]).toEqual([ |
| 120 | + { id: 'choice-0001', model: 'github-copilot/chat-test' }, |
| 121 | + { id: 'choice-0002', model: 'github-copilot/gpt-test', effort: 'low' }, |
| 122 | + { id: 'choice-0003', model: 'github-copilot/gpt-test', effort: 'max' }, |
| 123 | + { id: 'choice-0004', model: 'github-copilot/gpt-test', effort: 'none' }, |
| 124 | + ]); |
| 125 | + }); |
| 126 | + |
| 127 | + it('offers the full Copilot effort set', async () => { |
| 128 | + const efforts = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']; |
| 129 | + const { routed } = await runRoutedSession({ |
| 130 | + models: [{ id: 'gpt-test', efforts, protocols: ['responses'] }], |
| 131 | + pick: byEffort('max'), |
| 132 | + }); |
| 133 | + expect(routed[0].map(choice => choice.effort).sort()).toEqual([...efforts].sort()); |
| 134 | + }); |
| 135 | + |
| 136 | + describe.each(['none', 'max'])('selected effort %s', effort => { |
| 137 | + it('is published, reflected, and accepted only on the matching model, endpoint, and effort', async () => { |
| 138 | + const { session, result, records, outputDir } = await runRoutedSession({ pick: byEffort(effort) }); |
| 139 | + |
| 140 | + expect(result.ok).toBe(true); |
| 141 | + const selection = { |
| 142 | + schema: 'awf-routing-selection/v1', |
| 143 | + engine: 'copilot', |
| 144 | + provider: 'copilot', |
| 145 | + choice: { id: expect.any(String), model: 'github-copilot/gpt-test', effort }, |
| 146 | + wire_model: 'gpt-test', |
| 147 | + }; |
| 148 | + expect(result.selection).toEqual(selection); |
| 149 | + expect(readResult(outputDir, 'selection.json')).toEqual(selection); |
| 150 | + expect(records.find(record => record.stage === 'selection')).toMatchObject({ selected_effort: effort }); |
| 151 | + |
| 152 | + expect(session.getReflectState()).toEqual({ |
| 153 | + status: 'selected', |
| 154 | + selection: { |
| 155 | + provider: 'copilot', |
| 156 | + model: 'github-copilot/gpt-test', |
| 157 | + wire_model: 'gpt-test', |
| 158 | + effort, |
| 159 | + endpoint: '/responses', |
| 160 | + }, |
| 161 | + }); |
| 162 | + |
| 163 | + expect(sendRequest(session, records, '/responses', { model: 'gpt-test', reasoning: { effort } })) |
| 164 | + .toMatchObject({ |
| 165 | + routed: 'as_selected', |
| 166 | + deviations: [], |
| 167 | + requested_effort: effort, |
| 168 | + selected_effort: effort, |
| 169 | + selected_endpoint: '/responses', |
| 170 | + }); |
| 171 | + expect(sendRequest(session, records, '/v1/responses', { model: 'copilot/gpt-test', reasoning: { effort } })) |
| 172 | + .toMatchObject({ routed: 'as_selected', deviations: [] }); |
| 173 | + |
| 174 | + const otherEffort = effort === 'none' ? 'max' : 'none'; |
| 175 | + for (const [url, payload, deviations] of [ |
| 176 | + ['/responses', { model: 'gpt-test', reasoning: { effort: otherEffort } }, ['effort']], |
| 177 | + ['/responses', { model: 'gpt-test', reasoning: { effort: 'low' } }, ['effort']], |
| 178 | + ['/responses', { model: 'gpt-test' }, ['effort']], |
| 179 | + ['/responses', { model: 'other-model', reasoning: { effort } }, ['model']], |
| 180 | + ['/chat/completions', { model: 'gpt-test', reasoning_effort: effort }, ['endpoint']], |
| 181 | + ['/chat/completions', { model: 'gpt-test', reasoning: { effort } }, ['effort', 'endpoint']], |
| 182 | + ]) { |
| 183 | + expect(sendRequest(session, records, url, payload)).toMatchObject({ |
| 184 | + routed: 'deviated', |
| 185 | + deviations, |
| 186 | + selected_effort: effort, |
| 187 | + }); |
| 188 | + } |
| 189 | + }); |
| 190 | + }); |
| 191 | + |
| 192 | + it('keeps an omitted selected effort distinct from explicit none', async () => { |
| 193 | + const { session, result, records, outputDir } = await runRoutedSession({ pick: byEffort(undefined) }); |
| 194 | + |
| 195 | + expect(result.ok).toBe(true); |
| 196 | + expect(Object.hasOwn(result.selection.choice, 'effort')).toBe(false); |
| 197 | + expect(Object.hasOwn(readResult(outputDir, 'selection.json').choice, 'effort')).toBe(false); |
| 198 | + expect(records.find(record => record.stage === 'selection')).toMatchObject({ selected_effort: null }); |
| 199 | + expect(session.getReflectState().selection).toMatchObject({ |
| 200 | + model: 'github-copilot/chat-test', |
| 201 | + effort: null, |
| 202 | + endpoint: '/chat/completions', |
| 203 | + }); |
| 204 | + |
| 205 | + expect(sendRequest(session, records, '/chat/completions', { model: 'chat-test' })) |
| 206 | + .toMatchObject({ routed: 'as_selected', requested_effort: null, selected_effort: null }); |
| 207 | + expect(sendRequest(session, records, '/chat/completions', { model: 'chat-test', reasoning_effort: 'none' })) |
| 208 | + .toMatchObject({ routed: 'deviated', deviations: ['effort'], requested_effort: 'none' }); |
| 209 | + |
| 210 | + const none = await runRoutedSession({ pick: byEffort('none') }); |
| 211 | + expect(sendRequest(none.session, none.records, '/responses', { model: 'gpt-test' })) |
| 212 | + .toMatchObject({ routed: 'deviated', deviations: ['effort'], requested_effort: null, selected_effort: 'none' }); |
| 213 | + }); |
| 214 | + |
| 215 | + it.each([ |
| 216 | + ['an unsupported effort', { id: 'choice-0003', model: 'github-copilot/gpt-test', effort: 'ultra' }], |
| 217 | + ['a mismatched effort for an offered id', { id: 'choice-0003', model: 'github-copilot/gpt-test', effort: 'none' }], |
| 218 | + ['a null effort', { id: 'choice-0004', model: 'github-copilot/gpt-test', effort: null }], |
| 219 | + ['an omitted effort where none was offered', { id: 'choice-0004', model: 'github-copilot/gpt-test' }], |
| 220 | + ['an explicit none where the effort was omitted', { id: 'choice-0001', model: 'github-copilot/chat-test', effort: 'none' }], |
| 221 | + ])('fails closed when the router returns %s', async (_name, choice) => { |
| 222 | + const { session, result, outputDir } = await runRoutedSession({ pick: () => choice }); |
| 223 | + expect(result.ok).toBe(false); |
| 224 | + expect(result.failure.code).toBe('routing_contract_error'); |
| 225 | + expect(fs.existsSync(path.join(outputDir, 'selection.json'))).toBe(false); |
| 226 | + expect(readResult(outputDir, 'failure.json')).toMatchObject({ code: 'routing_contract_error' }); |
| 227 | + expect(session.getReflectState()).toEqual({ |
| 228 | + status: 'failed', |
| 229 | + failure_code: 'routing_contract_error', |
| 230 | + selection: null, |
| 231 | + }); |
| 232 | + }); |
| 233 | + |
| 234 | + it('excludes unsupported catalogue efforts and fails closed when none remain', async () => { |
| 235 | + const mixed = await runRoutedSession({ |
| 236 | + models: [{ id: 'gpt-test', efforts: ['ultra', 'max', 'NONE'], protocols: ['responses'] }], |
| 237 | + pick: choices => choices[0], |
| 238 | + }); |
| 239 | + expect(mixed.routed[0]).toEqual([{ id: 'choice-0001', model: 'github-copilot/gpt-test', effort: 'max' }]); |
| 240 | + expect(mixed.session.getReflectState().selection).toMatchObject({ effort: 'max', endpoint: '/responses' }); |
| 241 | + |
| 242 | + const unsupported = await runRoutedSession({ |
| 243 | + models: [{ id: 'gpt-test', efforts: ['ultra'], protocols: ['responses'] }], |
| 244 | + pick: choices => choices[0], |
| 245 | + }); |
| 246 | + expect(unsupported.routed).toEqual([]); |
| 247 | + expect(unsupported.result.failure.code).toBe('no_route'); |
| 248 | + expect(unsupported.session.getReflectState()).toEqual({ status: 'failed', failure_code: 'no_route', selection: null }); |
| 249 | + }); |
| 250 | +}); |
0 commit comments