Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1fc96f4ae | ||
|
|
967025f749 | ||
|
|
70504ddddb | ||
|
|
c3293ef4ef |
No files matched your search
@@ -22,9 +22,12 @@ export function createModelCatalogue({ client, settings, pluginDataRoot = null,
|
||||
const loader = makeLoader({ log: () => {}, dataRoot:pluginDataRoot });
|
||||
const result = await loader.load({ 'model-provider': names });
|
||||
if (result.refused.length || loader.snapshot('model-provider').length !== names.length) throw Error();
|
||||
const c = client(); if (!c) throw Error();
|
||||
// A module that needs no model proxy (fixture) answers without one; a module
|
||||
// that asks the proxy while none is configured gets a refusal and reads unavailable.
|
||||
const c = client();
|
||||
const proxy = method => async (...args) => { if (!c) throw Error('model proxy not configured'); return c[method](...args); };
|
||||
const answers = await Promise.all(loader.snapshot('model-provider').map(async ({ name, module }) => {
|
||||
const answer = await module.catalogue({ listAccounts: () => c.getCredentials(), listModels: name => c.getCredentialModels(name), listGroups: () => c.getUpstreamGroups(), now: () => new Date(now()).toISOString() });
|
||||
const answer = await module.catalogue({ listAccounts: proxy('getCredentials'), listModels: proxy('getCredentialModels'), listGroups: proxy('getUpstreamGroups'), now: () => new Date(now()).toISOString() });
|
||||
if (answer?.status === 'unavailable' && answer.reason === 'model-key-collision') throw Object.assign(Error(), { code: 'model-key-collision' });
|
||||
if (answer?.status !== 'ready' || !Array.isArray(answer.models) || answer.models.length > 10000 || !Array.isArray(answer.notices) || answer.notices.length > 100) throw Error();
|
||||
for (const m of answer.models) {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// D3 (P0-4, readiness-1111): an organization on the fixture model provider, with no model proxy
|
||||
// configured, changes routes and thinking, takes limits.set and starts a hire. The real catalogue
|
||||
// service runs here (no modelCatalogue stand-in), so a fixture-only organization reads ready
|
||||
// without a proxy client. A thinking-only change saves while the catalogue is unavailable, a
|
||||
// change that needs the proxy still refuses, and removing cliproxy (the Routing page's Disable)
|
||||
// makes the next save apply.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { busOrganization } from '../src/organization-context.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { createModelCatalogue } from '../src/model-catalogue.mjs';
|
||||
import { loadBusiness } from '../../organization/src/organization-config.mjs';
|
||||
import { resolveInstance } from '../../organization/src/resolve.mjs';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
import { FIXTURE_ENTRY, FIXTURE_KEY_NAME } from '../../plugins/model-provider/fixture/index.mjs';
|
||||
import { approveTemplateHire } from './hire-fixtures.mjs';
|
||||
|
||||
function busForm(root, rolesDir, id = 'demo') {
|
||||
const b = loadBusiness(id, { dir: root, rolesDir });
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(root), business: b, instance });
|
||||
return busOrganization(b, resolved);
|
||||
}
|
||||
|
||||
async function setup(t, providers = ['fixture'], edit = () => {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'd3-fixture-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = businessDoc(root, 'demo');
|
||||
doc.plugins = { harness: ['pi'], 'model-provider': providers };
|
||||
doc.launch.models[FIXTURE_KEY_NAME] = { ...FIXTURE_ENTRY };
|
||||
doc.roles.coder.vars = { harness: 'pi', model: FIXTURE_KEY_NAME, thinking: 'off', 'limits.network': 'none' };
|
||||
// The audit's instance: the assistant on the fixture route.
|
||||
doc.roles.assistant = { definition: 'assistant', vars: { harness: 'pi', model: FIXTURE_KEY_NAME, thinking: 'off' } };
|
||||
edit(doc);
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
// No proxy option: the runtime has no model proxy client and no proxy lock.
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busForm(root, rolesDir) }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(() => bus.close());
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const ask = (verb, args) => Promise.resolve().then(() => bus.broker.request(human, { verb, args }));
|
||||
const saved = () => JSON.parse(readFileSync(file, 'utf8'));
|
||||
return { root, rolesDir, file, bus, ask, saved };
|
||||
}
|
||||
const route = (thinking, model = FIXTURE_KEY_NAME) => ({ harness: 'pi', model, thinking, fallback: null });
|
||||
// readiness-1111 evidence patch-low.json: `mosaic limits set --file patch-low.json`.
|
||||
const patch = (revision, thinking) => ({ revision, assistants: { assistant: { definition: 'assistant', vars: { harness: 'pi', model: FIXTURE_KEY_NAME, thinking }, tracker: null } } });
|
||||
|
||||
test('D3 a fixture-only organization with no model proxy reads a ready catalogue and saves a thinking change and a fixture route', async t => {
|
||||
const f = await setup(t);
|
||||
const view = await f.ask('routes');
|
||||
assert.equal(view.catalogue.status, 'ready');
|
||||
assert.deepEqual(view.catalogue.models.map(m => m.key), [FIXTURE_KEY_NAME]);
|
||||
const thinking = await f.ask('routes.set', { revision: view.revision, routes: { coder: route('low') } });
|
||||
assert.ok(thinking.transaction);
|
||||
assert.equal(f.saved().roles.coder.vars.thinking, 'low');
|
||||
// A fixture route change: the reviewer moves onto the fixture model.
|
||||
const moved = await f.ask('routes.set', { revision: thinking.revision, routes: { reviewer: route('off') } });
|
||||
assert.ok(moved.transaction);
|
||||
assert.equal(f.saved().roles.reviewer.vars.model, FIXTURE_KEY_NAME);
|
||||
});
|
||||
|
||||
test('D3 limits.set takes the audit thinking patch on a fixture-only organization with no model proxy', async t => {
|
||||
const f = await setup(t);
|
||||
const { revision } = await f.ask('limits');
|
||||
const result = await f.ask('limits.set', patch(revision, 'low'));
|
||||
assert.ok(result.transaction);
|
||||
assert.equal(f.saved().roles.assistant.vars.thinking, 'low');
|
||||
});
|
||||
|
||||
test('D3 hire start on a fixture-only organization with no model proxy writes the hire', async t => {
|
||||
const f = await setup(t);
|
||||
const hired = await approveTemplateHire(f.bus.broker, f.rolesDir, { name: 'sec-1', definition: 'chief-information-security-officer', model: FIXTURE_KEY_NAME });
|
||||
assert.ok(hired.transaction);
|
||||
assert.equal(f.saved().roles['sec-1'].vars.model, FIXTURE_KEY_NAME);
|
||||
});
|
||||
|
||||
test('D3 with cliproxy enabled and no model proxy, a thinking change saves, a proxy model refuses, and removing cliproxy lets a route change save', async t => {
|
||||
const f = await setup(t, ['fixture', 'cliproxy']);
|
||||
let view = await f.ask('routes');
|
||||
assert.equal(view.catalogue.status, 'unavailable');
|
||||
// Thinking only: same harness, model key and entry, so no catalogue is asked.
|
||||
const thinking = await f.ask('routes.set', { revision: view.revision, routes: { coder: route('low') } });
|
||||
assert.equal(f.saved().roles.coder.vars.thinking, 'low');
|
||||
const limits = await f.ask('limits');
|
||||
await f.ask('limits.set', patch(limits.revision, 'medium'));
|
||||
assert.equal(f.saved().roles.assistant.vars.thinking, 'medium');
|
||||
// A route change truly needs the catalogue, which the absent proxy makes unavailable.
|
||||
const before = readFileSync(f.file, 'utf8');
|
||||
view = await f.ask('routes');
|
||||
await assert.rejects(f.ask('routes.set', { revision: view.revision, routes: { reviewer: route('off') } }), { code: 'model-catalogue-unavailable' });
|
||||
await assert.rejects(f.ask('routes.set', { revision: view.revision, routes: { reviewer: route('off', 'zai-not-offered') } }), { code: 'model-catalogue-unavailable' });
|
||||
assert.equal(readFileSync(f.file, 'utf8'), before);
|
||||
assert.ok(thinking.transaction);
|
||||
// Disable model proxy catalogue: plugins.set without cliproxy.
|
||||
const settings = await f.ask('plugins');
|
||||
await f.ask('plugins.set', { revision: settings.revision, plugins: { 'model-provider': ['fixture'] } });
|
||||
view = await f.ask('routes');
|
||||
assert.equal(view.catalogue.status, 'ready');
|
||||
await f.ask('routes.set', { revision: view.revision, routes: { reviewer: route('off') } });
|
||||
assert.equal(f.saved().roles.reviewer.vars.model, FIXTURE_KEY_NAME);
|
||||
});
|
||||
|
||||
test('D3 the catalogue asks a module that needs no proxy even with no proxy client, and a module that asks the proxy reads unavailable', async () => {
|
||||
let names = ['fixture'];
|
||||
const service = createModelCatalogue({ client: () => null, settings: () => ({ 'model-provider': names }) });
|
||||
const ready = await service.read('demo', { fresh: true });
|
||||
assert.equal(ready.status, 'ready');
|
||||
assert.deepEqual(ready.models.map(m => [m.key, m.available]), [[FIXTURE_KEY_NAME, true]]);
|
||||
names = ['cliproxy'];
|
||||
assert.equal((await service.read('demo', { fresh: true })).status, 'unavailable');
|
||||
names = ['fixture', 'cliproxy'];
|
||||
const both = await service.read('demo', { fresh: true });
|
||||
assert.equal(both.status, 'unavailable');
|
||||
assert.ok(both.models.every(m => m.available === false));
|
||||
});
|
||||
|
||||
// D3 review (finding 1): Disable can't make a move between two model-proxy models apply, which is
|
||||
// why the Routing page names Disable only for a move onto a model from another listed module.
|
||||
test('D3 a move between model-proxy models refuses before and after cliproxy is removed, and writes nothing', async t => {
|
||||
const proxyEntry = model => ({ provider: 'proxy', model, module: 'cliproxy', upstream: 'claude' });
|
||||
const f = await setup(t, ['fixture', 'cliproxy'], doc => {
|
||||
doc.launch.models['claude-a'] = proxyEntry('claude-a');
|
||||
doc.launch.models['claude-b'] = proxyEntry('claude-b');
|
||||
doc.roles.reviewer.vars = { ...doc.roles.reviewer.vars, harness: 'pi', model: 'claude-a', thinking: 'off' };
|
||||
});
|
||||
const before = readFileSync(f.file, 'utf8');
|
||||
let view = await f.ask('routes');
|
||||
await assert.rejects(f.ask('routes.set', { revision: view.revision, routes: { reviewer: route('off', 'claude-b') } }), { code: 'model-catalogue-unavailable' });
|
||||
assert.equal(readFileSync(f.file, 'utf8'), before);
|
||||
const settings = await f.ask('plugins');
|
||||
await f.ask('plugins.set', { revision: settings.revision, plugins: { 'model-provider': ['fixture'] } });
|
||||
const disabled = readFileSync(f.file, 'utf8');
|
||||
view = await f.ask('routes');
|
||||
assert.equal(view.catalogue.status, 'ready');
|
||||
await assert.rejects(f.ask('routes.set', { revision: view.revision, routes: { reviewer: route('off', 'claude-b') } }), { code: 'model-provider-unavailable' });
|
||||
assert.equal(readFileSync(f.file, 'utf8'), disabled);
|
||||
assert.notEqual(disabled, before);
|
||||
});
|
||||
@@ -132,15 +132,18 @@ export function checkRoutes({ proposed, old = null, levels, catalogue = null })
|
||||
}
|
||||
const currentEntry = proposed.launch?.models?.[vars[model]];
|
||||
const previousEntry = old?.launch?.models?.[before[model]];
|
||||
const changed = [harness, model, thinking].some(field => vars[field] !== before[field]) || canonicalJson(currentEntry ?? null) !== canonicalJson(previousEntry ?? null);
|
||||
if (changed && previousEntry?.module && !currentEntry?.module && vars[model] === before[model]) throw new OrganizationError('model-provider-unavailable');
|
||||
if (changed && currentEntry?.module) {
|
||||
// D3 (P0-4): the catalogue answers which harness a model runs on, not its thinking level, so
|
||||
// a change that keeps the harness, the model key and its entry needs no catalogue; the
|
||||
// harness checks below still run on the new thinking level.
|
||||
const routeChanged = [harness, model].some(field => vars[field] !== before[field]) || canonicalJson(currentEntry ?? null) !== canonicalJson(previousEntry ?? null);
|
||||
if (routeChanged && previousEntry?.module && !currentEntry?.module && vars[model] === before[model]) throw new OrganizationError('model-provider-unavailable');
|
||||
if (routeChanged && currentEntry?.module) {
|
||||
if (catalogue?.status !== 'ready') throw new OrganizationError('model-provider-unavailable');
|
||||
const offered = catalogue.models?.find(m => m.key === vars[model]);
|
||||
if (!offered || !offered.available || !proposed.plugins?.['model-provider']?.includes(currentEntry.module) || canonicalJson(currentEntry) !== canonicalJson({ provider: offered.provider, model: offered.model, module: offered.module, upstream: offered.upstream })) throw new OrganizationError('model-provider-unavailable');
|
||||
if (!offered.harnesses.includes(vars[harness])) throw new OrganizationError('model-harness-incompatible');
|
||||
}
|
||||
if (changed && currentEntry && !currentEntry.module && proposed.plugins?.['model-provider']?.length) {
|
||||
if (routeChanged && currentEntry && !currentEntry.module && proposed.plugins?.['model-provider']?.length) {
|
||||
if (catalogue?.status !== 'ready') throw new OrganizationError('model-provider-unavailable');
|
||||
const mapping = catalogue.resolveLegacy ? catalogue.resolveLegacy(currentEntry) : catalogue.legacy?.[vars[model]];
|
||||
const offered = mapping && catalogue.models?.find(m => m.module === mapping.module && m.upstream === mapping.upstream && m.model === currentEntry.model);
|
||||
@@ -369,6 +372,10 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
|
||||
for (const [choice, prefix] of [[route, ''], [fallback, 'fallback.']]) {
|
||||
if (!choice) continue;
|
||||
const unchanged = ROUTE_FIELDS.every(field => priorVars[prefix + field] === choice[field]);
|
||||
// D3 (P0-4): a choice that keeps the harness and model key needs no catalogue, as in
|
||||
// checkRoutes; only its thinking level may differ. A key the catalogue offers as a
|
||||
// different model still refuses model-key-collision below on any change.
|
||||
const sameRoute = ['harness', 'model'].every(field => priorVars[prefix + field] === choice[field]);
|
||||
const offered = catalogue?.models?.find(m => m.key === choice.model);
|
||||
const existing = next.launch.models[choice.model];
|
||||
const entry = offered ? { provider: offered.provider, model: offered.model, module: offered.module, upstream: offered.upstream } : null;
|
||||
@@ -377,7 +384,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
|
||||
if (!existing && !offered) throw new OrganizationError('unknown-model', `${instance} variable ${prefix}model isn't a launch.models key`);
|
||||
if (existing && offered && canonicalJson(existing) !== canonicalJson(entry) && !unchanged) throw new OrganizationError('model-key-collision');
|
||||
if (!existing || existing.module) {
|
||||
if (unchanged) continue;
|
||||
if (sameRoute) continue;
|
||||
if (catalogue?.status !== 'ready') throw new OrganizationError('model-catalogue-unavailable');
|
||||
if (!offered || !offered.available || !Array.isArray(next.plugins?.['model-provider']) || !next.plugins['model-provider'].includes(offered.module)) throw new OrganizationError('model-provider-unavailable');
|
||||
if (!offered.harnesses.includes(choice.harness)) throw new OrganizationError('model-harness-incompatible');
|
||||
|
||||
@@ -267,3 +267,35 @@ test('a role can add a legacy model when the model-provider module is off', asyn
|
||||
const saved=settings.set('acme',{revision:view.revision,models,instances:{coder:'legacy-new'}},'chief-of-staff.chief');
|
||||
assert.equal(saved.instances.coder.modelKey,'legacy-new');
|
||||
});
|
||||
|
||||
test('D3 P0-4: a thinking-only change on a provider model saves with no catalogue, and a model or harness change still refuses', async () => {
|
||||
const f=fixture();const {settings}=await routeSettings(f);const doc=JSON.parse(readFileSync(f.file,'utf8'));
|
||||
doc.plugins['model-provider']=['fixture','cliproxy'];doc.launch.models['fixture-deterministic']={provider:'proxy',model:'fixture-deterministic',module:'fixture',upstream:'fixture'};
|
||||
doc.roles.coder.vars={...doc.roles.coder.vars,harness:'pi',model:'fixture-deterministic',thinking:'off'};writeJson(f.file,doc);
|
||||
const pick=(thinking,extra={})=>({coder:{harness:'pi',model:'fixture-deterministic',thinking,fallback:null,...extra}});
|
||||
for(const catalogue of [null,{status:'unavailable',models:[],notices:[]}]){
|
||||
const view=settings.routes('acme'),next=view.instances.coder.thinking==='low'?'high':'low';
|
||||
settings.setRoutes('acme',{revision:view.revision,routes:pick(next)},'jason',catalogue);
|
||||
assert.equal(JSON.parse(readFileSync(f.file,'utf8')).roles.coder.vars.thinking,next);
|
||||
}
|
||||
// A level the harness doesn't apply still refuses on a thinking-only change.
|
||||
assert.throws(()=>settings.setRoutes('acme',{revision:settings.routes('acme').revision,routes:pick('medium')},'jason',null),code('thinking-unsupported'));
|
||||
// A harness or model change truly needs the catalogue.
|
||||
const rev=settings.routes('acme').revision,before=readFileSync(f.file,'utf8');
|
||||
assert.throws(()=>settings.setRoutes('acme',{revision:rev,routes:pick('low',{harness:'claude-code'})},'jason',{status:'unavailable',models:[],notices:[]}),code('model-catalogue-unavailable'));
|
||||
assert.throws(()=>settings.set('acme',{revision:rev,instances:{reviewer:'fixture-deterministic'}},'jason',{status:'unavailable',models:[],notices:[]}),code('model-provider-unavailable'));
|
||||
assert.equal(readFileSync(f.file,'utf8'),before);
|
||||
});
|
||||
|
||||
test('D3 review: a fallback thinking-only change on a provider model saves with no catalogue, and a fallback harness change still refuses', async () => {
|
||||
const f=fixture();const {settings}=await routeSettings(f);const doc=JSON.parse(readFileSync(f.file,'utf8'));
|
||||
doc.plugins['model-provider']=['fixture','cliproxy'];doc.launch.models['fixture-deterministic']={provider:'proxy',model:'fixture-deterministic',module:'fixture',upstream:'fixture'};
|
||||
doc.roles.coder.vars={...doc.roles.coder.vars,harness:'pi',model:'fixture-deterministic',thinking:'off','fallback.harness':'pi','fallback.model':'fixture-deterministic','fallback.thinking':'off'};writeJson(f.file,doc);
|
||||
const unavailable={status:'unavailable',models:[],notices:[]};
|
||||
const pick=fallback=>({coder:{harness:'pi',model:'fixture-deterministic',thinking:'off',fallback:{harness:'pi',model:'fixture-deterministic',...fallback}}});
|
||||
settings.setRoutes('acme',{revision:settings.routes('acme').revision,routes:pick({thinking:'low'})},'jason',unavailable);
|
||||
assert.equal(JSON.parse(readFileSync(f.file,'utf8')).roles.coder.vars['fallback.thinking'],'low');
|
||||
const before=readFileSync(f.file,'utf8');
|
||||
assert.throws(()=>settings.setRoutes('acme',{revision:settings.routes('acme').revision,routes:pick({harness:'claude-code',thinking:'low'})},'jason',unavailable),code('model-catalogue-unavailable'));
|
||||
assert.equal(readFileSync(f.file,'utf8'),before);
|
||||
});
|
||||
@@ -42,6 +42,21 @@ const BUS_CODES = {
|
||||
'decision-closed': 'decision-closed', 'decision-stale': 'decision-stale', 'invalid-choice': 'decision-stale',
|
||||
'invalid-request': 'invalid-request', 'hire-owner-required':'hire-owner-required','right-required':'right-required','decision-expired':'decision-expired','hire-resolution-required':'hire-resolution-required',
|
||||
};
|
||||
// D4 (#1873, P1-2): a hire, fire or change start the bus definitely refused keeps its own code,
|
||||
// but only for codes the broker raises before it records the decision: the proposal checks in
|
||||
// hire-decisions.mjs prepare(), which #raiseHire runs before its INSERT. hire.start.console then
|
||||
// resolves the decision and applies it in #hireConsole, and those steps can raise other codes (and
|
||||
// some of the same generic ones) after the decision exists or is consumed, so every code not listed
|
||||
// here stays outcome-unknown. Review P3: an allowlist, not a pass-through of any well-formed code.
|
||||
const HIRE_START_REFUSALS = {
|
||||
'model-provider-unavailable': [409, 'The model proxy does not offer this model, or its catalogue is not ready. Pick a model it offers, or ask the owner to check Settings > Model proxy. Nothing was applied.'],
|
||||
'model-catalogue-unavailable': [503, 'The model catalogue could not be read, so this model could not be checked. Check Settings > Model proxy, then try again. Nothing was applied.'],
|
||||
'model-harness-incompatible': [409, 'This model does not run on the chosen harness. Pick another model or harness. Nothing was applied.'],
|
||||
'thinking-unsupported': [409, 'The chosen harness does not apply this thinking level. Clear Thinking or pick a level it applies. Nothing was applied.'],
|
||||
'definition-not-hireable': [409, 'This organization does not hire agents from this role template. Pick another template. Nothing was applied.'],
|
||||
'definition-shared': [409, 'Another agent uses the same role definition, so this change cannot replace it. Nothing was applied.'],
|
||||
'no-chief-of-staff': [409, 'This organization has no chief of staff, so a hire, fire or change cannot be checked against it. Nothing was applied.'],
|
||||
};
|
||||
const BODY_MAX = 4096;
|
||||
const TOKEN = /^[a-f0-9]{64}$/;
|
||||
const DIGEST = /^[a-f0-9]{64}$/;
|
||||
@@ -136,7 +151,13 @@ export async function decisionHTTP(req, res, { path, decide, session, live, conf
|
||||
if(step==='templates' && !Array.isArray(result?.templates))return refuse('confirmation-unavailable');
|
||||
line(step==='start'?'hire=recorded':'templates=read');
|
||||
return json(res,200,step==='start'?{id:result.id,op:result.op,choice:result.choice}:result);
|
||||
} catch(err) {return refuse(busCode(err,step==='start'?'outcome-unknown':'confirmation-unavailable'));}
|
||||
} catch(err) {
|
||||
if(step!=='start' || !(err instanceof BusReadError) || !Object.hasOwn(HIRE_START_REFUSALS,err.code))
|
||||
return refuse(busCode(err,step==='start'?'outcome-unknown':'confirmation-unavailable'));
|
||||
const [status,message]=HIRE_START_REFUSALS[err.code];
|
||||
line(`error=${err.code}`);
|
||||
return json(res,status,{error:err.code,message});
|
||||
}
|
||||
}
|
||||
if (step === 'confirmation') {
|
||||
if (Object.keys(value).some(k=>!['choice','adjust'].includes(k)) || typeof value.choice !== 'string') return refuse('invalid-request');
|
||||
|
||||
@@ -60,6 +60,10 @@ table.s1-roles td,table.s1-roles th { overflow-wrap:normal; word-break:normal; }
|
||||
table.s1-roles { min-width: 0; width: 100%; table-layout: fixed; }
|
||||
table.s1-roles td, table.s1-roles th { overflow-wrap: anywhere; }
|
||||
}
|
||||
/* D4 (#1873): the hire form on the org-mode workspace inbox. */
|
||||
html[data-organizations] #hire-start.ws-hire{margin-top:22px}
|
||||
html[data-organizations] #hire-start.ws-hire h2{margin:0 0 4px;font-size:16px}
|
||||
html[data-organizations] #hire-start.ws-hire select{min-height:40px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text);font:inherit;max-width:100%}
|
||||
/* A4: use the Console's controls for authority proposals and keep grant labels beside their checkboxes. */
|
||||
#hire-start form,#hire-adjust{max-width:680px}
|
||||
#hire-start form>label,#hire-adjust>label{display:grid;gap:5px;margin:12px 0;color:var(--text)}
|
||||
|
||||
@@ -427,20 +427,26 @@
|
||||
route:{harness:$('hire-route-harness').value.trim(),model:$('hire-route-model').value.trim(),...(thinking?{thinking}:{})}};
|
||||
}
|
||||
const HIRE_START_LABEL='Record and approve';
|
||||
let hireDraft={op:'hire',level:'org'},hireTemplates=[],hireBusy=false,hireStatus='';
|
||||
let hireDraft={op:'hire',level:'org'},hireTemplates=[],hireBusy=false,hireStatus='',hireCode='';
|
||||
// D4 (#1873, P0-1): an org Console shows this form on the workspace inbox. Its posts go to the
|
||||
// current organization's bus (display.js), so the organization is that one and is not editable.
|
||||
const hireOrg=()=>window.MosaicOrg?.id ?? null;
|
||||
function hireStartHtml() {
|
||||
if(!canDecide)return '';
|
||||
const d=hireDraft,off=hireBusy?' disabled':'';
|
||||
const input=(key,label,extra='')=>`<label>${label} <input data-hire-field="${key}" value="${esc(d[key]??'')}" maxlength="${key==='reason'?500:200}" ${extra}${off}></label>`;
|
||||
return `<section class="panel" id="hire-start"><h2>Hire, fire or change an agent</h2><form id="hire-start-form">
|
||||
${input('business','Organization','required')}<button type="button" class="btn" data-hire-load${off}>Read role templates</button>
|
||||
<label>Operation <select data-hire-field="op"${off}>${['hire','fire','change'].map(v=>`<option${d.op===v?' selected':''}>${v}</option>`).join('')}</select></label>
|
||||
<label>Level <select data-hire-field="level"${off}>${['org','project','root'].map(v=>`<option${d.level===v?' selected':''}>${v}</option>`).join('')}</select></label>
|
||||
const org=hireOrg(),d=hireDraft,off=hireBusy?' disabled':'';
|
||||
const input=(key,label,extra='')=>`<label>${label} <input data-hire-field="${key}" data-focus-key="hire:${key}" value="${esc(d[key]??'')}" maxlength="${key==='reason'?500:200}" ${extra}${off}></label>`;
|
||||
const select=(key,label,options)=>`<label>${label} <select data-hire-field="${key}" data-focus-key="hire:${key}"${off}>${options}</select></label>`;
|
||||
const business=org?`<label>Organization <input value="${esc(org)}" readonly aria-describedby="hire-start-org"></label><p class="small muted" id="hire-start-org">This hire goes to ${txt(org)}'s bus. Switch organization to hire elsewhere.</p>`:input('business','Organization','required');
|
||||
const status=`${txt(hireStatus)}${hireCode?` (<code>${txt(hireCode)}</code>)`:''}`;
|
||||
return `<section class="${org?'ws-panel ws-hire':'panel'}" id="hire-start" aria-labelledby="hire-start-h"><h2 id="hire-start-h">Hire, fire or change an agent</h2><form id="hire-start-form">
|
||||
${business}<button type="button" class="btn" data-hire-load data-focus-key="hire:load"${off}>Read role templates</button>
|
||||
${select('op','Operation',['hire','fire','change'].map(v=>`<option${d.op===v?' selected':''}>${v}</option>`).join(''))}
|
||||
${select('level','Level',['org','project','root'].map(v=>`<option${d.level===v?' selected':''}>${v}</option>`).join(''))}
|
||||
${input('project','Project (project level only)')}${input('name','Agent name','required')}
|
||||
<label>Role template <select data-hire-field="template"${off}><option value="">Choose a template</option>${hireTemplates.map(t=>`<option value="${esc(t.name)}"${d.template===t.name?' selected':''}>${txt(t.name)}</option>`).join('')}</select></label>
|
||||
${select('template','Role template',`<option value="">Choose a template</option>${hireTemplates.map(t=>`<option value="${esc(t.name)}"${d.template===t.name?' selected':''}>${txt(t.name)}</option>`).join('')}`)}
|
||||
${input('harness','Harness')}${input('model','Model')}${input('thinking','Thinking (optional)')}${input('reason','Reason','required')}
|
||||
<p>Submitting raises and approves one decision. Flagged grants and credentials require the owner; root agents require the instance owner.</p>
|
||||
<button type="submit" class="btn primary"${off}>${hireBusy?'Working…':HIRE_START_LABEL}</button><p id="hire-start-status" role="status">${txt(hireStatus)}</p></form></section>`;
|
||||
<button type="submit" class="btn primary" data-focus-key="hire:submit"${off}>${hireBusy?'Working…':HIRE_START_LABEL}</button><p id="hire-start-status" role="status">${status}</p></form></section>`;
|
||||
}
|
||||
function paintHireStart() {const e=$('hire-start');if(e)e.outerHTML=hireStartHtml();}
|
||||
document.addEventListener('input',e=>{const f=e.target.closest?.('[data-hire-field]');if(f)hireDraft[f.dataset.hireField]=f.value;});
|
||||
@@ -448,15 +454,15 @@
|
||||
async function hireSubmit(templatesOnly=false) {
|
||||
if(hireBusy || out() || !canDecide)return;
|
||||
const d={...hireDraft},template=hireTemplates.find(t=>t.name===d.template);
|
||||
if(!templatesOnly && d.op!=='fire' && !template){hireStatus='Read and choose a role template first.';paintHireStart();return;}
|
||||
if(!templatesOnly && d.op!=='fire' && !template){hireStatus='Read and choose a role template first.';hireCode='';paintHireStart();return;}
|
||||
const thinking=d.thinking?.trim(),hire={op:d.op,level:d.level,name:d.name,reason:d.reason,...(d.level==='project'?{project:d.project}:{}),...(d.op==='fire'?{}:{role:{template:template?.name,sha256:template?.sha256},route:{harness:d.harness,model:d.model,...(thinking?{thinking}:{})}})};
|
||||
hireBusy=true;hireStatus='';paintHireStart();
|
||||
const {res,json}=await decidePost(`/api/decisions/hire/${templatesOnly?'templates':'start'}`,{business:d.business,...(templatesOnly?{}:{hire})});
|
||||
hireBusy=true;hireStatus='';hireCode='';paintHireStart();
|
||||
const {res,json}=await decidePost(`/api/decisions/hire/${templatesOnly?'templates':'start'}`,{business:hireOrg() ?? d.business,...(templatesOnly?{}:{hire})});
|
||||
hireBusy=false;
|
||||
if(res?.type==='opaqueredirect'){signOut();return;}
|
||||
if(res?.ok && templatesOnly && Array.isArray(json?.templates)){hireTemplates=json.templates;hireStatus=hireTemplates.length?'Templates read.':'No templates available.';}
|
||||
else if(res?.ok && !templatesOnly && json?.op==='resolved'){hireStatus=`Recorded decision ${json.id}.`;}
|
||||
else hireStatus=decideRefusal(res,json,templatesOnly?'confirmation-unavailable':'outcome-unknown').message;
|
||||
else {const r=decideRefusal(res,json,templatesOnly?'confirmation-unavailable':'outcome-unknown');hireStatus=r.message;hireCode=r.code;}
|
||||
paintHireStart();
|
||||
}
|
||||
document.addEventListener('click',e=>{if(e.target.closest?.('[data-hire-load]'))hireSubmit(true);});
|
||||
@@ -1184,8 +1190,13 @@
|
||||
return select(name,key,[...new Set([...legacy,...offered.map(m=>m.key)])],c[key],(v.providerModels?.[c[key]]||enabled&&v.models.includes(c[key]))?'unavailable':'not in the model table',labels);};
|
||||
const thinking=(key,h)=>select(name,key,levelsOf(c[h]),c[key],c[h]?`not applied by ${c[h]}`:'no harness');
|
||||
return `<tr class="route-row${['fallback.harness','fallback.model','fallback.thinking'].some(k=>c[k])?' fallback-open':''}"><th scope="row">${txt(name)}${instanceInfo?.instances?.[name]?.definition?`<small class="route-definition">${txt(instanceInfo.instances[name].definition)}</small>`:''}</th><td data-label="Harness">${harness('harness')}</td><td data-label="Model">${model('model')}</td><td data-label="Thinking">${thinking('thinking','harness')}</td><td class="fallback-cell" data-label="Fallback harness"><button type="button" class="fallback-toggle">Set fallback</button>${harness('fallback.harness')}</td><td class="fallback-cell" data-label="Fallback model">${model('fallback.model')}</td><td class="fallback-cell" data-label="Fallback thinking">${thinking('fallback.thinking','fallback.harness')}</td></tr>`;}).join('');
|
||||
const catalogueInfo=v.catalogue?.status==='ready'?`<p class="small muted">Model proxy catalogue, observed ${txt(window.MosaicLocalTime(v.catalogue.observedAt))}.</p>${(v.catalogue.notices??[]).map(n=>`<p class="small warn">${txt(n)}</p>`).join('')}`:v.catalogue?.status==='unavailable'?'<p class="small warn">Model catalogue unavailable.</p>':'<p class="small muted">Model catalogue not enabled.</p>';
|
||||
const providerControl=canSend&&!isTester?`<button type="button" id="provider-switch" data-enabled="${v.catalogue?.status!=='disabled'}">${v.catalogue?.status!=='disabled'?'Disable':'Enable'} model proxy catalogue</button><p role="status" id="provider-switch-status"></p>`:'';
|
||||
// D3 (P0-4): the switch adds or removes the cliproxy module only, so its label follows that
|
||||
// module in the organization's model-provider list, not the whole catalogue's status (a
|
||||
// fixture-only catalogue is ready with no model proxy). Before the setting is read it falls
|
||||
// back to the catalogue status, as before.
|
||||
const proxyOn=harnessSettings?(Array.isArray(harnessSettings.plugins?.['model-provider'])?harnessSettings.plugins['model-provider']:[]).includes('cliproxy'):v.catalogue?.status!=='disabled';
|
||||
const catalogueInfo=v.catalogue?.status==='ready'?`<p class="small muted">Model catalogue, observed ${txt(window.MosaicLocalTime(v.catalogue.observedAt))}.</p>${(v.catalogue.notices??[]).map(n=>`<p class="small warn">${txt(n)}</p>`).join('')}`:v.catalogue?.status==='unavailable'?`<p class="small warn">Model catalogue unavailable. A change to thinking alone still saves.${proxyOn&&canSend&&!isTester?' A new model needs the catalogue: set up the model proxy, or, when this organization has none, press Disable model proxy catalogue.':''}</p>`:'<p class="small muted">Model catalogue not enabled.</p>';
|
||||
const providerControl=canSend&&!isTester?`<button type="button" id="provider-switch" data-enabled="${proxyOn}">${proxyOn?'Disable':'Enable'} model proxy catalogue</button><p role="status" id="provider-switch-status"></p>`:'';
|
||||
const harnessControl=canSend&&!isTester?`<fieldset class="harness-control"><legend>Harnesses</legend>${harnessSettings ? [...new Set([...harnessSettings.available.harness,...(harnessSettings.plugins.harness??[])])].map(name=>`<label><input type="checkbox" data-harness="${esc(name)}"${(harnessDraft??harnessSettings.plugins.harness??[]).includes(name)?' checked':''}${harnessBusy?' disabled':''}> ${txt(name)}</label>`).join('')+` <button type="button" id="harness-save"${harnessBusy?' disabled':''}>Save harnesses</button>` : '<p>Harnesses unavailable.</p><button type="button" id="harness-read">Read harnesses again</button>'}<p role="status" id="harness-status">${txt(harnessReceipt)}</p></fieldset>`:'';
|
||||
const off=v.harnesses.length?'':'<p class="small warn">No harness is switched on in the business file\'s plugins.harness, so every launch refuses harness-off and a save that changes a harness refuses too. Switch one on under Harnesses.</p>';
|
||||
return `<section class="panel routing-panel" aria-labelledby="routes-title"><span class="sr-only">${title}</span>${catalogueInfo}${providerControl}${harnessControl}${off}<form id="routes-form"><div class="table-wrap" tabindex="0" role="region" aria-label="Routes, scroll for all columns"><table id="routes-table"><thead><tr><th scope="col" rowspan="2">Agent</th><th scope="colgroup" colspan="3">Primary</th><th scope="colgroup" colspan="3">Fallback</th></tr><tr>${ROUTE_CELLS.map(([,l])=>`<th scope="col">${l.replace('Fallback ','')}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div><p class="small muted">${canSend || isTester?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><div class="settings-actions route-actions${routesDraft?' has-draft':''}"><button type="submit"${disabled}>${routesBusy?'<span class="settings-spinner" aria-hidden="true"></span> Saving…':'Save routes'}</button> <button type="button" id="routes-reload"${routesDraft?'':' hidden'}${routesBusy||!routesDraft?' disabled':''}>Discard draft</button><p role="status" id="routes-receipt" class="${routesReceipt.startsWith('Outcome unknown')?'banner ref':routesReceipt.includes('Draft kept')?'banner err':routesReceipt.includes('saved')?'settings-success':''}">${txt(routesReceipt)}</p></div>${routesApplyMarkup()}</form></section>`;
|
||||
@@ -1202,6 +1213,19 @@
|
||||
return {revision:routesView.revision,routes};
|
||||
}
|
||||
const unknownRoutes='Outcome unknown. Read the routes again before trying. Draft kept.';
|
||||
// D3 (P0-4): after a catalogue refusal, Disable is named only when it can make this save apply:
|
||||
// cliproxy is listed and every model the change moves to comes from another listed module.
|
||||
// A viewer without the switch is sent to the owner.
|
||||
function catalogueStep(code,routes){
|
||||
if(!['model-catalogue-unavailable','model-provider-unavailable'].includes(code))return '';
|
||||
if(!canSend||isTester)return ' Ask the organization\'s owner to set up the model proxy, or to disable the model proxy catalogue when the change needs no proxy.';
|
||||
const names=Array.isArray(harnessSettings?.plugins?.['model-provider'])?harnessSettings.plugins['model-provider']:[];
|
||||
if(!names.includes('cliproxy'))return '';
|
||||
const moved=Object.entries(routes).flatMap(([name,r])=>{const was=routesView?.instances?.[name]??{};
|
||||
return [[r.harness,r.model,was.harness,was.model],[r.fallback?.harness,r.fallback?.model,was.fallback?.harness,was.fallback?.model]].filter(([h,m,wh,wm])=>m&&(h!==wh||m!==wm)).map(([,m])=>m);});
|
||||
const module=key=>routesView?.providerModels?.[key]?.module;
|
||||
return moved.length&&moved.every(key=>module(key)&&module(key)!=='cliproxy'&&names.includes(module(key)))?' This change needs no model proxy: press Disable model proxy catalogue, then save again.':'';
|
||||
}
|
||||
const routesAnswer=r=>/^[a-f0-9]{64}$/.test(r?.revision??'')&&Array.isArray(r.harnesses)&&Array.isArray(r.models)&&[r.levels,r.instances].every(o=>o&&typeof o==='object'&&!Array.isArray(o));
|
||||
for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=e.target?.closest?.('#routes-form');if(!form||!routesView)return;routesDraft=routesFields(form);routesEditVersion++;markSettingsDraft('routing',true);form.querySelector('.settings-actions')?.classList.add('has-draft');const discard=$('routes-reload');if(discard){discard.hidden=false;discard.disabled=false;}
|
||||
// A harness change redraws the row so its thinking cell offers that harness's levels.
|
||||
@@ -1224,7 +1248,9 @@
|
||||
const button=e.target,status=$('provider-switch-status');if(routesDraft){status.textContent='Save or discard the route draft first.';return;}button.disabled=true;
|
||||
try {const read=await fetch('/api/proxy/plugins',{cache:'no-store'});const settings=await read.json();if(!read.ok||!/^[a-f0-9]{64}$/.test(settings.revision??''))throw Error('Could not read provider settings.');
|
||||
const names=Array.isArray(settings.plugins?.['model-provider'])?settings.plugins['model-provider']:[];
|
||||
const next=button.dataset.enabled==='true'?names.filter(n=>n!=='cliproxy'):[...new Set([...names,'cliproxy'])];
|
||||
// The fresh read decides: a stale label never turns the proxy module on when it meant off.
|
||||
const on=names.includes('cliproxy');if(String(on)!==button.dataset.enabled){button.dataset.enabled=String(on);button.textContent=`${on?'Disable':'Enable'} model proxy catalogue`;button.disabled=false;status.textContent='The switch now shows the current setting. Press it again to change it.';return;}
|
||||
const next=on?names.filter(n=>n!=='cliproxy'):[...names,'cliproxy'];
|
||||
const response=await fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:settings.revision,plugins:{'model-provider':next}}),cache:'no-store',redirect:'manual'});
|
||||
if(!response.ok)throw Error('Provider setting was refused.');harnessSettings=null;routesView=null;last.delete('routes');render(true);
|
||||
}catch(error){status.textContent=error.message;button.disabled=false;}
|
||||
@@ -1237,7 +1263,7 @@
|
||||
routesBusy=true;routesReceipt='';routesApply=null;render(false);
|
||||
try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
|
||||
if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;routesApply=await routesApplyRead(Object.keys(payload.routes));}
|
||||
else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'} Draft kept.`;}}
|
||||
else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'}${catalogueStep(result?.error,payload.routes)} Draft kept.`;}}
|
||||
catch{routesReceipt=unknownRoutes;}finally{routesBusy=false;if(!out())render(false);}
|
||||
});
|
||||
// Row 194 (#1692, D7): the Agents section lives in agents.js (window.MosaicAgents) and draws from
|
||||
@@ -1524,7 +1550,7 @@
|
||||
if (n === 0 && window.MosaicOrg) return window.MosaicWorkspace.home();
|
||||
if (a === 'projects' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.projectsPage(q);
|
||||
if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard();
|
||||
if (a === 'inbox' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.inboxPage(q);
|
||||
if (a === 'inbox' && n === 1 && window.MosaicOrg) return configRead.then(() => window.MosaicWorkspace.inboxPage(q, { hire: hireStartHtml }));
|
||||
if (a === 'tasks' && n === 1 && window.MosaicOrg) return window.MosaicTasksBoard.view();
|
||||
if (a === 'inbox' && n === 1) return inboxView(get);
|
||||
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b, current) : notFound(`${txt(b)} is not a decision id.`);
|
||||
|
||||
@@ -663,7 +663,10 @@
|
||||
}
|
||||
|
||||
// ---- inbox across organizations (sample 05) ----
|
||||
async function inboxPage(q) {
|
||||
// `opts.hire`: a function that returns the hire, fire and change form bus.js builds for the current
|
||||
// organization (D4, P0-1). It is called after the workspace read so typing or a submit result that
|
||||
// lands during the read is not painted over with an older draft.
|
||||
async function inboxPage(q, opts = {}) {
|
||||
const org = listParam(q, 'org', id => ORG_ID.test(id));
|
||||
const want = String(q?.get?.('d') || '');
|
||||
let body, all;
|
||||
@@ -722,7 +725,8 @@
|
||||
${unread}
|
||||
<div class="ws-ibx-grid${insp ? ' with-insp' : ''}"><div class="ws-ibx-list">${table}
|
||||
${secH('Answered today', 'kept for 24 hours, then in the trail')}
|
||||
<p class="ws-panel tight note" data-ws-answered>Answered and expired items are not built yet.</p></div>
|
||||
<p class="ws-panel tight note" data-ws-answered>Answered and expired items are not built yet.</p>
|
||||
${typeof opts?.hire === 'function' ? opts.hire() : ''}</div>
|
||||
${insp}
|
||||
</div>
|
||||
</section>`;
|
||||
|
||||
@@ -583,6 +583,13 @@ const ROUTE_REASONS = {
|
||||
'organization-unknown-instance': ['400', 'the business has no such instance'],
|
||||
'organization-no-change': ['400', 'the business file already says that'],
|
||||
'revision-conflict': ['409', 'the business file changed since you read it. Read the routes again'],
|
||||
// D3 (P0-4): the model refusals name the next step. Status stays the bus status these codes
|
||||
// already had (502). The Routing page adds Disable model proxy catalogue only when that switch
|
||||
// can make the save apply (bus.js catalogueStep).
|
||||
'model-catalogue-unavailable': ['502', 'the model catalogue is unavailable, so a model it supplies can\'t be checked. A change to thinking alone still saves; a model from the model proxy needs the proxy set up first'],
|
||||
'model-provider-unavailable': ['502', 'the model provider doesn\'t offer that model now. A change to thinking alone still saves; otherwise pick a model the catalogue lists, or set up the model proxy when the model comes from it'],
|
||||
'model-harness-incompatible': ['502', 'that model doesn\'t run on that harness. Pick a harness the model lists, or another model'],
|
||||
'model-key-collision': ['502', 'that model key already names a different model in the model table. Pick the table\'s entry or another key'],
|
||||
};
|
||||
const routeObject = o => o && typeof o === 'object' && !Array.isArray(o);
|
||||
const routesAnswer = v => /^[a-f0-9]{64}$/.test(v?.revision ?? '') && Array.isArray(v.harnesses) && Array.isArray(v.models) && routeObject(v.levels) && routeObject(v.instances);
|
||||
|
||||
@@ -292,3 +292,64 @@ test('A4 confirmation holds the exact human adjustment, so resolve cannot substi
|
||||
const r=await s.resolve({choice:'new/autonomous',confirmation:c.json().confirmation});
|
||||
assert.equal(r.status,200,r.text);assert.deepEqual(s.calls[1].args.adjust,adjust);
|
||||
});
|
||||
|
||||
// D4 (#1873, P1-2): a hire start the bus definitely refused before it recorded the decision keeps its
|
||||
// own code and a next step; every other code, a lost or unreadable reply among them, is still
|
||||
// outcome-unknown, and the other steps keep their mapping.
|
||||
const START_HIRE={op:'hire',level:'org',name:'scout',reason:'Need help',role:{template:'coder',sha256:DIGEST},route:{harness:'pi',model:'gone-model'}};
|
||||
const startWith=async err=>{
|
||||
const s=setup({resolve:()=>{throw err;}});
|
||||
const r=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:START_HIRE}}));
|
||||
assert.equal(s.calls.length,1,'one bus call, never retried');
|
||||
return {r,s};
|
||||
};
|
||||
test('D4 hire start: model-provider-unavailable keeps its code and names Settings > Model proxy',async()=>{
|
||||
const {r,s}=await startWith(new BusReadError('model-provider-unavailable'));
|
||||
assert.equal(r.status,409,r.text);
|
||||
const body=r.json();
|
||||
assert.equal(body.error,'model-provider-unavailable');
|
||||
assert.match(body.message,/Settings > Model proxy/);
|
||||
assert.match(body.message,/Nothing was applied/);
|
||||
assert.equal(r.headers['cache-control'],'no-store');
|
||||
assert.match(s.lines.at(-1),/step=start error=model-provider-unavailable$/);
|
||||
});
|
||||
test('D4 hire start: the other model route codes keep their code with a next step',async()=>{
|
||||
for(const [code,status] of [['model-catalogue-unavailable',503],['model-harness-incompatible',409],['thinking-unsupported',409]]) {
|
||||
const {r}=await startWith(new BusReadError(code));
|
||||
assert.equal(r.status,status,`${code}: ${r.text}`);assert.equal(r.json().error,code);assert.match(r.json().message,/Nothing was applied/);
|
||||
}
|
||||
});
|
||||
test('D4 hire start: the other proposal checks that run before the decision insert keep their code',async()=>{
|
||||
for(const code of ['definition-not-hireable','definition-shared','no-chief-of-staff']) {
|
||||
const {r}=await startWith(new BusReadError(code));
|
||||
assert.equal(r.status,409,`${code}: ${r.text}`);assert.equal(r.json().error,code);assert.match(r.json().message,/Nothing was applied/);
|
||||
assert.doesNotMatch(r.json().message,/\bhire was\b/,'worded for hire, fire and change alike');
|
||||
}
|
||||
});
|
||||
// Review P3: a code the broker can raise after the decision exists or is consumed, or one the bus
|
||||
// server raises about the request itself, is not a definite refusal of the start.
|
||||
test('D4 hire start: a code that can follow the decision insert, or a transport code, stays outcome-unknown',async()=>{
|
||||
for(const code of ['revision-conflict-revoked','request-timeout','invalid-envelope','incomplete-request','instance-kept','organization-instance-exists','organization-unknown-instance','root-fire-shared','fire-in-progress','some-future-code'])
|
||||
refused((await startWith(new BusReadError(code))).r,'outcome-unknown');
|
||||
});
|
||||
test('D4 hire start: a lost or unreadable reply is still outcome-unknown',async()=>{
|
||||
for(const err of [new BusReadError('outcome-unknown'),new BusReadError('invalid-response'),new BusReadError('response-too-large'),new BusReadError('outcome-unknown-revoked'),new BusReadError('Not A Code'),new BusReadError(''),new Error('socket closed'),Object.assign(new Error('x'),{code:'model-provider-unavailable'})])
|
||||
refused((await startWith(err)).r,'outcome-unknown');
|
||||
// A reply that does not say the hire resolved yes is not a refusal either.
|
||||
const s=setup({resolve:()=>({id:'new-hire',op:'resolved',choice:'no'})});
|
||||
refused(await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:START_HIRE}})),'outcome-unknown');
|
||||
});
|
||||
test('D4 hire start: the bus codes the endpoint already names keep their refusal',async()=>{
|
||||
refused((await startWith(new BusReadError('unauthenticated'))).r,'human-required');
|
||||
refused((await startWith(new BusReadError('right-required'))).r,'right-required');
|
||||
refused((await startWith(new BusReadError('hire-owner-required'))).r,'hire-owner-required');
|
||||
});
|
||||
test('D4 templates, confirmation and resolve keep their open mapping for a definite unnamed code',async()=>{
|
||||
const t=setup({resolve:()=>{throw new BusReadError('model-provider-unavailable');}});
|
||||
refused(await t.run('/api/decisions/hire/templates',request({body:{business:'demo'}})),'confirmation-unavailable');
|
||||
const c=setup({answer:()=>{throw new BusReadError('model-provider-unavailable');}});
|
||||
refused(await c.confirm('yes'),'confirmation-unavailable');
|
||||
const s=setup({resolve:()=>{throw new BusReadError('model-provider-unavailable');}});
|
||||
const ok=await s.confirm('yes');assert.equal(ok.status,200);
|
||||
refused(await s.resolve({choice:'yes',confirmation:ok.json().confirmation}),'outcome-unknown');
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// D4 (#1873, readiness P0-1 and P1-2): on an org Console the hire, fire and change form is on the
|
||||
// workspace inbox (#/inbox), posts through the real decision endpoint to the current organization's
|
||||
// bus, and shows a definite refusal by its own code and next step. A lost reply stays outcome-unknown.
|
||||
// The bus is a fake decision transport behind the real server, page code and decision endpoint.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
||||
const SHA = 'b'.repeat(64);
|
||||
const DECISION = 'dec-acme-0001';
|
||||
|
||||
// One running organization. `decide` gives it the send and decision transports a --send Console has.
|
||||
// `gate.held`, when set, is a promise the workspace inbox read waits on: a slow read for the repaint race.
|
||||
function organizations({ decide, calls, gate }) {
|
||||
const LIST = [{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true }];
|
||||
const inbox = [{ id: DECISION, question: 'Ship the hero?', class: 'gated', blocking: false, raised_by_role: 'coder', at: new Date(Date.now() - 600000).toISOString(), options: [{ key: 'yes', text: 'Ship it' }, { key: 'no', text: 'Hold' }] }];
|
||||
const reads = { inbox: async () => { gate.reads++; await gate.held; return inbox; }, agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], usage: async () => ({}), limits: async () => ({}), decisions: async () => [] };
|
||||
// The model name picks the bus answer: a refusal before the decision exists, a lost reply, or a hire.
|
||||
const decisionWrite = async (verb, args, ctx) => {
|
||||
calls.push({ verb, args, ctx });
|
||||
if (verb === 'hire.templates.console') return { templates: [{ name: 'coder', sha256: SHA }] };
|
||||
if (verb !== 'hire.start.console') throw new BusReadError('decision-not-found');
|
||||
if (args.route?.model === 'gone-model') throw new BusReadError('model-provider-unavailable');
|
||||
if (args.route?.model === 'lost-model') throw new BusReadError('outcome-unknown');
|
||||
return { id: 'dec-hire-1', op: 'resolved', choice: 'yes' };
|
||||
};
|
||||
return {
|
||||
list: async () => LIST,
|
||||
members: async () => LIST.map(org => ({ id: org.id, doc: { appearance: { theme: org.theme } }, status: async () => ({ healthy: true, pid: 4242, boot: null }), options: () => ({ bus: reads, chat: { threads: async () => [], projects: async () => [] } }) })),
|
||||
resolve: async id => id !== 'acme' ? null : {
|
||||
busHealthy: true,
|
||||
reads: { business: async () => ({ business: { id, human: 'jason', projects: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
||||
bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }) },
|
||||
chat: { projects: async () => [], threads: async () => [] },
|
||||
...(decide ? { send: async () => ({}), decisionWrite } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function setup(t, { decide = true, viewport = [1440, 900] } = {}) {
|
||||
const calls = [], gate = { held: null, reads: 0 };
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: organizations({ decide, calls, gate }), log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(...viewport);
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
await b.navigate(`${base}/#/orgs/acme/inbox`);
|
||||
// hold() makes the next workspace inbox reads wait until the returned release() is called.
|
||||
const hold = () => { let release; gate.held = new Promise(r => { release = r; }); return () => { gate.held = null; release(); }; };
|
||||
return { b, calls, gate, hold, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
|
||||
}
|
||||
// MOSAIC_UX_SHOTS=<dir> also saves light and dark shots; `at` is the element centred in view.
|
||||
const shots = async (b, name, width, at) => {
|
||||
const dir = process.env.MOSAIC_UX_SHOTS;
|
||||
if (!dir) return;
|
||||
await mkdir(dir, { recursive: true });
|
||||
for (const mode of ['light', 'dark']) {
|
||||
await b.evaluate(`document.documentElement.dataset.mode=${JSON.stringify(mode)};document.querySelector(${JSON.stringify(at)}).scrollIntoView({block:'center'})`);
|
||||
await b.screenshot(join(dir, `inbox-hire-${name}-${width}-${mode}.png`));
|
||||
}
|
||||
};
|
||||
const fill = (b, values) => b.evaluate(`for (const [k, v] of Object.entries(${JSON.stringify(values)})) { const e = document.querySelector('[data-hire-field="' + k + '"]'); e.value = v; e.dispatchEvent(new Event('input', { bubbles: true })); }`);
|
||||
|
||||
for (const [width, height] of [[1440, 900], [390, 844]]) test(`D4 the org-mode inbox at ${width}px starts a hire on the current organization's bus, and a definite refusal keeps its code`, { timeout: 120000 }, async t => {
|
||||
const { b, calls, wait, text } = await setup(t, { viewport: [width, height] });
|
||||
await wait('document.querySelector("[data-ws-inbox] [data-ws-item]")');
|
||||
// P0-1: the form is on the workspace inbox, beside the cross-organization list, not a separate page.
|
||||
await wait('document.querySelector("[data-ws-inbox] #hire-start #hire-start-form")');
|
||||
assert.match(await text('#hire-start h2'), /Hire, fire or change an agent/);
|
||||
// The organization is the current one and is not editable: the posts go to its bus.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-hire-field=business]")'), null);
|
||||
assert.deepEqual(await b.evaluate('(e => [e.value, e.readOnly])(document.querySelector("#hire-start input[readonly]"))'), ['acme', true]);
|
||||
assert.match(await text('#hire-start-org'), /acme's bus/);
|
||||
await b.evaluate('document.querySelector("[data-hire-load]").click()');
|
||||
await wait('document.querySelector("[data-hire-field=template] option[value=coder]")');
|
||||
assert.deepEqual(calls.map(c => [c.verb, c.ctx.business]), [['hire.templates.console', 'acme']]);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true);
|
||||
await shots(b, 'form', width, '#hire-start h2');
|
||||
|
||||
// P1-2: the bus refuses before it records anything; the page shows that code and its next step.
|
||||
await fill(b, { name: 'scout', template: 'coder', harness: 'pi', model: 'gone-model', reason: 'Need a coder' });
|
||||
await b.evaluate('document.querySelector("#hire-start-form").requestSubmit()');
|
||||
await wait('document.querySelector("#hire-start-status code")');
|
||||
assert.equal(await text('#hire-start-status code'), 'model-provider-unavailable');
|
||||
assert.match(await text('#hire-start-status'), /Settings > Model proxy/);
|
||||
assert.doesNotMatch(await text('#hire-start-status'), /may have reached the bus/);
|
||||
const start = calls.filter(c => c.verb === 'hire.start.console');
|
||||
assert.equal(start.length, 1, 'one start, never retried');
|
||||
assert.equal(start[0].ctx.business, 'acme');
|
||||
assert.deepEqual(start[0].args, { op: 'hire', level: 'org', name: 'scout', reason: 'Need a coder', role: { template: 'coder', sha256: SHA }, route: { harness: 'pi', model: 'gone-model' } });
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true);
|
||||
await shots(b, 'refused', width, '#hire-start-status');
|
||||
|
||||
// A lost reply is still outcome-unknown.
|
||||
await fill(b, { model: 'lost-model' });
|
||||
await b.evaluate('document.querySelector("#hire-start-form").requestSubmit()');
|
||||
await wait('document.querySelector("#hire-start-status code")?.textContent === "outcome-unknown"');
|
||||
assert.match(await text('#hire-start-status'), /may have reached the bus/);
|
||||
|
||||
// And a hire the bus records.
|
||||
await fill(b, { model: 'fixture-model' });
|
||||
await b.evaluate('document.querySelector("#hire-start-form").requestSubmit()');
|
||||
await wait('document.querySelector("#hire-start-status")?.textContent.includes("Recorded decision dec-hire-1")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#hire-start-status code")'), null);
|
||||
assert.equal(calls.filter(c => c.verb === 'hire.start.console').length, 3);
|
||||
});
|
||||
|
||||
test('D4 a poll repaint of the inbox keeps the hire draft, focus and caret', { timeout: 120000 }, async t => {
|
||||
const { b, wait } = await setup(t);
|
||||
await wait('document.querySelector("#hire-start-form")');
|
||||
await fill(b, { name: 'scout', reason: 'Need a coder' });
|
||||
await b.evaluate('(e => { e.focus(); e.setSelectionRange(2, 2); })(document.querySelector("[data-hire-field=name]"))');
|
||||
await b.evaluate('window.__marker = document.querySelector("[data-ws-inbox]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
|
||||
await wait('document.querySelector("[data-ws-inbox]") && document.querySelector("[data-ws-inbox]") !== window.__marker');
|
||||
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'hire:name');
|
||||
assert.deepEqual(await b.evaluate('[document.activeElement.value, document.activeElement.selectionStart]'), ['scout', 2]);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-hire-field=reason]").value'), 'Need a coder');
|
||||
});
|
||||
|
||||
// Review P2: the form is built after the workspace read, so what lands while the read is slow survives
|
||||
// the repaint: typing into the draft, and the status of a submit that resolves during the read.
|
||||
test('D4 a slow inbox read does not paint an older hire draft over typing done during the read', { timeout: 120000 }, async t => {
|
||||
const { b, gate, hold, wait } = await setup(t);
|
||||
await wait('document.querySelector("#hire-start-form")');
|
||||
await fill(b, { name: 'sc' });
|
||||
const release = hold(), before = gate.reads;
|
||||
await b.evaluate('window.__marker = document.querySelector("[data-ws-inbox]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
|
||||
for (let i = 0; i < 200 && gate.reads === before; i++) await new Promise(r => setTimeout(r, 30));
|
||||
assert.ok(gate.reads > before, 'the repaint read reached the bus and is held');
|
||||
await fill(b, { name: 'scout', reason: 'Typed during the read' });
|
||||
release();
|
||||
await wait('document.querySelector("[data-ws-inbox]") !== window.__marker && document.querySelector("#hire-start-form")');
|
||||
assert.deepEqual(await b.evaluate('["name","reason"].map(k => document.querySelector("[data-hire-field=" + k + "]").value)'), ['scout', 'Typed during the read']);
|
||||
});
|
||||
|
||||
test('D4 a hire answer that arrives during a slow inbox read keeps its status after the repaint', { timeout: 120000 }, async t => {
|
||||
const { b, calls, gate, hold, wait } = await setup(t);
|
||||
await wait('document.querySelector("#hire-start-form")');
|
||||
await b.evaluate('document.querySelector("[data-hire-load]").click()');
|
||||
await wait('document.querySelector("[data-hire-field=template] option[value=coder]")');
|
||||
await fill(b, { name: 'scout', template: 'coder', harness: 'pi', model: 'gone-model', reason: 'Need a coder' });
|
||||
const release = hold(), before = gate.reads;
|
||||
await b.evaluate('window.__marker = document.querySelector("[data-ws-inbox]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
|
||||
for (let i = 0; i < 200 && gate.reads === before; i++) await new Promise(r => setTimeout(r, 30));
|
||||
assert.ok(gate.reads > before, 'the repaint read reached the bus and is held');
|
||||
await b.evaluate('document.querySelector("#hire-start-form").requestSubmit()');
|
||||
await wait('document.querySelector("#hire-start-status code")');
|
||||
release();
|
||||
await wait('document.querySelector("[data-ws-inbox]") !== window.__marker && document.querySelector("#hire-start-form")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#hire-start-status code")?.textContent ?? null'), 'model-provider-unavailable');
|
||||
assert.match(await b.evaluate('document.querySelector("#hire-start-status").innerText'), /Settings > Model proxy/);
|
||||
assert.equal(calls.filter(c => c.verb === 'hire.start.console').length, 1);
|
||||
});
|
||||
|
||||
test('D4 without decision answers (no --send) the org-mode inbox shows no hire form', { timeout: 120000 }, async t => {
|
||||
const { b, calls, wait } = await setup(t, { decide: false });
|
||||
await wait('document.querySelector("[data-ws-inbox] [data-ws-item]")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#hire-start")'), null);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
@@ -264,3 +264,76 @@ test('quick fallback edits survive the harness redraw', {timeout:60000}, async t
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -173,3 +173,21 @@ test('row 65 Console: the routes read takes no arguments, needs a bus and refuse
|
||||
const refused = await setup(t, { bus: { routes: async () => { throw new BusReadError('human-required'); } } });
|
||||
assert.equal((await get(refused, '/api/bus/routes')).status, 403);
|
||||
});
|
||||
|
||||
// The Disable step is the Routing page's to add (bus.js catalogueStep), since only the page knows
|
||||
// whether pressing it can help; the server text never names it.
|
||||
test('D3 P0-4: a model refusal keeps its status and names the next step that makes the save apply', async t => {
|
||||
for (const [code, step] of [
|
||||
['model-catalogue-unavailable', /A change to thinking alone still saves; a model from the model proxy needs the proxy set up first\. Nothing changed\.$/],
|
||||
['model-provider-unavailable', /A change to thinking alone still saves; otherwise pick a model the catalogue lists, or set up the model proxy when the model comes from it\. Nothing changed\.$/],
|
||||
['model-harness-incompatible', /Pick a harness the model lists, or another model/],
|
||||
['model-key-collision', /Pick the table's entry or another key/],
|
||||
]) {
|
||||
const s = await setup(t, { write: async () => { throw new BusReadError(code); } });
|
||||
const r = await send(s, '/api/routes', { revision: REV, routes: { coder: ROUTE } });
|
||||
assert.deepEqual([r.status, r.body.error, r.body.sent], [502, code, false], code);
|
||||
assert.match(r.body.message, new RegExp(`^${code}: .*Nothing changed\\.$`), code);
|
||||
assert.match(r.body.message, step, code);
|
||||
assert.doesNotMatch(r.body.message, /refused by the bus|Disable/, code);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user