// Row 65 (#1558, REQ-PLUG-2, mos-0840): Settings shows one routing row per instance with its six // cells, and a save posts the changed rows with the read's revision. The page's POST is held by a // fetch stub and sent on by the test, with the Console's own headers, to a second server on the // same real broker; the browser can't set Origin or Host itself. So a save reaches the business // file through routes.set, and each refusal comes from the bus with the draft kept. import test from 'node:test'; import assert from 'node:assert/strict'; import {request} from 'node:http'; import {readFileSync} from 'node:fs'; import {join} from 'node:path'; import {browser} from './browser.mjs'; import {startServer} from '../src/serve.mjs'; import {BusReadError} from '../src/bus.mjs'; import {close} from './fixture.mjs'; import {BusError} from '../../bus/src/broker.mjs'; import {startBroker} from '../../bus/src/runtime.mjs'; import {systemFor} from '../../organization/tests/helpers.mjs'; import {fixture,tmp} from '../../cli/tests/helpers.mjs'; import {createSsoSession} from './helpers/sso-session.mjs'; const KEYS=['harness','model','thinking','fallback.harness','fallback.model','fallback.thinking']; const post=(port,headers,data)=>new Promise((resolve,reject)=>{ const req=request({hostname:'127.0.0.1',port,path:'/api/routes',method:'POST',headers},r=>{let text='';r.on('data',c=>text+=c);r.on('end',()=>resolve({status:r.statusCode,body:JSON.parse(text)}));}); req.on('error',reject);req.end(JSON.stringify(data)); }); test('row 65 Settings: the routing table lists every instance, saves a harness, model, thinking and fallback to the file, and keeps the draft on each refusal',{timeout:120000},async t=>{ const root=tmp(t,'webui-routes-browser-'); // The reviewer starts with a fallback, so the table draws a set one as well as an empty one. const f=fixture(root,'acme',doc=>{doc.roles.reviewer.vars={...doc.roles.reviewer.vars,'fallback.harness':'pi','fallback.model':'glm-5.3-flash','fallback.thinking':'low'};return doc;}); const dir=join(root,'config'),file=join(dir,'businesses','acme.json'); const host=await startBroker({ dataRoot:f.dataRoot, businesses:{acme:{id:'acme',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}}, organization:{dir,rolesDir:f.roles,system:systemFor(root)}, }); t.after(()=>host.close()); const human=host.broker.bindHuman({business:'acme',human:'jason',via:'cli',outsideAgent:true}); const call=async(verb,args)=>{ try{return structuredClone(host.broker.request(human,{verb,args}));}catch(e){if(e instanceof BusError)throw new BusReadError(e.code);throw e;} }; const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},routes:()=>call('routes')}; const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}}); const lines=[]; const auth=await createSsoSession(t,{host:'console.example'}); const writer=await startServer({port:0,sso:auth.sso,send:async()=>({}),routesWrite:args=>call('routes.set',args),log:line=>lines.push(line)}); const session=await auth.signIn(writer.address().port),headers=auth.writeHeaders(session); const b=await browser(); try{ // The stub holds each POST until the test answers it with what the write server said. await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.enabled=new URL(location.href).searchParams.has('send');const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(url==='/api/routes'&&options.method==='POST'){posts.push(JSON.parse(options.body));return new Promise(resolve=>{window.answer=(status,body)=>resolve(new Response(JSON.stringify(body),{status}));});}return original(url,options);};`}); const port=web.address().port; const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`); const receipt=()=>b.evaluate('document.querySelector("#routes-receipt").textContent'); const cell=(name,key)=>`document.querySelector(${JSON.stringify(`[data-route='${JSON.stringify([name,key])}']`)})`; // A harness change redraws the form, so each cell is looked up again, and waited for until the // redraw offers the value. const set=async(name,key,value)=>{assert.equal(await b.evaluate(`(async()=>{for(let n=0;n<100;n++){const e=${cell(name,key)};if(e&&[...e.options].some(o=>o.value===${JSON.stringify(value)})){e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('change',{bubbles:true}));return true;}await new Promise(r=>setTimeout(r,30));}return false;})()`),true,`${name} ${key} offers ${value}`);}; const value=(name,key)=>b.evaluate(`${cell(name,key)}.value`); const shown=(name,key)=>b.evaluate(`${cell(name,key)}.selectedOptions[0].textContent`); let sent=0; const save=async()=>{ // The last receipt is cleared here, so only the answer's redraw writes one. await b.evaluate('document.querySelector("#routes-receipt").textContent="";document.querySelector("#routes-form").requestSubmit();true'); await wait(`posts.length>${sent}`);sent++; const r=await post(writer.address().port,headers,await b.evaluate('posts.at(-1)')); await b.evaluate(`window.answer(${r.status},${JSON.stringify(r.body)});true`); await wait('!document.querySelector("#routes-form button[type=submit]")?.textContent.startsWith("Saving")&&document.querySelector("#routes-receipt")?.textContent!==""'); return r; }; const reload=async()=>{ await b.evaluate('document.querySelector("#routes-reload").click();true'); await wait('!!document.querySelector("#routes-table")&&document.querySelector("#routes-receipt").textContent===""'); }; const vars=()=>JSON.parse(readFileSync(file,'utf8')).roles; // Read-only: one row per instance in the file, each with its six values, every select disabled. await b.navigate(`http://127.0.0.1:${port}/#/settings/routing`); await wait('!!document.querySelector("#routes-table")'); const rows=await b.evaluate("[...document.querySelectorAll('#routes-table tbody tr')].map(r=>[r.querySelector('th').textContent,...[...r.querySelectorAll('select')].map(s=>s.value)])"); assert.deepEqual(rows,Object.entries(vars()).map(([name,{vars:v}])=>[name,...KEYS.map(k=>v[k]??'')])); assert.ok(rows.length>=5,'every fixture instance has a row'); assert.deepEqual(rows.find(([name])=>name==='reviewer').slice(4),['pi','glm-5.3-flash','low']); assert.equal(await b.evaluate('document.querySelector("#routes-title").textContent'),'Routing'); assert.equal(await b.evaluate('[...document.querySelectorAll("#routes-form select,#routes-form button[type=submit]")].every(e=>e.disabled)'),true); // Send mode: a harness, a model, a thinking level and a whole fallback on the coder, saved and // read back from the file. await b.navigate(`http://127.0.0.1:${port}/?send=1#/settings/routing`); await wait('document.querySelector("#routes-form button[type=submit]")?.disabled===false'); const before=vars().coder.vars; await set('coder','harness','claude-code'); await set('coder','model','claude-opus-5-5'); await set('coder','thinking','xhigh'); await set('coder','fallback.harness','pi'); await set('coder','fallback.model','glm-5.3-flash'); await set('coder','fallback.thinking','off'); const saved=await save(); assert.equal(saved.status,200,JSON.stringify(saved.body)); assert.deepEqual(await b.evaluate('posts.at(-1).routes'),{coder:{harness:'claude-code',model:'claude-opus-5-5',thinking:'xhigh',fallback:{harness:'pi',model:'glm-5.3-flash',thinking:'off'}}},'only the changed row is sent'); assert.equal(await receipt(),'Routes saved for coder. The next launch of each uses the new route.'); const after=vars().coder.vars; assert.deepEqual(KEYS.map(k=>after[k]),['claude-code','claude-opus-5-5','xhigh','pi','glm-5.3-flash','off']); assert.deepEqual(Object.fromEntries(Object.entries(after).filter(([k])=>!KEYS.includes(k))),Object.fromEntries(Object.entries(before).filter(([k])=>!KEYS.includes(k))),'other vars kept'); assert.equal(await value('coder','harness'),'claude-code','the page draws the saved route'); assert.match(lines.at(-1),/ routes user="jason" instances="coder" done$/); // A partial fallback goes as it is and the bus refuses it; the draft stays on the page. const unchanged=readFileSync(file,'utf8'); await set('pm','fallback.harness','pi'); let r=await save(); assert.deepEqual([r.status,r.body.error],[400,'organization-partial-fallback']); assert.deepEqual(await b.evaluate('posts.at(-1).routes.pm.fallback'),{harness:'pi'}); assert.match(await receipt(),/^organization-partial-fallback: .* Nothing changed\. Draft kept\.$/); assert.equal(await value('pm','fallback.harness'),'pi'); // A level the new harness doesn't apply: shown as such, and refused. await reload(); assert.equal(await value('pm','fallback.harness'),'','reload discards the draft'); await set('pm','thinking','minimal'); await set('pm','harness','claude-code'); await wait(`${cell('pm','thinking')}?.selectedOptions[0]?.textContent.includes("(")`); assert.equal(await shown('pm','thinking'),'minimal (not applied by claude-code)'); r=await save(); assert.deepEqual([r.status,r.body.error],[400,'thinking-unsupported']); assert.match(await receipt(),/^thinking-unsupported: .* Draft kept\.$/); assert.deepEqual([await value('pm','harness'),await value('pm','thinking')],['claude-code','minimal']); // A stale revision: the human narrows plugins.harness to pi outside the page, so the page's read // is old, and a save on it is refused. await reload(); const {revision}=await call('plugins'); await call('plugins.set',{revision,plugins:{harness:['pi']}}); const narrowed=readFileSync(file,'utf8'); await set('cto','model','claude-sonnet-5-5'===(await value('cto','model'))?'claude-opus-5-5':'claude-sonnet-5-5'); r=await save(); assert.deepEqual([r.status,r.body.error],[409,'revision-conflict']); assert.match(await receipt(),/^revision-conflict: the business file changed since you read it\. Read the routes again\. Nothing changed\. Draft kept\.$/); // Read again: the coder's harness is now off, shown as such, and a thinking change on that route // is refused harness-off. await reload(); assert.equal(await shown('coder','harness'),'claude-code (not switched on)'); await set('coder','thinking','high'); r=await save(); assert.deepEqual([r.status,r.body.error],[400,'harness-off']); assert.match(await receipt(),/^harness-off: .*`mosaic plugins set` changes\. Nothing changed\. Draft kept\.$/); assert.equal(await value('coder','thinking'),'high'); // Nothing changed is caught on the page and posts nothing. await reload(); await b.evaluate('document.querySelector("#routes-form").requestSubmit();true'); await wait('document.querySelector("#routes-receipt")?.textContent==="Nothing changed."'); assert.equal(await b.evaluate('posts.length'),sent); assert.equal(readFileSync(file,'utf8'),narrowed,'no refused save wrote the file'); assert.notEqual(narrowed,unchanged); }finally{await b.close();await close(web);await close(writer);} }); test('Routing offers catalogue models only for compatible harnesses and saves a generated entry',{timeout:60000},async t=>{ const root=tmp(t,'provider-route-'),f=fixture(root,'acme',doc=>{doc.plugins['model-provider']=['cliproxy'];return doc;}); const dir=join(root,'config'),file=join(dir,'businesses','acme.json'); const offered={key:'zai-fixture-id',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-id',label:'z.ai / fixture-id',available:true,reason:null,harnesses:['pi']}; const modelCatalogue={read:async()=>({status:'ready',observedAt:'2026-11-06T00:00:00.000Z',models:[offered],notices:[]}),invalidate(){}}; const host=await startBroker({dataRoot:f.dataRoot,businesses:{acme:{id:'acme',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir,rolesDir:f.roles,system:systemFor(root)},modelCatalogue});t.after(()=>host.close()); const human=host.broker.bindHuman({business:'acme',human:'jason',via:'cli',outsideAgent:true}); const call=async(verb,args)=>structuredClone(await host.broker.request(human,{verb,args})); const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},routes:()=>call('routes')}; const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});t.after(()=>close(web)); const b=await browser();t.after(()=>b.close()); await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const original=window.fetch;window.posts=[];window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:true}));if(url==='/api/routes'&&options.method==='POST'){posts.push(JSON.parse(options.body));return new Promise(r=>window.answer=(status,body)=>r(new Response(JSON.stringify(body),{status})));}return original(url,options);};`}); await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`); const cell=(name,key)=>`document.querySelector(${JSON.stringify(`[data-route='${JSON.stringify([name,key])}']`)})`; const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed');})()`); await wait(`!!${cell('coder','model')}`); assert.equal(await b.evaluate(`[...${cell('coder','model')}.options].some(o=>o.value==='zai-fixture-id')`),true); await b.evaluate(`${cell('coder','harness')}.value='claude-code';${cell('coder','harness')}.dispatchEvent(new Event('change',{bubbles:true}));true`); await wait(`!([...${cell('coder','model')}.options].some(o=>o.value==='zai-fixture-id'))`); assert.equal(await b.evaluate(`[...${cell('coder','model')}.options].some(o=>o.value==='zai-fixture-id')`),false); await b.evaluate(`${cell('coder','harness')}.value='pi';${cell('coder','harness')}.dispatchEvent(new Event('change',{bubbles:true}));true`); await wait(`[...${cell('coder','model')}.options].some(o=>o.value==='zai-fixture-id')`); await b.evaluate(`${cell('coder','model')}.value='zai-fixture-id';${cell('coder','model')}.dispatchEvent(new Event('change',{bubbles:true}));document.querySelector('#routes-form').requestSubmit();true`); await wait('posts.length===1'); const payload=await b.evaluate('posts[0]'); const result=await call('routes.set',payload);await b.evaluate(`window.answer(200,${JSON.stringify(result)});true`); assert.equal(JSON.parse(readFileSync(file,'utf8')).launch.models['zai-fixture-id'].model,'fixture-id'); }); test('Routing switch writes the model-provider setting and keeps other modules', {timeout:60000}, async t => { const root=tmp(t,'provider-switch-'), f=fixture(root,'acme'); const dir=join(root,'config'),file=join(dir,'businesses','acme.json'); const host=await startBroker({dataRoot:f.dataRoot,businesses:{acme:{id:'acme',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir,rolesDir:f.roles,system:systemFor(root)}});t.after(()=>host.close()); const human=host.broker.bindHuman({business:'acme',human:'jason',via:'cli',outsideAgent:true}); const call=async(verb,args)=>structuredClone(await host.broker.request(human,{verb,args})); const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},routes:()=>call('routes')}; const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});t.after(()=>close(web)); const b=await browser();t.after(()=>b.close()); const settings=await call('plugins'); await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const original=window.fetch;window.posts=[];window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:true}));if(url==='/api/proxy/plugins')return new Response(JSON.stringify(${JSON.stringify(settings)}));if(url==='/api/proxy/plugins/set'){posts.push(JSON.parse(options.body));return new Promise(r=>window.answer=(status,body)=>r(new Response(JSON.stringify(body),{status})));}return original(url,options);};`}); await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`); const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed');})()`); await wait('document.querySelector("#provider-switch")?.textContent.startsWith("Enable")'); await b.evaluate('document.querySelector("#provider-switch").click();true'); await wait('posts.length===1');const payload=await b.evaluate('posts[0]'); assert.deepEqual(payload.plugins,{'model-provider':['cliproxy']}); const before=JSON.parse(readFileSync(file,'utf8')).plugins; const result=await call('plugins.set',payload);await b.evaluate(`window.answer(200,${JSON.stringify(result)});true`); const after=JSON.parse(readFileSync(file,'utf8')).plugins; assert.deepEqual(after,{...before,'model-provider':['cliproxy']}); }); test('Routing offers a catalogue model absent from an otherwise valid legacy model table',{timeout:60000},async t=>{ const offered={key:'fixture-provider-model',module:'fixture',upstream:'fixture',provider:'fixture',model:'fixture-model',label:'Fixture model',available:true,reason:null,harnesses:['pi']}; const view={business:'acme',revision:'a'.repeat(64),harnesses:['pi'],models:['legacy'],providerModels:{},levels:{pi:['low']},instances:{coder:{harness:'pi',model:'legacy',thinking:'low',fallback:null}},catalogue:{status:'ready',observedAt:'2026-11-06T00:00:00.000Z',models:[offered],notices:[],legacy:{}}}; const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('fixture');},routes:async()=>view}; const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});t.after(()=>close(web)); const b=await browser();t.after(()=>b.close());await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`); const options=await b.evaluate(`(async()=>{for(let n=0;n<200;n++){const el=document.querySelector('select[aria-label="coder model"]');if(el)return [...el.options].map(o=>o.value);await new Promise(r=>setTimeout(r,20));}throw Error('routing missing');})()`); assert.ok(options.includes('fixture-provider-model'),'the offered model is missing from Routing'); }); test('Routing marks a saved legacy selection unavailable and filters incompatible legacy models',{timeout:60000},async t=>{ const view={business:'acme',revision:'a'.repeat(64),harnesses:['pi','claude-code'],models:['legacy-missing','legacy-other'],providerModels:{},levels:{pi:['low'],'claude-code':['low']},instances:{coder:{harness:'claude-code',model:'legacy-missing',thinking:'low',fallback:null}},catalogue:{status:'ready',observedAt:'2026-11-06T00:00:00.000Z',models:[{key:'fixture-other',module:'fixture',upstream:'fixture',provider:'fixture',model:'fixture-other',label:'Fixture',available:true,reason:null,harnesses:['pi']}],notices:[],legacy:{'legacy-missing':{module:'fixture',upstream:'fixture',model:'fixture-missing'},'legacy-other':{module:'fixture',upstream:'fixture',model:'fixture-other'}}}}; const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:{messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('fixture');},routes:async()=>view},reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});t.after(()=>close(web)); const b=await browser();t.after(()=>b.close());await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`); const options=await b.evaluate(`(async()=>{for(let n=0;n<200;n++){const el=document.querySelector('select[aria-label="coder model"]');if(el)return [...el.options].map(o=>({value:o.value,label:o.textContent}));await new Promise(r=>setTimeout(r,20));}throw Error('routing missing');})()`); assert.deepEqual(options,[{value:'legacy-missing',label:'legacy-missing (unavailable)'}]); }); test('enabling a discovered harness refreshes Routing and offers its compatible model', {timeout:60000}, async t => { const root=tmp(t,'harness-switch-'),f=fixture(root,'acme',doc=>{doc.plugins.harness=['claude-code'];doc.plugins['model-provider']=['cliproxy'];for(const r of Object.values(doc.roles)){r.vars.harness='claude-code';delete r.vars['fallback.harness'];delete r.vars['fallback.model'];delete r.vars['fallback.thinking'];}return doc;}); const dir=join(root,'config'); const offered={key:'fixture-pi',module:'cliproxy',upstream:'zai',provider:'zai',model:'fixture-pi',label:'Fixture pi',available:true,reason:null,harnesses:['pi']}; const modelCatalogue={read:async()=>({status:'ready',observedAt:'2026-11-07T02:24:02.021Z',models:[offered],notices:[]}),invalidate(){}}; const host=await startBroker({dataRoot:f.dataRoot,businesses:{acme:{id:'acme',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir,rolesDir:f.roles,system:systemFor(root)},modelCatalogue});t.after(()=>host.close()); const human=host.broker.bindHuman({business:'acme',human:'jason',via:'cli',outsideAgent:true});const call=async(verb,args)=>structuredClone(await host.broker.request(human,{verb,args})); const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:{messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('fixture')},routes:()=>call('routes')},reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});t.after(()=>close(web)); const b=await browser();t.after(()=>b.close());const settings=await call('plugins'); await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const original=window.fetch;window.posts=[];window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:true}));if(url==='/api/proxy/plugins')return new Response(JSON.stringify(${JSON.stringify(settings)}));if(url==='/api/proxy/plugins/set'){posts.push(JSON.parse(options.body));return new Promise(r=>window.answer=(status,body)=>r(new Response(JSON.stringify(body),{status})));}return original(url,options);};`}); await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`); const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`); await wait('!!document.querySelector("[data-harness=pi]")'); await b.evaluate('document.querySelector("[data-harness=pi]").click();document.querySelector("#harness-save").click();true');await wait('posts.length===1'); const payload=await b.evaluate('posts[0]');assert.deepEqual(payload.plugins.harness,['claude-code','pi']);assert.equal(payload.revision,settings.revision); const result=await call('plugins.set',payload);await b.evaluate(`window.answer(200,${JSON.stringify(result)});true`); const cell=(name,key)=>`document.querySelector(${JSON.stringify(`[data-route='${JSON.stringify([name,key])}']`)})`; await wait(`[...${cell('coder','harness')}.options].some(o=>o.value==='pi')`); await b.evaluate(`${cell('coder','harness')}.value='pi';${cell('coder','harness')}.dispatchEvent(new Event('change',{bubbles:true}));true`); await wait(`[...${cell('coder','model')}.options].some(o=>o.value==='fixture-pi')`); assert.match(await b.evaluate('document.querySelector("#harness-status").textContent'),/^Harnesses saved\./); await b.evaluate('document.querySelector("#routes-reload").click();true');await wait('document.querySelector("#routes-reload")?.hidden'); await b.evaluate('document.querySelector("[data-harness=claude-code]").click();document.querySelector("#harness-save").click();true'); await wait('document.querySelector("#harness-status")?.textContent.includes("saved route")'); assert.equal(await b.evaluate('posts.length'),1,'a harness used by a saved route is not switched off'); }); test('quick fallback edits survive the harness redraw', {timeout:60000}, async t => { const view={business:'acme',revision:'a'.repeat(64),harnesses:['pi','claude-code'],models:['first','second'],providerModels:{},levels:{pi:['low','high'],'claude-code':['low','high']},instances:{coder:{harness:'pi',model:'first',thinking:'low',fallback:{harness:'pi',model:'first',thinking:'low'}}},catalogue:{status:'disabled'}}; const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:{messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('fixture')},routes:async()=>view},reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});t.after(()=>close(web));const b=await browser();t.after(()=>b.close()); await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const original=window.fetch;window.fetch=(u,o)=>u==='/api/config'?Promise.resolve(new Response(JSON.stringify({send:true}))):original(u,o);`});await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`); const cell=key=>`document.querySelector(${JSON.stringify(`[data-route='${JSON.stringify(['coder',key])}']`)})`; await b.evaluate(`(async()=>{for(let n=0;n<200&&!${cell('fallback.harness')};n++)await new Promise(r=>setTimeout(r,30));})()`); await b.evaluate(`(async()=>{for(let n=0;n<200&&document.querySelector('#routes-form button[type=submit]').disabled;n++)await new Promise(r=>setTimeout(r,30));})()`);await b.viewport(390,900);assert.equal(await b.evaluate(`getComputedStyle(document.querySelector('.route-actions')).position`),'fixed');assert.equal(await b.evaluate(`getComputedStyle(document.querySelector('#routes-reload')).display`)!=='none',true);await b.viewport(1440,1000);assert.notEqual(await b.evaluate(`getComputedStyle(document.querySelector('.route-actions')).position`),'fixed'); // Inject the two quick edits after the next render has built its markup but before it paints. await b.evaluate(`{const status=document.querySelector('#fresh-bus'),descriptor=Object.getOwnPropertyDescriptor(Element.prototype,'innerHTML');let armed=true;Object.defineProperty(status,'innerHTML',{configurable:true,get(){return descriptor.get.call(this)},set(value){descriptor.set.call(this,value);if(!armed)return;armed=false;for(const [e,v] of [[${cell('fallback.model')},'second'],[${cell('fallback.thinking')},'high']]){e.value=v;e.dispatchEvent(new Event('change',{bubbles:true}));}}});const e=${cell('fallback.harness')};e.value='claude-code';e.dispatchEvent(new Event('change',{bubbles:true}));}true`); await b.evaluate('new Promise(r=>setTimeout(r,500))'); assert.deepEqual(await b.evaluate(`[${cell('fallback.harness')}.value,${cell('fallback.model')}.value,${cell('fallback.thinking')}.value]`),['claude-code','second','high']); }); // D3 (P0-4): an organization on the fixture provider with cliproxy listed and no model proxy reads // an unavailable catalogue. The page says a thinking change still saves, the switch reads Disable // from the cliproxy module itself, and pressing it removes cliproxy only. A fixture-only // organization's catalogue is ready with no proxy, and its switch reads Enable. test('D3 Routing: with cliproxy listed and no model proxy the switch reads Disable and removes cliproxy only; a fixture-only org reads Enable', {timeout:90000}, async t => { for (const [providers, label, line, next] of [ [['fixture','cliproxy'], 'Disable', /Model catalogue unavailable\. A change to thinking alone still saves\. A new model needs the catalogue: set up the model proxy, or, when this organization has none, press Disable model proxy catalogue\./, ['fixture']], [['fixture'], 'Enable', /^Model catalogue, observed /, ['fixture','cliproxy']], ]) { const root=tmp(t,'d3-switch-'), f=fixture(root,'acme',doc=>{doc.plugins['model-provider']=providers;return doc;}); const dir=join(root,'config'),file=join(dir,'businesses','acme.json'); // No modelCatalogue stand-in and no proxy: the broker's own catalogue service answers. const host=await startBroker({dataRoot:f.dataRoot,businesses:{acme:{id:'acme',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:{dir,rolesDir:f.roles,system:systemFor(root)}});t.after(()=>host.close()); const human=host.broker.bindHuman({business:'acme',human:'jason',via:'cli',outsideAgent:true}); const call=async(verb,args)=>structuredClone(await host.broker.request(human,{verb,args})); const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},routes:()=>call('routes')}; const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});t.after(()=>close(web)); const b=await browser();t.after(()=>b.close()); const settings=await call('plugins'); await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const original=window.fetch;window.posts=[];window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:true}));if(url==='/api/proxy/plugins')return new Response(JSON.stringify(${JSON.stringify(settings)}));if(url==='/api/proxy/plugins/set'){posts.push(JSON.parse(options.body));return new Promise(r=>window.answer=(status,body)=>r(new Response(JSON.stringify(body),{status})));}return original(url,options);};`}); await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`); const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed');})()`); await wait(`document.querySelector("#provider-switch")?.textContent.startsWith(${JSON.stringify(label)})&&!!document.querySelector('[data-harness]')`); const info=await b.evaluate('document.querySelector(".routing-panel > p.small")?.textContent'); assert.match(info,line,providers.join(',')); await b.evaluate('document.querySelector("#provider-switch").click();true'); await wait('posts.length===1');const payload=await b.evaluate('posts[0]'); assert.deepEqual(payload.plugins,{'model-provider':next},providers.join(',')); const result=await call('plugins.set',payload);await b.evaluate(`window.answer(200,${JSON.stringify(result)});true`); assert.deepEqual(JSON.parse(readFileSync(file,'utf8')).plugins['model-provider'],next); await b.close(); } }); // D3 review (finding 1, 3): after a catalogue refusal the receipt names Disable model proxy // catalogue only when pressing it can make the save apply: cliproxy is listed and the model the // change moves to comes from another listed module. A model from the proxy, or a list without // cliproxy (Disable already pressed), gets no Disable step; a tester is sent to the owner. test('D3 Routing: a catalogue refusal names Disable only when it can make the save apply, and sends a tester to the owner', {timeout:90000}, async t => { const refusal=code=>({error:code,message:`${code}: the reason. Nothing changed.`,sent:false}); for (const [providers, tester, instance, code, expect] of [ [['fixture','cliproxy'], false, 'coder', 'model-catalogue-unavailable', /Nothing changed\. This change needs no model proxy: press Disable model proxy catalogue, then save again\. Draft kept\.$/], [['fixture','cliproxy'], false, 'reviewer', 'model-catalogue-unavailable', /Nothing changed\. Draft kept\.$/], [['fixture'], false, 'reviewer', 'model-provider-unavailable', /Nothing changed\. Draft kept\.$/], [['fixture','cliproxy'], true, 'coder', 'model-catalogue-unavailable', /Nothing changed\. Ask the organization's owner to set up the model proxy, or to disable the model proxy catalogue when the change needs no proxy\. Draft kept\.$/], ]) { const label=`${providers.join(',')} tester=${tester} ${instance}`; const view={business:'acme',revision:'a'.repeat(64),harnesses:['pi','claude-code'],models:['fixture-deterministic','proxy-x'], providerModels:{'fixture-deterministic':{module:'fixture',upstream:'fixture',model:'fixture-deterministic'},'proxy-x':{module:'cliproxy',upstream:'u',model:'x'}}, levels:{pi:['off','low'],'claude-code':['off','low']}, instances:{coder:{harness:'pi',model:'fixture-deterministic',thinking:'off',fallback:null},reviewer:{harness:'pi',model:'proxy-x',thinking:'off',fallback:null}}, catalogue:{status:'unavailable',models:[],notices:[]}}; const plugins={revision:'b'.repeat(64),plugins:{harness:['pi','claude-code'],'model-provider':providers},available:{harness:['pi','claude-code']}}; const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:{messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('fixture')},routes:async()=>view},reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});t.after(()=>close(web)); const b=await browser();t.after(()=>b.close()); await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const original=window.fetch;window.posts=[];window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:${!tester},tester:${tester}}));if(url==='/api/proxy/plugins')return new Response(JSON.stringify(${JSON.stringify(plugins)}));if(url==='/api/routes'&&options.method==='POST'){posts.push(JSON.parse(options.body));return new Response(JSON.stringify(${JSON.stringify(refusal(code))}),{status:502});}return original(url,options);};`}); await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`); const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`); const cell=key=>`document.querySelector(${JSON.stringify(`[data-route='${JSON.stringify([instance,key])}']`)})`; await wait(`${cell('harness')}&&!document.querySelector('#routes-form button[type=submit]').disabled${tester?'':"&&!!document.querySelector('[data-harness]')"}`); // The harness change moves the instance's route, so the bus would ask the catalogue. await b.evaluate(`{const e=${cell('harness')};e.value='claude-code';e.dispatchEvent(new Event('change',{bubbles:true}));}true`); await wait(`${cell('harness')}.value==='claude-code'`); await b.evaluate(`document.querySelector('#routes-form').requestSubmit();true`); await wait('posts.length===1&&document.querySelector("#routes-receipt").textContent.includes("Draft kept")'); assert.deepEqual(await b.evaluate('posts[0].routes'),{[instance]:{harness:'claude-code',model:view.instances[instance].model,thinking:'off',fallback:null}},label); const text=await b.evaluate('document.querySelector("#routes-receipt").textContent'); assert.match(text,new RegExp(`^${code}: the reason\\. `),label); assert.match(text,expect,label); await b.close();await close(web); } });