// F3: after a route save, Settings > Routing says how the change reaches each saved agent, read // from the instances: the launcher and a stopped agent take it at their next start, a running // session keeps its old route until it ends (a thread when it goes idle), and a running agent // offers "Restart now". Restart now terminates with the reason "route change", waits for the // session to end and launches it again; a thread is only terminated. Real server under the dev // local owner, a stub bus, every write gated by the browser's own Origin and recorded in the trail. import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { browser } from './browser.mjs'; import { startServer } from '../src/serve.mjs'; import { createDevOwner } from '../src/dev-owner.mjs'; import { actionTrail } from '../src/action-trail.mjs'; import { close } from './fixture.mjs'; const REV = 'a'.repeat(64), REV2 = 'b'.repeat(64); const END = '10000000-0000-4000-8000-0000000000e1', LAUNCH = '10000000-0000-4000-8000-0000000000a1', END2 = '10000000-0000-4000-8000-0000000000e2'; const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' }; const route = thinking => ({ harness: 'claude', model: 'opus', thinking, fallback: null }); test('F3 Settings > Routing: a save lists how the change reaches each agent, and Restart now moves a running one', { timeout: 120000 }, async t => { const root = mkdtempSync(join(tmpdir(), 'f3r-')); t.after(() => rmSync(root, { recursive: true, force: true })); const entry = (definition, extra) => ({ definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: [], alive: [], ...extra }); const instances = { chief: entry('chief', { launcher: true }), coder: entry('coder', { running: ['run-1'], alive: ['run-1'] }), assistant: entry('assistant', { running: ['run-a'], alive: ['run-a'] }), reviewer: entry('reviewer', { launched: false }), }; const view = () => structuredClone({ business: 'acme', revision: REV, launch: true, noHire: [], retired: [], instances, definitions: Object.fromEntries(Object.values(instances).map(e => [e.definition, { services: [], accepted: true }])) }); let routes = { revision: REV, harnesses: ['claude'], models: ['opus'], levels: { claude: ['low', 'high'] }, instances: Object.fromEntries(Object.keys(instances).map(n => [n, route('high')])) }; const asked = [], requests = new Map(); const bus = { messages: async () => ({ messages: [], unread: 0 }), usage: async () => { throw Error('no usage'); }, instances: async () => view(), routes: async () => structuredClone(routes) }; const routesWrite = async args => { asked.push(['routes.set', args]); routes = { ...routes, revision: REV2, instances: { ...routes.instances, ...structuredClone(args.routes) } }; return structuredClone(routes); }; const agentWrite = async (verb, args) => { asked.push([verb, args]); if (verb === 'instance.end') { const id = args.instance === 'coder' ? END : END2; requests.set(id, { instance: args.instance, action: 'end' }); // The session ends a moment after the request closes, so the page has to wait for it. setTimeout(() => { instances[args.instance].running = []; instances[args.instance].alive = []; }, 400); return { request: id, runs: [...instances[args.instance].running], cancelled: [], taken: [] }; } if (verb === 'instance.launch') { requests.set(LAUNCH, { instance: args.instance, action: 'launch' }); instances[args.instance].running = ['run-2']; return { request: LAUNCH }; } if (verb === 'instance.request') { const r = requests.get(args.request); return { request: args.request, instance: r.instance, action: r.action, state: 'closed', done: { run: r.action === 'launch' ? 'run-2' : null, transaction: null, refusal: null }, cancelled: null }; } throw Error(`unexpected ${verb}`); }; const dir = join(root, 'console'); const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), bus, routesWrite, agentWrite, reads: { settings: () => ({ business: 'acme', release: 'fixture', notifier: {} }) }, trail: actionTrail(dir), log: () => {} }); t.after(() => close(web)); const b = await browser(); t.after(() => b.close()); const wait = (expr, ms = 20000) => b.evaluate(`(async()=>{const end=Date.now()+${ms};for(;;){if(${expr})return true;if(Date.now()>end)throw Error('wait failed: '+${JSON.stringify(expr)});await new Promise(r=>setTimeout(r,30));}})()`); const cell = (name, key) => `[...document.querySelectorAll('[data-route]')].find(e=>e.dataset.route===${JSON.stringify(JSON.stringify([name, key]))})`; const items = () => b.evaluate(`[...document.querySelectorAll('.routes-apply li')].map(li=>{const c=li.cloneNode(true);c.querySelector('.agent-restart')?.remove();return c.textContent.trim();})`); await b.viewport(1440, 900); await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`); await wait(`${cell('coder', 'thinking')}&&!${cell('coder', 'thinking')}.disabled`); assert.equal(await b.evaluate(`!!document.querySelector('.routes-apply')`), false, 'nothing is listed before a save'); for (const name of ['chief', 'coder', 'assistant', 'reviewer']) await b.evaluate(`(()=>{const s=${cell(name, 'thinking')};s.value='low';s.dispatchEvent(new Event('change',{bubbles:true}));return true;})()`); await b.evaluate(`document.getElementById('routes-form').requestSubmit();true`); await wait(`document.querySelector('.routes-apply')`); assert.deepEqual(asked, [['routes.set', { revision: REV, routes: { chief: route('low'), coder: route('low'), assistant: route('low'), reviewer: route('low') } }]]); assert.equal(await b.evaluate(`document.getElementById('routes-receipt').textContent`), 'Routes saved for chief, coder, assistant, reviewer. The next launch of each uses the new route.'); assert.deepEqual(await items(), [ 'chief: The launcher. It takes the new route at its next start.', 'coder: Running. Its session keeps the old route until it ends. Restart it to move it now.', 'assistant: Running. Its session keeps the old route until the thread goes idle; the next message after that starts it on the new route. Restart it to move it now.', 'reviewer: Not running. Its next start uses the new route.', ]); // Only the running agents offer the restart, and the thread is marked as one. assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('.routes-apply [data-agent-restart]')].map(e=>[e.dataset.agentRestart,e.dataset.agentThread,e.textContent])`), [['coder', 'no', 'Restart coder now'], ['assistant', 'yes', 'Restart assistant now']]); // Restart now on a session: terminate with the reason, wait for it to end, then one launch. await b.evaluate(`document.querySelector('[data-agent-restart="coder"]').click();true`); await wait(`document.querySelector('[data-agent-restart-status="coder"]')?.textContent==='Restarted coder on the new route (run run-2).'`, 30000); assert.deepEqual(asked.slice(1).filter(([v]) => v !== 'instance.request'), [['instance.end', { instance: 'coder', reason: 'route change' }], ['instance.launch', { instance: 'coder' }]]); assert.equal(asked.filter(([v, a]) => v === 'instance.launch' && a.instance === 'coder').length, 1, 'one launch, after the session ended'); // A thread is terminated, not launched: its next message starts it on the new route. await b.evaluate(`document.querySelector('[data-agent-restart="assistant"]').click();true`); await wait(`/^Terminated assistant at .+\\. Its next message starts it on the new route\\.$/.test(document.querySelector('[data-agent-restart-status="assistant"]')?.textContent??'')`, 30000); assert.equal(asked.filter(([v, a]) => v === 'instance.launch' && a.instance === 'assistant').length, 0); // The trail names the dev owner for the save and each lifecycle write. const trail = readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => { const { at, ...e } = JSON.parse(l); assert.match(at, /^\d{4}-\d\d-\d\dT/); return e; }); assert.deepEqual(trail.map(e => [e.action, e.by?.provider, e.outcome?.split(' ')[0]]), [ ['routes.set', 'local-dev', 'done'], ['instance.end', 'local-dev', 'done'], ['instance.launch', 'local-dev', 'done'], ['instance.end', 'local-dev', 'done'], ]); assert.deepEqual(trail[0].by, DEV); await b.viewport(390, 844); assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=390`), true, 'no sideways scroll at phone width'); });