webui: Console chat area over the bus's project and thread verbs (row 129, #1623)

Adds the Console chat view: project and thread lists, named create forms,
per-thread drafts, explicit Resume, paging and signed-in sends. The one
HTTP-to-bus seam is packages/webui/src/chat.mjs, with fixed routes in
chat-http.mjs: exact HTTPS Origin, same-origin fetch site, JSON content
type, and 403 for each. Every label and message body renders inert,
including bidi controls. CSP unchanged, no inline script.

Owner Rocko. Reviewer Filbert: r1 changes (comment 28933), r2 approve
(comment 28950) on candidate manifest 9a665434...4034, with the four r1
survivors killed by new assertions. Mos decision 123 go on the public/
diff, sha 78574095 (mos-0780). Sage gate on 19fe85c3e: 25 steps, node
2216/0, scripts 422/0, webui 179/179.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 12:16:42 -05:00
co-authored by Claude Opus 5.5
parent 8e7ea89a1e
commit 850484a7d2
15 changed files with 1065 additions and 15 deletions
+26
View File
@@ -665,3 +665,29 @@ safe integer range are decimal strings in JSON. Settings has the same
read-only Token usage section. Counts are a best reading, not proof: the
session can edit its own transcript. This command changes no setting and
performs no token-limit enforcement.
### 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.
+33
View File
@@ -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.
+121
View File
@@ -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);
}
}
+14
View File
@@ -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 }),
});
}
+4 -2
View File
@@ -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.`);
+10 -4
View File
@@ -239,7 +239,7 @@
// ---------------------------------------------------------------- views
let inboxCount = null, messageCount = null;
function sections(route) {
const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Messages', messageCount === null ? '' : `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>`, '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 ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/chat', 'Chat', '', 'inbox'], ['/messages', 'Messages', messageCount === null ? '' : `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>`, '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 `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true"><use href="/icons.svg#i-${icon}"/></svg>${l}${extra ? ` ${extra}` : ''}</a></li>`;
@@ -771,7 +771,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))}<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err)} Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>${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 => `<div class="banner err s1-stale" role="alert"><p><b>The last read failed.</b> ${why(err)} This is what was read before, ${when(readAt)}. Nothing was changed.</p>${retry}</div>`;
// Freshness line in the command bar for bus pages.
@@ -840,6 +840,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(); }
@@ -933,7 +939,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)}` }));
@@ -1031,7 +1037,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));
+19
View File
@@ -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; } }
+247
View File
@@ -0,0 +1,247 @@
/* 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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, tailRequested: false, receiptId: null });
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) ? `<a href="#/chat/${type}/${id}">${txt(label)}</a>` : txt(label);
const heading = text => `<h1 tabindex="-1">${txt(text)}</h1>`;
const disabled = pending => !current.canSend || out() || pending ? ' disabled' : '';
const stamp = at => `<time>${txt(at)}</time>`;
function creation(kind, key) {
const f = form(key), project = kind === 'project';
const repo = project && current.repos.length ? `<label>Repository (optional)<select id="chat-repo" data-chat-field="repo"><option value="">No repository link</option>${current.repos.map(r => `<option value="${esc(r)}"${r === f.repo ? ' selected' : ''}>${txt(r)}</option>`).join('')}</select></label>` : '';
return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}<button class="btn" type="submit"${disabled(f.pending)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(f.receipt)}</p></form>`;
}
function threads(rows) {
return rows.length ? `<ul class="chat-thread-list">${rows.map(t => `<li>${link('threads', t.id, t.name)} <span class="muted">${txt(t.instance)}</span></li>`).join('')}</ul>` : '<p>No threads yet.</p>';
}
function conversation(t) {
const s = state(t.id);
const messages = s.rows.map(m => `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(m.from_role)}</strong> ${stamp(m.at)}</div><div class="chat-body">${txt(m.body)}</div></li>`).join('');
const receipt = s.receipt;
return `<section class="chat-conversation" data-thread="${esc(t.id)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p><button class="btn" type="button" id="chat-resume"${disabled(s.pending)}>Resume assistant</button><ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || '<li class="muted">No messages yet.</li>'}</ol><div class="chat-paging">${s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div><form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabled(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabled(s.pending)}>Send</button></form><p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
}
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 = '<div class="chat-area"><p><a href="#/chat">Chat projects</a></p>';
if (c.error) html += `<p class="chat-error banner err" role="alert">${txt(c.error)}</p>`;
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') + '<p>Business projects and your named assistant threads.</p><div class="chat-projects">' +
c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id))}</section>`).join('') + (c.projects.length ? '' : '<p>No projects yet.</p>') + '</div>' + creation('project', 'projects');
}
if (!c.canSend) html += '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
c.container.innerHTML = html + '</div>';
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.tailRequested || s.follow && (keep?.tail ?? true)) log.scrollTop = log.scrollHeight;
else if (keep?.top !== undefined) log.scrollTop = keep.top;
s.tailRequested = false;
}
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 = '<p class="chat-loading" role="status">Reading chat…</p>';
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;
if (s.receiptId && s.rows.some(m => m.id === s.receiptId)) {
s.receipt = 'Message shown in this conversation.'; s.receiptId = null;
}
}
// 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;
}
const startedRoute = location.hash;
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; s.tailRequested = true; s.receiptId = result.id;
} else if (action === 'open') s.receipt = 'Assistant resume requested.';
else {
s.name = ''; s.repo = '';
if (location.hash === startedRoute) 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).tailRequested = true; state(current.id).follow = true; paint(); }
});
document.addEventListener('mosaic-signed-out', () => { signedOut = true; forget(); });
window.MosaicChat = Object.freeze({ render, leave, forget });
})();
+2 -2
View File
@@ -1,8 +1,8 @@
<!doctype html>
<html lang="en" data-design="console" data-palette="harbor">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Mosaic Console</title>
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/shell.css">
<script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/bus.js" defer></script></head>
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/shell.css">
<script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/chat.js" defer></script><script src="/bus.js" defer></script></head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
+7 -3
View File
@@ -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']]),
@@ -261,7 +262,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);
@@ -310,6 +311,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.
+2 -1
View File
@@ -18,7 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
await b.evaluate('document.querySelector("#hide-offline").click()');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
assert.equal(await b.evaluate('document.querySelectorAll("script").length'), 4);
assert.equal(await b.evaluate('document.querySelectorAll("script").length'), 5);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
assert.equal(await b.evaluate('!!window.injected'), false);
// Project filtering does not hide another project's waiting requests.
await b.evaluate('document.querySelector("[data-project=proj]").click()');
+266
View File
@@ -0,0 +1,266 @@
// 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 = '<img src=x onerror="window.injected=1">\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:'\"><img src=x>',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.startsWith('/api/bus/'))return new Response(JSON.stringify({rows:[],unread:0,at:new Date().toISOString()}));
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")');
const refresh = async () => {
await wait('!document.querySelector("#refresh").disabled');
await b.evaluate('document.querySelector(".chat-area").dataset.beforeRefresh="yes";document.querySelector("#refresh").click();true');
await wait('!document.querySelector(".chat-area")?.dataset.beforeRefresh');
};
return { b, wait, set, click, route, refresh };
}
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|␛/);
const projectText = await b.evaluate('document.querySelector(".chat-projects").textContent');
assert.doesNotMatch(projectText, /[\u202a-\u202e\u2066-\u2069]/u);
assert.match(projectText, /\\u202e/);
assert.equal(await b.evaluate(`!!document.querySelector('#sections a[href="#/chat"]')`), true);
await b.key('k', 'KeyK', 2);
await wait('document.querySelector("#cmdk-dialog").open');
assert.equal(await b.evaluate(`!!document.querySelector('#cmdk-list [data-href="#/chat"]')`), true);
await b.key('Escape');
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' });
assert.equal(await b.evaluate('window.__writes[0].path'), '/api/chat/projects');
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[1].path'), '/api/chat/threads');
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, refresh } = 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);
const messageText = await b.evaluate('document.querySelector(".chat-log").textContent');
assert.doesNotMatch(messageText, /[\u202a-\u202e\u2066-\u2069]/u);
assert.match(messageText, /\\u202e/);
await set('#chat-body', 'alpha draft');
await b.evaluate('(()=>{const e=document.querySelector("#chat-body");e.focus();e.setSelectionRange(2,5);return true})()');
await refresh();
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');
assert.equal(await b.evaluate('window.__writes[0].path'), `/api/chat/threads/${A}/messages`);
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);
assert.equal(await b.evaluate('window.__writes[1].path'), `/api/chat/threads/${B}/open`);
});
test('chat browser: uncertain writes retain draft, ownership refusal clears it, read-only cannot send', async t => {
const { b, wait, set, click, route, refresh } = 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";true');
await refresh();
await wait('document.querySelector(".chat-error")');
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'keep this');
await wait('!document.querySelector("#refresh").disabled');
await b.evaluate('window.__readFailure="thread-not-owned";true');
await refresh();
await wait('document.querySelector(".chat-error")?.textContent.includes("not available")');
assert.equal(await b.evaluate('!!document.querySelector("#chat-body")'), false);
await wait('!document.querySelector("#refresh").disabled');
await b.evaluate('window.__readFailure=null;true');
await refresh();
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);
await set('#chat-body','read-only must not submit');
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'), 0);
});
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, refresh } = 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, refresh } = 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, refresh } = 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"));return true})()');
await refresh();
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, refresh } = 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})});true`);
await refresh();
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'), '');
});
test('chat browser: a late project creation receipt cannot navigate away from another thread', async t => {
const { b, wait, set, click, route, refresh } = await setup(t);
await set('#chat-project-name','late project');
await b.evaluate(`window.__answers.push({hold:true,body:{id:'${P}'}}),true`);
await click('#chat-project-form button[type=submit]');
await wait('typeof window.__releaseWrite === "function"');
await route(A); await set('#chat-body','stay in alpha');
await b.evaluate('(async()=>{window.__releaseWrite();await new Promise(r=>setTimeout(r,100));return true})()');
assert.equal(await b.evaluate('location.hash'), '#/chat/threads/' + A);
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'stay in alpha');
});
+310
View File
@@ -0,0 +1,310 @@
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 createThread receipt requires an instance even when its id is valid', async t => {
const c = await setup(t);
for (const answer of [{ id: T }, { id: T, instance: 'invalid/instance' }, { id: T, instance: 3 }]) {
c.state.answer = answer;
const before = c.calls.length;
const result = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'discussion' } });
assert.equal(result.status, 503);
assert.equal(result.data.error, 'outcome-unknown');
assert.equal(result.data.sent, 'unknown');
assert.equal(c.calls.length, before + 1);
}
});
test('chat write receipts expose only the fields belonging to their operation', async t => {
for (const [path, value, , , answer] of posts) {
const c = await setup(t);
c.state.answer = { owner: 'private owner', capability: 'not-a-capability', body: 'private body', ...answer };
const result = await c.get(path, { method: 'POST', value });
assert.equal(result.status, 200);
const { at, ...receipt } = result.data;
assert.equal(typeof at, 'string');
assert.deepEqual(receipt, answer);
}
});
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/);
}
c.state.answer = { rows: [] };
for (const path of ['/api/chat/projects', '/api/chat/threads']) {
const result = await c.get(path);
assert.equal(result.status, 502); assert.equal(result.data.error, 'invalid-response');
}
});
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, []);
});
+3 -2
View File
@@ -263,7 +263,8 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
await flat(); await shot(`bus-empty-${h1.toLowerCase()}-400`);
}
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#fresh-board")).display'), 'none');
assert.equal(await b.evaluate('document.querySelectorAll("#sections svg use").length'), 9);
assert.equal(await b.evaluate('document.querySelectorAll("#sections svg use").length'), 10);
assert.deepEqual(await b.evaluate(`(() => { const a = document.querySelector('#sections a[href="#/chat"]'); return [a?.textContent.trim(), a?.querySelector('svg use')?.getAttribute('href')]; })()`), ['Chat', '/icons.svg#i-inbox']);
for (const sel of ['#fresh', '#session-count']) assert.equal(await b.evaluate(`getComputedStyle(document.querySelector(${JSON.stringify(sel)})).fontVariantNumeric`), 'tabular-nums', sel);
// Before any copy the toast is an empty live region still in the accessibility tree, out of sight.
assert.deepEqual(await b.evaluate('(()=>{const t=document.querySelector("#toast"),c=getComputedStyle(t),r=t.getBoundingClientRect();return [c.display!=="none",c.visibility,t.textContent,r.width<=1&&r.height<=1]})()'), [true, 'visible', '', true]);
@@ -459,7 +460,7 @@ test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 }
}
// After a refusal the palette lists only the views: no kept decision or task.
const views = ['#/', '#/inbox', '#/messages', '#/tasks', '#/agents', '#/queue', '#/business', '#/docs', '#/settings'];
const views = ['#/', '#/chat', '#/inbox', '#/messages', '#/tasks', '#/agents', '#/queue', '#/business', '#/docs', '#/settings'];
const palette = '[...document.querySelectorAll("#cmdk-list [data-href]")].map(e=>e.dataset.href)';
await b.evaluate('location.hash="#/inbox"'); await wait('document.querySelectorAll("#sections .count").length===1');
assert.match(await text('#sections'), /Inbox 1 1 blocking/);
+1 -1
View File
@@ -56,7 +56,7 @@ async function page(b, base) {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: PROBE });
await b.viewport(400, 900);
await b.navigate(base);
await wait('document.querySelectorAll("#sections a").length===9');
await wait(`document.querySelectorAll("#sections a").length===10 && document.querySelector('#sections a[href="#/chat"]')?.textContent.trim()==='Chat'`);
return { wait, text, count, flat, refresh, go };
}