// Row 272, sage-3357: UI session negotiation over the real broker's account boundary. // The loopback fixture binds one already granted principal; login/CSRF are covered by // granted-user.test.mjs. No real account, provider, config or owner transport is used. import test from 'node:test'; import assert from 'node:assert/strict'; import { createServer } from 'node:http'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Broker } from '../../bus/src/broker.mjs'; import { Store } from '../../bus/src/store.mjs'; import { browser } from './browser.mjs'; async function setup(t, { owner = false, catalogError = null } = {}) { const root = mkdtempSync(join(tmpdir(), 'huey-session-access-')); const store = new Store(root), calls = []; const access = { accessVersion:1, revision:1, users:[], grants:[{id:'g-1',user:'alice',business:'demo',projects:['*'],instances:['assistant'],level:'collaborator'}] }; const broker = new Broker({store, access:()=>access, businesses:{demo:{ id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'}, roles:{chief:{definition:'chief-of-staff',authority:{withinRole:['message.send'],crossRole:[]}},assistant:{definition:'assistant',authority:{withinRole:['message.send'],crossRole:[]}}}, definitions:{'chief-of-staff':{},assistant:{messaging:JSON.parse(readFileSync(new URL('../../../roles/assistant.json',import.meta.url),'utf8')).messaging}}, }}}); const human = broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}); const cap = owner ? human : broker.bindUser({business:'demo',user:'alice'}); const invoke = (verb,args={}) => broker.request(cap,{verb,args}); const project = broker.request(human,{verb:'project.create',args:{name:'shared'}}).id; const thread = invoke('thread.create',{project,name:'account thread',instance:'assistant'}).id; invoke('thread.send',{thread,body:'existing account history'}); const session = store.get('SELECT session FROM session_defaults WHERE thread=? ORDER BY seq DESC LIMIT 1',thread).session; let b; const server = createServer(async (req,res) => { const url = new URL(req.url,'http://fixture'); const json = (status,value) => {res.writeHead(status,{'content-type':'application/json','cache-control':'no-store'});res.end(JSON.stringify(value));}; if(url.pathname === '/') {res.writeHead(200,{'content-type':'text/html'});res.end('Session access fixture
');return;} if(url.pathname === '/chat.js') {res.writeHead(200,{'content-type':'text/javascript'});res.end(readFileSync(new URL('../src/public/chat.js',import.meta.url)));return;} try { let verb,args={}; const match=/^\/api\/chat\/threads\/([^/]+)\/(sessions|messages|open)$/.exec(url.pathname); if(url.pathname === '/api/chat/projects') verb='project.list'; else if(url.pathname === '/api/chat/threads') verb='thread.list'; else if(match) { args.thread=match[1]; if(match[2] === 'sessions') verb='session.list'; else if(match[2] === 'open') verb='thread.open'; else verb=req.method === 'POST' ? 'thread.send' : 'thread.messages'; if(req.method === 'POST') { let body='';for await(const part of req)body+=part; Object.assign(args,JSON.parse(body)); if(verb === 'thread.send')args.class='REQUEST'; } else if(verb === 'thread.messages') { args.limit=Number(url.searchParams.get('limit')??100); for(const key of ['session','after'])if(url.searchParams.has(key))args[key]=url.searchParams.get(key); } } else return json(404,{error:'not-found'}); calls.push({verb,args:structuredClone(args),owner}); if(verb === 'session.list' && catalogError === 'malformed')return json(200,{rows:{invalid:true}}); if(verb === 'session.list' && catalogError)throw Object.assign(new Error(catalogError),{code:catalogError}); const value=invoke(verb,args); if(req.method === 'POST')return json(200,value); return json(200,{rows:value,after:verb==='thread.messages'?value.at(-1)?.id??null:null,hasMore:false}); } catch(e) {json(['access-refused','human-required'].includes(e.code)?403:503,{error:e.code??'invalid-response',sent:false});} }); t.after(async()=>{await b?.close();await new Promise(r=>server.close(r));store.close();rmSync(root,{recursive:true,force:true});}); await new Promise(r=>server.listen(0,'127.0.0.1',r)); b=await browser(); if(catalogError === 'timeout')await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`{const actual=fetch.bind(window);window.fetch=(url,opts)=>String(url).endsWith('/sessions')?Promise.reject(new DOMException('fixture transport timeout','TimeoutError')):actual(url,opts);}`}); await b.navigate(`http://127.0.0.1:${server.address().port}/`); const render = async explicit => { const path=`/chat/threads/${thread}${explicit?`/sessions/${session}`:''}`; await b.evaluate(`(async()=>{location.hash=${JSON.stringify('#'+path)};await window.MosaicChat.render({path:${JSON.stringify(path)},container:document.getElementById('view'),canSend:true,navigated:true});return true;})()`); }; const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30));}return false;})()`); return {b,calls,render,wait,thread,session,store}; } test('272 fallback: granted legacy route keeps default history, send and open without session controls', async t => { const {b,calls,render,wait,thread,store}=await setup(t); await render(false); assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'),true,'legacy granted-user thread must remain usable after session.list access-refused'); assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'),/existing account history/); assert.equal(await b.evaluate('!!document.querySelector(".chat-session-controls")'),false); assert.match(await b.evaluate('document.getElementById("view").textContent'),/sessions.*not.*available.*account/i); await b.evaluate('(()=>{const input=document.getElementById("chat-body");input.value="new granted message";input.dispatchEvent(new Event("input",{bubbles:true}));document.querySelector("#chat-send-form button").click();return true;})()'); assert.equal(await wait('document.getElementById("chat-receipt").textContent.includes("Accepted")'),true); assert.equal(store.get("SELECT count(*) n FROM messages WHERE body='new granted message'").n,1); const send=calls.find(c=>c.verb==='thread.send'); assert.equal(send.args.thread,thread);assert.equal(Object.hasOwn(send.args,'session'),false); await b.evaluate('document.getElementById("chat-resume").click(),true'); assert.equal(await wait('document.getElementById("chat-receipt").textContent.includes("resume requested")'),true); assert.deepEqual(calls.find(c=>c.verb==='thread.open').args,{thread}); assert.ok(calls.some(c=>c.verb==='session.list')); assert.ok(calls.filter(c=>c.verb==='thread.messages').every(c=>!Object.hasOwn(c.args,'session'))); assert.ok(calls.every(c=>c.owner===false),'no request may switch to owner authority'); }); test('272 fallback guard: granted explicit-session route refuses without default history or writes', async t => { const {b,calls,render}=await setup(t); await render(true); assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'),false); assert.equal(await b.evaluate('!!document.querySelector(".chat-session-controls")'),false); assert.equal(calls.some(c=>['thread.messages','thread.send','thread.open'].includes(c.verb)),false); }); for(const failure of ['human-required','timeout','no-bus-host','malformed']) { test(`272 fallback guard: ${failure} catalog failure never enables default-thread access`, async t => { const {b,calls,render}=await setup(t,{catalogError:failure}); await render(false); assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'),false); assert.equal(calls.some(c=>c.verb==='thread.messages'),false); }); } test('272 fallback guard: owner route retains chooser and explicit default-session addressing', async t => { const {b,calls,render,session}=await setup(t,{owner:true}); await render(false); assert.equal(await b.evaluate('!!document.querySelector(".chat-session-controls")'),true); assert.equal(await b.evaluate('document.getElementById("chat-session").value'),session); assert.equal(calls.find(c=>c.verb==='thread.messages').args.session,session); assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'),/existing account history/); }); test('missing request ids stop all thread-page polling until the thread gains a message', async t => { const project = '10000000-0000-4000-8000-000000000001', thread = '20000000-0000-4000-8000-000000000001', session = '30000000-0000-4000-8000-000000000001'; const ids = ['request-real', 'request-missing-1', 'request-missing-2']; const rows = ids.map((request, i) => ({ id: `40000000-0000-4000-8000-00000000000${i + 1}`, from_role: 'human', body: 'Build it', request, at: '2026-11-06T12:00:00Z' })); const calls = [], available = new Set([ids[0]]); let b; const server = createServer((req, res) => { const url = new URL(req.url, 'http://fixture'); const json = (status, value) => { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(value)); }; if (url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end('
'); } if (['/files.js', '/chat.js', '/requests.js'].includes(url.pathname)) { res.writeHead(200, { 'content-type': 'text/javascript' }); return res.end(readFileSync(new URL('../src/public' + url.pathname, import.meta.url))); } if (url.pathname === '/api/config') return json(200, { requests: true, requestWrites: true }); if (url.pathname === '/api/chat/projects') return json(200, { rows: [{ id: project, name: 'Project', repo: 'timer' }] }); if (url.pathname === '/api/chat/projects/archived') return json(200, { rows: [] }); if (url.pathname === '/api/chat/threads') return json(200, { rows: url.searchParams.has('shared') ? [] : [{ id: thread, project, name: 'Thread', instance: 'assistant' }] }); if (url.pathname.endsWith('/sessions')) return json(200, { rows: [{ id: session, name: 'Default', default: true, state: 'active', revision: 1 }] }); if (url.pathname.endsWith('/shares')) return json(200, { rows: [] }); if (url.pathname.includes('/messages')) return json(200, { rows, after: rows.at(-1).id, hasMore: false }); const match = /^\/api\/requests\/([^/]+)(?:\/(workspace|draft))?$/.exec(url.pathname); if (match) { const [, id, part] = match; calls.push({ id, part: part ?? 'get' }); if (!available.has(id)) return json(404, { error: 'request-not-found' }); if (part === 'workspace') return json(200, { available: true, entries: [{ name: `draft-${id}.html` }], truncated: false }); if (part === 'draft') return json(200, { candidate: null, prd: null, requirements: [], snapshot: { id: null, revision: 0 } }); return json(200, { version: 1, request: id, revision: 1, phase: 'discovery', project: 'timer', mode: 'guided', mode_revision: 1, snapshot: null, candidate: null, tasks: [], delivery: null, question: null, wait: null }); } return json(404, { error: 'not-configured' }); }); t.after(async () => { await b?.close(); await new Promise(resolve => server.close(resolve)); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); b = await browser({ machine: false }); await b.navigate(`http://127.0.0.1:${server.address().port}/`); const path = `/projects/threads/${thread}`; const render = () => b.evaluate(`window.MosaicChat.render({path:${JSON.stringify(path)},container:document.querySelector('#view'),canSend:true,navigated:false})`); const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,20));}throw Error('fixture wait failed: '+${JSON.stringify(expression)});})()`); await render(); await wait(`document.querySelector('[data-chat-summary]')?.textContent.includes(${JSON.stringify(ids[0])}) && document.querySelector('[data-chat-files]')?.textContent.includes('draft-request-real.html')`); // Give all three parallel sources time to settle, then compare the fixture's network log. await b.evaluate('new Promise(r=>setTimeout(()=>r(true),200))'); const before = calls.length; for (let i = 0; i < 3; i++) { await render(); await wait(`document.querySelector('[data-chat-summary]')?.textContent.includes(${JSON.stringify(ids[0])})`); } await b.evaluate('new Promise(r=>setTimeout(()=>r(true),200))'); const later = calls.slice(before); assert.deepEqual(later.filter(c => ids.slice(1).includes(c.id)), [], 'no get or workspace fetch for either missing request'); assert.ok(later.some(c => c.id === ids[0] && c.part === 'get')); assert.ok(later.some(c => c.id === ids[0] && c.part === 'workspace')); available.add(ids[1]); rows.push({ id: '40000000-0000-4000-8000-000000000004', from_role: 'assistant', body: 'Request opened', at: '2026-11-06T12:01:00Z' }); await render(); await wait(`document.querySelector('[data-chat-summary]')?.textContent.includes(${JSON.stringify(ids[1])}) && document.querySelector('[data-chat-files]')?.textContent.includes('draft-request-missing-1.html')`); // ids[2] is still remembered as missing. An aborted read keeps the native AbortError. assert.equal(await b.evaluate(`(async()=>{const a=new AbortController();a.abort();try{await fetch('/api/requests/${ids[2]}',{signal:a.signal});return 'resolved';}catch(e){return e.name;}})()`), 'AbortError'); // Event-only open: the newest message is unchanged, so refreshes alone keep the miss. available.add(ids[2]); const idle = calls.length; for (let i = 0; i < 2; i++) { await render(); await wait(`document.querySelector('[data-chat-summary]')?.textContent.includes(${JSON.stringify(ids[1])})`); } await b.evaluate('new Promise(r=>setTimeout(()=>r(true),200))'); assert.deepEqual(calls.slice(idle).filter(c => c.id === ids[2]), [], 'timer refreshes do not retry a remembered miss'); // A request state for that id observed anywhere on the page clears the miss for the next refresh. await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-request-state',{detail:{state:{version:1,request:${JSON.stringify(ids[2])},revision:1,phase:'discovery'},observedAt:new Date().toISOString()}}));true`); await render(); await wait(`document.querySelector('[data-chat-summary]')?.textContent.includes(${JSON.stringify(ids[2])}) && document.querySelector('[data-chat-files]')?.textContent.includes('draft-request-missing-2.html')`); }); test('opening a thread again retries its missing requests without a new message', async t => { const project = '10000000-0000-4000-8000-000000000001', thread = '20000000-0000-4000-8000-000000000001', session = '30000000-0000-4000-8000-000000000001'; const ids = ['request-real', 'request-missing-1']; const rows = ids.map((request, i) => ({ id: `40000000-0000-4000-8000-00000000000${i + 1}`, from_role: 'human', body: 'Build it', request, at: '2026-11-06T12:00:00Z' })); const calls = [], available = new Set([ids[0]]); let b; const server = createServer((req, res) => { const url = new URL(req.url, 'http://fixture'); const json = (status, value) => { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(value)); }; if (url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end('
'); } if (['/files.js', '/chat.js', '/requests.js'].includes(url.pathname)) { res.writeHead(200, { 'content-type': 'text/javascript' }); return res.end(readFileSync(new URL('../src/public' + url.pathname, import.meta.url))); } if (url.pathname === '/api/config') return json(200, { requests: true, requestWrites: true }); if (url.pathname === '/api/chat/projects') return json(200, { rows: [{ id: project, name: 'Project', repo: 'timer' }] }); if (url.pathname === '/api/chat/projects/archived') return json(200, { rows: [] }); if (url.pathname === '/api/chat/threads') return json(200, { rows: url.searchParams.has('shared') ? [] : [{ id: thread, project, name: 'Thread', instance: 'assistant' }] }); if (url.pathname.endsWith('/sessions')) return json(200, { rows: [{ id: session, name: 'Default', default: true, state: 'active', revision: 1 }] }); if (url.pathname.endsWith('/shares')) return json(200, { rows: [] }); if (url.pathname.includes('/messages')) return json(200, { rows, after: rows.at(-1).id, hasMore: false }); const match = /^\/api\/requests\/([^/]+)(?:\/(workspace|draft))?$/.exec(url.pathname); if (match) { const [, id, part] = match; calls.push({ id, part: part ?? 'get' }); if (!available.has(id)) return json(404, { error: 'request-not-found' }); if (part === 'workspace') return json(200, { available: true, entries: [{ name: `draft-${id}.html` }], truncated: false }); if (part === 'draft') return json(200, { candidate: null, prd: null, requirements: [], snapshot: { id: null, revision: 0 } }); return json(200, { version: 1, request: id, revision: 1, phase: 'discovery', project: 'timer', mode: 'guided', mode_revision: 1, snapshot: null, candidate: null, tasks: [], delivery: null, question: null, wait: null }); } return json(404, { error: 'not-configured' }); }); t.after(async () => { await b?.close(); await new Promise(resolve => server.close(resolve)); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); b = await browser({ machine: false }); await b.navigate(`http://127.0.0.1:${server.address().port}/`); const path = `/projects/threads/${thread}`; const render = navigated => b.evaluate(`window.MosaicChat.render({path:${JSON.stringify(path)},container:document.querySelector('#view'),canSend:true,navigated:${navigated}})`); const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,20));}throw Error('fixture wait failed: '+${JSON.stringify(expression)});})()`); await render(true); await wait(`document.querySelector('[data-chat-files]')?.textContent.includes('draft-request-real.html')`); await b.evaluate('new Promise(r=>setTimeout(()=>r(true),200))'); available.add(ids[1]); const before = calls.length; await render(false); await wait(`document.querySelector('[data-chat-files]')?.textContent.includes('draft-request-real.html')`); await b.evaluate('new Promise(r=>setTimeout(()=>r(true),200))'); assert.deepEqual(calls.slice(before).filter(c => c.id === ids[1]), [], 'a refresh keeps the miss'); await render(true); await wait(`document.querySelector('[data-chat-summary]')?.textContent.includes(${JSON.stringify(ids[1])}) && document.querySelector('[data-chat-files]')?.textContent.includes('draft-request-missing-1.html')`); }); test('a request state observed before an in-flight 404 settles keeps that miss from being remembered', async t => { const project = '10000000-0000-4000-8000-000000000001', thread = '20000000-0000-4000-8000-000000000001', session = '30000000-0000-4000-8000-000000000001'; const ids = ['request-real', 'request-late']; const rows = ids.map((request, i) => ({ id: `40000000-0000-4000-8000-00000000000${i + 1}`, from_role: 'human', body: 'Build it', request, at: '2026-11-06T12:00:00Z' })); const calls = [], available = new Set([ids[0]]), held = []; let b, hold = true; const server = createServer((req, res) => { const url = new URL(req.url, 'http://fixture'); const json = (status, value) => { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(value)); }; if (url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end('
'); } if (['/files.js', '/chat.js', '/requests.js'].includes(url.pathname)) { res.writeHead(200, { 'content-type': 'text/javascript' }); return res.end(readFileSync(new URL('../src/public' + url.pathname, import.meta.url))); } if (url.pathname === '/api/config') return json(200, { requests: true, requestWrites: true }); if (url.pathname === '/api/chat/projects') return json(200, { rows: [{ id: project, name: 'Project', repo: 'timer' }] }); if (url.pathname === '/api/chat/projects/archived') return json(200, { rows: [] }); if (url.pathname === '/api/chat/threads') return json(200, { rows: url.searchParams.has('shared') ? [] : [{ id: thread, project, name: 'Thread', instance: 'assistant' }] }); if (url.pathname.endsWith('/sessions')) return json(200, { rows: [{ id: session, name: 'Default', default: true, state: 'active', revision: 1 }] }); if (url.pathname.endsWith('/shares')) return json(200, { rows: [] }); if (url.pathname.includes('/messages')) return json(200, { rows, after: rows.at(-1).id, hasMore: false }); const match = /^\/api\/requests\/([^/]+)(?:\/(workspace|draft))?$/.exec(url.pathname); if (match) { const [, id, part] = match; calls.push({ id, part: part ?? 'get' }); // The late id's first reads are answered request-not-found only after the test releases them. if (id === ids[1] && hold) return held.push(() => json(404, { error: 'request-not-found' })); if (!available.has(id)) return json(404, { error: 'request-not-found' }); if (part === 'workspace') return json(200, { available: true, entries: [{ name: `draft-${id}.html` }], truncated: false }); if (part === 'draft') return json(200, { candidate: null, prd: null, requirements: [], snapshot: { id: null, revision: 0 } }); return json(200, { version: 1, request: id, revision: 1, phase: 'discovery', project: 'timer', mode: 'guided', mode_revision: 1, snapshot: null, candidate: null, tasks: [], delivery: null, question: null, wait: null }); } return json(404, { error: 'not-configured' }); }); t.after(async () => { for (const send of held.splice(0)) send(); await b?.close(); await new Promise(resolve => server.close(resolve)); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); b = await browser({ machine: false }); await b.navigate(`http://127.0.0.1:${server.address().port}/`); const path = `/projects/threads/${thread}`; const render = navigated => b.evaluate(`window.MosaicChat.render({path:${JSON.stringify(path)},container:document.querySelector('#view'),canSend:true,navigated:${navigated}})`); const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,20));}throw Error('fixture wait failed: '+${JSON.stringify(expression)});})()`); await render(true); for (let i = 0; i < 150 && held.length === 0; i++) await new Promise(r => setTimeout(r, 20)); await b.evaluate('new Promise(r=>setTimeout(()=>r(true),200))'); assert.ok(held.length > 0, 'the late id has reads in flight'); // The request opens and the page observes its state while the older 404s are still in flight. available.add(ids[1]); hold = false; await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-request-state',{detail:{state:{version:1,request:${JSON.stringify(ids[1])},revision:1,phase:'discovery'},observedAt:new Date().toISOString()}}));true`); for (const send of held.splice(0)) send(); await b.evaluate('new Promise(r=>setTimeout(()=>r(true),200))'); // An ordinary refresh, with no navigation or new message, reads the real request and workspace. const before = calls.length; await render(false); await wait(`document.querySelector('[data-chat-summary]')?.textContent.includes(${JSON.stringify(ids[1])}) && document.querySelector('[data-chat-files]')?.textContent.includes('draft-request-late.html')`); // Files paints its cached copy first and reads behind it, so wait (bounded) for this refresh's // own reads to reach the server. A remembered miss is answered in the page and never arrives. const reached = part => calls.slice(before).some(c => c.id === ids[1] && c.part === part); for (let i = 0; i < 150 && !(reached('get') && reached('workspace')); i++) await new Promise(r => setTimeout(r, 20)); assert.ok(reached('get')); assert.ok(reached('workspace')); }); test('a workspace request-not-found never hides a request whose state still reads', async t => { const project = '10000000-0000-4000-8000-000000000001', thread = '20000000-0000-4000-8000-000000000001', session = '30000000-0000-4000-8000-000000000001'; const id = 'request-real'; const rows = [{ id: '40000000-0000-4000-8000-000000000001', from_role: 'human', body: 'Build it', request: id, at: '2026-11-06T12:00:00Z' }]; const calls = []; let b; const server = createServer((req, res) => { const url = new URL(req.url, 'http://fixture'); const json = (status, value) => { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(value)); }; if (url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end('
'); } if (['/files.js', '/chat.js', '/requests.js'].includes(url.pathname)) { res.writeHead(200, { 'content-type': 'text/javascript' }); return res.end(readFileSync(new URL('../src/public' + url.pathname, import.meta.url))); } if (url.pathname === '/api/config') return json(200, { requests: true, requestWrites: true }); if (url.pathname === '/api/chat/projects') return json(200, { rows: [{ id: project, name: 'Project', repo: 'timer' }] }); if (url.pathname === '/api/chat/projects/archived') return json(200, { rows: [] }); if (url.pathname === '/api/chat/threads') return json(200, { rows: url.searchParams.has('shared') ? [] : [{ id: thread, project, name: 'Thread', instance: 'assistant' }] }); if (url.pathname.endsWith('/sessions')) return json(200, { rows: [{ id: session, name: 'Default', default: true, state: 'active', revision: 1 }] }); if (url.pathname.endsWith('/shares')) return json(200, { rows: [] }); if (url.pathname.includes('/messages')) return json(200, { rows, after: rows.at(-1).id, hasMore: false }); const match = /^\/api\/requests\/([^/]+)(?:\/(workspace|draft))?$/.exec(url.pathname); if (match) { const [, rid, part] = match; calls.push({ id: rid, part: part ?? 'get' }); // Only the workspace route answers request-not-found; the request's own state reads. if (part === 'workspace') return json(404, { error: 'request-not-found' }); if (part === 'draft') return json(200, { candidate: null, prd: null, requirements: [], snapshot: { id: null, revision: 0 } }); return json(200, { version: 1, request: rid, revision: 1, phase: 'discovery', project: 'timer', mode: 'guided', mode_revision: 1, snapshot: null, candidate: null, tasks: [], delivery: null, question: null, wait: null }); } return json(404, { error: 'not-configured' }); }); t.after(async () => { await b?.close(); await new Promise(resolve => server.close(resolve)); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); b = await browser({ machine: false }); await b.navigate(`http://127.0.0.1:${server.address().port}/`); const path = `/projects/threads/${thread}`; const render = () => b.evaluate(`window.MosaicChat.render({path:${JSON.stringify(path)},container:document.querySelector('#view'),canSend:true,navigated:false})`); const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,20));}throw Error('fixture wait failed: '+${JSON.stringify(expression)});})()`); await render(); await wait(`document.querySelector('[data-chat-summary]')?.textContent.includes(${JSON.stringify(id)})`); await b.evaluate('new Promise(r=>setTimeout(()=>r(true),200))'); assert.ok(calls.some(c => c.part === 'workspace'), 'the workspace 404 was served'); // A refresh after that 404 still reads the request from the network and still shows it. const before = calls.length; await render(); await wait(`document.querySelector('[data-chat-summary]')?.textContent.includes(${JSON.stringify(id)})`); await b.evaluate('new Promise(r=>setTimeout(()=>r(true),200))'); assert.ok(calls.slice(before).some(c => c.id === id && c.part === 'get'), 'the request state is read again, not served a remembered miss'); assert.equal(await b.evaluate(`(async()=>(await fetch('/api/requests/${id}')).status)()`), 200); });