llm-switcher 1.2.12 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,190 @@
1
+ // Unit tests for the Antigravity CLI (agy) in the launch state, the shims and the launch hook.
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+
8
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'llm-switcher-agy-unit-'));
9
+ process.env.LLM_SWITCHER_STATE_DIR = tmp;
10
+ process.env.LLM_SWITCHER_CONFIG = path.join(tmp, 'config.json');
11
+ const s = await import('../state.mjs');
12
+ const shim = await import('../shim.mjs');
13
+ const plugin = await import('../plugin.mjs');
14
+
15
+ const cfgWith = (active) => ({
16
+ activeProfiles: { claude: null, codex: null, agy: null, ...active },
17
+ profiles: { pool: { name: 'pool', tool: 'agy', baseURL: 'https://intact.example/v1', apiKey: 'k', defaultModels: { main: 'antigravity/*' } } }
18
+ });
19
+
20
+ test('agy alone gets CLOUD_CODE_URL and starts no interceptor', () => {
21
+ const st = s.computeLaunchState(cfgWith({ agy: 'pool' }), 3456);
22
+ assert.equal(st.active, true);
23
+ assert.equal(st.blindfold, null);
24
+ assert.deepEqual(st.envAgy, [['CLOUD_CODE_URL', 'http://127.0.0.1:3456']]);
25
+ assert.deepEqual(st.envClaude, []);
26
+ assert.match(st.routeAgy, /^agy -> pool \| intact\.example \| antigravity\/\*/);
27
+ });
28
+
29
+ test('agy off writes no agy variables', () => {
30
+ const st = s.computeLaunchState(cfgWith({}), 3456);
31
+ assert.equal(st.active, false);
32
+ assert.deepEqual(st.envAgy, []);
33
+ assert.equal(st.routeAgy, '');
34
+ });
35
+
36
+ test('only an agy profile serves agy, and the old single pointer never turns agy on', () => {
37
+ assert.equal(s.profileAcceptsTarget({ tool: 'agy' }, 'agy'), true);
38
+ assert.equal(s.profileAcceptsTarget({ inFormat: 'auto' }, 'agy'), false);
39
+ assert.equal(s.profileAcceptsTarget({ tool: 'agy' }, 'claude'), false);
40
+ assert.equal(s.getActiveMap({ activeProfile: 'legacy' }).agy, null);
41
+ assert.deepEqual(s.modelSlotsForProfile({ tool: 'agy' }), ['main']);
42
+ });
43
+
44
+ test('the launch files of agy are written and emptied with the others', () => {
45
+ s.applyLaunchState(cfgWith({ agy: 'pool' }), 3456);
46
+ assert.match(fs.readFileSync(s.paths.envAgySh, 'utf8'), /export CLOUD_CODE_URL='http:\/\/127\.0\.0\.1:3456'/);
47
+ assert.match(fs.readFileSync(s.paths.envAgyCmd, 'utf8'), /SET "CLOUD_CODE_URL=http:\/\/127\.0\.0\.1:3456"/);
48
+ assert.ok(fs.existsSync(s.paths.activeFlag));
49
+ s.emptyToolEnvFiles('agy');
50
+ assert.equal(fs.readFileSync(s.paths.envAgySh, 'utf8'), '');
51
+ assert.equal(fs.readFileSync(s.paths.routeAgy, 'utf8'), '');
52
+ s.applyLaunchState(cfgWith({ agy: 'pool' }), 3456);
53
+ s.clearLaunchState(3456);
54
+ assert.equal(fs.readFileSync(s.paths.envAgyCmd, 'utf8'), '');
55
+ assert.equal(fs.existsSync(s.paths.activeFlag), false);
56
+ });
57
+
58
+ test('the agy shim reads only its own files, takes no CA, and scrubs a stale gateway URL only while off', () => {
59
+ const win = shim.renderShim('agy', 'win32');
60
+ assert.match(win, /env-agy\.cmd/);
61
+ assert.match(win, /route-agy\.txt/);
62
+ assert.doesNotMatch(win, /env-claude|NODE_EXTRA_CA_CERTS|STATE_HELPER/);
63
+ assert.match(win, /if not "%TOOL_ACTIVE%"=="1" if defined CLOUD_CODE_URL call :SCRUB_URL CLOUD_CODE_URL/);
64
+ assert.match(win, /where agy\.exe/);
65
+ const posix = shim.renderShim('agy', 'linux');
66
+ assert.match(posix, /env-agy\.sh/);
67
+ assert.doesNotMatch(posix, /export NODE_EXTRA_CA_CERTS/);
68
+ assert.match(posix, /if \[ "\$TOOL_ACTIVE" != "1" \] && \[ -n "\$_u" \]; then/);
69
+ assert.match(shim.renderShim('claude', 'win32'), /NODE_EXTRA_CA_CERTS/, 'Claude keeps its CA block');
70
+ assert.equal(shim.routeEvidence('agy', ' CLOUD_CODE_URL=http://127.0.0.1:3456 PATH=x'), true);
71
+ });
72
+
73
+ test('the agy hook is merged into hooks.json, unquoted, and removed without touching other hooks', () => {
74
+ const agyHome = path.join(tmp, 'agy');
75
+ fs.mkdirSync(agyHome, { recursive: true });
76
+ const file = path.join(agyHome, 'hooks.json');
77
+ fs.writeFileSync(file, JSON.stringify({ 'lint-checker': { PostToolUse: [] } }));
78
+ const opts = { agyHome, claudeSkillsDir: path.join(tmp, 'skills'), codexHome: path.join(tmp, 'codex') };
79
+ const r = plugin.installPlugin(opts, 'test');
80
+ assert.ok(r.installed.includes('agy'), JSON.stringify(r));
81
+ const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
82
+ assert.ok(doc['lint-checker'], 'another hook stays');
83
+ const cmd = doc[plugin.AGY_HOOK_NAME].SessionStart[0].command;
84
+ assert.match(cmd, /^node \S+\/hook-status\.mjs agy$/);
85
+ assert.doesNotMatch(cmd, /["\\]/, 'agy keeps quotes literally, so the command has none');
86
+ assert.equal(plugin.pluginStatus(opts).agy.installed, true);
87
+ plugin.uninstallPlugin(opts);
88
+ assert.deepEqual(Object.keys(JSON.parse(fs.readFileSync(file, 'utf8'))), ['lint-checker']);
89
+ assert.equal(plugin.agyHookCommand('C:/Program Files/x/hook-status.mjs'), null, 'a path with a space cannot be run by agy');
90
+ });
91
+
92
+ test.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
93
+
94
+ test('the launch files of every tool stay out of git and out of the npm package', () => {
95
+ const ignored = fs.readFileSync(new URL('../.gitignore', import.meta.url), 'utf8').split(/\r?\n/);
96
+ for (const tool of s.TOOLS) {
97
+ for (const f of [`env-${tool}.cmd`, `env-${tool}.sh`, `route-${tool}.txt`]) {
98
+ assert.ok(ignored.includes(f), `${f} must be ignored: it holds this machine's route`);
99
+ }
100
+ }
101
+ });
102
+
103
+ test('migration keeps the agy pointer and renames every pointer of a command-word profile', () => {
104
+ const mig = (cfg) => s.migrateConfigInMemory(cfg).config.activeProfiles;
105
+ assert.equal(mig({ profiles: { old: { baseURL: 'x' }, a: { tool: 'agy', baseURL: 'x' } }, activeProfiles: { claude: null, codex: null, agy: 'a' } }).agy, 'a');
106
+ assert.equal(mig({ profiles: { agy: { tool: 'claude', baseURL: 'x' } }, activeProfiles: { claude: 'agy' } }).claude, 'agy-profile');
107
+ assert.equal(mig({ profiles: { agy: { tool: 'agy', baseURL: 'x' } }, activeProfiles: { agy: 'agy' } }).agy, 'agy-profile');
108
+ assert.equal(mig({ profiles: { agy: { tool: 'codex', baseURL: 'x' } }, activeProfiles: { codex: 'agy' } }).codex, 'agy-profile');
109
+ assert.equal(mig({ profiles: { c: { tool: 'claude', baseURL: 'x' } }, activeProfiles: { agy: 'c' } }).agy, null);
110
+ });
111
+
112
+ test('every target alias of the switch command is a reserved command word', () => {
113
+ // A word that is not reserved can also be a profile key, and then `switch <word>` is ambiguous.
114
+ const src = fs.readFileSync(new URL('../switch.mjs', import.meta.url), 'utf8');
115
+ const literal = src.match(/const TARGET_ALIASES = (\{[^}]*\})/)[1];
116
+ const aliases = Function(`return ${literal}`)();
117
+ for (const key of Object.keys(aliases)) assert.ok(s.COMMAND_WORDS.has(key), `${key} is not in COMMAND_WORDS`);
118
+ });
119
+
120
+ const pluginDirs = (name) => {
121
+ const root = path.join(tmp, name);
122
+ const opts = { agyHome: path.join(root, 'agy'), claudeSkillsDir: path.join(root, 'skills'), codexHome: path.join(root, 'codex') };
123
+ for (const d of Object.values(opts)) fs.mkdirSync(d, { recursive: true });
124
+ return opts;
125
+ };
126
+
127
+ test('a hook script path with a space skips agy, and the other tools still install', () => {
128
+ const opts = { ...pluginDirs('space'), hookScript: path.join(tmp, 'Program Files', 'hook-status.mjs') };
129
+ const r = plugin.installPlugin(opts);
130
+ assert.deepEqual(r.installed.sort(), ['claude', 'codex']);
131
+ assert.deepEqual(r.failed, []);
132
+ assert.equal(r.skipped.length, 1);
133
+ assert.equal(r.skipped[0].tool, 'agy');
134
+ assert.match(r.skipped[0].reason, /space/);
135
+ assert.equal(fs.existsSync(path.join(opts.agyHome, 'hooks.json')), false);
136
+ });
137
+
138
+ test('an agy hooks.json whose root is not an object is left exactly as it is', () => {
139
+ for (const body of ['[{"a":1}]', '"text"', '7']) {
140
+ const opts = pluginDirs(`root-${body.length}`);
141
+ const file = path.join(opts.agyHome, 'hooks.json');
142
+ fs.writeFileSync(file, body);
143
+ const inst = plugin.installPlugin(opts);
144
+ assert.equal(inst.failed.find(f => f.tool === 'agy')?.tool, 'agy', `install ${body}`);
145
+ assert.equal(fs.readFileSync(file, 'utf8'), body);
146
+ const un = plugin.uninstallPlugin(opts);
147
+ assert.ok(un.installed.includes('agy') && !un.failed.length, `uninstall ${body}: our hook cannot be there`);
148
+ assert.equal(fs.readFileSync(file, 'utf8'), body);
149
+ }
150
+ });
151
+
152
+ test('a tool whose install throws is reported, and the other tools still install', () => {
153
+ const opts = pluginDirs('throws');
154
+ // A directory where the file must go makes the write throw.
155
+ fs.mkdirSync(path.join(opts.codexHome, 'hooks.json'));
156
+ const r = plugin.installPlugin(opts);
157
+ assert.ok(r.installed.includes('claude') && r.installed.includes('agy'), JSON.stringify(r));
158
+ assert.equal(r.failed.find(f => f.tool === 'codex')?.tool, 'codex');
159
+ });
160
+
161
+ test('plugin status names an agy hooks.json that does not parse', () => {
162
+ const opts = pluginDirs('status-broken');
163
+ fs.writeFileSync(path.join(opts.agyHome, 'hooks.json'), '{ broken');
164
+ const st = plugin.pluginStatus(opts).agy;
165
+ assert.equal(st.installed, false);
166
+ assert.match(st.error, /parse/);
167
+ fs.writeFileSync(path.join(opts.agyHome, 'hooks.json'), '[]');
168
+ assert.match(plugin.pluginStatus(opts).agy.error, /object/);
169
+ });
170
+
171
+ test('the agy launch hook warns when agy is routed and no gateway answers, and is silent when it is not routed', async () => {
172
+ const { spawnSync } = await import('node:child_process');
173
+ const net = await import('node:net');
174
+ const closed = await new Promise((resolve) => { const sv = net.createServer().listen(0, '127.0.0.1', () => { const { port } = sv.address(); sv.close(() => resolve(port)); }); });
175
+ const run = () => spawnSync(process.execPath, [new URL('../hook-status.mjs', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'), 'agy'], {
176
+ env: { ...process.env, LLM_SWITCHER_STATE_DIR: tmp, LLM_SWITCHER_CONFIG: path.join(tmp, 'config.json'), LLM_SWITCHER_PORT: String(closed) },
177
+ encoding: 'utf8'
178
+ });
179
+ fs.writeFileSync(s.paths.routeAgy, 'agy -> pool | intact.example | antigravity/*');
180
+ fs.writeFileSync(s.paths.activeFlag, '1');
181
+ try {
182
+ const warned = JSON.parse(run().stdout);
183
+ assert.match(warned.systemMessage, /^WARNING: .*agy -> pool/);
184
+ fs.writeFileSync(s.paths.routeAgy, '');
185
+ assert.deepEqual(JSON.parse(run().stdout), {});
186
+ } finally {
187
+ fs.rmSync(s.paths.routeAgy, { force: true });
188
+ fs.rmSync(s.paths.activeFlag, { force: true });
189
+ }
190
+ });
@@ -7,11 +7,11 @@ import assert from 'node:assert/strict';
7
7
  import { launchBrowser, skipReason } from './cdp.mjs';
8
8
  import { startDashboardFixture } from './dashboard-fixture.mjs';
9
9
 
10
- const TOOLS = ['claude', 'codex'];
11
- const LABEL = { claude: 'Claude', codex: 'Codex' };
10
+ const TOOLS = ['claude', 'codex', 'agy'];
11
+ const LABEL = { claude: 'Claude', codex: 'Codex', agy: 'agy' };
12
12
  const MASKED = '__LLM_SWITCHER_KEEP_KEY__';
13
13
  // A profile's `tool` in config.json is one tool, or nothing for a profile that serves both.
14
- const VALID_TOOLS = [undefined, null, 'claude', 'codex'];
14
+ const VALID_TOOLS = [undefined, null, 'claude', 'codex', 'agy'];
15
15
 
16
16
  describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
17
17
  let fx, browser, page;
@@ -59,8 +59,8 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
59
59
  const readScreen = () => page.evaluate(`(() => {
60
60
  const text = id => document.getElementById(id)?.textContent.trim();
61
61
  return {
62
- badges: { claude: text('badge-slot-claude'), codex: text('badge-slot-codex') },
63
- selects: { claude: document.getElementById('select-slot-claude').value, codex: document.getElementById('select-slot-codex').value },
62
+ badges: { claude: text('badge-slot-claude'), codex: text('badge-slot-codex'), agy: text('badge-slot-agy') },
63
+ selects: { claude: document.getElementById('select-slot-claude').value, codex: document.getElementById('select-slot-codex').value, agy: document.getElementById('select-slot-agy').value },
64
64
  aside: text('status-aside-indicator'),
65
65
  cards: [...document.querySelectorAll('#profiles-grid .pcard')].map(c => ({
66
66
  key: c.querySelector('.mono.faint').textContent.trim(),
@@ -75,8 +75,8 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
75
75
  const active = cfg.activeProfiles;
76
76
  const on = TOOLS.filter(t => active[t]);
77
77
  return {
78
- badges: { claude: active.claude || 'OFF', codex: active.codex || 'OFF' },
79
- selects: { claude: active.claude || '', codex: active.codex || '' },
78
+ badges: { claude: active.claude || 'OFF', codex: active.codex || 'OFF', agy: active.agy || 'OFF' },
79
+ selects: { claude: active.claude || '', codex: active.codex || '', agy: active.agy || '' },
80
80
  aside: on.length ? `Active (${on.length})` : 'Idle',
81
81
  cards: Object.keys(cfg.profiles).map(key => {
82
82
  const tools = TOOLS.filter(t => active[t] === key);
@@ -100,7 +100,8 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
100
100
  async function expectScreenMatchesDisk(step, opts) {
101
101
  const cfg = fx.config();
102
102
  const served = (await fx.api('/api/status')).json;
103
- assert.deepEqual(served.activeProfiles, cfg.activeProfiles, `${step}: the gateway reports routes that are not on disk`);
103
+ // A config written before agy existed has no agy key: on disk that means agy is off.
104
+ assert.deepEqual(served.activeProfiles, { agy: null, ...cfg.activeProfiles }, `${step}: the gateway reports routes that are not on disk`);
104
105
  assert.deepEqual(await readScreen(), expectedScreen(cfg), `${step}: the page and the file disagree`);
105
106
  expectQuiet(step, opts);
106
107
  }
@@ -139,22 +140,22 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
139
140
  it('turns one profile off and on again for the tool it serves, and leaves the other tool alone', async () => {
140
141
  await act(() => page.click(switchOf('intact-claude')));
141
142
  assert.match(await toast(), /intact-claude deactivated/);
142
- assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: 'intact-codex' });
143
+ assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: 'intact-codex', agy: null });
143
144
  await expectScreenMatchesDisk('claude profile off');
144
145
 
145
146
  await act(() => page.click(switchOf('intact-claude')));
146
147
  assert.match(await toast(), /intact-claude activated/);
147
- assert.deepEqual(fx.config().activeProfiles, { claude: 'intact-claude', codex: 'intact-codex' });
148
+ assert.deepEqual(fx.config().activeProfiles, { claude: 'intact-claude', codex: 'intact-codex', agy: null });
148
149
  await expectScreenMatchesDisk('claude profile back on');
149
150
  });
150
151
 
151
152
  it('turns a profile that serves both tools on for both, and off again', async () => {
152
153
  await act(() => page.click(switchOf('shared')));
153
- assert.deepEqual(fx.config().activeProfiles, { claude: 'shared', codex: 'shared' });
154
+ assert.deepEqual(fx.config().activeProfiles, { claude: 'shared', codex: 'shared', agy: null });
154
155
  await expectScreenMatchesDisk('shared on');
155
156
 
156
157
  await act(() => page.click(switchOf('shared')));
157
- assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: null });
158
+ assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: null, agy: null });
158
159
  await expectScreenMatchesDisk('shared off');
159
160
  });
160
161
 
@@ -173,11 +174,11 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
173
174
  describe('the route list of each tool', () => {
174
175
  it('sets a route for one tool and can return it to the official endpoint', async () => {
175
176
  await act(() => page.select('#select-slot-claude', 'shared'));
176
- assert.deepEqual(fx.config().activeProfiles, { claude: 'shared', codex: 'intact-codex' });
177
+ assert.deepEqual(fx.config().activeProfiles, { claude: 'shared', codex: 'intact-codex', agy: null });
177
178
  await expectScreenMatchesDisk('claude to shared');
178
179
 
179
180
  await act(() => page.select('#select-slot-codex', ''));
180
- assert.deepEqual(fx.config().activeProfiles, { claude: 'shared', codex: null });
181
+ assert.deepEqual(fx.config().activeProfiles, { claude: 'shared', codex: null, agy: null });
181
182
  await expectScreenMatchesDisk('codex to official');
182
183
  });
183
184
 
@@ -191,7 +192,7 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
191
192
  describe('the buttons that turn every route off and on', () => {
192
193
  it('"Use official endpoints" in the page header turns every route off', async () => {
193
194
  await act(() => page.clickText('#ph-actions button', 'Use official endpoints'));
194
- assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: null });
195
+ assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: null, agy: null });
195
196
  await expectScreenMatchesDisk('all off');
196
197
  const labels = await page.evaluate(`[...document.querySelectorAll('#ph-actions button')].map(b => b.textContent.trim())`);
197
198
  assert.deepEqual(labels, ['+ Add Profile', 'Activate compatible routes', 'Refresh']);
@@ -200,13 +201,13 @@ describe('dashboard, driven in a real browser', { skip: skipReason() }, () => {
200
201
  it('turning every route off and on again gives each tool its route back', async () => {
201
202
  await act(() => page.clickText('#ph-actions button', 'Use official endpoints'));
202
203
  await act(() => page.clickText('#ph-actions button', 'Activate compatible routes'));
203
- assert.deepEqual(fx.config().activeProfiles, { claude: 'intact-claude', codex: 'intact-codex' }, 'off and on lost a route');
204
+ assert.deepEqual(fx.config().activeProfiles, { claude: 'intact-claude', codex: 'intact-codex', agy: null }, 'off and on lost a route');
204
205
  await expectScreenMatchesDisk('off and on');
205
206
  });
206
207
 
207
208
  it('"Toggle All" in the sidebar does the same as the header button', async () => {
208
209
  await act(() => page.click('.prefs button.btn-outline'));
209
- assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: null });
210
+ assert.deepEqual(fx.config().activeProfiles, { claude: null, codex: null, agy: null });
210
211
  await expectScreenMatchesDisk('sidebar toggle');
211
212
  });
212
213
  });
@@ -2,7 +2,7 @@
2
2
  import { test } from 'node:test';
3
3
  import assert from 'node:assert/strict';
4
4
  import {
5
- anthropicToIR, responsesToIR,
5
+ anthropicToIR, responsesToIR, codeAssistToIR,
6
6
  healToolPairs, irToChatBody, irToAnthropicBody, irToVertexBody, toGeminiSchema,
7
7
  createUpstreamNormalizer, createCollector, createThinkTagSplitter,
8
8
  createAnthropicStream, createResponsesStream, createVertexStream,
@@ -10,7 +10,8 @@ import {
10
10
  buildResponsesMessage, createUpstreamNormalizer as makeNormalizer, emitUpstreamBody,
11
11
  isAntigravityModel, smartUsage,
12
12
  createChatStream, buildChatMessage, chatFinish, anthropicStopReason,
13
- smartText, smartReasoning, sanitizeJsonSchema, normalizeUpstream, buildVertexMessage
13
+ smartText, smartReasoning, sanitizeJsonSchema, normalizeUpstream, buildVertexMessage,
14
+ createCodeAssistStream
14
15
  } from '../formats.mjs';
15
16
  import { assertValidAnthropicEvents } from './helpers.mjs';
16
17
 
@@ -832,3 +833,83 @@ test('structured output: Responses text.format keeps name and strict; JSON mode
832
833
  assert.equal(irToChatBody(txt, 'm').response_format, undefined);
833
834
  assert.equal(irToVertexBody(txt, 'm').generationConfig, undefined);
834
835
  });
836
+
837
+ // ---- Code Assist (Antigravity CLI) input ----
838
+ const ca = (contents, request = {}) => codeAssistToIR({ model: 'm', request: { contents, ...request } });
839
+ const call = (name, id) => ({ functionCall: { ...(id ? { id } : {}), name, args: {} } });
840
+ const resp = (name, id, output = 'r') => ({ functionResponse: { ...(id ? { id } : {}), name, response: { output } } });
841
+ const toolMsgs = (ir) => ir.messages.filter(m => m.role === 'tool');
842
+
843
+ test('codeAssistToIR: an answered id leaves the queue, so a later id-less result pairs with its own call', () => {
844
+ const ir = ca([
845
+ { role: 'user', parts: [{ text: 'go' }] },
846
+ { role: 'model', parts: [call('f', 'x')] }, { role: 'model', parts: [resp('f', 'x')] },
847
+ { role: 'model', parts: [call('f')] }, { role: 'model', parts: [resp('f')] }
848
+ ]);
849
+ const generated = ir.messages.find(m => m.role === 'assistant' && m.toolCalls?.[0]?.id !== 'x').toolCalls[0].id;
850
+ assert.deepEqual(toolMsgs(ir).map(m => m.toolCallId), ['x', generated]);
851
+ });
852
+
853
+ test('codeAssistToIR: two id-less parallel calls of one name pair first in, first out', () => {
854
+ const ir = ca([{ role: 'user', parts: [{ text: 'go' }] }, { role: 'model', parts: [call('f'), call('f')] }, { role: 'user', parts: [resp('f', null, 'a'), resp('f', null, 'b')] }]);
855
+ const ids = ir.messages.find(m => m.role === 'assistant').toolCalls.map(c => c.id);
856
+ assert.deepEqual(toolMsgs(ir).map(m => [m.toolCallId, m.content]), [[ids[0], 'a'], [ids[1], 'b']]);
857
+ });
858
+
859
+ test('codeAssistToIR: two id-less orphans of one name get distinct ids', () => {
860
+ const ir = ca([{ role: 'user', parts: [{ text: 'go' }] }, { role: 'user', parts: [resp('f'), resp('f')] }]);
861
+ const ids = toolMsgs(ir).map(m => m.toolCallId);
862
+ assert.equal(ids.length, 2);
863
+ assert.notEqual(ids[0], ids[1]);
864
+ });
865
+
866
+ test('codeAssistToIR: an unknown explicit id does not consume the open call of that name', () => {
867
+ const ir = ca([{ role: 'user', parts: [{ text: 'go' }] }, { role: 'model', parts: [call('f')] }, { role: 'model', parts: [resp('f', 'zzz')] }, { role: 'model', parts: [resp('f')] }]);
868
+ assert.deepEqual(toolMsgs(ir).map(m => m.toolCallId), ['zzz', 'call_agy_0_f']);
869
+ });
870
+
871
+ test('codeAssistToIR: thought parts stay out of the text', () => {
872
+ const ir = ca([{ role: 'user', parts: [{ text: 'q' }] }, { role: 'model', parts: [{ text: 'secret', thought: true }, { text: 'answer' }] }]);
873
+ assert.equal(ir.messages[1].content, 'answer');
874
+ });
875
+
876
+ test('codeAssistToIR: nested schema types are lowered', () => {
877
+ const ir = ca([{ role: 'user', parts: [{ text: 'q' }] }], { tools: [{ functionDeclarations: [{ name: 't', parameters: {
878
+ type: 'OBJECT', properties: { list: { type: 'ARRAY', items: { type: 'STRING' } }, pick: { anyOf: [{ type: 'INTEGER' }, { type: 'BOOLEAN' }] } }
879
+ } }] }] });
880
+ const p = ir.tools[0].parameters;
881
+ assert.equal(p.type, 'object');
882
+ assert.equal(p.properties.list.type, 'array');
883
+ assert.equal(p.properties.list.items.type, 'string');
884
+ assert.ok(JSON.stringify(p.properties.pick).includes('"integer"'));
885
+ });
886
+
887
+ test('codeAssistToIR: each thinkingConfig form maps to ir.thinking', () => {
888
+ const think = (thinkingConfig) => ca([{ role: 'user', parts: [{ text: 'q' }] }], { generationConfig: { thinkingConfig } }).thinking;
889
+ assert.deepEqual(think({ thinkingBudget: 0 }), { type: 'disabled' });
890
+ assert.equal(think({ thinkingBudget: 4000 }).type, 'enabled');
891
+ assert.equal(think({ thinkingLevel: 'HIGH' }).effort, 'high');
892
+ assert.deepEqual(think({ includeThoughts: true, thinkingBudget: -1 }), { type: 'enabled', budget: 2048 });
893
+ });
894
+
895
+ test('codeAssistToIR: generation parameters map to ir.params', () => {
896
+ const ir = ca([{ role: 'user', parts: [{ text: 'q' }] }], { generationConfig: { maxOutputTokens: 900, temperature: 0.2, topP: 0.9, stopSequences: ['END'] } });
897
+ assert.equal(ir.params.maxTokens, 900);
898
+ assert.equal(ir.params.temperature, 0.2);
899
+ assert.equal(ir.params.topP, 0.9);
900
+ assert.deepEqual(ir.params.stop, ['END']);
901
+ });
902
+
903
+ test('Code Assist stream: a chunk is wrapped with a trace id, an error is sent bare as Code Assist does', () => {
904
+ const out = [];
905
+ const stream = createCodeAssistStream((event, data) => out.push(data), 'gemini-3.8-flash-high');
906
+ stream.text('hi');
907
+ stream.error('upstream broke');
908
+ const chunk = out.find(d => d.response);
909
+ assert.equal(typeof chunk.traceId, 'string');
910
+ assert.match(chunk.response.responseId, /^agy-/);
911
+ const err = out.at(-1);
912
+ assert.deepEqual(Object.keys(err), ['error'], 'an error is not wrapped in response/traceId');
913
+ assert.equal(err.error.status, 'INTERNAL');
914
+ assert.equal(err.error.message, 'upstream broke');
915
+ });
@@ -616,6 +616,24 @@ test('Bifrost: Claude Code to a Claude Code account on intact changes only the k
616
616
  assert.equal(logs[0].outFormat, 'bifrost');
617
617
  });
618
618
 
619
+ // intact pins a conversation to one account by this header, so its prompt cache hits and a
620
+ // compacted conversation can move. Every route that reaches intact must carry it.
621
+ test('The Claude Code session id reaches the upstream on every route', async () => {
622
+ const routes = [
623
+ { name: 'direct', model: 'claude-opus-4-6', profile: 'ant', ua: 'claude-cli/2.1.300 (external, cli)', url: '/ant/' },
624
+ { name: 'convert', model: 'claude-sonnet-5', profile: 'intactcc', ua: 'claude-cli/2.1.300 (external, cli)', url: '/intact/v1/chat/completions' },
625
+ { name: 'bifrost', model: 'claude-opus-4-6', profile: 'intactcc', ua: 'claude-cli/2.1.300 (external, cli)', url: '/intact/v1/messages' }
626
+ ];
627
+ for (const r of routes) {
628
+ const res = await post('/v1/messages', { model: r.model, max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] },
629
+ { 'x-llm-profile': r.profile, 'user-agent': r.ua, 'x-claude-code-session-id': `sess-${r.name}` });
630
+ assert.equal(res.status, 200, r.name);
631
+ const up = received.at(-1);
632
+ assert.ok(up.url.startsWith(r.url), `${r.name} went to ${up.url}`);
633
+ assert.equal(up.headers['x-claude-code-session-id'], `sess-${r.name}`, `${r.name} dropped the session id`);
634
+ }
635
+ });
636
+
619
637
  test('Bifrost stays off for a model that is not a Claude Code account', async () => {
620
638
  const res = await post('/v1/messages', { model: 'claude-sonnet-5', max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] },
621
639
  { 'x-llm-profile': 'intactcc', 'user-agent': 'claude-cli/2.1.300 (external, cli)' });
@@ -1279,7 +1297,7 @@ test('/api/switch deactivate: the dashboard payload works, the CLI payload works
1279
1297
  // What the dashboard sends: the profile in `profile`, a boolean in `deactivate`.
1280
1298
  const dash = await post('/api/switch', { profile: 'chat', deactivate: true });
1281
1299
  assert.equal(dash.status, 200);
1282
- assert.deepEqual((await dash.json()).activeProfiles, { claude: null, codex: null });
1300
+ assert.deepEqual((await dash.json()).activeProfiles, { claude: null, codex: null, agy: null });
1283
1301
 
1284
1302
  // The same profile is now inactive, so asking again must not report success.
1285
1303
  const again = await post('/api/switch', { profile: 'chat', deactivate: true });
@@ -1290,7 +1308,7 @@ test('/api/switch deactivate: the dashboard payload works, the CLI payload works
1290
1308
  assert.equal((await post('/api/switch', { profile: 'chat' })).status, 200);
1291
1309
  const cli = await post('/api/switch', { deactivate: 'chat' });
1292
1310
  assert.equal(cli.status, 200);
1293
- assert.deepEqual((await cli.json()).activeProfiles, { claude: null, codex: null });
1311
+ assert.deepEqual((await cli.json()).activeProfiles, { claude: null, codex: null, agy: null });
1294
1312
 
1295
1313
  // `deactivate: true` with no profile names nothing.
1296
1314
  assert.equal((await post('/api/switch', { deactivate: true })).status, 400);
@@ -115,15 +115,15 @@ test('routeEvidence does not take the shim --config arguments for an environment
115
115
 
116
116
  // ==================== Task 5 ====================
117
117
 
118
- // The gateway stands between two tools and nothing else. An agent that is offered the retired
118
+ // The gateway stands between three tools and nothing else. An agent that is offered the retired
119
119
  // names would be told they worked, then fail at the gateway.
120
- test('R6: switcher_switch_profile accepts claude and codex targets and rejects legacy targets', async (t) => {
120
+ test('R6: switcher_switch_profile accepts claude, codex and agy targets and rejects legacy targets', async (t) => {
121
121
  const [list] = await mcpCall(t, {}, [{ method: 'tools/list' }]);
122
122
  const def = list.result.tools.find(x => x.name === 'switcher_switch_profile');
123
123
  assert.ok(def, 'the tool is advertised');
124
- assert.deepEqual(def.inputSchema.properties.target.enum, ['claude', 'codex']);
125
- assert.match(def.inputSchema.properties.target.description, /"claude" \(Claude Code\) or "codex" \(Codex\)/);
126
- assert.match(def.description, /Claude Code \(claude\) or Codex \(codex\)/);
124
+ assert.deepEqual(def.inputSchema.properties.target.enum, ['claude', 'codex', 'agy']);
125
+ assert.match(def.inputSchema.properties.target.description, /"claude" \(Claude Code\), "codex" \(Codex\) or "agy" \(Antigravity CLI\)/);
126
+ assert.match(def.description, /Claude Code \(claude\), Codex \(codex\) or the Antigravity CLI \(agy\)/);
127
127
  // openai and vertex are not tools a caller may name, so they are not in the schema at all.
128
128
  assert.ok(!JSON.stringify(def).includes('openai'), 'no retired target in the schema');
129
129
  assert.ok(!JSON.stringify(def).includes('vertex'), 'no retired target in the schema');
@@ -20,9 +20,11 @@ function dirs(t) {
20
20
  t.after(() => fs.rmSync(home, { recursive: true, force: true }));
21
21
  const claudeSkillsDir = path.join(home, '.claude', 'skills');
22
22
  const codexHome = path.join(home, '.codex');
23
+ // Every tool gets a home of its own here: a test must never write the hooks of this machine.
24
+ const agyHome = path.join(home, '.gemini', 'antigravity-cli');
23
25
  fs.mkdirSync(claudeSkillsDir, { recursive: true });
24
26
  fs.mkdirSync(codexHome, { recursive: true });
25
- return { home, claudeSkillsDir, codexHome, opts: { claudeSkillsDir, codexHome } };
27
+ return { home, claudeSkillsDir, codexHome, agyHome, opts: { claudeSkillsDir, codexHome, agyHome } };
26
28
  }
27
29
 
28
30
  const readJson = (f) => JSON.parse(fs.readFileSync(f, 'utf8'));
@@ -55,7 +57,7 @@ test('install writes a plugin for Claude Code and a hooks file for Codex, and re
55
57
  const { claudeSkillsDir, codexHome, opts } = dirs(t);
56
58
  const first = installPlugin(opts);
57
59
  assert.deepEqual(first.failed, [], 'nothing failed');
58
- assert.deepEqual(first.installed.sort(), ['claude', 'codex']);
60
+ assert.deepEqual(first.installed.sort(), ['agy', 'claude', 'codex']);
59
61
 
60
62
  const manifest = path.join(claudeSkillsDir, 'llm-switcher-status', '.claude-plugin', 'plugin.json');
61
63
  assert.ok(fs.existsSync(manifest), 'the manifest is what makes the folder a plugin');