`:'';
let harnessSettings=null, harnessDraft=null, harnessLoading=false, harnessReceipt='', harnessBusy=false;
function readHarnesses(){
- if(harnessLoading || harnessSettings !== null || !canSend || isTester)return; harnessLoading=true;
- fetch('/api/proxy/plugins',{cache:'no-store',redirect:'manual'}).then(async r=>{const v=await r.json();if(!r.ok || !/^[a-f0-9]{64}$/.test(v.revision??'') || !Array.isArray(v.available?.harness) || !v.available.harness.every(n=>typeof n==='string') || !Array.isArray(v.plugins?.harness??[]))throw Error();harnessSettings=v;}).catch(()=>{harnessSettings=false;harnessReceipt='Harnesses could not be read.';}).finally(()=>{harnessLoading=false;if(routeOf().split('?')[0]==='/settings/routing')render(false);});
+ if(harnessLoading || harnessSettings !== null || !canSend || isTester)return; harnessLoading=true;const epoch=orgEpoch;
+ fetch('/api/proxy/plugins',{cache:'no-store',redirect:'manual'}).then(async r=>{const v=await r.json();if(epoch!==orgEpoch)return;if(!r.ok || !/^[a-f0-9]{64}$/.test(v.revision??'') || !Array.isArray(v.available?.harness) || !v.available.harness.every(n=>typeof n==='string') || !Array.isArray(v.plugins?.harness??[]))throw Error();harnessSettings=v;}).catch(()=>{if(epoch!==orgEpoch)return;harnessSettings=false;harnessReceipt='Harnesses could not be read.';}).finally(()=>{if(epoch!==orgEpoch)return;harnessLoading=false;if(routeOf().split('?')[0]==='/settings/routing')render(false);});
}
const ROUTE_CELLS=[['harness','Harness'],['model','Model'],['thinking','Thinking'],['fallback.harness','Fallback harness'],['fallback.model','Fallback model'],['fallback.thinking','Fallback thinking']];
const routeCells=r=>({harness:r.harness??'',model:r.model??'',thinking:r.thinking??'','fallback.harness':r.fallback?.harness??'','fallback.model':r.fallback?.model??'','fallback.thinking':r.fallback?.thinking??''});
@@ -1296,32 +1303,32 @@
if(harnessSettings.revision!==routesView?.revision){harnessReceipt='The business changed. Reload this page before saving harnesses.';render(false);return;}
const used=old.filter(n=>!selected.includes(n) && Object.values(routesView.instances).some(r=>r.harness===n || r.fallback?.harness===n));
if(used.length){harnessReceipt=`A saved route uses ${used.join(', ')}. Change the route before switching it off.`;render(false);return;}
- harnessBusy=true;const button=e.target;button.disabled=true;harnessReceipt='';
- try{const response=await fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:harnessSettings.revision,plugins:{harness:selected}}),cache:'no-store',redirect:'manual'});const result=await response.json();if(!response.ok){harnessReceipt=result.message||result.error||'Harness setting was refused.';return;}harnessSettings={...result,available:harnessSettings.available};harnessDraft=null;harnessReceipt='Harnesses saved. The next launch uses the new settings.';routesView=null;last.delete('routes');}
- catch{harnessReceipt='Outcome unknown. Read the harnesses again before trying.';harnessSettings=false;}
- finally{harnessBusy=false;if(!out())render(false);}
+ harnessBusy=true;const button=e.target;button.disabled=true;harnessReceipt='';const epoch=orgEpoch;
+ try{const response=await fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:harnessSettings.revision,plugins:{harness:selected}}),cache:'no-store',redirect:'manual'});const result=await response.json();if(epoch!==orgEpoch)return;if(!response.ok){harnessReceipt=result.message||result.error||'Harness setting was refused.';return;}harnessSettings={...result,available:harnessSettings.available};harnessDraft=null;harnessReceipt='Harnesses saved. The next launch uses the new settings.';routesView=null;last.delete('routes');}
+ catch{if(epoch!==orgEpoch)return;harnessReceipt='Outcome unknown. Read the harnesses again before trying.';harnessSettings=false;}
+ finally{if(epoch===orgEpoch){harnessBusy=false;if(!out())render(false);}}
});
document.addEventListener('click',async e=>{if(e.target.id!=='provider-switch'||!canSend||isTester)return;
- 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 button=e.target,status=$('provider-switch-status');if(routesDraft){status.textContent='Save or discard the route draft first.';return;}button.disabled=true;const epoch=orgEpoch;
+ try {const read=await fetch('/api/proxy/plugins',{cache:'no-store'});const settings=await read.json();if(epoch!==orgEpoch)return;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']:[];
// 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;}
+ if(epoch!==orgEpoch)return;if(!response.ok)throw Error('Provider setting was refused.');harnessSettings=null;routesView=null;last.delete('routes');render(true);
+ }catch(error){if(epoch!==orgEpoch)return;status.textContent=error.message;button.disabled=false;}
});
document.addEventListener('click',e=>{if(e.target.matches?.('.fallback-toggle'))e.target.closest('tr')?.classList.add('fallback-open');});
document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';routesApply=null;last.delete('routes');render(true);});
document.addEventListener('submit',async e=>{
if(e.target.id!=='routes-form')return;e.preventDefault();if(!(canSend || isTester)||out()||routesBusy||!routesView)return;
routesDraft=routesFields(e.target);let payload;try{payload=routesPayload(routesDraft);}catch(err){routesReceipt=err.message;render(false);return;}
- 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));}
+ routesBusy=true;routesReceipt='';routesApply=null;render(false);const epoch=orgEpoch;
+ try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(epoch!==orgEpoch){if(response.type==='opaqueredirect')signOut();return;}if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();if(epoch!==orgEpoch)return;
+ 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.`;const applied=await routesApplyRead(Object.keys(payload.routes));if(epoch!==orgEpoch)return;routesApply=applied;}
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);}
+ catch{if(epoch!==orgEpoch)return;routesReceipt=unknownRoutes;}finally{if(epoch===orgEpoch){routesBusy=false;if(!out())render(false);}}
});
// Row 194 (#1692, D7): the Agents section lives in agents.js (window.MosaicAgents) and draws from
// the same instances read. A refresh drops that read so the next render asks the bus again.
@@ -1935,11 +1942,13 @@
if (out()) { receipt = { kind: 'refused', to: to || 'no role', code: 'sign-in', message: 'signed out; reload the page to sign in again' }; paintSend(); announce('Not sent: ' + receipt.message); return; }
if (!to || !text.trim()) { receipt = { kind: 'refused', to: to || 'no role', code: 'invalid-request', message: to ? 'the message is empty' : 'choose a role first' }; paintSend(); announce('Not sent: ' + receipt.message); return; }
sending = true; receipt = null; paintSend();
+ const epoch = orgEpoch;
let res = null, body = null;
try {
res = await fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });
try { body = await res.json(); } catch { body = null; }
} catch { res = null; }
+ if (epoch !== orgEpoch) { if (res?.type === 'opaqueredirect') signOut(); return; }
// Sent: an id came back. Not sent: a 4xx (refused before the bus wrote), the server's
// sent:false, or a redirect, which only the sign-in proxy gives (an expired session), so the
// request never reached the Console. Anything else, no answer included, may have been sent,
@@ -1999,8 +2008,16 @@
document.addEventListener('mosaic-org-changed', e => { if (window.MosaicOrg && e.detail?.org === window.MosaicOrg.id && ['/organization', '/agents'].includes(routeOf().split('?')[0])) render(false); });
// #1873 SP1: another organization opened in place (display.js, before this file's hashchange
// render). Nothing read, drafted or confirmed for the old one carries over; the new one's config
- // is read before the next page draws. A write already sent finishes against the old organization.
+ // is read before the next page draws. A write already sent finishes against the old organization,
+ // and its answer is not drawn on the new one.
document.addEventListener('mosaic-org-switch', () => {
+ // Writes still in flight keep going to the old organization; their answers are dropped (orgEpoch)
+ // and every busy flag and receipt starts clean here.
+ orgEpoch++;
+ sending = false; receipt = null; replies = [];
+ hireBusy = false; fireBusy = false; settingsSaving = false; resetReceipt = '';
+ assistantBusy = false; assistantFormsBusy.clear(); instanceBusyOps = new Set();
+ routesBusy = false; routesApply = null; harnessBusy = false; harnessLoading = false;
forget(); clearTimeout(timer);
answer = null; heldErr = null; heldHtml = null; readAt = null; shown = null; projectContext = null;
draft.to = ''; draft.body = '';
@@ -2011,7 +2028,8 @@
instanceView = null; instanceDrafts = { add: null, change: null }; instanceReceipt = ''; instanceReceiptOp = null; instanceReceipts = Object.create(null);
routesView = null; routesDraft = null; routesReceipt = ''; routesEditVersion++;
harnessSettings = null; harnessDraft = null; harnessReceipt = '';
- docKey = null; pkAll = []; pkShown = []; pkAt = 0;
+ // A file draft is keyed by project and path only; the page reload that used to drop it is gone.
+ docKey = null; docStates.clear(); pkAll = []; pkShown = []; pkAt = 0;
configRead = switchRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => applyConfig(c, { quiet: true })).catch(() => {});
});
document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') { applyConfig(e.detail); if (window.MosaicOrg && routeOf().split('?')[0] === '/organization') render(false); } });
diff --git a/packages/webui/src/public/display.js b/packages/webui/src/public/display.js
index 880c07d00..459eaf141 100644
--- a/packages/webui/src/public/display.js
+++ b/packages/webui/src/public/display.js
@@ -135,9 +135,12 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
// Capture phase: this runs before bus.js renders, so a switch is scoped before the page reads.
window.addEventListener('hashchange', event => {
const oldHash = event.oldURL ? new URL(event.oldURL).hash : previous;
- const raw = location.hash, seg = orgOf(raw) ?? current, next = normalize(raw, seg);
+ const raw = location.hash;
+ // First run (no organization yet) keeps its own addresses: #/ is its board, not #/dashboard.
+ // Before the first list read settles, the settle step normalizes whatever address it finds.
+ if (!settled || !current) { previous = raw; return; }
+ const seg = orgOf(raw) ?? current, next = normalize(raw, seg);
if (next !== raw) history.replaceState(null, '', next);
- if (!settled || !current) { previous = next; return; }
const switching = seg !== current;
if (!skipGuard && next !== oldHash && (switching || window.MosaicOrgCreate?.hasDraft?.()) && dirty()) {
event.stopImmediatePropagation();
diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js
index b5dc943b8..bfe978cba 100644
--- a/packages/webui/src/public/workspace.js
+++ b/packages/webui/src/public/workspace.js
@@ -230,10 +230,18 @@
// SP1: the reason an organization's bus can't be read, for its pages. `row` is its GET /api/workspace
// status (state, reason, boot), or null when that read gave none.
+ // A reason that only restates the heading ("the bus is not running", the server's fallback) is not
+ // repeated; the last start time, when read, still shows.
+ const restates = reason => /^the bus is not (running|answering)\.?$/i.test(reason.trim());
const downWhy = row => (row && row.state !== 'ok' && typeof row.reason === 'string' && row.reason ? `${row.reason}${row.boot?.at ? `, last start ${when(row.boot.at)}` : ''}` : '');
function downBanner(org, row) {
- const stopped = org.busStopped === true, why = downWhy(row);
- return `
${stopped ? 'The bus is not running.' : 'The bus is not answering.'} Nothing about ${esc(label(org))}'s agents or packs can be read until it ${stopped ? 'starts' : 'answers'}.${why ? ` Reason: ${esc(why)}.` : ' No reason was read.'}
${stopped ? 'The bus is not running.' : 'The bus is not answering.'} Nothing about ${esc(label(org))}'s agents or packs can be read until it ${stopped ? 'starts' : 'answers'}.${tail}
`;
}
// A side-by-side pane passes its own organization and { pane: true, idPrefix }: the pane has no bus
// restart (that stays in the organization's full view) and its ids carry the pane's prefix.
diff --git a/packages/webui/tests/org-switch-browser.test.mjs b/packages/webui/tests/org-switch-browser.test.mjs
index 036e8ce19..64dde0657 100644
--- a/packages/webui/tests/org-switch-browser.test.mjs
+++ b/packages/webui/tests/org-switch-browser.test.mjs
@@ -217,15 +217,9 @@ test('organization switch isolates browser board preferences', async t => {
await b.navigate(base + '#/orgs/alpha/');
await b.evaluate(`document.querySelector('#show-machine').click()`);
assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), true);
- // The switch reloads the page. The navigate helper's load checks can be in flight when that
- // reload commits, so set the hash in the page and poll one short check at a time.
+ // #1873 SP1: the switch happens in place, with no reload, and beta's own board setting applies.
await b.evaluate(`location.hash = '#/orgs/beta/'`);
- let loaded = false;
- for (let i = 0; i < 150 && !loaded; i++) {
- try { loaded = await b.evaluate(`window.MosaicOrg?.id === 'beta' && !!document.querySelector('#show-machine')`); } catch {}
- if (!loaded) await new Promise(r => setTimeout(r, 30));
- }
- assert.equal(loaded, true, 'beta did not load');
+ await b.evaluate(`(async () => { for (let i = 0; i < 150; i++) { if (window.MosaicOrg?.id === 'beta' && !!document.querySelector('#show-machine')) return; await new Promise(r => setTimeout(r, 30)); } throw Error('beta did not load'); })()`);
assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), false);
});
diff --git a/packages/webui/tests/single-pane-browser.test.mjs b/packages/webui/tests/single-pane-browser.test.mjs
index 29db7545d..40e01b623 100644
--- a/packages/webui/tests/single-pane-browser.test.mjs
+++ b/packages/webui/tests/single-pane-browser.test.mjs
@@ -13,7 +13,7 @@ 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 at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
-async function fixture() {
+async function fixture({ empty = false, bootless = false } = {}) {
const { checkRootAgents } = await import('../../organization/src/index.mjs');
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
const root = { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: {} }, { rolesDir }) };
@@ -35,6 +35,8 @@ async function fixture() {
},
beta: { projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }], agents: [], inbox: [] },
};
+ // Launch settings differ per organization so a value carried from one to the other shows.
+ const LAUNCH = id => ({ business: id, revision: (id === 'acme' ? 'a' : 'b').repeat(64), models: { opus: { provider: 'anthropic', model: 'claude-opus-5-5' } }, limits: { total: id === 'acme' ? 4 : 7 }, instances: {}, usage: { total: 0, providers: {}, models: {} } });
const value = (id, name) => Promise.resolve(DATA[id]?.[name] ?? (['usage', 'limits'].includes(name) ? {} : []));
const opts = id => ({
bus: { inbox: () => value(id, 'inbox'), agents: () => value(id, 'agents'), 'thread.claims': () => value(id, 'claims'), tasks: () => value(id, 'tasks'), usage: () => value(id, 'usage'), limits: () => value(id, 'limits'), decisions: () => value(id, 'decisions') },
@@ -42,19 +44,19 @@ async function fixture() {
});
const member = org => ({
id: org.id, doc: { appearance: { theme: org.theme }, roles: ROLES[org.id], projects: {} },
- status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }),
+ status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : bootless ? { healthy: false, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }),
options: () => opts(org.id),
});
const instances = id => Object.fromEntries(Object.entries(ROLES[id]).map(([n, r]) => [n, { definition: r.definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: n === 'coder' ? ['run-1'] : [], alive: [] }]));
const organizations = {
- list: async () => LIST,
- members: async () => LIST.map(member),
+ list: async () => (empty ? [] : LIST),
+ members: async () => (empty ? [] : LIST.map(member)),
resolve: async id => {
const org = LIST.find(o => o.id === id); if (!org) return null;
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
return {
busHealthy: true,
- reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo) } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
+ reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo) } }), settings: async () => ({ business: id, release: 'fixture', notifier: {}, launch: LAUNCH(id) }), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => DATA[id].agents, 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: instances(id) }) },
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
};
@@ -63,12 +65,14 @@ async function fixture() {
return { organizations, agentLevels: { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } } };
}
-async function setup(t, { viewport = [1440, 900], path = '/' } = {}) {
- const { organizations, agentLevels } = await fixture();
+async function setup(t, { viewport = [1440, 900], path = '/', empty = false, init = null, bootless = false } = {}) {
+ const { organizations, agentLevels } = await fixture({ empty, bootless });
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, agentLevels, log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(...viewport);
+ // `init` runs before the page's own scripts, under the display.js fetch wrapper, so it sees the scoped URL.
+ if (init) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: init });
const base = `http://127.0.0.1:${server.address().port}`;
await b.navigate(`${base}${path}`);
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
@@ -120,7 +124,7 @@ test('an organization opens inside the same shell with a breadcrumb, and its pag
assert.equal(await b.evaluate('document.querySelector(".ws-crumb a")?.getAttribute("href")'), '#/dashboard');
assert.equal(await b.evaluate('document.getElementById("current-organization")'), null);
// The sidebar marks the open organization.
- await wait('document.querySelector("#workspace-nav [aria-current=page]")?.closest("[data-org]")?.dataset.org === "acme" || document.querySelector("#workspace-nav a[aria-current=page]")?.getAttribute("href") === "#/orgs/acme/organization"');
+ await wait('document.querySelector("#workspace-nav a[aria-current=page]")?.getAttribute("href") === "#/orgs/acme/organization"');
// The sidebar opens another organization in place; its page reads that organization's routes.
await b.evaluate('performance.clearResourceTimings(); document.querySelector("#workspace-nav a[href=\'#/orgs/beta/organization\']").click()');
@@ -172,3 +176,112 @@ test('at 390 px the dashboard lists the organizations without a horizontal scrol
await wait('document.querySelectorAll("[data-ws-orgrow]").length === 3');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= window.innerWidth'), true);
});
+
+// Review round finding 1: with no organization yet the page keeps its first-run addresses, so the
+// wordmark (href "#/") opens the first-run board, not a #/dashboard the first-run page has no view for.
+test('first run: the wordmark opens the first-run board, not a Not found page', async t => {
+ const { b, wait } = await setup(t, { path: '/#/settings/appearance', empty: true });
+ await wait('document.querySelector("#bus-view h1")');
+ await b.evaluate('document.querySelector(".wordmark").click()');
+ // The board route empties the page view; give the router and render time to settle before checking.
+ await wait('location.hash !== "#/settings/appearance"');
+ await new Promise(r => setTimeout(r, 800));
+ assert.equal(await b.evaluate('location.hash'), '#/');
+ assert.equal(await b.evaluate('!!document.querySelector("#bus-view .nf")'), false);
+ assert.equal(await b.evaluate('window.MosaicOrg'), undefined);
+});
+
+test('a switch stops following an agent request: no poll reaches the next organization and its page shows no receipt', async t => {
+ const ID = '11111111-2222-4333-8444-555555555555';
+ const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
+ const init = `window.__reads = []; window.__posts = []; const original = window.fetch;
+ window.fetch = async (url, options = {}) => { const s = String(url); let x;
+ if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true }; return ${json}; }
+ if (options.method === 'POST' && /\\/agent\\/launch$/.test(s)) { __posts.push(s); x = { request: '${ID}', at: 'x' }; return ${json}; }
+ if (/\\/agent\\/request\\?/.test(s)) { __reads.push(s); x = { request: '${ID}', action: 'launch', instance: 'reviewer', state: 'open', done: null, cancelled: null }; return ${json}; }
+ return original(url, options); };`;
+ const { b, wait } = await setup(t, { path: '/#/orgs/acme/settings/agents', init });
+ const launch = '#agents-section [data-agent-action=launch][data-agent-name=reviewer]';
+ await wait(`document.querySelector(${JSON.stringify(launch)}) && !document.querySelector(${JSON.stringify(launch)}).disabled`);
+ await b.evaluate(`document.querySelector(${JSON.stringify(launch)}).click()`);
+ await wait('window.__reads.length >= 1');
+ assert.deepEqual(await b.evaluate('window.__posts'), ['/api/orgs/acme/agent/launch']);
+ assert.match(await b.evaluate('window.__reads[0]'), /^\/api\/orgs\/acme\/agent\/request\?id=/);
+ await b.evaluate('location.hash = "#/orgs/beta/settings/agents"');
+ await wait('location.hash === "#/orgs/beta/settings/agents" && document.querySelector("#agents-section [data-agent-name=chief]") && !document.querySelector("#agents-section [data-agent-name=reviewer]")');
+ const seen = await b.evaluate('window.__reads.length');
+ // Two poll periods pass on beta's page.
+ await new Promise(r => setTimeout(r, 4500));
+ const reads = await b.evaluate('window.__reads');
+ assert.equal(reads.filter(u => u.includes('/orgs/beta/')).length, 0, reads.join(' '));
+ assert.equal(reads.length, seen, 'the poll stopped at the switch');
+ assert.equal(await b.evaluate('document.querySelector("#agents-receipt")?.textContent.trim() ?? ""'), '');
+ assert.equal(await b.evaluate('document.querySelector("#agents-section [data-agent-action=launch][data-agent-name=chief]")?.disabled'), false);
+});
+
+test('a save still in flight at a switch: its answer is not drawn on the next organization and its Save is not held', async t => {
+ const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
+ // acme's settings POST is held until the test releases it, then answers with acme's saved values.
+ const init = `window.__posts = []; const original = window.fetch;
+ window.fetch = async (url, options = {}) => { const s = String(url); let x;
+ if (/^\\/api\\/orgs\\/[^/]+\\/config$/.test(s)) { const r = await original(url, options), body = await r.json(); x = { ...body, send: true }; return ${json}; }
+ if (options.method === 'POST' && s === '/api/orgs/acme/settings') { __posts.push(JSON.parse(options.body)); await new Promise(r => { window.__release = r; });
+ x = { business: 'acme', revision: 'c'.repeat(64), models: { opus: { provider: 'anthropic', model: 'claude-opus-5-5' } }, limits: { total: 5 }, instances: {}, usage: { total: 0, providers: {}, models: {} } }; return ${json}; }
+ return original(url, options); };`;
+ const { b, wait } = await setup(t, { path: '/#/orgs/acme/settings/limits', init });
+ const total = '#launch-settings-form input[data-setting=\'["total"]\']';
+ const submit = '#launch-settings-form button[type=submit]';
+ await wait(`document.querySelector(${JSON.stringify(total)})?.value === "4" && !document.querySelector(${JSON.stringify(submit)}).disabled`);
+ await b.evaluate(`(() => { const i = document.querySelector(${JSON.stringify(total)}); i.value = "5"; i.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector(${JSON.stringify(submit)}).click(); })()`);
+ await wait('typeof window.__release === "function"');
+ assert.equal(await b.evaluate('window.__posts[0]?.revision'), 'a'.repeat(64));
+ await b.evaluate('location.hash = "#/orgs/beta/settings/limits"');
+ await wait(`location.hash === "#/orgs/beta/settings/limits" && document.querySelector(${JSON.stringify(total)})?.value === "7"`);
+ // beta's Save is its own: not held by acme's write.
+ assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(submit)}).disabled`), false);
+ await b.evaluate('window.__release()');
+ await new Promise(r => setTimeout(r, 700));
+ assert.equal(await b.evaluate('location.hash'), '#/orgs/beta/settings/limits');
+ assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(total)})?.value`), '7');
+ assert.equal(await b.evaluate('document.getElementById("launch-settings-receipt")?.textContent.trim() ?? ""'), '');
+ assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(submit)}).disabled`), false);
+});
+
+test('a down banner does not repeat its heading as the reason', async t => {
+ // No boot record: the server's reason is its fallback, "the bus is not running", the heading's own words.
+ const { b, wait } = await setup(t, { path: '/#/orgs/gamma/organization', bootless: true });
+ await wait('document.querySelector("[data-org-dashboard] [data-org-down]")');
+ const down = await b.evaluate('document.querySelector("[data-org-down]").innerText.replace(/\\s+/g, " ")');
+ assert.match(down, /The bus is not running\./);
+ assert.equal((down.match(/the bus is not running/gi) || []).length, 1, down);
+ assert.doesNotMatch(down, /Reason:|No reason was read/);
+});
+
+test('a restart started on one organization finishes on that organization after a switch', { timeout: 60000 }, async t => {
+ const END = '11111111-2222-4333-8444-5555555555e1', LAUNCHED = '11111111-2222-4333-8444-5555555555a1';
+ const json = 'new Response(JSON.stringify(x), { status: 200, headers: { "content-type": "application/json" } })';
+ // Every agent request read and write is recorded, whichever organization it names; the bus answers
+ // are stubs (terminate, closed requests, one launch). Only acme's instances read is stubbed and
+ // recorded (the session gone): beta's own page reads beta's instances, which is not the chain.
+ const init = `window.__chain = []; const original = window.fetch;
+ window.fetch = async (url, options = {}) => { const s = String(url); let x;
+ if (options.method === 'POST' && /\\/agent\\/terminate$/.test(s)) { __chain.push(s); x = { request: '${END}' }; return ${json}; }
+ if (options.method === 'POST' && /\\/agent\\/launch$/.test(s)) { __chain.push(s); x = { request: '${LAUNCHED}' }; return ${json}; }
+ if (/\\/agent\\/request\\?/.test(s)) { __chain.push(s.split('?')[0]); const id = new URL(s, location.href).searchParams.get('id');
+ x = { request: id, instance: 'coder', action: id === '${END}' ? 'end' : 'launch', state: 'closed', done: { run: id === '${END}' ? null : 'run-9' }, cancelled: null }; return ${json}; }
+ if (s === '/api/orgs/acme/bus/instances' && window.__restarting) { __chain.push(s); x = { rows: [{ instances: { coder: { running: [] } } }] }; return ${json}; }
+ return original(url, options); };`;
+ const { b, wait } = await setup(t, { path: '/#/orgs/acme/organization', init });
+ await wait('window.MosaicAgents && document.querySelector("[data-org-dashboard] h1")');
+ // The button the Routing receipt offers (agents.js restartMarkup), placed on acme's page.
+ await b.evaluate('window.__restarting = true; document.querySelector("[data-org-dashboard]").insertAdjacentHTML("beforeend", window.MosaicAgents.restartMarkup("coder")); document.querySelector("[data-agent-restart=coder]").click()');
+ await wait('window.__chain.length >= 1');
+ await b.evaluate('location.hash = "#/orgs/beta/organization"');
+ await wait('location.hash === "#/orgs/beta/organization"');
+ // Done when the chain has launched and read the launch request back, or as soon as any of it names beta.
+ await wait('window.__chain.some(u => u.includes("/orgs/beta/")) || (window.__chain.some(u => /\\/agent\\/launch$/.test(u)) && window.__chain.filter(u => /\\/agent\\/request$/.test(u)).length >= 2)', 30000);
+ const chain = await b.evaluate('window.__chain');
+ assert.deepEqual([...new Set(chain)], ['/api/orgs/acme/agent/terminate', '/api/orgs/acme/agent/request', '/api/orgs/acme/bus/instances', '/api/orgs/acme/agent/launch'], chain.join(' '));
+ // beta's page draws nothing of acme's chain.
+ assert.equal(await b.evaluate('!!document.querySelector("[data-agent-restart-status=coder]")'), false);
+});