diff --git a/docs/TOOLS.md b/docs/TOOLS.md index aff8a9c95..c8ca60316 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -653,3 +653,29 @@ record-repository path; the default is `/projects///`. `root` remains the code checkout. The package README describes schemas and the shared agent record writer; agent writes never enter `spec/`, never absorb pending edits and refuse remotes. + +### Console Chat (row 129, #1623) + +The Console's **Chat** section uses `#/chat`, `#/chat/projects/UUID` and +`#/chat/threads/UUID`. Create and name a project and its threads, then send a +message in that thread's conversation. Names are creation-only. The project +catalog is business-wide; thread lists and histories belong to the current +human. This is separate from the Board's seat reply route. + +`GET/POST /api/chat/projects` lists/creates projects; `GET/POST /api/chat/threads` +lists/creates threads (`project=UUID` optionally filters the GET). +`GET /api/chat/threads/UUID/messages?limit=100&after=UUID` pages history forward; +`POST` on that messages route sends `{body}` as a REQUEST. +`POST /api/chat/threads/UUID/open` takes `{}` and explicitly resumes the assistant. +GET never writes, opens a thread or marks messages delivered/read. No browser +owner, tenant, proof or arbitrary bus verb is accepted. A same-business thread +owned by someone else is 403 `thread-not-owned`; foreign/missing ids are 404. + +The four chat POSTs use the existing signed-in `--send` boundary: named Host, +one exact proxy user header, exact HTTPS Origin, same-origin Fetch Metadata, +strict JSON and 16 KiB. Read-only mode disables them. Bodies are inert text; +drafts are memory-only per thread and cleared on sign-out. History starts +oldest-first in pages of 100, with a 200-message display window. Sending does +not claim an assistant answer; an uncertain receipt is never retried. Read the +current list/history before trying again. Activation still needs Mos's separate +window; changing these files does not authorize a live Console action. diff --git a/packages/webui/README.md b/packages/webui/README.md index 7fcfd1eb0..fed66052a 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -720,3 +720,36 @@ route response shapes, then the page against a temporary broker with a scheduled poll accelerated in the browser test. It proves literal content, late unsolicited arrival, pagination, full unread count and unchanged delivery receipts. No live bus data or service is used. + +## Chat projects and threads (row 129, #1623) + +Open **Chat** in the section navigation or command palette. The business project +catalog is shared; each project's thread list includes only the current human's +threads. Create a named project, optionally linked to a configured repository +key, then create a named thread. Names are set at creation; rename is not shipped. +No default project or thread is created implicitly. A thread has its own URL, +draft and conversation. This is separate from the Board's seat conversations. + +The routes are `#/chat`, `#/chat/projects/UUID` and `#/chat/threads/UUID`. +The fixed `/api/chat/projects`, `/api/chat/threads` and +`/api/chat/threads/UUID/messages` endpoints list/create projects, list/create +threads and read/send messages. `POST /api/chat/threads/UUID/open` is the explicit +**Resume assistant** action. Reading a thread never opens it or acknowledges a +message. Sending uses `thread.send` with class REQUEST, as the human CLI does. + +All four POST operations need `--send`, the named preview Host, the exact +single authenticated user header, the Console's HTTPS Origin, same-origin Fetch +Metadata and a strict JSON object within 16 KiB. The broker binds the business +human and enforces ownership. A same-business thread owned by someone else +returns 403 `thread-not-owned`; a missing/foreign thread returns 404. No browser +owner, business, capability, arbitrary bus verb or filesystem path is accepted. +Read-only mode disables creation, Resume and Send. Chat uses the existing human +transport; a later channel-module conversion has its own brief (sage-0724). + +History starts at the oldest message, reads forward 100 at a time and retains a +bounded 200-message display window. **Read next messages** advances older +history; a full page means more may exist. Once caught up, polling uses the last +id and de-duplicates replies. An accepted receipt confirms the message only, +not that the assistant has answered. An uncertain result never retries: read +the list/history before explicitly trying again. Drafts are memory-only and +thread-scoped; sign-out clears private chat state. Bodies are inert text. diff --git a/packages/webui/src/chat-http.mjs b/packages/webui/src/chat-http.mjs new file mode 100644 index 000000000..94f765482 --- /dev/null +++ b/packages/webui/src/chat-http.mjs @@ -0,0 +1,121 @@ +// HTTP validation and write fence local to chat; existing Console routes keep their own fences. +import { BusReadError, sendOutcome } from './bus.mjs'; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const KEY = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/; +const id = value => typeof value === 'string' && UUID.test(value); +const name = value => typeof value === 'string' && value.length > 0 && value.length <= 200 && !/[\r\n\0]/.test(value); +const one = (req, key) => req.headersDistinct[key]?.length === 1 ? req.headersDistinct[key][0] : null; +const json = (res, status, value) => { + res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); + res.end(JSON.stringify(value)); +}; +const STATUS = { + 'invalid-request': 400, 'unknown-repo': 400, 'message-not-found': 400, + 'project-not-found': 404, 'thread-not-found': 404, 'thread-not-owned': 403, + 'project-exists': 409, 'thread-exists': 409, 'human-required': 403, unauthenticated: 403, + 'unknown-business': 403, 'read-only': 403, 'not-configured': 503, 'no-bus-host': 503, + 'thread-instance-missing': 503, 'thread-instance-ambiguous': 503, 'outcome-unknown': 503, +}; +function safeMessage(code) { + if (code === 'thread-not-owned') return 'This thread is not available to this user.'; + if (code === 'outcome-unknown') return 'Outcome unknown. Read the current list or conversation before trying again. Nothing was retried.'; + return `Chat operation refused (${code}).`; +} +async function payload(req) { + if ((req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') throw Error(); + const chunks = []; let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > 16384) throw Error(); + chunks.push(chunk); + } + const value = JSON.parse(Buffer.concat(chunks).toString('utf8')); + if (!value || typeof value !== 'object' || Array.isArray(value)) throw Error(); + return value; +} +function query(search, allowed) { + const q = new URLSearchParams(search); + if ([...q.keys()].some(k => !allowed.includes(k) || q.getAll(k).length !== 1)) throw Error(); + return q; +} +const exact = (value, allowed, required = allowed) => + Object.keys(value).every(k => allowed.includes(k)) && required.every(k => Object.hasOwn(value, k)); +export async function chatHTTP(req, res, { path, search, chat, named, send, preview, log }) { + const write = req.method === 'POST'; + let action, args, thread; + const refuse = (status, code, sent = false) => json(res, status, { + error: code, message: safeMessage(code), ...(write ? { sent } : {}), + }); + if (!['GET', 'POST'].includes(req.method)) return refuse(405, 'method-not-allowed'); + if (write) { + if (!send || !named || !preview) return refuse(403, 'read-only'); + if (one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated'); + if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin'); + if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site'); + } + try { + const match = /^\/api\/chat\/threads\/([^/]+)\/(messages|open)$/.exec(path); + if (match) { + thread = match[1]; + if (!id(thread)) throw Error(); + } + if (write) { + query(search, []); + const v = await payload(req); + if (path === '/api/chat/projects') { + if (!exact(v, ['name', 'repo'], ['name']) || !name(v.name) || + (Object.hasOwn(v, 'repo') && (typeof v.repo !== 'string' || !KEY.test(v.repo)))) throw Error(); + action = 'createProject'; args = v; + } else if (path === '/api/chat/threads') { + if (!exact(v, ['project', 'name']) || !id(v.project) || !name(v.name)) throw Error(); + action = 'createThread'; args = v; + } else if (match?.[2] === 'messages') { + if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error(); + action = 'send'; args = { thread, body: v.body }; + } else if (match?.[2] === 'open') { + if (!exact(v, [])) throw Error(); + action = 'open'; args = { thread }; + } else return refuse(404, 'not-found'); + } else if (path === '/api/chat/projects') { + query(search, []); action = 'projects'; args = {}; + } else if (path === '/api/chat/threads') { + const q = query(search, ['project']); + if (q.has('project') && !id(q.get('project'))) throw Error(); + action = 'threads'; args = q.has('project') ? { project: q.get('project') } : {}; + } else if (match?.[2] === 'messages') { + const q = query(search, ['after', 'limit']); + if (q.has('after') && !id(q.get('after'))) throw Error(); + if (q.has('limit') && (!/^[1-9]\d*$/.test(q.get('limit')) || Number(q.get('limit')) > 200)) throw Error(); + action = 'messages'; args = { thread, limit: q.has('limit') ? Number(q.get('limit')) : 100 }; + if (q.has('after')) args.after = q.get('after'); + } else return refuse(404, 'not-found'); + } catch { return refuse(400, 'invalid-request'); } + if (!chat) return refuse(503, 'not-configured'); + const line = outcome => log(`${new Date().toISOString()} chat action=${action} user=${JSON.stringify(preview?.user ?? null)} ${outcome}`); + try { + const value = await chat[action](args); + if (!write) { + if (!Array.isArray(value)) return refuse(502, 'invalid-response'); + const page = action === 'messages' ? { + after: value.at(-1)?.id ?? args.after ?? null, hasMore: value.length === args.limit, + } : {}; + return json(res, 200, { rows: value, ...page, at: new Date().toISOString() }); + } + if (!value || (action === 'open' ? value.opened !== thread : !id(value.id)) || + (action === 'createThread' && (typeof value.instance !== 'string' || !KEY.test(value.instance)))) { + line('error=outcome-unknown'); + return refuse(503, 'outcome-unknown', 'unknown'); + } + line(`id=${action === 'open' ? thread : value.id}`); + // Only the fields of this operation's receipt are exposed. + const receipt = action === 'open' ? { opened: thread } : { id: value.id }; + if (action === 'createThread') receipt.instance = value.instance; + return json(res, 200, { ...receipt, at: new Date().toISOString() }); + } catch (err) { + const outcome = write ? sendOutcome(err) : { code: err instanceof BusReadError ? err.code : 'invalid-response' }; + // Unrecognized transport text is never reflected. Known refusal codes have fixed messages. + const code = Object.hasOwn(STATUS, outcome.code) ? outcome.code : 'invalid-response'; + if (write) line(`error=${code}`); + return refuse(STATUS[code] ?? 502, code, outcome.sent); + } +} diff --git a/packages/webui/src/chat.mjs b/packages/webui/src/chat.mjs new file mode 100644 index 000000000..85d3cda92 --- /dev/null +++ b/packages/webui/src/chat.mjs @@ -0,0 +1,14 @@ +// Row 129: the single seam between Console chat operations and the current human transport. +// No browser-selected bus verb, tenant, owner, capability or message class crosses this seam. +export function createChat({ call }) { + if (typeof call !== 'function') throw new TypeError('chat needs a human transport'); + return Object.freeze({ + projects: () => call('project.list', {}), + createProject: ({ name, repo }) => call('project.create', { name, ...(repo === undefined ? {} : { repo }) }), + threads: ({ project } = {}) => call('thread.list', project === undefined ? {} : { project }), + createThread: ({ project, name }) => call('thread.create', { project, name }), + messages: ({ thread, after, limit }) => call('thread.messages', { thread, ...(after ? { after } : {}), limit }), + send: ({ thread, body }) => call('thread.send', { thread, body, class: 'REQUEST' }), + open: ({ thread }) => call('thread.open', { thread }), + }); +} diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index 35a761ce7..874d03c70 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { createChat } from './chat.mjs'; import { startServer, previewOptions, DEFAULT_BOARD } from './serve.mjs'; import { busCaller } from './bus.mjs'; import { views } from '../../bus/src/views.mjs'; @@ -51,11 +52,12 @@ try { // the system config, Business can't resolve authority and Settings has // no notifier. A refusal printed here names no path (redactor). // --send without a bus refuses to start rather than serve a composer that can't send. - let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, busLine, system = null; + let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, busLine, system = null; try { system = loadSystem(); const { dataRoot } = system; const call = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null }); + chat = createChat({ call }); bus = views({ call }); // Row 148: the spec and docs editor reads like the other reads and saves only under --send. projectFiles = args => call('project.files', args); @@ -68,7 +70,7 @@ try { busLine = `Bus: not configured (${why})`; } const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null }); - const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send, settingsWrite, projectFiles, projectFile, projectSave }); + const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send, settingsWrite, projectFiles, projectFile, projectSave, chat }); const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : ''; const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : ''; console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}\nCtrl-C stops this server.`); diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index abf786a8c..4398382bb 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -239,7 +239,7 @@ // ---------------------------------------------------------------- views let inboxCount = null, messageCount = null; function sections(route) { - const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `${inboxCount.open}${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Messages', messageCount === null ? '' : `${messageCount}`, 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/docs', 'Docs', '', 'library'], ['/settings', 'Settings', '', 'settings']]; + const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `${inboxCount.open}${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/chat', 'Chat', '', 'inbox'], ['/messages', 'Messages', messageCount === null ? '' : `${messageCount}`, 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/docs', 'Docs', '', 'library'], ['/settings', 'Settings', '', 'settings']]; $('sections').innerHTML = items.map(([h, l, extra, icon]) => { const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message'))); return `
  • ${l}${extra ? ` ${extra}` : ''}
  • `; @@ -757,7 +757,7 @@ // A refusal fails closed: no kept data, the code in mono. Any other failure keeps the last read, labelled with its time. const refusal = err => err.status === 403 || ['not-configured', 'no-bus-host', 'queue-refused'].includes(err.code); // After a refusal nothing read before is shown anywhere: not in a view, the palette or the Inbox count. - function forget() { last.clear(); inboxCount = null; messageCount = null; } + function forget() { window.MosaicChat?.forget(); last.clear(); inboxCount = null; messageCount = null; } const failedView = err => `${head(failTitle(err))}${empty('Nothing to show.', refusal(err) ? 'The view stays empty until the problem above is fixed.' : err.module ? 'Read again.' : 'Read again, or come back when the bus answers.')}`; const staleBanner = err => ``; // Freshness line in the command bar for bus pages. @@ -826,6 +826,12 @@ document.body.classList.toggle('on-bus', !board); $('bus-view').hidden = board; sections(path); + window.MosaicChat?.leave(path); + if (path === '/chat' || path.startsWith('/chat/')) { + await window.MosaicChat.render({ path, container: $('bus-view'), canSend, navigated }); + if (g !== gen || out()) return; + shown = route; schedule(); return; + } if (board) { // The board page keeps its own title; the Inbox count still follows the bus. if (shown !== null) { shown = null; $('bus-view').innerHTML = ''; document.title = 'Mosaic Console'; if (navigated) $('main').focus(); } @@ -919,7 +925,7 @@ // Ctrl+K opens it. It lists the views, and the decisions and tasks from the last inbox and // tasks reads (opening it reads both again, GET only). Enter goes; Esc closes and puts focus // back where it was. It only navigates. - const VIEWS = [['#/', 'Board'], ['#/inbox', 'Inbox'], ['#/messages', 'Messages'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings']]; + const VIEWS = [['#/', 'Board'], ['#/chat', 'Chat'], ['#/inbox', 'Inbox'], ['#/messages', 'Messages'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings']]; let pkAll = [], pkShown = [], pkAt = 0, pkBack = null; function pkItems() { const decisions = (last.get('inbox')?.rows || []).map(d => ({ href: `#/inbox/${enc(d.id)}`, label: first(d.question), group: `decision ${short(d.id)}` })); @@ -1017,7 +1023,7 @@ if (c?.send !== true) return; canSend = true; const path = routeOf().split('?')[0]; - if (path.startsWith('/inbox')||path==='/settings'||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr); + if (path.startsWith('/chat')||path.startsWith('/inbox')||path==='/settings'||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr); }).catch(() => {}); window.addEventListener('hashchange', () => render(true)); $('refresh').addEventListener('click', () => render(false)); diff --git a/packages/webui/src/public/chat.css b/packages/webui/src/public/chat.css new file mode 100644 index 000000000..e7edbfee4 --- /dev/null +++ b/packages/webui/src/public/chat.css @@ -0,0 +1,19 @@ +/* Row 129: local chat styles, using the Console's existing palette and type. */ +.chat-area { max-width: 72rem; margin-inline: auto; } +.chat-projects { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); gap: 1rem; } +.chat-thread-list { display: grid; gap: .65rem; padding-inline-start: 1.25rem; } +.chat-thread-list li { overflow-wrap: anywhere; } +.chat-create { display: grid; gap: .75rem; margin-block: 1.5rem; max-width: 34rem; } +.chat-create label, #chat-send-form { display: grid; gap: .5rem; } +.chat-create input, .chat-create select, #chat-body { width: 100%; min-width: 0; box-sizing: border-box; font: inherit; } +.chat-create button, #chat-send-form button { justify-self: start; } +.chat-conversation { min-width: 0; } +.chat-log { list-style: none; padding: .75rem; margin-block: 1rem; max-height: 56vh; overflow: auto; overflow-anchor: none; border: 1px solid var(--border); border-radius: .5rem; } +.chat-log li { padding-block: .8rem; border-bottom: 1px solid var(--border); } +.chat-log li:last-child { border-bottom: 0; } +.chat-message-meta { font-size: .85rem; opacity: .8; overflow-wrap: anywhere; } +.chat-body { white-space: pre-wrap; overflow-wrap: anywhere; margin-top: .4rem; unicode-bidi: plaintext; } +#chat-body { resize: vertical; min-height: 6rem; padding: .75rem; } +.chat-paging { display: flex; gap: .75rem; align-items: center; flex-wrap: wrap; margin-bottom: 1rem; } +#chat-receipt { min-height: 1.5em; overflow-wrap: anywhere; } +@media (max-width: 40rem) { .chat-log { max-height: 48vh; padding: .5rem; } } diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js new file mode 100644 index 000000000..c0c997581 --- /dev/null +++ b/packages/webui/src/public/chat.js @@ -0,0 +1,242 @@ +/* Row 129: business chat, isolated from the Board's seat conversations and role composer. */ +(() => { + 'use strict'; + const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + const inert = value => String(value ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g, + c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')); + const txt = value => esc(inert(value)); + const states = new Map(), forms = new Map(); + let current = null, controller = null, generation = 0, signedOut = false; + const out = () => signedOut || document.body.classList.contains('signed-out'); + const state = id => { + if (!states.has(id)) states.set(id, { rows: [], after: null, more: false, loaded: false, draft: '', receipt: '', pending: false, follow: true }); + return states.get(id); + }; + const form = key => { + if (!forms.has(key)) forms.set(key, { name: '', repo: '', receipt: '', pending: false }); + return forms.get(key); + }; + class ChatError extends Error { + constructor(code, status = 0, sent) { super(code); this.code = code; this.status = status; this.sent = sent; } + } + function forget() { + generation++; controller?.abort(); states.clear(); forms.clear(); + if (current) current.container.replaceChildren(); + current = null; + } + function signOut() { + signedOut = true; forget(); + document.dispatchEvent(new Event('mosaic-signed-out')); + } + async function request(path, value, signal) { + if (out()) throw new ChatError('signed-out'); + let res; + try { + res = await fetch('/api/chat/' + path, { + cache: 'no-store', redirect: 'manual', signal, + ...(value === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) }), + }); + } catch (err) { + if (err.name === 'AbortError') throw err; + throw new ChatError(value === undefined ? 'unreachable' : 'outcome-unknown'); + } + if (res.type === 'opaqueredirect' || res.status === 401) { signOut(); throw new ChatError('signed-out'); } + let data; + try { data = await res.json(); } + catch { throw new ChatError(value === undefined ? 'invalid-response' : 'outcome-unknown'); } + if (!res.ok) throw new ChatError(typeof data?.error === 'string' ? data.error : 'invalid-response', res.status, data?.sent); + if (!data || typeof data !== 'object' || (value === undefined && !Array.isArray(data.rows))) { + throw new ChatError(value === undefined ? 'invalid-response' : 'outcome-unknown'); + } + return data; + } + const link = (type, id, label) => UUID.test(id) ? `${txt(label)}` : txt(label); + const heading = text => `

    ${txt(text)}

    `; + const disabled = pending => !current.canSend || out() || pending ? ' disabled' : ''; + const stamp = at => ``; + function creation(kind, key) { + const f = form(key), project = kind === 'project'; + const repo = project && current.repos.length ? `` : ''; + return `

    New ${kind}

    ${repo}

    ${txt(f.receipt)}

    `; + } + function threads(rows) { + return rows.length ? `
      ${rows.map(t => `
    • ${link('threads', t.id, t.name)} ${txt(t.instance)}
    • `).join('')}
    ` : '

    No threads yet.

    '; + } + function conversation(t) { + const s = state(t.id); + const messages = s.rows.map(m => `
  • ${txt(m.from_role)} ${stamp(m.at)}
    ${txt(m.body)}
  • `).join(''); + const receipt = s.receipt; + return `

    ${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}

    ${heading(t.name)}

    Reading never wakes the assistant. An accepted message is not an answer.

      ${messages || '
    1. No messages yet.
    2. '}
    ${s.more ? '

    Showing earlier history. Read forward to reach the current conversation.

    ' : '

    Caught up to the last read.

    '}

    ${txt(receipt)}

    `; + } + function capture() { + if (!current) return null; + const active = document.activeElement, log = current.container.querySelector('.chat-log'); + const field = active?.id?.startsWith('chat-') ? { id: active.id, start: active.selectionStart, end: active.selectionEnd } : null; + return { field, top: log?.scrollTop, tail: log ? log.scrollHeight - log.scrollTop - log.clientHeight < 40 : true }; + } + function paint(navigated = false) { + if (!current || out()) return; + const keep = capture(), c = current; + let html = '

    Chat projects

    '; + if (c.error) html += ``; + if (c.kind === 'thread') { + const t = c.threads.find(t => t.id === c.id); + if (t && !c.refused) html += conversation(t); + else html += heading('Thread unavailable'); + } else if (c.kind === 'project') { + const p = c.projects.find(p => p.id === c.id); + html += heading(p?.name ?? 'Project unavailable'); + if (p && !c.refused) html += threads(c.threads) + creation('thread', c.id); + } else { + html += heading('Chat') + '

    Business projects and your named assistant threads.

    ' + + c.projects.map(p => `

    ${link('projects', p.id, p.name)}

    ${p.repo ? `

    Repository: ${txt(p.repo)}

    ` : ''}${threads(c.threads.filter(t => t.project === p.id))}
    `).join('') + (c.projects.length ? '' : '

    No projects yet.

    ') + '
    ' + creation('project', 'projects'); + } + if (!c.canSend) html += '

    Creating and sending require signed-in send mode. This Console only reads.

    '; + c.container.innerHTML = html + '
    '; + document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`; + const log = c.container.querySelector('.chat-log'); + if (log) { + const s = state(c.id); + if (navigated || s.follow && (keep?.tail ?? true)) log.scrollTop = log.scrollHeight; + else if (keep?.top !== undefined) log.scrollTop = keep.top; + } + if (!navigated && keep?.field) { + const f = document.getElementById(keep.field.id); + if (f && !f.disabled) { + f.focus({ preventScroll: true }); + if (keep.field.start !== null) f.setSelectionRange(keep.field.start, keep.field.end); + } + } else if (navigated) c.container.querySelector('h1')?.focus({ preventScroll: true }); + } + function readError(err) { + return err.code === 'thread-not-owned' ? 'This thread is not available to this user.' : + err.code === 'thread-not-found' ? 'Thread not found.' : 'Chat could not be read. Refresh to try again; your draft is kept.'; + } + async function render({ path, container, canSend, navigated = false, next = false }) { + if (out()) return; + const match = /^\/chat(?:\/(projects|threads)\/([0-9a-f-]+))?$/.exec(path); + controller?.abort(); controller = new AbortController(); + const signal = controller.signal, g = ++generation; + const kind = match?.[1] === 'projects' ? 'project' : match?.[1] === 'threads' ? 'thread' : 'list'; + const id = match?.[2]; + if (!match || (id && !UUID.test(id))) { + current = { container, kind: 'list', canSend, projects: [], threads: [], repos: [], error: 'Invalid chat route.', refused: true }; + paint(); return; + } + const old = current; + current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], error: '', refused: false }; + if (navigated || !old) container.innerHTML = '

    Reading chat…

    '; + try { + const s = kind === 'thread' ? state(id) : null; + const after = s?.loaded && (!s.more || next) ? s.after : null; + const [projects, listed, page] = await Promise.all([ + request('projects', undefined, signal), + request('threads' + (kind === 'project' ? '?project=' + id : ''), undefined, signal), + s ? request(`threads/${id}/messages?limit=100${after ? '&after=' + after : ''}`, undefined, signal) : null, + ]); + if (g !== generation || out()) return; + current.projects = projects.rows; current.threads = listed.rows; + if (s) { + if (typeof page.hasMore !== 'boolean' || !(page.after === null || UUID.test(page.after))) throw new ChatError('invalid-response'); + const rows = after ? [...s.rows, ...page.rows] : page.rows; + s.rows = [...new Map(rows.map(m => [m.id, m])).values()].slice(-200); + s.after = page.after ?? s.after; s.more = page.hasMore; s.loaded = true; + } + // Repository choices are optional metadata; an unavailable read must not block unlinked chat. + if (kind === 'list') { + try { + const r = await fetch('/api/business', { cache: 'no-store', redirect: 'manual', signal }); + if (r.type === 'opaqueredirect' || r.status === 401) { signOut(); return; } + if (r.ok) { + const business = await r.json(); + if (g !== generation || out()) return; + current.repos = Array.isArray(business.projects) ? business.projects.filter(v => typeof v === 'string') : []; + } + } catch (err) { if (err.name === 'AbortError') return; } + } + if (g === generation) paint(navigated); + } catch (err) { + if (g !== generation || out() || err.name === 'AbortError' || err.code === 'signed-out') return; + current.error = readError(err); + if (err.status === 403 || err.status === 404) { + states.delete(id); current.projects = []; current.threads = []; current.refused = true; + } + paint(navigated); + } + } + function refresh(next = false) { + if (current) return render({ ...current, path: location.hash.slice(1).split('?')[0], next }); + } + function leave(path) { + if (!path.startsWith('/chat')) { generation++; controller?.abort(); current = null; } + } + document.addEventListener('input', event => { + if (!current || out()) return; + if (event.target.id === 'chat-body') state(current.id).draft = event.target.value; + const f = event.target.closest('.chat-create'); + if (f && event.target.dataset.chatField) form(f.dataset.key)[event.target.dataset.chatField] = event.target.value; + }); + document.addEventListener('scroll', event => { + if (event.target.matches?.('.chat-log') && current?.kind === 'thread') { + const el = event.target; + state(current.id).follow = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + } + }, true); + async function write(action, key, value, path) { + if (!current || !current.canSend || out()) return; + const isThread = action === 'send' || action === 'open'; + const s = isThread ? state(key) : form(key); + if (s.pending) return; + if (action === 'send' && (!value.body.trim() || new TextEncoder().encode(JSON.stringify(value)).length > 16384)) { + s.receipt = value.body.trim() ? 'Message is too long (16 KiB JSON maximum).' : 'Write a message first.'; + paint(); return; + } + s.pending = true; s.receipt = ''; paint(); + try { + const result = await request(path, value); + if (out()) return; + if (action === 'open' ? result.opened !== key : !UUID.test(result.id)) throw new ChatError('outcome-unknown'); + if (action === 'send') { + s.receipt = `Accepted message ${result.id}. Waiting for the conversation to show it.`; + if (s.draft === value.body) s.draft = ''; + s.follow = true; + } else if (action === 'open') s.receipt = 'Assistant resume requested.'; + else { + s.name = ''; s.repo = ''; + location.hash = '#/chat/' + (action === 'createProject' ? 'projects/' : 'threads/') + result.id; + } + } catch (err) { + if (out() || err.code === 'signed-out') return; + s.receipt = err.sent === false ? `Not sent (${inert(err.code)}). Your draft is kept.` : + 'Outcome unknown. Read the current list or conversation before trying again. Nothing was retried; your draft is kept.'; + if (err.status === 403 && err.code === 'thread-not-owned') { + states.delete(key); + if (current?.id === key) { current.threads = []; current.refused = true; current.error = readError(err); } + } + } finally { + s.pending = false; + if (!out() && current && (isThread ? current.id === key : (key === 'projects' ? current.kind === 'list' : current.id === key))) paint(); + } + } + document.addEventListener('submit', event => { + if (!current || !event.target.id.startsWith('chat-')) return; + event.preventDefault(); + if (event.target.id === 'chat-send-form') { + const id = current.id; + void write('send', id, { body: state(id).draft }, `threads/${id}/messages`); + } else if (event.target.classList.contains('chat-create')) { + const key = event.target.dataset.key, f = form(key), project = key === 'projects'; + const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name }; + void write(project ? 'createProject' : 'createThread', key, value, project ? 'projects' : 'threads'); + } + }); + document.addEventListener('click', event => { + if (!current || out()) return; + if (event.target.id === 'chat-resume') void write('open', current.id, {}, `threads/${current.id}/open`); + if (event.target.id === 'chat-next') void refresh(true); + if (event.target.id === 'chat-new') { state(current.id).follow = true; paint(); } + }); + document.addEventListener('mosaic-signed-out', () => { signedOut = true; forget(); }); + window.MosaicChat = Object.freeze({ render, leave, forget }); +})(); diff --git a/packages/webui/src/public/index.html b/packages/webui/src/public/index.html index 81a1ffb68..4ab935041 100644 --- a/packages/webui/src/public/index.html +++ b/packages/webui/src/public/index.html @@ -1,8 +1,8 @@ Mosaic Console - - + +
    MosaicConsole diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 1249349f8..cd5d3fd29 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -4,6 +4,7 @@ import { resolve } from 'node:path'; import { isIP } from 'node:net'; import { BusReadError, busStatus, subjectOk, sendOutcome } from './bus.mjs'; import { ReadError, readStatus, fieldTable } from './reads.mjs'; +import { chatHTTP } from './chat-http.mjs'; export const DEFAULT_BOARD = 'http://127.0.0.1:7331'; export function isLoopback(host) { @@ -21,8 +22,8 @@ export function boardURL(value) { const root = resolve(import.meta.dirname, 'public'); const files = new Map([ ['/', ['index.html', 'text/html; charset=utf-8']], - ...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]), - ...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]), + ...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]), + ...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]), ['/icons.svg', ['icons.svg', 'image/svg+xml']], ...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]), ...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]), @@ -249,7 +250,7 @@ async function projectFileRead(res, read, path, search) { // `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log` // takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for // tests; the field table is otherwise the committed packages/tasks/fields.json. -export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, log = line => console.log(line), fieldsFile = undefined } = {}) { +export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, log = line => console.log(line), fieldsFile = undefined } = {}) { if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host'); if (host === 'localhost') host = '127.0.0.1'; const upstream = boardURL(board); @@ -298,6 +299,9 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' }); return busSend(req, res, { send, preview, log }); } + if (path === '/api/chat' || path.startsWith('/api/chat/')) { + return chatHTTP(req, res, { path, search, chat, named, send, preview, log }); + } // Nothing else but GET and HEAD gets past here in preview mode, whatever the Host. if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' }); // The conversation routes (#1507, CHAT-02) keep their query; the board validates it. diff --git a/packages/webui/tests/chat-browser.test.mjs b/packages/webui/tests/chat-browser.test.mjs new file mode 100644 index 000000000..8a88e9855 --- /dev/null +++ b/packages/webui/tests/chat-browser.test.mjs @@ -0,0 +1,223 @@ +// Fixture-only browser coverage uses the repository's existing CDP test helper. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { startServer } from '../src/serve.mjs'; +import { fixture, close } from './fixture.mjs'; +import { browser } from './browser.mjs'; +const P = '10000000-0000-4000-8000-000000000001'; +const A = '20000000-0000-4000-8000-000000000002'; +const B = '30000000-0000-4000-8000-000000000003'; +const M = '40000000-0000-4000-8000-000000000004'; +const HOSTILE = '\u001b[31m\u202eevil'; +const stub = `(() => { + const real = fetch.bind(window); + window.__writes=[];window.__reads=[];window.__answers=[];window.__pages=[]; + window.__projects=[{id:'${P}', name:${JSON.stringify(HOSTILE)}, repo:null},{id:'\">',name:'Invalid record'}]; + window.__threads=[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}, {id:'${B}',project:'${P}',name:'beta',instance:'assistant'}]; + window.fetch=async (url,init={})=>{ + const path=String(url), method=init.method||'GET'; + if(path==='/api/config')return new Response(JSON.stringify({send:!location.search.includes('readonly')})); + if(window.__busRefuse&&path.startsWith('/api/bus/'))return new Response(JSON.stringify({error:'human-required'}),{status:403}); + if(path==='/api/business')return new Response(JSON.stringify({projects:['stack']})); + if(!path.startsWith('/api/chat'))return real(url,init); + const data=value=>new Response(JSON.stringify({...value,at:new Date().toISOString()}),{headers:{'content-type':'application/json'}}); + if(method==='POST'){ + window.__writes.push({path,body:JSON.parse(init.body),redirect:init.redirect}); + const a=window.__answers.shift();if(!a)throw new Error('No write answer'); + if(a.hold)await new Promise(r=>window.__releaseWrite=r); + if(a.reject)throw new TypeError('network'); + if(a.redirect)return {type:'opaqueredirect',status:0,ok:false}; + return new Response(JSON.stringify(a.body),{status:a.status||200}); + } + window.__reads.push(path); + if(window.__holdRead && path.includes(window.__holdRead)){window.__heldSignal=init.signal;await new Promise(r=>window.__releaseRead=r);} + if(window.__readFailure)return new Response(JSON.stringify({error:window.__readFailure}),{status:window.__readFailure==='thread-not-owned'?403:503}); + if(path==='/api/chat/projects')return data({rows:window.__projects}); + if(path.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:window.__threads}); + if(window.__pages.length)return data(window.__pages.shift()); + return data({rows:[],after:null,hasMore:false}); + }; +})();`; +async function setup(t, suffix = '') { + const f = await fixture(); t.after(() => f.close()); + const server = await startServer({ port: 0, board: f.boardURL }); t.after(() => close(server)); + const b = await browser(); t.after(() => b.close()); + await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub }); + await b.navigate(`http://127.0.0.1:${server.address().port}/${suffix}#/chat`); + const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30));}throw new Error(${JSON.stringify(expression)});})()`); + const set = (selector, text) => b.evaluate(`(()=>{const el=document.querySelector(${JSON.stringify(selector)});el.value=${JSON.stringify(text)};el.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`); + const click = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)}).click(),true`); + const route = async id => { await b.evaluate(`location.hash='#/chat/threads/${id}',true`); await wait(`document.querySelector('.chat-conversation')?.dataset.thread==='${id}'`); }; + await wait('document.querySelector(".chat-projects")'); + return { b, wait, set, click, route }; +} +test('chat browser: list/create named projects and threads, hostile labels inert and no implicit writes', async t => { + const { b, wait, set, click } = await setup(t); + assert.equal(await b.evaluate('window.__writes.length'), 0); + assert.equal(await b.evaluate('window.injected'), undefined); + assert.equal(await b.evaluate('!!document.querySelector(".chat-projects img")'), false); + assert.match(await b.evaluate('document.querySelector(".chat-projects").textContent'), /\\u001b|␛/); + assert.equal(await b.evaluate(`!!document.querySelector('#sections a[href="#/chat"]')`), true); + await set('#chat-project-name', 'new project'); + await set('#chat-repo', 'stack'); + await b.evaluate(`window.__answers.push({body:{id:'${P}'}}),true`); + await click('#chat-project-form button[type=submit]'); + await wait('document.querySelector("#chat-thread-form")'); + assert.deepEqual(await b.evaluate('window.__writes[0].body'), { name: 'new project', repo: 'stack' }); + await set('#chat-thread-name', 'named thread'); + await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'assistant'}}),true`); + await click('#chat-thread-form button[type=submit]'); + await wait('document.querySelector("#chat-send-form")'); + assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread' }); + assert.equal(await b.evaluate('window.__writes.length'), 2); +}); +test('chat browser: thread drafts, receipts and cursor history stay isolated; refresh never writes', async t => { + const { b, wait, set, click, route } = await setup(t); + await b.evaluate(`window.__pages.push({rows:[{id:'${M}',body:${JSON.stringify(HOSTILE)},from_role:'human',at:'2026-10-18T00:00:00Z'}],after:'${M}',hasMore:true}),true`); + await route(A); + assert.equal(await b.evaluate('!!document.querySelector(".chat-log img")'), false); + await set('#chat-body', 'alpha draft'); + await b.evaluate('(()=>{const e=document.querySelector("#chat-body");e.focus();e.setSelectionRange(2,5);document.querySelector("#refresh").click();return true})()'); + await wait('document.querySelector("#chat-body")?.value === "alpha draft" && !document.querySelector(".chat-loading")'); + assert.deepEqual(await b.evaluate('(()=>{const e=document.querySelector("#chat-body");return [document.activeElement===e,e.selectionStart,e.selectionEnd]})()'), [true,2,5]); + await route(B); await set('#chat-body','beta draft'); await route(A); + assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'alpha draft'); + await b.evaluate(`window.__answers.push({body:{id:'${M}'}}),true`); + await click('#chat-send-form button[type=submit]'); await wait('document.querySelector("#chat-receipt")?.textContent.includes("Accepted")'); + assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), ''); + await route(B); assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'beta draft'); + assert.doesNotMatch(await b.evaluate('document.querySelector("#chat-receipt").textContent'), /Accepted/); + assert.deepEqual(await b.evaluate('window.__writes.map(x=>x.body)'), [{body:'alpha draft'}]); + assert.equal(await b.evaluate('window.__writes[0].redirect'), 'manual'); + await b.evaluate('window.__answers.push({body:{opened:location.hash.split("/").at(-1)}}),true'); + await click('#chat-resume'); await wait('document.querySelector("#chat-receipt")?.textContent.includes("requested")'); + assert.equal(await b.evaluate('window.__writes.length'), 2); +}); +test('chat browser: uncertain writes retain draft, ownership refusal clears it, read-only cannot send', async t => { + const { b, wait, set, click, route } = await setup(t); + await route(A); await set('#chat-body', 'keep this'); + await b.evaluate('window.__answers.push({reject:true}),true'); await click('#chat-send-form button[type=submit]'); + await wait('document.querySelector("#chat-receipt")?.textContent.includes("unknown")'); + assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'keep this'); + assert.equal(await b.evaluate('window.__writes.length'), 1); + await b.evaluate('window.__readFailure="no-bus-host";document.querySelector("#refresh").click();true'); + await wait('document.querySelector(".chat-error")'); + assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'keep this'); + await b.evaluate('window.__readFailure="thread-not-owned";document.querySelector("#refresh").click();true'); + await wait('document.querySelector(".chat-error")'); + assert.equal(await b.evaluate('!!document.querySelector("#chat-body")'), false); + await b.evaluate('window.__readFailure=null;document.querySelector("#refresh").click();true'); + await wait('document.querySelector("#chat-body")'); assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), ''); + await b.navigate((await b.evaluate('location.href')).replace('/#/','/?readonly#/')); + await wait('document.querySelector("#chat-body")'); + assert.equal(await b.evaluate('document.querySelector("#chat-body").disabled'), true); +}); + +test('chat browser: a late read from A cannot paint B; sign-out clears private state and stops reads', async t => { + const { b, wait, set, click, route } = await setup(t); + await route(A); await set('#chat-body', 'private alpha'); + await b.evaluate(`window.__holdRead='${A}/messages';document.querySelector('#refresh').click();true`); + await wait('typeof window.__releaseRead === "function"'); + await route(B); await set('#chat-body', 'private beta'); + assert.equal(await b.evaluate('window.__heldSignal.aborted'), true); + await b.evaluate('(async()=>{window.__holdRead=null;window.__readFailure="thread-not-owned";window.__releaseRead();await new Promise(r=>setTimeout(r,100));window.__readFailure=null;return true})()'); + assert.equal(await b.evaluate('!!document.querySelector(".chat-error")'), false); + assert.equal(await b.evaluate('document.querySelector(".chat-conversation").dataset.thread'), B); + assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'private beta'); + await b.evaluate('window.__answers.push({hold:true,redirect:true}),true'); + await click('#chat-send-form button[type=submit]'); + await wait('typeof window.__releaseWrite === "function"'); + await b.evaluate('document.dispatchEvent(new Event("mosaic-signed-out"));window.__releaseWrite();true'); + await wait('document.body.classList.contains("signed-out")'); + const count = await b.evaluate('window.__reads.length'); + await b.evaluate('document.querySelector("#refresh").click();location.hash="#/chat";true'); + await new Promise(r => setTimeout(r, 150)); + assert.equal(await b.evaluate('window.__reads.length'), count); + assert.doesNotMatch(await b.evaluate('document.body.innerText'), /private alpha|private beta/); + assert.equal(await b.evaluate('window.__writes.length'), 1); +}); + +test('chat browser: page forward and deduplicate replies; pending and byte-limit submissions do not duplicate', async t => { + const { b, wait, set, click, route } = await setup(t); + const row = { id: M, body: 'first', from_role: 'human', at: '2026-10-18T00:00:00Z' }; + await b.evaluate(`window.__pages.push(${JSON.stringify({rows:[row],after:M,hasMore:true})}),true`); + await route(A); + const reply = { ...row, id: P, body: 'assistant reply', from_role: 'assistant' }; + await b.evaluate(`window.__pages.push(${JSON.stringify({rows:[row,reply],after:P,hasMore:false})}),true`); + await click('#chat-next'); await wait('document.querySelector(".chat-log")?.textContent.includes("assistant reply")'); + assert.equal(await b.evaluate('document.querySelectorAll(".chat-log li").length'), 2); + assert.ok((await b.evaluate('window.__reads')).some(x => x.includes('after=' + M))); + await set('#chat-body', '界'.repeat(6000)); await click('#chat-send-form button[type=submit]'); + await wait('document.querySelector("#chat-receipt")?.textContent.includes("too long")'); + assert.equal(await b.evaluate('window.__writes.length'), 0); + await set('#chat-body', 'one send'); + await b.evaluate(`window.__answers.push({hold:true,body:{id:'${P}'}}),true`); + await click('#chat-send-form button[type=submit]'); await wait('typeof window.__releaseWrite === "function"'); + await b.evaluate('document.querySelector("#chat-send-form").dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}));true'); + assert.equal(await b.evaluate('window.__writes.length'), 1); + await b.evaluate('window.__releaseWrite();true'); + await wait('!document.querySelector("#chat-send-form button[type=submit]").disabled'); +}); + +test('chat browser: scrolled history stays put, New messages and an own send return to the tail', async t => { + const { b, wait, set, click, route } = await setup(t); + const rows = Array.from({length:40}, (_,i)=>({id:`90000000-0000-4000-8000-${String(i).padStart(12,'0')}`,body:'history '+i+'\nsecond line',from_role:'assistant',at:'2026-10-18T00:00:00Z'})); + await b.evaluate(`window.__pages.push(${JSON.stringify({rows,after:rows.at(-1).id,hasMore:false})}),true`); + await route(A); + await set('#chat-body','scrolled draft'); + await b.evaluate('(()=>{const l=document.querySelector(".chat-log");l.scrollTop=100;l.dispatchEvent(new Event("scroll"));document.querySelector("#refresh").click();return true})()'); + await wait('!document.querySelector("#chat-new").hidden'); + assert.equal(await b.evaluate('document.querySelector(".chat-log").scrollTop'), 100); + await click('#chat-new'); + assert.equal(await b.evaluate('(()=>{const l=document.querySelector(".chat-log");return l.scrollTop+l.clientHeight>=l.scrollHeight-2})()'), true); + await b.evaluate('(()=>{const l=document.querySelector(".chat-log");l.scrollTop=100;l.dispatchEvent(new Event("scroll"));return true})()'); + await b.evaluate(`window.__answers.push({body:{id:'${M}'}}),true`); + await click('#chat-send-form button[type=submit]'); + await wait('document.querySelector("#chat-receipt").textContent.includes("Accepted")'); + assert.equal(await b.evaluate('(()=>{const l=document.querySelector(".chat-log");return l.scrollTop+l.clientHeight>=l.scrollHeight-2})()'), true); +}); + +test('chat browser: automatic polling follows a reply without a POST or a lost draft', {timeout:20000}, async t => { + const { b, wait, set, route } = await setup(t); + await route(A); await set('#chat-body','draft during reply'); + await b.evaluate(`window.__pages.push(${JSON.stringify({rows:[{id:M,body:'automatic assistant reply',from_role:'assistant',at:'2026-10-18T00:00:00Z'}],after:M,hasMore:false})}),true`); + await b.evaluate('(async()=>{for(let n=0;n<300;n++){if(document.querySelector(".chat-log").textContent.includes("automatic assistant reply"))return;await new Promise(r=>setTimeout(r,50));}throw new Error("poll did not follow reply")})()'); + assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'draft during reply'); + assert.equal(await b.evaluate('window.__writes.length'), 0); +}); + +test('chat browser: display stays bounded while every forward page remains reachable', async t => { + const { b, wait, click, route } = await setup(t); + const rows = Array.from({length:300}, (_,i)=>({id:`91000000-0000-4000-8000-${String(i).padStart(12,'0')}`,body:'bounded message '+i,from_role:'assistant',at:'2026-10-18T00:00:00Z'})); + const pages = [0,100,200].map(start=>({rows:rows.slice(start,start+100),after:rows[start+99].id,hasMore:start<200})); + await b.evaluate(`window.__pages.push(...${JSON.stringify(pages)}),true`); + await route(A); + await click('#chat-next'); await wait('document.querySelector(".chat-log").textContent.includes("bounded message 199")'); + await click('#chat-next'); await wait('document.querySelector(".chat-log").textContent.includes("bounded message 299")'); + assert.equal(await b.evaluate('document.querySelectorAll(".chat-log li").length'), 200); + assert.doesNotMatch(await b.evaluate('document.querySelector(".chat-log").textContent'), /bounded message 0\n/); +}); + +test('chat browser: a confirmed visible message replaces its pending receipt; malformed receipts stay unknown', async t => { + const { b, wait, set, click, route } = await setup(t); + await route(A); await set('#chat-body','sent then visible'); + await b.evaluate(`window.__answers.push({body:{id:'${M}'}}),true`); + await click('#chat-send-form button[type=submit]'); await wait('document.querySelector("#chat-receipt").textContent.includes("Accepted")'); + await b.evaluate(`window.__pages.push(${JSON.stringify({rows:[{id:M,body:'sent then visible',from_role:'human'}],after:M,hasMore:false})});document.querySelector('#refresh').click();true`); + await wait('document.querySelector("#chat-receipt").textContent.includes("shown")'); + await set('#chat-body','keep malformed'); + await b.evaluate('window.__answers.push({body:{}}),true'); + await click('#chat-send-form button[type=submit]'); await wait('document.querySelector("#chat-receipt").textContent.includes("unknown")'); + assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'keep malformed'); + assert.equal(await b.evaluate('window.__writes.length'), 2); +}); + +test('chat browser: a refusal elsewhere forgets private chat drafts', async t => { + const { b, wait, set, route } = await setup(t); + await route(A); await set('#chat-body','private draft before refusal'); + await b.evaluate('window.__busRefuse=true;location.hash="#/tasks";true'); + await wait('document.querySelector("#bus-view")?.textContent.includes("refused")'); + await b.evaluate('window.__busRefuse=false;true'); + await route(A); + assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), ''); +}); diff --git a/packages/webui/tests/chat.test.mjs b/packages/webui/tests/chat.test.mjs new file mode 100644 index 000000000..dcb832d58 --- /dev/null +++ b/packages/webui/tests/chat.test.mjs @@ -0,0 +1,280 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { request } from 'node:http'; +import { createChat } from '../src/chat.mjs'; +import { startServer } from '../src/serve.mjs'; +import { BusReadError } from '../src/bus.mjs'; +import { close } from './fixture.mjs'; + +const P = '10000000-0000-4000-8000-000000000001'; +const T = '20000000-0000-4000-8000-000000000002'; +const M = '30000000-0000-4000-8000-000000000003'; +const preview = { host: 'chat.example.com', header: 'remote-user', user: 'jason' }; +const headers = { 'remote-user': 'jason', origin: 'https://chat.example.com', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }; +const posts = [ + ['/api/chat/projects', { name: 'work', repo: 'stack' }, 'project.create', { name: 'work', repo: 'stack' }, { id: P }], + ['/api/chat/threads', { project: P, name: 'discussion' }, 'thread.create', { project: P, name: 'discussion' }, { id: T, instance: 'assistant' }], + [`/api/chat/threads/${T}/messages`, { body: ' hi\nthere ' }, 'thread.send', { thread: T, body: ' hi\nthere ', class: 'REQUEST' }, { id: M }], + [`/api/chat/threads/${T}/open`, {}, 'thread.open', { thread: T }, { opened: T }], +]; +function http(port, path, { method = 'GET', value, raw, extra = {}, host = preview.host } = {}) { + const data = raw ?? (value === undefined ? undefined : JSON.stringify(value)); + return new Promise((resolve, reject) => { + const req = request({ host: '127.0.0.1', port, method, path, headers: { + host, ...headers, ...(data === undefined ? {} : { 'content-length': Buffer.byteLength(data) }), ...extra, + } }, res => { + let text = ''; + res.on('data', c => { text += c; }); + res.on('end', () => resolve({ status: res.statusCode, data: JSON.parse(text), cache: res.headers['cache-control'] })); + }); + req.on('error', reject); req.end(data); + }); +} +async function setup(t, options = {}) { + const calls = [], logs = []; + const state = { answer: [] }; + const chat = createChat({ call: async (verb, args) => { + calls.push([verb, args]); + if (state.answer instanceof Error) throw state.answer; + return typeof state.answer === 'function' ? state.answer(verb, args) : state.answer; + } }); + const server = await startServer({ port: 0, chat, preview, send: async () => ({}), log: l => logs.push(l), ...options }); + t.after(() => close(server)); + return { calls, logs, state, get: (path, opt) => http(server.address().port, path, opt), port: server.address().port }; +} + +test('chat reads map only fixed verbs, strict forward paging and pure GET', async t => { + const c = await setup(t); + for (const [path, verb, args] of [ + ['/api/chat/projects', 'project.list', {}], + ['/api/chat/threads', 'thread.list', {}], + [`/api/chat/threads?project=${P}`, 'thread.list', { project: P }], + [`/api/chat/threads/${T}/messages`, 'thread.messages', { thread: T, limit: 100 }], + [`/api/chat/threads/${T}/messages?after=${M}&limit=2`, 'thread.messages', { thread: T, after: M, limit: 2 }], + ]) { + const result = await c.get(path); + assert.equal(result.status, 200); assert.deepEqual(result.data.rows, []); assert.equal(result.cache, 'no-store'); + assert.deepEqual(c.calls.at(-1), [verb, args]); + } + c.state.answer = [{ id: M }, { id: T }]; + const page = await c.get(`/api/chat/threads/${T}/messages?limit=2`); + assert.equal(page.data.after, T); assert.equal(page.data.hasMore, true); + assert.ok(c.calls.every(([v]) => ['project.list', 'thread.list', 'thread.messages'].includes(v))); +}); + +test('chat refuses malformed and repeated query/id values before transport', async t => { + const c = await setup(t); + for (const path of [ + '/api/chat/projects?owner=jason', '/api/chat/threads?project=no', + `/api/chat/threads?project=${P}&project=${P}`, `/api/chat/threads/${T}/messages?limit=0`, + `/api/chat/threads/${T}/messages?limit=201`, `/api/chat/threads/${T}/messages?limit=1e2`, + `/api/chat/threads/${T}/messages?limit=1&limit=2`, `/api/chat/threads/${T}/messages?after=no`, + `/api/chat/threads/${T}/messages?owner=jason`, '/api/chat/threads/not-a-uuid/messages', + ]) assert.equal((await c.get(path)).status, 400, path); + assert.deepEqual(c.calls, []); +}); + +for (const [path, value, verb, args, answer] of posts) { + test(`chat ${verb}: one verified write, fixed identity/class and private log`, async t => { + const c = await setup(t); c.state.answer = answer; + const result = await c.get(path, { method: 'POST', value }); + assert.equal(result.status, 200); assert.deepEqual(c.calls, [[verb, args]]); + for (const [key, v] of Object.entries(answer)) assert.equal(result.data[key], v); + assert.equal(result.cache, 'no-store'); assert.equal(c.logs.length, 1); + assert.doesNotMatch(c.logs[0], /discussion|there|stack|capability/); + }); + test(`chat ${verb}: each existing Console write fence makes zero bus calls`, async t => { + const c = await setup(t); + for (const extra of [ + { 'remote-user': '' }, { 'remote-user': 'other' }, { 'remote-user': ['jason', 'jason'] }, + { origin: '' }, { origin: 'https://evil.example' }, { origin: ['https://chat.example.com', 'https://chat.example.com'] }, + { 'sec-fetch-site': '' }, { 'sec-fetch-site': 'cross-site' }, { 'sec-fetch-site': ['same-origin', 'same-origin'] }, + { 'content-type': 'text/plain' }, + ]) assert.ok((await c.get(path, { method: 'POST', value, extra })).status >= 400); + for (const raw of ['[]', 'null', '{', JSON.stringify({ ...value, owner: 'other' }), JSON.stringify({ body: 'x'.repeat(17000) })]) { + assert.equal((await c.get(path, { method: 'POST', raw })).status, 400); + } + assert.equal((await c.get(path + '?x=1', { method: 'POST', value })).status, 400); + assert.deepEqual(c.calls, []); + const ro = await setup(t, { send: null }); + assert.equal((await ro.get(path, { method: 'POST', value })).status, 403); assert.deepEqual(ro.calls, []); + assert.equal((await c.get(path, { method: 'POST', value, host: `127.0.0.1:${c.port}`, extra: { 'remote-user': '', origin: '' } })).status, 403); + assert.deepEqual(c.calls, []); + }); + test(`chat ${verb}: refusal or lost/malformed receipt never retries or invents success`, async t => { + const c = await setup(t); + for (const [answer_, status, error, sent] of [ + [new BusReadError('thread-not-owned', 'SENTINEL private'), 403, 'thread-not-owned', false], + [new BusReadError('thread-not-found'), 404, 'thread-not-found', false], + [new BusReadError('outcome-unknown'), 503, 'outcome-unknown', 'unknown'], + [new Error('SENTINEL'), 503, 'outcome-unknown', 'unknown'], + [{}, 503, 'outcome-unknown', 'unknown'], + ]) { + c.state.answer = answer_; const before = c.calls.length; + const result = await c.get(path, { method: 'POST', value }); + assert.equal(result.status, status); assert.equal(result.data.error, error); assert.equal(result.data.sent, sent); + assert.doesNotMatch(JSON.stringify(result.data), /SENTINEL/); assert.equal(c.calls.length, before + 1); + } + }); +} + +test('chat validates names, body, project and strict empty open payload', async t => { + const c = await setup(t); + for (const [path, value] of [ + ['/api/chat/projects', { name: '' }], ['/api/chat/projects', { name: 'x'.repeat(201) }], + ['/api/chat/projects', { name: 'a\nb' }], ['/api/chat/projects', { name: 'a\0b' }], + ['/api/chat/projects', { name: 'x', repo: '/private/path' }], + ['/api/chat/threads', { name: 'x', project: 'bad' }], ['/api/chat/threads', { project: P }], + [`/api/chat/threads/${T}/messages`, { body: '' }], [`/api/chat/threads/${T}/messages`, { body: ' ' }], + [`/api/chat/threads/${T}/messages`, { body: 'x', class: 'INFO' }], + [`/api/chat/threads/${T}/open`, { thread: T }], + ]) assert.equal((await c.get(path, { method: 'POST', value })).status, 400); + assert.deepEqual(c.calls, []); +}); + +test('chat read failures redact ownership and refuse invalid transport arrays', async t => { + const c = await setup(t); + for (const [answer, status, error] of [ + [new BusReadError('thread-not-owned', 'private-name'), 403, 'thread-not-owned'], + [new BusReadError('thread-not-found'), 404, 'thread-not-found'], + [new BusReadError('human-required'), 403, 'human-required'], + [new BusReadError('no-bus-host'), 503, 'no-bus-host'], [{ rows: [] }, 502, 'invalid-response'], + ]) { + c.state.answer = answer; const r = await c.get(`/api/chat/threads/${T}/messages`); + assert.equal(r.status, status); assert.equal(r.data.error, error); assert.doesNotMatch(JSON.stringify(r.data), /private-name/); + } +}); + +test('REQ-USER-2 real broker: owned catalog, create/name/filter, isolated history and 403/404 without mutations', async t => { + const { Store } = await import('../../bus/src/store.mjs'); + const { Broker } = await import('../../bus/src/broker.mjs'); + const { mkdtempSync, rmSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const root = mkdtempSync(join(tmpdir(), 'chat-broker-')); + const store = new Store(root); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const roles = { assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } }, chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } } }; + const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles, definitions: { assistant: {} }, projects: { stack: {} } } }; + let b = new Broker({ store, businesses }); + let cap = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + const calls = []; + const chat = createChat({ call: async (verb, args) => { + calls.push(verb); + try { return structuredClone(await b.request(cap, { verb, args })); } + catch (err) { throw new BusReadError(err.code); } + } }); + const server = await startServer({ port: 0, chat, preview, send: async () => ({}), log: () => {} }); + t.after(() => close(server)); + const get = (p, opt) => http(server.address().port, p, opt); + const write = (p, value) => get(p, { method: 'POST', value }); + const project = (await write('/api/chat/projects', { name: 'Project one', repo: 'stack' })).data.id; + assert.match(project, /^[a-f0-9-]{36}$/); + assert.equal((await write('/api/chat/projects', { name: 'Project one' })).status, 409); + assert.equal((await write('/api/chat/projects', { name: 'bad repo', repo: 'unknown' })).status, 400); + const a = (await write('/api/chat/threads', { project, name: 'alpha' })).data.id; + const bb = (await write('/api/chat/threads', { project, name: 'beta' })).data.id; + assert.equal((await write('/api/chat/threads', { project, name: 'alpha' })).status, 409); + assert.deepEqual((await get(`/api/chat/threads?project=${project}`)).data.rows.map(x => x.id), [a, bb]); + assert.deepEqual((await get(`/api/chat/threads?project=${P}`)).data.rows, []); + const other = '40000000-0000-4000-8000-000000000004'; + store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', + other, new Date().toISOString(), 'demo', project, 'assistant', 'someone-else', 'PRIVATE-NAME'); + store.run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)', + M, new Date().toISOString(), 'demo', 'human', 'human-cli', 'assistant', 'INFO', 'PRIVATE-BODY'); + store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', M, other); + const foreignProject = '50000000-0000-4000-8000-000000000005'; + const foreignThread = '60000000-0000-4000-8000-000000000006'; + store.run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', + foreignProject, new Date().toISOString(), 'foreign', 'FOREIGN-PROJECT', null, 'jason'); + store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', + foreignThread, new Date().toISOString(), 'foreign', foreignProject, 'assistant', 'jason', 'FOREIGN-THREAD'); + assert.equal((await get('/api/chat/threads')).data.rows.length, 2); + const before = ['messages', 'thread_opens', "events WHERE kind='action.allowed'"].map(table => store.get(`SELECT count(*) n FROM ${table}`).n); + for (const id of [other, T, foreignThread]) for (const suffix of ['messages', 'open']) { + const methods = suffix === 'messages' ? ['GET', 'POST'] : ['POST']; + for (const method of methods) { + const r = await get(`/api/chat/threads/${id}/${suffix}`, { method, value: method === 'GET' ? undefined : suffix === 'open' ? {} : { body: 'not allowed' } }); + assert.equal(r.status, id === other ? 403 : 404); + assert.equal(r.data.error, id === other ? 'thread-not-owned' : 'thread-not-found'); + assert.doesNotMatch(JSON.stringify(r.data), /PRIVATE|FOREIGN|someone-else/); + } + } + assert.deepEqual(['messages', 'thread_opens', "events WHERE kind='action.allowed'"].map(table => store.get(`SELECT count(*) n FROM ${table}`).n), before); + const one = (await write(`/api/chat/threads/${a}/messages`, { body: 'only alpha' })).data.id; + const two = (await write(`/api/chat/threads/${a}/messages`, { body: 'second alpha' })).data.id; + await write(`/api/chat/threads/${bb}/messages`, { body: 'only beta' }); + const opens = store.get('SELECT count(*) n FROM thread_opens').n; + const deliveries = store.get('SELECT count(*) n FROM deliveries').n; + assert.deepEqual((await get(`/api/chat/threads/${a}/messages?limit=1`)).data.rows.map(x => x.id), [one]); + assert.deepEqual((await get(`/api/chat/threads/${a}/messages?limit=1&after=${one}`)).data.rows.map(x => x.id), [two]); + assert.equal((await get(`/api/chat/threads/${bb}/messages?after=${one}`)).status, 400); + assert.equal(store.get('SELECT count(*) n FROM deliveries').n, deliveries); + assert.equal(store.get('SELECT count(*) n FROM thread_opens').n, opens); + assert.equal((await write(`/api/chat/threads/${a}/open`, {})).data.opened, a); + assert.equal(store.get('SELECT count(*) n FROM thread_opens').n, opens + 1); + for (const [roles_, code] of [[{ chief: roles.chief }, 'thread-instance-missing'], [{ ...roles, second: roles.assistant }, 'thread-instance-ambiguous']]) { + b = new Broker({ store, businesses: { demo: { ...businesses.demo, roles: roles_ } } }); + cap = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + assert.equal((await write('/api/chat/threads', { project, name: code })).data.error, code); + } +}); + +test('chat repeats every POST boundary even when called behind an admitted request', async () => { + const { Readable } = await import('node:stream'); + const { chatHTTP } = await import('../src/chat-http.mjs'); + for (const [path, value] of posts) for (const fence of ['send', 'named', 'identity', 'origin', 'site']) { + const req = Readable.from([Buffer.from(JSON.stringify(value))]); + req.method = 'POST'; req.headers = { ...headers }; + req.headersDistinct = Object.fromEntries(Object.entries(headers).map(([k,v]) => [k,[v]])); + if (fence === 'identity') req.headersDistinct['remote-user'] = ['jason', 'jason']; + if (fence === 'origin') req.headersDistinct.origin = []; + if (fence === 'site') req.headersDistinct['sec-fetch-site'] = []; + let status, result, calls = 0; + const chat = createChat({ call: async () => { calls++; return { id: M }; } }); + const res = { writeHead(code) { status = code; }, end(bytes) { result = JSON.parse(bytes); } }; + await chatHTTP(req, res, { path, search: '', chat, named: fence !== 'named', + send: fence === 'send' ? null : async () => ({}), preview, log: () => {} }); + assert.equal(status, fence === 'identity' ? 401 : 403, path + ' ' + fence); + assert.equal(result.sent, false); assert.equal(calls, 0); + } +}); + +test('configured Console process injects chat through its existing human transport', { timeout: 15000 }, async t => { + const { spawn } = await import('node:child_process'); + const { tmp, fixture } = await import('../../cli/tests/helpers.mjs'); + const { fileURLToPath } = await import('node:url'); + const root = tmp(t, 'webui-chat-cli-'), { env } = fixture(root); + const cli = fileURLToPath(new URL('../src/cli.mjs', import.meta.url)); + const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', + '--public-host', preview.host, '--auth-header', preview.header, '--allow-user', preview.user, '--send'], + { env, stdio: ['ignore', 'pipe', 'pipe'] }); + t.after(async () => { + if (child.exitCode === null) { child.kill(); await new Promise(resolve => child.once('exit', resolve)); } + }); + let output = '', errors = ''; + child.stderr.on('data', c => { errors += c; }); + const port = await new Promise((resolve, reject) => { + child.stdout.on('data', c => { output += c; const m = output.match(/Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//); if(m) resolve(Number(m[1])); }); + child.once('exit', code => reject(Error(`Console fixture exited ${code}: ${errors}`))); + }); + // With a private fixture config but no fixture host, reaching the configured human + // transport gives no-bus-host; missing adapter injection would give not-configured. + for (const [path, options] of [['/api/chat/projects', {}], ['/api/chat/projects', { method:'POST', value:{name:'fixture'} }]]) { + const r = await http(port, path, options); + assert.equal(r.status, 503); assert.equal(r.data.error, 'no-bus-host'); + } +}); + +test('chat assets, GET purity, method and unknown-route refusals are explicit', async t => { + const c = await setup(t); + for (const name of ['chat.js', 'chat.css']) { + const result = await fetch(`http://127.0.0.1:${c.port}/${name}`); + assert.equal(result.status, 200); + assert.match(result.headers.get('content-type'), name.endsWith('.js') ? /javascript/ : /css/); + } + assert.equal((await c.get(`/api/chat/threads/${T}/open`)).status, 404); + assert.equal((await c.get('/api/chat/private')).status, 404); + assert.equal((await c.get('/api/chat/projects', { method:'DELETE' })).status, 405); + assert.deepEqual(c.calls, []); +});