Files
stack/packages/webui/tests/chat-sessions-access.test.mjs
jason.woltje 1186cf7b1e fix(webui): record request misses only from the request read (#797)
The chat.js fetch wrapper recorded a request-not-found from /api/requests/<id>/workspace as a missing request and then served a synthetic 404 to the request panel's rereads, failing 11 request-browser tests. Only the request's own read records a miss now. Adds a regression test that fails on 9d3eb9e11, and bounds the r3 held-404 test's check on its refresh reads, which raced Files' cached repaint under load.
2026-11-06 12:34:00 -06:00

335 lines
29 KiB
JavaScript

// 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('<!doctype html><title>Session access fixture</title><div id="view"></div><script src="/chat.js"></script>');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('<!doctype html><body><div id="view"></div><script src="/files.js"></script><script src="/chat.js"></script><script src="/requests.js"></script></body>'); }
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('<!doctype html><body><div id="view"></div><script src="/files.js"></script><script src="/chat.js"></script><script src="/requests.js"></script></body>'); }
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('<!doctype html><body><div id="view"></div><script src="/files.js"></script><script src="/chat.js"></script><script src="/requests.js"></script></body>'); }
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('<!doctype html><body><div id="view"></div><script src="/files.js"></script><script src="/chat.js"></script><script src="/requests.js"></script></body>'); }
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);
});