Compare commits

...
7 changed files with 237 additions and 31 deletions

No files matched your search

+6
View File
@@ -6144,3 +6144,9 @@ Task 8b final whole-package gates, all zero failures/cancellations/skips: bus 15
Lead-only live window checks, not executed here: (1) explicitly authorize and select a named isolated development instance on .53 with its own data root/marker, installed source/board, scoped credentials, real PM/CTO/chief/coder/reviewer/assistant routes and signed-in owner Console; retain preflight and refuse any missing resource rather than provision it. (2) Run packages/cli/live/request-lifecycle.mjs with explicit --instance, --config, --business, --owner, --request-file and --evidence plus --authorize-isolated-run, from a genuine owner shell so the existing process proof applies; use --submit with an existing owned --thread, or observe the owner's existing --request. The owner selects/confirms the exact displayed autonomous plan once in Console. No user relay, manual task creation or worker shell command is part of the lifecycle. (3) Retain original request/message bytes, exact plan confirmation, approved snapshot/decision, tracker create/assignment, working commit, independent reviewer fetch/test/record/verdict, matching closure, delivery and chief/assistant notification; check identical project/snapshot/task/commit/review/verdict IDs across the chain. (4) Download through the owner-only Console URL, rerun observation with --request and --artifact, and require byte length/SHA to equal the reviewed delivery. Open that downloaded HTML with network disabled and verify the approved durations, start, pause/resume/reset, both transitions and the phase-end cue after Start, with no external requests/assets. Inspect actual independent model/session/test receipts before declaring live acceptance. (5) If live crash checks are authorized, kill only the owned isolated broker at the eight named checkpoints; preserve DB/WAL/SHM and repositories. Follow the existing operator rule: establish the exact writer PID is dead, retain its lock identity, and explicitly remove only the stale lock/socket before supported restart. There is no production automatic stale-lock reclaim and this task does not add one. Require unchanged historical rows, identical known IDs/commits and no duplicate project/task/artifact; an unprovable remote outcome must remain a visible wait. Full invocation and qualifications are in packages/cli/live/README.md. Stage 4 live acceptance remains with the lead.
2026-11-04 | Lead Codex, #815 / Gitea #1843 chat-channel recovery before | Owner /chat screenshots show failed read plus false empty state and Create project channel-disabled. Read-only .53 checks: plugins.channel absent; existing usage-source transcript and harness claude-code retained; Console journal has matching refusal; existing typed tester plugins API is authorized. Failure precedes project dispatch. Plan: typed tester channel-only correction with private config backup/readback and no restart; Codex source fix for factory channel declaration and accurate failure UI, tests/review before integration. No signed-owner workflow or schema/role/secret change.
2026-11-04 | Codex implementation sub-agent | #815 / Gitea #1843 Task 2, before (recorded during implementation) | Started in T3-prepared branch t3/channel-disabled-chat-recovery at controller base 3572bf9b32f414283686e828155ae859a45c537e, source baseline 51525a31d. Scope: explicit Case 1 CLI/WebUI opt-in and Console channel-refusal/failed-read recovery; preserve global defaults, enforcement and no-tracker starter. Controller owns coordination and live repair; no live config, role, queue or transport changes.
2026-11-04 | Codex implementation sub-agent | #815 / Gitea #1843 Task 2, after | Case 1 explicitly opts into cli and webui; no-tracker starter and global defaults remain unchanged. Console renders a fixed disabled-channel explanation, retains in-memory drafts and distinguishes definite refusal from unknown outcome, disables known-refused creation/send actions, marks cached reads stale and does not claim empty on failed reads. TDD red logs and green gates under /mnt/storage/scratch/tmp/mosaic-815-logs/: final targeted chat/room HTTP and Chromium 100/100, full WebUI with MOSAIC_REQUIRE_EDITOR_BUILD=1 574/574, business 307/307, zero skips/cancellations. First full WebUI after root npm ci: 569 passed, 1 missing WebUI esbuild failure, 1 editor-build skip; WebUI lockfile install restored the pinned dependency. Concurrent run: 571/572 with one unrelated decide-browser I1 wait timeout; isolated decide-browser 9/9, subsequent full gate green. Superseded full gates were stopped when later regression corrections changed the source. No bus.mjs/proof pin, role, policy, auth, live host/config, queue, push or deployment change. Draft recovery tested through the Console Refresh action; full browser reload does not persist in-memory drafts.
2026-11-04 | Codex implementation sub-agent | #815 / Gitea #1843 source fix round 1, stopped | At base 5558fe020 reproduced room channel state loss and sticky legacy-session controls in real Chromium, then made the scoped state corrections; focused red/green logs and the 47/47 covering-browser pass are under /mnt/storage/scratch/tmp/mosaic-815-r1/. The single authorized full WebUI run with MOSAIC_REQUIRE_EDITOR_BUILD=1 and concurrency 2 finished 575/576, zero skips/cancellations: unrelated thread-claims-browser row278 timed out waiting for its table; that entire file then passed 6/6 alone. The unchanged vendored-editor import warning remained, and the editor rebuild passed. No commit: the full applicable suite is not green, and the brief specified one full run. Controller owns the decision on further verification. Minor cached-room stale-label finding remains unchanged. No live, bus/proof, authentication, policy, role, schema, tracker, push, merge or PR change.
2026-11-04 | #815 / Gitea #1843 source fix round 1 controller verification | Existing authorization covered another verification run after the unchanged row278 browser timeout. Same command and test settings: MOSAIC_REQUIRE_EDITOR_BUILD=1 node --test --test-concurrency=2 packages/webui/tests/; 576 passed, zero failures/cancellations/skips, required editor rebuild passed, duration 168128 ms. Source/test blob hashes unchanged from pre-run snapshot; no test weakening or unrelated source changes. Both earlier full failure and isolated 6/6 evidence retained. Log: /mnt/storage/scratch/tmp/mosaic-815-r1/full-webui-controller-retry.log. Local commit now permitted; independent I1/I2 re-review next.
+3
View File
@@ -2029,3 +2029,6 @@ are never rewritten or removed; corrections are new entries.
2026-11-04 | Codex implementation sub-agent, t3/request-lifecycle-task-8-76f8d365 | #797 / Gitea #1839 Task 8b repository acceptance | Three paths, eight separate-process crashes, tracker uncertainty, real typed tracker/Git/review/delivery, offline Chromium and guarded live runner; assistant-origin owner controls and exact PRD cancellation fixed red-to-green. Five older launcher fixture protocols forwarded to real request-host methods. Final gates 4300 tests across bus/CLI/tasks/project/harness/business/seat/WebUI, zero failures/skips/cancellations; full CLI includes opt-in R7 in isolated fake HOME. Earlier CLI 52 failures/1 skip and isolated reproductions retained. Logs/evidence index /mnt/storage/scratch/tmp/tupyBK/. No migration, role/ROUTED/security expansion, install, canonical edit, live acceptance or push. Second local commit; lead-only live checks in BUILD-LOG and packages/cli/live/README.md.
2026-11-04 | GPT-6 Luna implementation sub-agent, t3/lifecycle-task-7b-r2 | Gitea #1839 lifecycle Task 7b rebase | Stopped before branch creation: packages/webui/src/bus.mjs differs between requested base 34e9af616 and source 1904c9775 (blob hashes 2d19a538b809a808a53307f049363e62e854ea96 vs 593d12b1582a4a3a285e0b89b32d9ee6e3ab726d); pinned-hash guard requires reporting. No cherry-pick, tests, or commit.
2026-11-04 | Lead Codex GPT-6.1 Sol, provider recovery | #815 / Gitea #1843 live MVP 3 /chat failure | Recovered through .53 Window D at 51525a31d/schema 3s; canonical fast-forwarded from b5a58b48b, prior Luna uncommitted append preserved. Diagnosed absent plugins.channel and channel-disabled before project dispatch; typed tester plugins read succeeded. Repair limited to validated channel setting and source error/template regressions; no owner acceptance or secret access.
2026-11-04 | Codex implementation sub-agent, t3/channel-disabled-chat-recovery | #815 / Gitea #1843 Task 2 source fix | Explicit cli/webui Case 1 opt-in and fixed disabled-channel/error/empty-state UI, draft retention and safe retry distinction; targeted chat/room 100, full WebUI 574 and business 307 passed with zero skips. Earlier dependency and concurrent browser timeout results and logs recorded in BUILD-LOG; Console Refresh preserves in-memory drafts, full browser reload does not. Local commit only; controller owns independent review and integration.
2026-11-04 | Codex implementation sub-agent, t3/channel-disabled-chat-recovery | #815 / Gitea #1843 source fix round 1 | Both browser regressions red then green; covering 47/47. Single full WebUI run 575/576, zero skips, unrelated thread-claims browser timeout; that file passed 6/6 isolated. No local commit because applicable full suite did not pass and brief specified one full run. Logs /mnt/storage/scratch/tmp/mosaic-815-r1/; controller to decide further verification. No live or policy changes.
2026-11-04 | Lead Codex GPT-6.1 Sol, controller verification | #815 / Gitea #1843 fix round 1 | Repeated unchanged full affected WebUI gate in completed implementer checkout: 576/576, zero skips/cancellations, required editor rebuild passed; candidate source/test hashes unchanged. Preserved prior 575/576 and isolated 6/6 results; local commit and independent re-review next. No live change or acceptance claim.
@@ -15,6 +15,7 @@
"bus.tester": true
},
"plugins": {
"channel": ["cli", "webui"],
"harness": ["claude-code"]
},
"roles": {
@@ -70,6 +70,8 @@ test("row 323: committed Case 1 document loads unchanged through the business lo
assert.deepEqual(b.projects, { "stack": { root: "/srv/mosaic/src/mosaic-stack" } });
assert.deepEqual(Object.keys(b.roles), ["chief", "assistant"]);
assert.deepEqual(b.plugins.harness, ["claude-code"]);
assert.deepEqual(document().plugins.channel, ["cli", "webui"]);
assert.deepEqual(b.plugins.channel, ["cli", "webui"]);
assert.equal(b.launch.by, "chief");
assert.deepEqual(b.launch.instances, ["assistant"]);
assert.deepEqual(b.arbiters, { delivery: "chief", technical: "chief" });
+42 -29
View File
@@ -31,6 +31,7 @@
class ChatError extends Error {
constructor(code, status = 0, sent) { super(code); this.code = code; this.status = status; this.sent = sent; }
}
const channelMessage = 'The chat channel is disabled for this business. Ask the person who manages it to enable WebUI chat, then refresh.';
function forget() {
generation++; controller?.abort(); states.clear(); forms.clear(); libraries.clear(); reverts.clear();
window.MosaicRequests?.forget();
@@ -87,10 +88,10 @@
const picker = project ? '' : `<label>Thread with<select id="chat-instance" data-chat-field="instance" required${disabled(f.pending || !!current.instanceError || !choices.length)}><option value="">Choose who this thread is with</option>${choices.map(row => `<option value="${esc(row.instance)}"${row.instance === f.instance ? ' selected' : ''}>${txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)}</option>`).join('')}</select></label>`;
const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));
const status = !project && current.instanceError ? current.instanceError : f.receipt || (!project && !choices.length ? 'No thread instances are configured.' : '');
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}${picker}<button class="btn" type="submit"${disabled(f.pending || unavailable)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(status)}</p></form>`;
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}${picker}<button class="btn" type="submit"${disabled(f.pending || unavailable || current.channelDisabled)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(status)}</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)}${t.shared ? ` · Shared by ${txt(t.owner)}` : ''}</span></li>`).join('')}</ul>` : '<p>No threads yet.</p>';
return rows.length ? `<ul class="chat-thread-list">${rows.map(t => `<li>${link('threads', t.id, t.name)} <span class="muted">${txt(t.instance)}${t.shared ? ` · Shared by ${txt(t.owner)}` : ''}</span></li>`).join('')}</ul>` : current.readFailed ? '<p>Threads unavailable.</p>' : '<p>No threads yet.</p>';
}
const sessionPath = (thread, session) => `#/chat/threads/${thread}/sessions/${session}`;
const countLabel = value => !value ? 'unavailable (count collector not installed)' :
@@ -102,7 +103,7 @@
const c = current, selected = c.sessions.find(r => r.id === c.session), f = form('session:' + t.id);
const clear = form('clear:' + c.key);
const reason = !Number.isSafeInteger(selected?.revision) ? 'Safe clear is unavailable until its dependency is installed.' : '';
return `<section class="chat-session-controls" aria-label="Sessions"><label>Session<select id="chat-session">${c.sessions.map(r => `<option value="${esc(r.id)}"${r.id === c.session ? ' selected' : ''}>${txt(r.name)}${r.default ? ' (default)' : ''} — ${txt(r.state ?? 'status unavailable')}</option>`).join('')}</select></label><p id="chat-compactions">Compactions: ${txt(countLabel(selected?.compactions))}</p><p>${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · <a href="${sessionPath(t.id, selected.successor)}">Open fresh successor</a>` : ''}</p><form id="chat-session-form" class="chat-create" data-key="session:${esc(t.id)}"><label>New session name<input id="chat-session-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label><button class="btn" type="submit"${disabled(f.pending)}>New session</button><p role="status">${txt(f.receipt)}</p></form><button class="btn" type="button" id="chat-clear"${disabled(clear.pending || !!reason || selected?.state && selected.state !== 'active')}>Clear context</button>${reason ? `<p>${txt(reason)}</p>` : ''}<p id="chat-clear-receipt" role="status">${txt(clear.receipt)}</p><form id="chat-operation-form"><label>Check clear operation<input id="chat-operation" value="${esc(clear.op ?? '')}" maxlength="160"${clear.pending ? ' disabled' : ''}></label><button class="btn" type="submit">Check status</button><button class="btn" type="button" id="chat-cancel"${disabled(clear.pending || !!reason || !clear.op || ['cancelled', 'failed'].includes(clear.record?.state) || clear.record?.start === 'started')}>Cancel clear</button></form></section>`;
return `<section class="chat-session-controls" aria-label="Sessions"><label>Session<select id="chat-session">${c.sessions.map(r => `<option value="${esc(r.id)}"${r.id === c.session ? ' selected' : ''}>${txt(r.name)}${r.default ? ' (default)' : ''} — ${txt(r.state ?? 'status unavailable')}</option>`).join('')}</select></label><p id="chat-compactions">Compactions: ${txt(countLabel(selected?.compactions))}</p><p>${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · <a href="${sessionPath(t.id, selected.successor)}">Open fresh successor</a>` : ''}</p><form id="chat-session-form" class="chat-create" data-key="session:${esc(t.id)}"><label>New session name<input id="chat-session-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label><button class="btn" type="submit"${disabled(f.pending || current.channelDisabled)}>New session</button><p role="status">${txt(f.receipt)}</p></form><button class="btn" type="button" id="chat-clear"${disabled(clear.pending || !!reason || selected?.state && selected.state !== 'active')}>Clear context</button>${reason ? `<p>${txt(reason)}</p>` : ''}<p id="chat-clear-receipt" role="status">${txt(clear.receipt)}</p><form id="chat-operation-form"><label>Check clear operation<input id="chat-operation" value="${esc(clear.op ?? '')}" maxlength="160"${clear.pending ? ' disabled' : ''}></label><button class="btn" type="submit">Check status</button><button class="btn" type="button" id="chat-cancel"${disabled(clear.pending || !!reason || !clear.op || ['cancelled', 'failed'].includes(clear.record?.state) || clear.record?.start === 'started')}>Cancel clear</button></form></section>`;
}
// A cite is changed when the file's last commit at the served tip (`last`, read with the page) is not
// its last commit at the cited one (`file`, stored with the check). Either one missing marks nothing.
@@ -204,7 +205,7 @@
const withRequest = requestRows(s.rows);
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>${messageBody(m)}${withRequest.has(m.id) ? `<div class="request-mount" data-request="${esc(m.request)}"></div>` : ''}</li>`).join('');
const receipt = s.receipt;
return `<section class="chat-conversation" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}${libraryLine(libraryOf(current, t))}${sessionControls(t)}${sharing(t)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(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>${threadWrites() ? `<form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabledSession(s.pending)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
return `<section class="chat-conversation" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}${libraryLine(libraryOf(current, t))}${sessionControls(t)}${sharing(t)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}<ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || (current.readFailed ? '<li class="muted">Messages unavailable.</li>' : '<li class="muted">No messages yet.</li>')}</ol><div class="chat-paging">${current.readFailed ? '' : 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>${threadWrites() ? `<form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabledSession(s.pending || current.channelDisabled)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
}
// C2-10c: rooms reuse the same state, renderer and generation fences as threads.
const ROOM_LINES = Object.freeze({
@@ -213,7 +214,7 @@
'room-thread-name-taken': 'A room thread name is already in use. Resolve the conflicting thread before opening the room.',
'room-not-open': 'This room is not open. Open the room before sending.',
});
const roomError = err => ROOM_LINES[err.code] ?? (err.sent === false || err.status >= 400 && err.status < 500
const roomError = err => err.code === 'channel-disabled' ? channelMessage : ROOM_LINES[err.code] ?? (err.sent === false || err.status >= 400 && err.status < 500
? `Room operation refused (${inert(err.code)}).`
: 'Room unavailable. Refresh to read it again; nothing was retried.');
const roomOwner = () => current && !current.user && !current.tester;
@@ -231,7 +232,7 @@
function roomOpenControl(project) {
if (!roomOwner()) return '';
const f = form('room-open:' + project);
return `<section class="chat-room-entry" aria-label="Project room"><button class="btn" type="button" id="chat-room-open"${disabled(f.pending)}>Open room</button> <a href="#/chat/rooms/${esc(project)}">Read room</a><p id="chat-room-open-receipt" role="status">${txt(f.receipt)}</p></section>`;
return `<section class="chat-room-entry" aria-label="Project room"><button class="btn" type="button" id="chat-room-open"${disabled(f.pending || current.channelDisabled)}>Open room</button> <a href="#/chat/rooms/${esc(project)}">Read room</a><p id="chat-room-open-receipt" role="status">${txt(f.receipt)}</p></section>`;
}
function roomConversation() {
const c = current, s = state(c.key), p = c.projects.find(p => p.id === c.id);
@@ -240,8 +241,8 @@
const recipients = m.from_role === 'human' ? ` → ${(m.to ?? []).map(r => txt(roomName(r))).join(', ')}` : '';
return `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(speaker)}</strong>${recipients} ${stamp(m.at)}</div>${messageBody({ ...m, from_role: m.from_role === 'human' ? m.from_role : speaker })}</li>`;
}).join('');
const off = !c.roomReady || s.pending || !roomOwner();
return `<section class="chat-room" data-project="${esc(c.id)}"><p>${link('projects', c.id, p?.name ?? 'Project')}</p>${heading((p?.name ?? 'Project') + ' room')}${roomOpenControl(c.id)}${libraryLine(libraryOf(c))}<p>Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}</p><ol class="chat-log chat-room-log" tabindex="0" aria-label="Room conversation">${messages || '<li>No messages yet.</li>'}</ol><div class="chat-paging">${s.more ? '<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-room-send-form"><label for="chat-room-body">Message the room</label><p id="chat-room-help">Without leading @tags, your message goes to every member. The broker checks the addressees.</p><textarea id="chat-room-body" rows="4" aria-describedby="chat-room-help" aria-autocomplete="list" aria-controls="chat-room-completion" aria-expanded="false"${disabled(off)}>${esc(s.draft)}</textarea><div id="chat-room-tags"></div><ul id="chat-room-completion" class="chat-room-completion" role="listbox" aria-label="Room members" hidden></ul><button class="btn primary" type="submit"${disabled(off)}>Send to room</button></form><p id="chat-room-receipt" role="status">${txt(s.receipt)}</p></section>`;
const off = !c.roomReady || c.channelDisabled || s.pending || !roomOwner();
return `<section class="chat-room" data-project="${esc(c.id)}"><p>${link('projects', c.id, p?.name ?? 'Project')}</p>${heading((p?.name ?? 'Project') + ' room')}${roomOpenControl(c.id)}${libraryLine(libraryOf(c))}<p>Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}</p><ol class="chat-log chat-room-log" tabindex="0" aria-label="Room conversation">${messages || (c.readFailed ? '<li>Messages unavailable.</li>' : '<li>No messages yet.</li>')}</ol><div class="chat-paging">${c.readFailed ? '' : s.more ? '<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-room-send-form"><label for="chat-room-body">Message the room</label><p id="chat-room-help">Without leading @tags, your message goes to every member. The broker checks the addressees.</p><textarea id="chat-room-body" rows="4" aria-describedby="chat-room-help" aria-autocomplete="list" aria-controls="chat-room-completion" aria-expanded="false"${disabled(off)}>${esc(s.draft)}</textarea><div id="chat-room-tags"></div><ul id="chat-room-completion" class="chat-room-completion" role="listbox" aria-label="Room members" hidden></ul><button class="btn primary" type="submit"${disabled(off)}>Send to room</button></form><p id="chat-room-receipt" role="status">${txt(s.receipt)}</p></section>`;
}
function drawRoomCompletion() {
const list = document.getElementById('chat-room-completion'), field = document.getElementById('chat-room-body');
@@ -281,7 +282,7 @@
const signal = controller.signal, g = ++generation;
const match = /^\/chat\/rooms\/([0-9a-f-]+)$/i.exec(path), id = match?.[1];
current = { kind: 'room', id, key: 'room:' + id, path, container, canSend, user, tester,
projects: [], threads: [], members: [], roomReady: false, refused: false, error: '' };
projects: [], threads: [], members: [], roomReady: false, channelDisabled: current?.channelDisabled ?? false, refused: false, error: '' };
if (!UUID.test(id ?? '') || !roomOwner()) {
current.error = UUID.test(id ?? '') ? 'Rooms require the business owner.' : 'Invalid room route.';
current.refused = true; paint(navigated); return;
@@ -301,6 +302,7 @@
const page = await request(`rooms/${id}/messages?limit=100${after ? '&after=' + after : ''}`, undefined, signal);
if (g !== generation || out()) return;
if (typeof page.hasMore !== 'boolean' || !(page.after === null || UUID.test(page.after)) || page.rows.some(m => !UUID.test(m?.id) || !Array.isArray(m.ids) || !m.ids.every(id => UUID.test(id)) || !Array.isArray(m.to))) throw new ChatError('invalid-response');
current.channelDisabled = false;
s.rows = [...new Map((after ? [...s.rows, ...page.rows] : page.rows).map(m => [m.id, m])).values()].slice(-200);
s.after = page.after ?? s.after; s.more = page.hasMore; s.loaded = true;
current.roomReady = current.members.length > 0;
@@ -308,13 +310,15 @@
} catch (err) {
if (g !== generation || out() || err.name === 'AbortError' || err.code === 'signed-out') return;
current.error = roomError(err);
current.readFailed = true;
if (err.code === 'channel-disabled') current.channelDisabled = true;
if (err.status === 403 || err.status === 404) { s.rows = []; current.members = []; }
}
if (g === generation && !out()) { paint(navigated); void loadLibrary(libraryOf(current), g, signal); }
}
async function openRoom() {
const c = current, id = c?.id;
if (!roomOwner() || !c.canSend || out() || !UUID.test(id ?? '')) return;
if (!roomOwner() || !c.canSend || c.channelDisabled || out() || !UUID.test(id ?? '')) return;
const f = form('room-open:' + id);
if (f.pending) return;
f.pending = true; f.receipt = ''; paint();
@@ -329,12 +333,13 @@
}
} catch (err) {
if (out() || err.code === 'signed-out') return;
f.receipt = err.sent === false ? roomError(err) : 'Outcome unknown. Read the room before opening again; nothing was retried.';
f.receipt = err.sent === false ? roomError(err) : `${err.code === 'channel-disabled' ? channelMessage + ' ' : ''}Outcome unknown. Read the room before opening again; nothing was retried.`;
if (err.code === 'channel-disabled' && current?.id === id) current.channelDisabled = true;
} finally { f.pending = false; if (current?.id === id && !out()) paint(); }
}
async function sendRoom() {
const c = current;
if (!roomOwner() || !c.canSend || !c.roomReady || out()) return;
if (!roomOwner() || !c.canSend || !c.roomReady || c.channelDisabled || out()) return;
const s = state(c.key), body = s.draft;
if (s.pending) return;
if (!body.trim() || new TextEncoder().encode(JSON.stringify({ body })).length > 16384) {
@@ -350,7 +355,8 @@
s.follow = true; s.tailRequested = true;
} catch (err) {
if (out() || err.code === 'signed-out') return;
s.receipt = err.sent === false ? roomError(err) : 'Outcome unknown. Read the room before trying again. Nothing was retried; your draft is kept.';
s.receipt = err.sent === false ? roomError(err) : `${err.code === 'channel-disabled' ? channelMessage + ' ' : ''}Outcome unknown. Read the room before trying again. Nothing was retried; your draft is kept.`;
if (err.code === 'channel-disabled' && current?.key === c.key) current.channelDisabled = true;
} finally { s.pending = false; if (current?.key === c.key && !out()) paint(); }
}
document.addEventListener('keydown', event => {
@@ -373,6 +379,7 @@
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.readFailed && (c.projects.length || c.threads.length || c.kind === 'thread' && state(c.key).loaded)) html += '<p role="status">Showing previously read chat content; it may be out of date.</p>';
if (c.kind === 'room') {
html += c.refused ? heading('Room unavailable') : roomConversation();
} else if (c.kind === 'thread') {
@@ -385,7 +392,7 @@
if (p && !c.refused) html += roomOpenControl(c.id) + threads(c.threads.filter(t => !t.shared)) + '<h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + 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 && !t.shared))}</section>`).join('') + (c.projects.length ? '' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('project', '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 && !t.shared))}</section>`).join('') + (c.projects.length ? '' : c.readFailed ? '<p>Projects unavailable.</p>' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + 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>';
@@ -408,7 +415,7 @@
updateRoomCompletion();
}
function readError(err) {
return err.code === 'thread-not-owned' ? 'This thread is not available to this user.' :
return err.code === 'channel-disabled' ? channelMessage : err.code === 'thread-not-owned' ? 'This thread is not available to this user.' :
err.code === 'thread-not-found' ? 'Thread not found.' :
err.code === 'session-not-found' ? 'Session not found in this thread.' : 'Chat could not be read. Refresh to try again; your draft is kept.';
}
@@ -425,7 +432,7 @@
paint(); return;
}
const old = current;
current = { container, kind, id, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', refused: false, legacyDefault: false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
current = { container, kind, id, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
try {
if (user) {
@@ -443,11 +450,12 @@
request('projects', undefined, signal),
request('threads' + (kind === 'project' ? '?project=' + id : ''), undefined, signal),
tester ? { rows: [] } : request('threads?shared=true' + (kind === 'project' ? '&project=' + id : ''), undefined, signal).catch(err => {
if (['signed-out', 'access-revoked'].includes(err.code)) throw err;
return { rows: [] };
if (err.code !== 'access-revoked') err.sharedRead = true;
throw err;
}),
]);
if (g !== generation || out()) return;
current.channelDisabled = false;
current.projects = projects.rows;
const own = new Set(listed.rows.map(t => t.id));
current.threads = [...listed.rows, ...shared.rows.filter(t => !own.has(t.id)).map(t => ({ ...t, shared: true }))];
@@ -493,12 +501,13 @@
clear.record = receipt; clear.receipt = clearText(receipt);
} catch (err) {
if (g !== generation || out() || err.name === 'AbortError') return;
clear.receipt = `Status unavailable (${inert(err.code)}). Operation ${clear.op}; no write was retried.`;
clear.receipt = err.code === 'channel-disabled' ? `${channelMessage} Operation ${clear.op}; no write was retried.` : `Status unavailable (${inert(err.code)}). Operation ${clear.op}; no write was retried.`;
}
}
}
if (s) {
if (typeof page.hasMore !== 'boolean' || !(page.after === null || UUID.test(page.after))) throw new ChatError('invalid-response');
if (catalog) current.legacyDefault = false;
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;
@@ -522,7 +531,8 @@
} catch (err) {
if (err.name === 'AbortError' || out() || g !== generation) return;
current.instances = [];
current.instanceError = "Couldn't read the thread instances; reload to try again";
current.instanceError = err.code === 'channel-disabled' ? channelMessage : "Couldn't read the thread instances; reload to try again";
if (err.code === 'channel-disabled') current.channelDisabled = true;
}
}
// Repository choices are optional metadata; an unavailable read must not block unlinked chat.
@@ -545,13 +555,15 @@
} catch (err) {
if (g !== generation || out() || err.name === 'AbortError' || err.code === 'signed-out') return;
current.error = readError(err);
current.readFailed = true;
if (err.code === 'channel-disabled') current.channelDisabled = true;
// A bad session selector is local to that read. Only a thread-level refusal
// invalidates all sibling drafts, receipts and recovery operation keys.
if (['thread-not-owned', 'thread-not-found', 'access-refused'].includes(err.code)) {
if (!err.sharedRead && ['thread-not-owned', 'thread-not-found', 'access-refused'].includes(err.code)) {
for (const key of states.keys()) if (key === id || key.startsWith(id + '/')) states.delete(key);
for (const key of forms.keys()) if (key.includes(id)) forms.delete(key);
}
if (err.status === 403 || err.status === 404) {
if (!err.sharedRead && (err.status === 403 || err.status === 404)) {
current.projects = []; current.threads = []; current.thread = null; current.shared = false; current.refused = true;
}
paint(navigated);
@@ -583,7 +595,7 @@
}
}, true);
async function write(action, key, value, path) {
if (!current || !current.canSend || out()) return;
if (!current || !current.canSend || out() || current.channelDisabled) return;
const isThread = action === 'send' || action === 'open';
if ((isThread || action === 'createSession') && !threadWrites()) return;
if (action === 'createProject' && current.user || action === 'createThread' && !scopeWrites(value.project, value.instance)) return;
@@ -611,8 +623,9 @@
}
} 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.';
s.receipt = err.sent === false ? (err.code === 'channel-disabled' ? `Not sent. ${channelMessage} Your draft is kept.` : `Not sent (${inert(err.code)}). Your draft is kept.`) :
`${err.code === 'channel-disabled' ? channelMessage + ' ' : ''}Outcome unknown. Read the current list or conversation before trying again. Nothing was retried; your draft is kept.`;
if (err.code === 'channel-disabled' && current) current.channelDisabled = true;
if (action === 'createSession' && err.sent === false) { s.op = null; s.payload = null; }
if (err.status === 403 && ['thread-not-owned', 'access-refused'].includes(err.code)) {
states.delete(key);
@@ -680,7 +693,7 @@
f.receipt = op === 'share' ? 'Shared read-only.' : 'Share ended.';
if (op === 'share') f.to = '';
} catch (e) {
f.receipt = e.sent === false ? `Share refused (${inert(e.code)}).` : 'Outcome unknown. Read shares before trying again; nothing was retried.';
f.receipt = e.sent === false ? (e.code === 'channel-disabled' ? `Share refused. ${channelMessage}` : `Share refused (${inert(e.code)}).`) : `${e.code === 'channel-disabled' ? channelMessage + ' ' : ''}Outcome unknown. Read shares before trying again; nothing was retried.`;
} finally {
f.pending = false;
if (!out() && current?.id === id) { await refresh(); paint(); }
@@ -695,7 +708,7 @@
if (out() || f.op !== op) return;
f.record = r; f.receipt = clearText(r);
if (current?.key === key) await refresh();
} catch (e) { if (!out()) f.receipt = `Status unavailable (${inert(e.code)}). Operation ${f.op}; no write was retried.`; }
} catch (e) { if (!out()) f.receipt = e.code === 'channel-disabled' ? `${channelMessage} Operation ${f.op}; no write was retried.` : `Status unavailable (${inert(e.code)}). Operation ${f.op}; no write was retried.`; }
if (current?.key === key) paint();
}
async function cancelClear() {
@@ -710,7 +723,7 @@
f.record = r; f.receipt = clearText(r);
} catch (e) {
if (out()) return;
f.receipt = e.sent === false ? `Cancel refused (${inert(e.code)}).` : `Outcome unknown. Check operation ${op}; cancel was not retried.`;
f.receipt = e.sent === false ? (e.code === 'channel-disabled' ? `Cancel refused. ${channelMessage}` : `Cancel refused (${inert(e.code)}).`) : `${e.code === 'channel-disabled' ? channelMessage + ' ' : ''}Outcome unknown. Check operation ${op}; cancel was not retried.`;
} finally {
f.pending = false;
if (!out() && current?.key === key) { await refresh(); paint(); }
@@ -729,7 +742,7 @@
f.record = r; f.receipt = clearText(r);
} catch (e) {
if (out()) return;
f.receipt = e.sent === false ? `Clear refused (${inert(e.code)}).` : `Outcome unknown. Check operation ${f.op}; nothing was retried.`;
f.receipt = e.sent === false ? (e.code === 'channel-disabled' ? `Clear refused. ${channelMessage}` : `Clear refused (${inert(e.code)}).`) : `${e.code === 'channel-disabled' ? channelMessage + ' ' : ''}Outcome unknown. Check operation ${f.op}; nothing was retried.`;
if (e.sent === false) f.op = null;
} finally {
f.pending = false;
+151 -2
View File
@@ -35,7 +35,7 @@ const stub = `(() => {
}
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(window.__readFailure && (!window.__failurePath || path.includes(window.__failurePath)))return new Response(JSON.stringify({error:window.__readFailure}),{status:['thread-not-owned','access-refused'].includes(window.__readFailure)?403:503});
if(path==='/api/chat/instances')return window.__instanceFailure ? new Response(JSON.stringify({error:'not-configured'}),{status:503}) : data({rows:window.__instances});
if(path==='/api/chat/projects')return data({rows:window.__projects});
if(path.endsWith('/sessions')){const t=path.split('/')[4];return data({rows:window.__sessions[t]||[{id:t,name:'default',default:true}]});}
@@ -45,11 +45,12 @@ const stub = `(() => {
return data({rows:[],after:null,hasMore:false});
};
})();`;
async function setup(t, suffix = '') {
async function setup(t, suffix = '', initial = '') {
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 });
if (initial) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: initial });
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;})()`);
@@ -63,6 +64,154 @@ async function setup(t, suffix = '') {
};
return { b, wait, set, click, route, refresh };
}
test('chat browser: initial disabled channel reports unavailable lists and does not offer creation', async t => {
const { b } = await setup(t, '', 'window.__readFailure="channel-disabled"');
const text = await b.evaluate('document.querySelector(".chat-area").textContent');
assert.match(text, /chat.*channel.*disabled/i);
assert.doesNotMatch(text, /No projects yet|No threads yet|mosaic plugins|SENTINEL/i);
assert.equal(await b.evaluate('document.querySelector("#chat-project-form button")?.disabled ?? true'), true);
assert.equal(await b.evaluate('window.__writes.length'), 0);
});
test('chat browser: refused creation keeps the draft, disables creation and recovers after refresh', async t => {
const { b, wait, set, click, refresh } = await setup(t);
await set('#chat-project-name', 'Pomodoro Timer');
await b.evaluate('window.__answers.push({status:503,body:{error:"channel-disabled",sent:false,message:"SENTINEL private transport"}}),true');
await click('#chat-project-form button[type=submit]');
await wait('document.querySelector("#chat-project-form [role=status]")?.textContent.includes("Not sent")');
assert.match(await b.evaluate('document.querySelector("#chat-project-form [role=status]").textContent'), /channel.*disabled/i);
assert.doesNotMatch(await b.evaluate('document.querySelector(".chat-area").textContent'), /SENTINEL/);
assert.equal(await b.evaluate('document.querySelector("#chat-project-name").value'), 'Pomodoro Timer');
assert.equal(await b.evaluate('document.querySelector("#chat-project-form button").disabled'), true);
await refresh();
await wait('!document.querySelector("#chat-project-form button").disabled');
assert.equal(await b.evaluate('document.querySelector("#chat-project-name").value'), 'Pomodoro Timer');
assert.equal(await b.evaluate('window.__writes.length'), 1);
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.equal(await b.evaluate('window.__writes[1].body.name'), 'Pomodoro Timer');
});
test('chat browser: failed refresh marks cached lists stale; successful empty reads retain creation', async t => {
const { b, wait, refresh } = await setup(t);
await b.evaluate('window.__readFailure="channel-disabled";true');
await refresh();
await wait('document.querySelector(".chat-error")');
const failed = await b.evaluate('document.querySelector(".chat-area").textContent');
assert.match(failed, /channel.*disabled/i);
assert.match(failed, /previous|cached|stale/i);
assert.doesNotMatch(failed, /No projects yet|No threads yet/);
assert.equal(await b.evaluate('document.querySelector("#chat-project-form button").disabled'), true);
await b.evaluate('window.__readFailure=null;window.__projects=[];window.__threads=[];true');
await refresh();
await wait('document.querySelector(".chat-area").textContent.includes("No projects yet")');
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /No threads yet/);
assert.equal(await b.evaluate('document.querySelector("#chat-project-form button").disabled'), false);
assert.equal(await b.evaluate('window.__writes.length'), 0);
});
test('chat browser: disabled thread creation retains its draft and refresh restores the action', async t => {
const { b, wait, set, click, refresh } = await setup(t);
await b.evaluate(`location.hash='#/chat/projects/${P}';true`);
await wait('document.querySelector("#chat-thread-form button") && !document.querySelector("#chat-thread-form button").disabled');
await set('#chat-thread-name', 'planning');
await b.evaluate('window.__answers.push({status:503,body:{error:"channel-disabled",sent:false}}),true');
await click('#chat-thread-form button');
await wait('document.querySelector("#chat-thread-form [role=status]").textContent.includes("Not sent")');
assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /channel.*disabled/i);
assert.equal(await b.evaluate('document.querySelector("#chat-thread-name").value'), 'planning');
assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
await refresh();
await wait('!document.querySelector("#chat-thread-form button").disabled');
assert.equal(await b.evaluate('document.querySelector("#chat-thread-name").value'), 'planning');
assert.equal(await b.evaluate('window.__writes.length'), 1);
});
test('chat browser: uncertain disabled send remains unknown, preserves draft and does not retry', async t => {
const { b, wait, set, click, route, refresh } = await setup(t);
await route(A); await set('#chat-body', 'unsure draft');
await b.evaluate('window.__answers.push({status:503,body:{error:"channel-disabled",message:"SENTINEL"}}),true');
await click('#chat-send-form button');
await wait('document.querySelector("#chat-receipt").textContent.includes("Outcome unknown")');
assert.doesNotMatch(await b.evaluate('document.querySelector("#chat-receipt").textContent'), /Not sent|SENTINEL/);
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'unsure draft');
assert.equal(await b.evaluate('document.querySelector("#chat-send-form button").disabled'), true);
assert.equal(await b.evaluate('document.querySelector("#chat-session-form button").disabled'), true);
await refresh();
await wait('!document.querySelector("#chat-send-form button").disabled');
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'unsure draft');
assert.equal(await b.evaluate('window.__writes.length'), 1);
});
test('chat browser: a refused send explains the disabled channel and retains the message', async t => {
const { b, wait, set, click, route } = await setup(t);
await route(A); await set('#chat-body', 'keep my message');
await b.evaluate('window.__answers.push({status:503,body:{error:"channel-disabled",sent:false,message:"SENTINEL"}}),true');
await click('#chat-send-form button');
await wait('document.querySelector("#chat-receipt").textContent.includes("Not sent")');
const receipt = await b.evaluate('document.querySelector("#chat-receipt").textContent');
assert.match(receipt, /channel.*disabled/i);
assert.doesNotMatch(receipt, /Outcome unknown|SENTINEL/);
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'keep my message');
assert.equal(await b.evaluate('document.querySelector("#chat-send-form button").disabled'), true);
assert.equal(await b.evaluate('window.__writes.length'), 1);
});
test('chat browser: failed shared-thread read does not claim an empty shared list', async t => {
const { b, wait, refresh } = await setup(t);
await b.evaluate('window.__failurePath="shared=true";window.__readFailure="channel-disabled";true');
await refresh();
await wait('document.querySelector(".chat-error")');
const text = await b.evaluate('document.querySelector(".chat-area").textContent');
assert.match(text, /channel.*disabled/i);
assert.doesNotMatch(text, /No threads yet/);
});
test('chat browser: a shared-list refusal does not discard an owned thread draft', async t => {
const { b, wait, set, route, refresh } = await setup(t);
await route(A); await set('#chat-body', 'owned thread draft');
await b.evaluate('window.__failurePath="shared=true";window.__readFailure="access-refused";true');
await refresh(); await wait('document.querySelector(".chat-error")');
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'), 'owned thread draft');
});
test('chat browser: a refused share explains the disabled channel without exposing transport text', async t => {
const { b, wait, set, click, route } = await setup(t);
await route(A); await set('#chat-share-target', 'user:reader');
await b.evaluate('window.__answers.push({status:503,body:{error:"channel-disabled",sent:false,message:"SENTINEL"}}),true');
await click('#chat-share-form button');
await wait('document.querySelector("#chat-share-form + [role=status]")?.textContent.includes("refused")');
const receipt = await b.evaluate('document.querySelector("#chat-share-form + [role=status]").textContent');
assert.match(receipt, /chat channel.*disabled/i);
assert.doesNotMatch(receipt, /SENTINEL/);
assert.equal(await b.evaluate('document.querySelector("#chat-share-target").value'), 'user:reader');
assert.equal(await b.evaluate('window.__writes.length'), 1);
});
test('chat browser: session catalog recovery restores controls and scopes drafts and messages', async t => {
const { b, wait, set, click, route, refresh } = await setup(t);
await b.evaluate('window.__failurePath="/sessions";window.__readFailure="access-refused";true');
await route(A);
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /sessions.*not.*available/i);
await set('#chat-body', 'legacy draft');
await b.evaluate(`window.__readFailure=null;window.__sessions['${A}']=[{id:'${B}',name:'default',default:true,state:'active',revision:2}];window.__pages.push({rows:[{id:'${M}',body:'session history',from_role:'assistant',at:'2026-10-18T00:00:00Z'}],after:'${M}',hasMore:false});true`);
await refresh();
assert.equal(await b.evaluate('document.querySelector("#chat-session")?.value'), B);
assert.equal(await b.evaluate('!!document.querySelector("#chat-session-form") && !!document.querySelector("#chat-clear")'), true);
assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'), /session history/);
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), '', 'legacy draft is not copied into another session');
await set('#chat-body', 'session draft');
await b.evaluate(`window.__answers.push({body:{id:'${P}'}}),true`);
await click('#chat-send-form button');
await wait('document.querySelector("#chat-receipt").textContent.includes("Accepted")');
assert.deepEqual(await b.evaluate('window.__writes[0].body'), { body: 'session draft', session: B });
await b.evaluate('window.__readFailure="access-refused";true');
await refresh();
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'legacy draft');
});
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);
@@ -40,6 +40,7 @@ const stub = `(() => {
if(path==='/api/library/revert'){window.__writes.push({path,body:JSON.parse(init.body)});return answer({project:'books',path:'Notes/One.md',reverted:'${SHA}',commit:'${LAST}'});}
if(!path.startsWith('/api/chat/'))return real(url,init);
if(method==='GET')window.__reads.push(path);
if(method==='GET' && window.__chatOffline)throw new TypeError('offline');
if(path==='/api/chat/projects')return answer({rows:window.__projects});
if(path==='/api/chat/instances')return answer({rows:window.__instances});
if(path.includes('/rooms/')){
@@ -76,6 +77,37 @@ async function setup(t, { path = `/chat/rooms/${P}`, before = '', suffix = '' }
await wait(path.includes('/rooms/') ? 'document.querySelector(".chat-room")' : 'document.querySelector("#chat-thread-form")');
return { b, wait, text, set };
}
test('disabled room read shows unavailable history and disables opening', async t => {
const { b, text } = await setup(t, { before: 'window.__readError="channel-disabled";' });
assert.match(await text('.chat-error'), /chat channel.*disabled/i);
assert.match(await text('.chat-room-log'), /unavailable/i);
assert.doesNotMatch(await text('.chat-room'), /No messages yet|Caught up to the last read/);
assert.equal(await b.evaluate('document.querySelector("#chat-room-open").disabled'), true);
assert.equal(await b.evaluate('window.__writes.length'), 0);
});
test('room keeps a known disabled channel through a failed refresh and recovers on a successful read', async t => {
const { b, wait, text } = await setup(t);
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');
};
assert.match(await text('.chat-room-log'), /Broadcast once/);
await b.evaluate('window.__readError="channel-disabled";true');
await refresh();
assert.match(await text('.chat-error'), /channel.*disabled/i);
assert.equal(await b.evaluate('document.querySelector("#chat-room-open").disabled'), true);
await b.evaluate('window.__readError=null;window.__chatOffline=true;true');
await refresh();
assert.equal(await b.evaluate('document.querySelector("#chat-room-open").disabled'), true);
await b.evaluate('document.querySelector("#chat-room-open").dispatchEvent(new MouseEvent("click",{bubbles:true}));true');
assert.equal(await b.evaluate('window.__writes.length'), 0, 'the write guard also refuses a synthetic click');
assert.match(await text('.chat-room-log'), /Broadcast once/);
await b.evaluate('window.__chatOffline=false;true');
await refresh();
assert.equal(await b.evaluate('document.querySelector("#chat-room-open").disabled'), false);
assert.equal(await b.evaluate('window.__writes.length'), 0, 'recovery reads never open a room');
});
test('C2-10c room merges members, shows a broadcast once, reuses cited renderer and suppresses checked notice forgery', async t => {
const { b, wait, text } = await setup(t);
await wait('document.querySelector(".chat-cites")');