diff --git a/BUILD-LOG.md b/BUILD-LOG.md index cd8dcdc15..08ea00d5e 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6146,3 +6146,13 @@ Lead-only live window checks, not executed here: (1) explicitly authorize and se 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 | Lead Codex, #815 / Gitea #1843 live configuration outcome | Operator enabled cli+webui through pinned 51525a31d tester plugin setter at 22:43:43Z, transaction e94c4db7-c3e3-4d31-825f-0b204a1ccf76; committed actor tester, only plugins.channel changed, other configuration deeply equal. Private backup/rollback report: /mnt/storage/scratch/mosaic-lead/reports/chat-channel-815.md. Lead independently verified configured channels, committed receipt and real tester WebUI GET /api/chat/projects HTTP 200. Services retained PIDs; no deployment/schema/credential/model/role change or owner workflow. Source regression fix remains active and issue open. + +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. + +2026-11-04 | #815 / Gitea #1843 integration phase started | Independent scoped re-review APPROVE at a1ba9cd5669cf084ef69eb663b42234fbce38824: I1/I2 addressed; no new critical/important finding. Applied only the five reviewed source/test/template paths to refactor and appended worker records while preserving all existing controller log bytes. Entire tracked source outside BUILD-LOG/SESSIONS matches the reviewed candidate. Running final canonical WebUI gate and factory regression before commit/push. M1 cached-room stale label and M2 unchanged tooling/audit observations remain deferred/nonblocking. No deployment or owner acceptance. + +2026-11-04 | #815 / Gitea #1843 integration phase verified | Canonical full WebUI: MOSAIC_REQUIRE_EDITOR_BUILD=1 node --test --test-concurrency=2 packages/webui/tests/ passed 576/576, zero failures/cancellations/skips, required editor rebuild passed (171530 ms); factory regression passed 8/8, zero failures/cancellations/skips. Logs: /mnt/storage/scratch/tmp/mosaic-815-r1/full-webui-canonical.log and factory-canonical.log. Entire source outside append-only logs still equals independently approved candidate a1ba9cd56. Prior full business 307/307 applies to identical business/template source. All existing controller and worker log bytes preserved. Committing and pushing the reviewed repair under the established plan; source deployment and owner PRD-to-artifact acceptance remain separate. Known in-memory full-reload draft loss, minor cached-room stale-label omission and unchanged tooling observations retained. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 59cc4010b..28b6d8bab 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2030,3 +2030,9 @@ are never rewritten or removed; corrections are new entries. 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 Sol operator, verified/recorded by lead Sol | #815 / Gitea #1843 .53 channel correction | Typed tester setter enabled cli+webui with one channel-only receipt; remaining config unchanged, private backup retained, service PIDs unchanged; operator and lead actual WebUI tester reads HTTP 200. No owner project creation or acceptance; source fix ongoing. +2026-11-04 | Lead Codex GPT-6.1 Sol, context continuation | #815 source repair and owner chat/rooms/threads question | Recovered existing fix-round ownership without restarting or polling the implementer; verified PRD thread/session and Case 2 room semantics against Console and broker. Explained conversation-container versus product-project distinction; broader UX change remains a design discussion. Queue next sage returned historical row 7; continued the current owner-directed #815 assignment. +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. +2026-11-04 | Lead Codex GPT-6.1 Sol, reviewed-source integration | #815 / Gitea #1843 | Re-review approved I1/I2 at a1ba9cd56. Integrated reviewed source into refactor with byte-preserving append-only records; canonical verification and authorized push next. Queue next sage named unrelated historical row 7; continued current owner assignment. No live operation or owner acceptance. +2026-11-04 | Lead Codex GPT-6.1 Sol, #815 integration verification | #815 / Gitea #1843 | Canonical WebUI 576/576 and factory 8/8, zero skips/failures/cancellations; independent R2 approved; source exactly matches a1ba9cd56 outside preserved append-only records. Existing plan authorizes commit/push to refactor. No source deployment or owner end-to-end acceptance. diff --git a/docs/guides/case1-business.mosaic-stack.json b/docs/guides/case1-business.mosaic-stack.json index fa8dc7b71..372fc7022 100644 --- a/docs/guides/case1-business.mosaic-stack.json +++ b/docs/guides/case1-business.mosaic-stack.json @@ -15,6 +15,7 @@ "bus.tester": true }, "plugins": { + "channel": ["cli", "webui"], "harness": ["claude-code"] }, "roles": { diff --git a/packages/business/tests/case1-business-file.test.mjs b/packages/business/tests/case1-business-file.test.mjs index 0d5853236..0886d6ef2 100644 --- a/packages/business/tests/case1-business-file.test.mjs +++ b/packages/business/tests/case1-business-file.test.mjs @@ -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" }); diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index 3d6b7686b..d358afe05 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -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 ? '' : ``; 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 `
`; + return ``; } function threads(rows) { - return rows.length ? `No threads yet.
'; + return rows.length ? `Threads unavailable.
' : 'No threads yet.
'; } 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 `Compactions: ${txt(countLabel(selected?.compactions))}
${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · Open fresh successor` : ''}
${reason ? `${txt(reason)}
` : ''}${txt(clear.receipt)}
Compactions: ${txt(countLabel(selected?.compactions))}
${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · Open fresh successor` : ''}
${reason ? `${txt(reason)}
` : ''}${txt(clear.receipt)}
${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}
${heading(t.name)}${libraryLine(libraryOf(current, t))}${sessionControls(t)}${sharing(t)}Reading never wakes the assistant. An accepted message is not an answer.
${threadWrites() ? `` : ''}Showing earlier history. Read forward to reach the current conversation.
' : 'Caught up to the last read.
'}${txt(receipt)}
${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}
${heading(t.name)}${libraryLine(libraryOf(current, t))}${sessionControls(t)}${sharing(t)}Reading never wakes the assistant. An accepted message is not an answer.
${threadWrites() ? `` : ''}Showing earlier history. Read forward to reach the current conversation.
' : 'Caught up to the last read.
'}${txt(receipt)}
${txt(f.receipt)}
${txt(f.receipt)}
${link('projects', c.id, p?.name ?? 'Project')}
${heading((p?.name ?? 'Project') + ' room')}${roomOpenControl(c.id)}${libraryLine(libraryOf(c))}Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}
Caught up to the last read.
'}${txt(s.receipt)}
${link('projects', c.id, p?.name ?? 'Project')}
${heading((p?.name ?? 'Project') + ' room')}${roomOpenControl(c.id)}${libraryLine(libraryOf(c))}Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}
Caught up to the last read.
'}${txt(s.receipt)}
Showing previously read chat content; it may be out of date.
'; 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)) + 'Business projects and your named assistant threads.
Repository: ${txt(p.repo)}
` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}No projects yet.
') + 'Repository: ${txt(p.repo)}
` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}Projects unavailable.
' : 'No projects yet.
') + 'Creating and sending require signed-in send mode. This Console only reads.
'; c.container.innerHTML = html + ''; @@ -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 = 'Reading chat…
'; 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; diff --git a/packages/webui/tests/chat-browser.test.mjs b/packages/webui/tests/chat-browser.test.mjs index b020fc29f..f1eabe21f 100644 --- a/packages/webui/tests/chat-browser.test.mjs +++ b/packages/webui/tests/chat-browser.test.mjs @@ -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); diff --git a/packages/webui/tests/room-browser.test.mjs b/packages/webui/tests/room-browser.test.mjs index 1e375c235..03c8a76ef 100644 --- a/packages/webui/tests/room-browser.test.mjs +++ b/packages/webui/tests/room-browser.test.mjs @@ -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")');