The catalogue refusals no longer tell every user to press Disable model proxy catalogue. The server text names the proxy setup step. The Routing page adds the Disable step only when cliproxy is listed and every moved model comes from another listed provider, and sends a tester to the owner. The switch mismatch message no longer claims a setting changed. Tests: a fallback thinking-only change saves with no catalogue; a move between model-proxy models refuses before and after cliproxy is removed.
340 lines
35 KiB
JavaScript
340 lines
35 KiB
JavaScript
// 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);
|
|
}
|
|
});
|