diff --git a/BUILD-LOG.md b/BUILD-LOG.md index e77a9dc0..d8e2c253 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -3192,3 +3192,37 @@ never opened on the Discord side, as expected. Row 25 is live: records, approval request, button, and the approval recorded in SetSpark. The service-side approver validation gap went to the SetSpark lead (see DEFERRED). + +## 2026-09-26: CHAT-02 Console (#1507, row 5, Dewey, Filbert, Sage) + +The WebUI can now show a seat's conversation, read-only, through the two +CHAT-02 board routes. History opens it from the Waiting card, the table +row and the inspector. The view pages through the whole branch without +clipping. Untrusted text is set only with textContent, and control and +bidi characters are drawn as visible marks. On the last page it polls +with the follow cursor and replies through the existing /api/reply path. +The WebUI proxy passes only the two conversation routes' query strings +upstream. + +Review: Filbert asked for changes on revision 1 (24b046af). The blocker, +B1: after a reconcile, "Reload conversation" reopened on the default +branch without saying so. Revision 2 (d06de6a7) keeps the branch, adds a +`gone` marker when that branch no longer exists, and takes notes N1 to +N3. Filbert approved it in review 160dd68d, with 188/188 including two +probes of his own. Sage checked the ten pins on the staged index: webui, +conversation, control-board and seat tests 186/186 serial. The eight +suites are green, and the chat-00, chat-01 and chat-01c checks exit 0. +Dewey's mutation runs caught 17 of 17. + +Deviation from brief §2.3 item 6, accepted by Filbert (who wrote the +item) and recorded by Sage: a relaunched seat shows the `newer` marker +("Open the newest session"), not `reconcile`. The relaunch writes a new +session file, while the old one stays readable, so nothing needs +reconciling. Item 6's wording was too narrow. + +Also in this commit: Filbert's CHAT-02 backend reproduction scripts, +referenced by his backend review (a5beb6d9), and Darkwing's two +test-only route notes. Filbert's idsDigest note waits in +agents/dewey/work/chat-02/FOLLOWUPS.md. Still due: a WebUI restart, +because the running proxy predates these routes, then the brief §4 live +check. diff --git a/agents/dewey/work/chat-02/CONSOLE-r1-24b046af.md b/agents/dewey/work/chat-02/CONSOLE-r1-24b046af.md new file mode 100644 index 00000000..919e5168 --- /dev/null +++ b/agents/dewey/work/chat-02/CONSOLE-r1-24b046af.md @@ -0,0 +1,161 @@ +# CHAT-02 Console: review packet (#1507, row 5) + +Author: Dewey, 2026-09-26. Brief: `BRIEF.md` R4 (`636b0fac…`), §2.2, §2.3 +and §4. Base: `3a209eea` on `refactor`, which includes the backend commit +`a5beb6d9`. The commits after `a4d38a3d` touch only `packages/ledger` and +records. Nothing here is committed; the candidate is the working tree, +pinned by the hashes below. + +Reviewer (Sage's order): Filbert, all ten files. + +## 1. Candidate hashes + +``` +77039b18845c913dfcbfaf4cd6854ed97865a14eb762773af6f6d19cb704fc11 packages/webui/README.md +68a054ee61f033f200a5d53b16b3274cbfb89d8bd065c8a049d5f804d51fe0a7 packages/webui/src/public/app.js +b972e2f7f22dafbbf7425773c0715875f9bbcf795af1e5f66a2d9dc394c77047 packages/webui/src/public/index.html +8747b83d16d93d880cf1502267aefc3e48f02e7b5b364177c6fb492320f63024 packages/webui/src/public/live.css +1187a98f52e937f30dc0fbbb3d83445feff7c0a38d3535252af1c7e7e704b7b3 packages/webui/src/serve.mjs +0477f66d5caf9d8f76ba3fb14d3f122f124d6701259f3d7447da639f69982525 packages/webui/tests/serve.test.mjs +9ed68e39904181ba37961d22eea200b024344ef5a7de06bd7c2e8a41ebb1b0e0 packages/webui/tests/conversation.test.mjs +b312c8a192a7a92f0642ee977b136a2ef5909b55632a4d0f669f4ca06e30eee0 packages/webui/tests/history-fixture.mjs +0918aeeaac89cac3d6977ad2e106d299c623e8f2ac6af631ab116ae7f14d3013 packages/webui/tests/history-return-flow.test.mjs +105d87ec3394589afb5c5a43230dd0a43752fcac05d6558b6205013a611a69b2 packages/control-board/tests/serve.test.mjs +``` + +The last three webui tests are new files; the rest are diffs against the +base (`git diff 3a209eea -- packages/webui packages/control-board/tests`). +The `packages/ledger` edits in the shared tree belong to another seat and +are not part of this candidate. + +## 2. What it does + +- **Entry points.** A History button on each seat's Waiting card, table row + and inspector opens a read-only conversation view in place of the board. + Back returns focus to the button that opened it. +- **Rendering.** The view reads the whole branch through + `/api/conversation`, page by page, and joins fragments and continuation + parts by block. Nothing is clipped. Tool calls, tool results and thinking + are collapsed `details`; redacted thinking says "not available". +- **Untrusted text.** Session content is set only with `textContent`. + Markdown stays as source. C0 controls and DEL show as Unicode control + pictures (`␛`, `␇`, `␡`); bidi overrides and isolates show as `[U+202E]` + and similar. Newlines and tabs stay. +- **Polling.** On the last page the view keeps the `follow` cursor and reads + from it on each board refresh (every 10 s; Pause stops it). It scrolls only + when the reader was already near the end. +- **Nothing switches silently.** Three markers, each with a button: + - `branch`: `view.defaultBranch` differs from the open branch. "Open the + latest branch" reopens without a branch parameter. + - `newer`: the board row's session id changed after the view opened, which + is a relaunch. "Open the newest session." + - `reconcile`: a refusal with `reconcile: true`. The view keeps what it + showed, stops polling and offers "Reload conversation". +- **Session picker.** Lists every catalogue row for the seat, newest first; + unavailable ones say why. Non-Pi harnesses and seats without files say so + and show no reply form. +- **Reply.** The view's form uses the same `/api/reply` path, draft map, + pending-send lock and receipts as the inspector. A delivered send clears + the box only if its text is unchanged. +- **Proxy.** `webui/src/serve.mjs` adds `GET /api/conversations` and + `/api/conversation`. Only those two carry their query string upstream; the + board validates it. Upstream status and body pass through unchanged. +- **Age** is unchanged from `42c08d52` (no Age lines in the diff). + +## 3. Choices to review + +1. **The relaunch marker is `newer`, not `reconcile`.** Brief §2.3 item 6 + says "shows the reconcile marker". A new session file does not change the + open file, so the reader has nothing to refuse and the open view is still + accurate. The view shows a separate `newer` marker instead, and the test + asserts it, the kept file, the absence of the new file's text and the + draft. `reconcile` is kept for refusals (tested in `conversation.test.mjs` + with a same-inode rewrite). +2. **Relaunch detection uses the board's `sessionId`** for the newest + readable conversation only. An older session opened from the picker never + shows `newer`, because it was never the board's current session. +3. **The conversation form has its own `conv-form` and `conv-receipt` + classes.** My first draft reused `reply-form` and `receipt`. The hidden + view sits earlier in the DOM, so `querySelector(".reply-form")` in four + existing browser tests found it first and failed; those are the four + failures Filbert saw in the shared tree during the backend review. The + inspector's classes are unchanged. CSS rules list both classes, and the + submit handler matches `.reply-form, #conv-form`. +4. **Heading focus ring.** Opening the view focuses its heading + (`tabindex="-1"`). The shared `:focus-visible` rule draws its ring, as it + does for the inspector title. A synthetic `click()` counts as keyboard + focus, so the screenshots show the ring; a real mouse click does not. I + kept it for keyboard users. +5. **Darkwing's two R2 notes** on the backend routes are taken here, as Sage + offered: the refusal-status test now scans every `.mjs` in + `packages/conversation/src` and pins the whole `REFUSAL_STATUS` object. + Test-only; `serve.mjs` is unchanged from `a5beb6d9`. + +## 4. Evidence + +### 4.1 Suites and contract checks + +- In `/tmp/dewey-chat02/overlay-console`, a `git archive` of `3a209eea` + with only the ten files overlaid: conversation 29, control-board 124, + webui 13 and seat 19, 185 of 185 pass. +- `node docs/plans/chat-00/check.mjs`, `chat-01/check.mjs` and + `chat-01c/check.mjs` all exit 0 there. +- The shared tree gives the same 185/185. + +### 4.2 Acceptance map (brief §4) + +| Item | Test | +|---|---| +| §2.2 hostile-render fixture | `conversation.test.mjs` test 1: ` [click](javascript:window.injected=3) link \u001b[31mRED\u001b[0m \u001b]8;;http://example.invalid\u0007osc\u001b]8;;\u0007 ‮evil'; ++const HOSTILE = ' [click](javascript:window.injected=3) link \u001b[31mRED\u001b[0m \u001b]8;;http://example.invalid\u0007osc\u001b]8;;\u0007 \u009b31mCSI \u200emark \u202eevil'; + const LONG = 'This answer is longer than the board summary. '.repeat(40) + 'LONG_END'; + + test('conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers', { timeout: 120000 }, async () => { +@@ -53,11 +53,11 @@ + await b.evaluate('document.querySelectorAll("#conv-log details").forEach(d => d.open = true)'); + const shown = await b.evaluate('[...document.querySelectorAll("#conv-log .conv-text")].find(e => e.textContent.startsWith("Here: ")).textContent'); + assert.ok(shown.startsWith('Here: [click](javascript:window.injected=3) link ␛[31mRED'), shown); +- assert.ok(shown.includes('␛]8;;http://example.invalid␇osc') && shown.endsWith('[U+202E]evil'), shown); ++ assert.ok(shown.includes('␛]8;;http://example.invalid␇osc') && shown.endsWith('[U+009B]31mCSI [U+200E]mark [U+202E]evil'), shown); + assert.equal(await b.evaluate('document.querySelector(".conv-tool-result pre").textContent'), shown.slice(6)); + assert.equal(await b.evaluate('document.querySelectorAll("#conversation script, #conversation img, #conversation a, #conversation iframe, #conversation object, #conversation embed, #conversation svg, #conversation style, #conversation link").length'), 0); + assert.equal(await b.evaluate('[...document.querySelectorAll("*")].some(e => [...e.attributes].some(a => a.name.startsWith("on")))'), false); +- assert.equal(await b.evaluate('document.body.textContent.includes("\\u001b") || document.body.textContent.includes("\\u202e")'), false); ++ assert.equal(await b.evaluate('/[\\u001b\\u009b\\u200e\\u202e]/.test(document.body.textContent)'), false); + await b.evaluate('[...document.querySelectorAll("#conv-log .conv-text")].find(e => e.textContent.startsWith("Here: ")).click()'); + assert.equal(await b.evaluate('typeof window.injected'), 'undefined'); + assert.equal(await b.evaluate('location.href'), href); +@@ -84,7 +84,7 @@ + } + + // Reload takes a fresh snapshot of the rewritten file; Back returns focus to the History button. +- await b.evaluate('document.querySelector("[data-conv-action=reopen]").click()'); ++ await b.evaluate('document.querySelector("[data-conv-action=reload]").click()'); + await wait('document.querySelector("#conv-log").textContent.includes("Show me the FILE")'); + assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null); + await b.evaluate('document.querySelector("#conv-back").click()'); +@@ -125,16 +125,61 @@ + await wait('document.querySelector("#conv-log").textContent.includes("MAIN_MORE") || !!document.querySelector("[data-marker=reconcile]") || document.querySelector("#conv-status").textContent.includes("unavailable")'); + assert.deepEqual(await turns(b), [['User', 'Question'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]); + assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null); +- // The fork becomes the default leaf again before the view reopens. ++ // The fork becomes the default leaf again. A same-inode rewrite then refuses the next check. + log.raw(JSON.stringify({ type: 'message', id: 'fork-2', parentId: 'fork-1', timestamp: at(0), message: user('FORK_MORE') })); +- await b.evaluate('document.querySelector("[data-conv-action=reopen]").click()'); ++ writeFileSync(file, readFileSync(file, 'utf8').replace('"Question"', '"QUESTION"')); ++ await b.evaluate('document.querySelector("#refresh").click()'); ++ await wait('document.querySelector("[data-marker=reconcile]")'); ++ // Reload stays on the branch the view was on, and still says the conversation went elsewhere. ++ await b.evaluate('document.querySelector("[data-conv-action=reload]").click()'); ++ await wait('document.querySelector("#conv-log").textContent.includes("QUESTION") && !document.querySelector("[data-marker=reconcile]")'); ++ await wait('document.querySelector("[data-marker=branch]")'); ++ assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]); ++ // "Open the latest branch" takes the default. ++ await b.evaluate('document.querySelector("[data-conv-action=latest]").click()'); + await wait('document.querySelector("#conv-log").textContent.includes("FORK_MORE")'); +- assert.deepEqual(await turns(b), [['User', 'Question'], ['Assistant', 'FORK_ANSWER'], ['User', 'FORK_MORE']]); ++ assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'FORK_ANSWER'], ['User', 'FORK_MORE']]); + assert.equal(await b.evaluate('document.querySelector("[data-marker=branch]")'), null); ++ // The fork is rewritten away. Reload cannot keep a branch that is gone, so it opens the default and says so. ++ writeFileSync(file, readFileSync(file, 'utf8').split('\n').filter(l => !l.includes('"fork-')).join('\n')); ++ await b.evaluate('document.querySelector("#refresh").click()'); ++ await wait('document.querySelector("[data-marker=reconcile]")'); ++ await b.evaluate('document.querySelector("[data-conv-action=reload]").click()'); ++ await wait('document.querySelector("[data-marker=gone]")'); ++ assert.match(await b.evaluate('document.querySelector("[data-marker=gone]").textContent'), /^The branch this view was on is no longer in the session\. This view shows the latest branch\.$/); ++ assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]); ++ assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null); ++ } finally { if (b) await b.close(); await f.close(); } ++}); ++ ++test('conversation view: a newer session with no readable history keeps the marker', { timeout: 60000 }, async () => { ++ const f = await historyFixture(); ++ let b; ++ try { ++ const log = session(join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl'), f.projectRoot); ++ log.add(user('Question'), at(-120)); ++ log.add(assistant('OLD_ANSWER'), at(-110)); ++ b = await browser(); await b.viewport(1440, 1000); ++ const wait = waiter(b); ++ await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")'); ++ await b.evaluate('document.querySelector("table.sessions [data-history]").click()'); ++ await wait('document.querySelector("#conv-status").textContent.startsWith("2 messages")'); ++ // The board takes the newest file by mtime; the catalogue orders by last entry, so a header-only file sorts last. ++ const log2 = session(join(f.sessionsDir, '2026-09-26T11-00-00_s2.jsonl'), f.projectRoot, { id: 'sess-2', timestamp: at(0) }); ++ await b.evaluate('document.querySelector("#refresh").click()'); ++ await wait('document.querySelector("[data-marker=newer]")'); ++ await b.evaluate('document.querySelector("[data-conv-action=newest]").click()'); ++ await wait('/not readable yet/.test(document.querySelector("[data-marker=newer]")?.textContent)'); ++ assert.match(await b.evaluate('document.querySelector("#conv-log").textContent'), /OLD_ANSWER/); ++ // Once the new file has entries, the same button opens it. ++ log2.add(user('NEW_QUESTION'), at(1)); ++ await b.evaluate('document.querySelector("[data-conv-action=newest]").click()'); ++ await wait('document.querySelector("#conv-log").textContent.includes("NEW_QUESTION")'); ++ assert.equal(await b.evaluate('document.querySelector("[data-marker=newer]")'), null); + } finally { if (b) await b.close(); await f.close(); } + }); + +-test('conversation view: seats without history say so and offer no reply', { timeout: 60000 }, async () => { ++test('conversation view: seats without history say so and offer no reply',{ timeout: 60000 }, async () => { + const f = await historyFixture(); + let b; + try { diff --git a/agents/dewey/work/chat-02/evidence/console/screens/conversation-1440-dark.png b/agents/dewey/work/chat-02/evidence/console/screens/conversation-1440-dark.png new file mode 100644 index 00000000..c5438112 Binary files /dev/null and b/agents/dewey/work/chat-02/evidence/console/screens/conversation-1440-dark.png differ diff --git a/agents/dewey/work/chat-02/evidence/console/screens/conversation-1440-light.png b/agents/dewey/work/chat-02/evidence/console/screens/conversation-1440-light.png new file mode 100644 index 00000000..66b96e6a Binary files /dev/null and b/agents/dewey/work/chat-02/evidence/console/screens/conversation-1440-light.png differ diff --git a/agents/dewey/work/chat-02/evidence/console/screens/conversation-320-dark.png b/agents/dewey/work/chat-02/evidence/console/screens/conversation-320-dark.png new file mode 100644 index 00000000..4c7e0ec5 Binary files /dev/null and b/agents/dewey/work/chat-02/evidence/console/screens/conversation-320-dark.png differ diff --git a/agents/dewey/work/chat-02/evidence/console/screens/conversation-320-light.png b/agents/dewey/work/chat-02/evidence/console/screens/conversation-320-light.png new file mode 100644 index 00000000..ee247c77 Binary files /dev/null and b/agents/dewey/work/chat-02/evidence/console/screens/conversation-320-light.png differ diff --git a/agents/filbert/work/chat-02-backend-review-evidence/branch.mjs b/agents/filbert/work/chat-02-backend-review-evidence/branch.mjs new file mode 100644 index 00000000..c8c862a0 --- /dev/null +++ b/agents/filbert/work/chat-02-backend-review-evidence/branch.mjs @@ -0,0 +1,19 @@ +import { mkdirSync, writeFileSync, appendFileSync, mkdtempSync, realpathSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createReader } from "../../../../packages/conversation/src/reader.mjs"; +const proj = realpathSync(mkdtempSync(join(tmpdir(), "fb-proj-"))); +const dir = join(proj, ".pi", "state", "x", "sessions"); mkdirSync(dir, { recursive: true }); +const f = join(dir, "s.jsonl"); +const L = (o) => JSON.stringify(o) + "\n"; +const msg = (id, parentId, role, text) => L({ type: "message", id, parentId, timestamp: "2026-09-26T00:00:00Z", message: role === "assistant" ? { role, content: [{ type: "text", text }], stopReason: "stop" } : { role, content: text } }); +writeFileSync(f, L({ type: "session", id: "sess", cwd: proj, timestamp: "2026-09-26T00:00:00Z" }) + msg("a", null, "user", "hi") + msg("b", "a", "assistant", "hello")); +const reader = createReader({ roots: [{ seat: "x", project: "p", projectRoot: proj, dir, harness: "pi", unsupportedReason: null, engineStartedAt: null }] }); +const conv = reader.catalogue().conversations[0].conversation; +const p1 = reader.open({ conversation: conv }); +console.log("open branch", p1.page.branch, "entries", p1.page.entries.map(e => e.id + "@" + e.branch)); +appendFileSync(f, msg("c", "b", "user", "more") + msg("d", "c", "assistant", "ok")); +const p2 = reader.next({ cursor: p1.follow.id, conversation: conv, branch: p1.page.branch }); +console.log("follow ok", p2.ok, "page branch", p2.page?.branch, "entries", p2.page?.entries.map(e => e.id + "@" + e.branch), "view", JSON.stringify(p2.view?.branches)); +const p3 = reader.open({ conversation: conv, branch: p1.page.branch }); +console.log("reopen with first branch id:", JSON.stringify(p3.refusal ?? p3.page.branch)); diff --git a/agents/filbert/work/chat-02-backend-review-evidence/bridge.mjs b/agents/filbert/work/chat-02-backend-review-evidence/bridge.mjs new file mode 100644 index 00000000..661ef058 --- /dev/null +++ b/agents/filbert/work/chat-02-backend-review-evidence/bridge.mjs @@ -0,0 +1,15 @@ +import { mkdirSync, writeFileSync, mkdtempSync, realpathSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createReader } from "../../../../packages/conversation/src/reader.mjs"; +const proj = realpathSync(mkdtempSync(join(tmpdir(), "fb-proj-"))); +const dir = join(proj, ".pi", "state", "x", "sessions"); mkdirSync(dir, { recursive: true }); +const L = (o) => JSON.stringify(o) + "\n"; +const msg = (id, parentId, text) => L({ type: "message", id, parentId, timestamp: "2026-09-26T00:00:00Z", message: { role: "user", content: text } }); +// a -> b is one branch. X (lost, malformed) was a child of a: a fork. y -> X. +writeFileSync(join(dir, "s.jsonl"), L({ type: "session", id: "sess", cwd: proj, timestamp: "2026-09-26T00:00:00Z" }) + msg("a", null, "root") + msg("b", "a", "ON THE OTHER BRANCH") + '{"type":"message","id":"X","parentId":"a","timest\n' + msg("y", "X", "leaf")); +const reader = createReader({ roots: [{ seat: "x", project: "p", projectRoot: proj, dir, harness: "pi", unsupportedReason: null, engineStartedAt: null }] }); +const conv = reader.catalogue().conversations[0].conversation; +const p = reader.open({ conversation: conv }); +console.log("branches", JSON.stringify(p.view.branches)); +for (const e of p.page.entries) console.log(e.branch, e.role, JSON.stringify(e.content[0].text).slice(0, 90)); diff --git a/agents/filbert/work/chat-02-backend-review-evidence/fork.mjs b/agents/filbert/work/chat-02-backend-review-evidence/fork.mjs new file mode 100644 index 00000000..945da6cc --- /dev/null +++ b/agents/filbert/work/chat-02-backend-review-evidence/fork.mjs @@ -0,0 +1,24 @@ +import { mkdirSync, writeFileSync, appendFileSync, mkdtempSync, realpathSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createReader } from "../../../../packages/conversation/src/reader.mjs"; +const proj = realpathSync(mkdtempSync(join(tmpdir(), "fb-proj-"))); +const dir = join(proj, ".pi", "state", "x", "sessions"); mkdirSync(dir, { recursive: true }); +const f = join(dir, "s.jsonl"); +const L = (o) => JSON.stringify(o) + "\n"; +const m = (id, parentId, text) => L({ type: "message", id, parentId, timestamp: "2026-09-26T00:00:00Z", message: { role: "user", content: text } }); +writeFileSync(f, L({ type: "session", id: "s", cwd: proj, timestamp: "2026-09-26T00:00:00Z" }) + m("a", null, "A") + m("b", "a", "B") + m("c", "b", "C")); +const reader = createReader({ roots: [{ seat: "x", project: "p", projectRoot: proj, dir, harness: "pi", unsupportedReason: null, engineStartedAt: null }] }); +const conv = reader.catalogue().conversations[0].conversation; +const t = (p) => p.ok ? `${p.page.branch} [${p.page.entries.map((e) => e.content[0].text).join(",")}] default=${p.view.defaultBranch} branches=${p.view.branches.map((b) => b.branch).join("|")}` : JSON.stringify(p.refusal); +let main = reader.open({ conversation: conv }); console.log("1 open", t(main)); +// Pi navigates back to b and continues: fork from the middle of main. +appendFileSync(f, m("x", "b", "X") + m("y", "x", "Y")); +let fm = reader.next({ cursor: main.follow.id, conversation: conv, branch: "main" }); console.log("2 main follow", t(fm)); +let dx = reader.open({ conversation: conv }); console.log("3 default open", t(dx)); +// Pi goes back to c on main and continues; then resetLeaf starts a new root. +appendFileSync(f, m("d", "c", "D") + m("r", null, "R")); +console.log("4 main follow", t(reader.next({ cursor: fm.follow.id, conversation: conv, branch: "main" }))); +console.log("5 b.x follow", t(reader.next({ cursor: dx.follow.id, conversation: conv, branch: dx.page.branch }))); +console.log("6 open b.x", t(reader.open({ conversation: conv, branch: "b.x" }))); +console.log("7 open default", t(reader.open({ conversation: conv }))); diff --git a/agents/filbert/work/chat-02-backend-review-evidence/perm.mjs b/agents/filbert/work/chat-02-backend-review-evidence/perm.mjs new file mode 100644 index 00000000..ec1df993 --- /dev/null +++ b/agents/filbert/work/chat-02-backend-review-evidence/perm.mjs @@ -0,0 +1,15 @@ +import { mkdirSync, writeFileSync, mkdtempSync, realpathSync, chmodSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createReader } from "../../../../packages/conversation/src/reader.mjs"; +const proj = realpathSync(mkdtempSync(join(tmpdir(), "fb-proj-"))); +const mk = (seat) => { const d = join(proj, ".pi", "state", seat, "sessions"); mkdirSync(d, { recursive: true }); writeFileSync(join(d, "s.jsonl"), JSON.stringify({ type: "session", id: "s", cwd: proj, timestamp: "2026-09-26T00:00:00Z" }) + "\n"); return d; }; +const good = mk("good"), bad = mk("bad"); +chmodSync(join(proj, ".pi", "state", "bad"), 0o000); +const root = (seat, dir) => ({ seat, project: "p", projectRoot: proj, dir, harness: "pi", unsupportedReason: null, engineStartedAt: null }); +const reader = createReader({ roots: [root("good", good), root("bad", bad)] }); +try { const c = reader.catalogue(); console.log("catalogue ok", c.conversations.length, JSON.stringify(c.refusedRoots)); } +catch (e) { console.log("catalogue THREW", e.code, e.message); } +try { console.log("open", JSON.stringify(reader.open({ conversation: "pi-" + "0".repeat(32) }).refusal)); } +catch (e) { console.log("open THREW", e.code); } +chmodSync(join(proj, ".pi", "state", "bad"), 0o755); diff --git a/agents/filbert/work/chat-02-console-review-2026-09-26.md b/agents/filbert/work/chat-02-console-review-2026-09-26.md new file mode 100644 index 00000000..974d8442 --- /dev/null +++ b/agents/filbert/work/chat-02-console-review-2026-09-26.md @@ -0,0 +1,264 @@ +# CHAT-02 Console, revision 1: Filbert's code review + +Reviewer: Filbert, 2026-09-26. Requested by Dewey, assigned by Sage (#1507, +row 5). Measured against brief R4 (`agents/dewey/work/chat-02/BRIEF.md`) §2.2, +§2.3 and §4, and Sage's D1–D4. + +Candidate: packet `agents/dewey/work/chat-02/CONSOLE.md`, sha256 +`24b046af…cd9ccd`, ten files on base `3a209eea`. All ten pins verify in the +shared tree and in my overlay. + +**Verdict: changes requested.** One blocking finding (B1): "Reload +conversation" after a reconcile moves a view that sits on an older branch to +the default branch without saying so. The rest of the candidate is sound, and +I accept all five §3 choices. The fix is small, and I'll re-review only the +delta. + +## 1. What I ran + +- **Overlay.** A detached worktree at `3a209eea` (`/tmp/fb-console-wt`), with + only the ten pinned files overlaid (`sha256sum -c` clean) and + `node_modules` symlinked. +- **Suites.** Running `node --test --test-concurrency=1` over the + conversation, control-board, webui and seat tests gives 185/185. The + `chat-00`, `chat-01` and `chat-01c` `check.mjs` scripts each exit 0. +- **Screens.** I regenerated test 1's screens with `WEBUI_EVIDENCE` at the + pinned hashes. The four PNGs have the same dimensions as Dewey's + (305×3315 at 320, 1425×1586 at 1440), and I looked at 320-dark and + 1440-light. They show the long answer in full, the tool call and result, + inert hostile text (with ␛, ␇ and `[U+202E]` visible), the line-5 notice + and the reconcile marker. Test 1 asserts no horizontal overflow at 320 and + 1440 in both modes. +- **Two probes of my own,** in a scratch test file in the overlay (not the + shared tree). Both results are below: B1 and N1. + +## 2. Against the brief + +- **§2.2 rendering.** + - Every session string goes through `node()`, which sets `textContent` + after `inert()`. No session value reaches `innerHTML`. The existing + card, table and inspector templates gain only `historyButton(r)`, and + both values it interpolates go through `esc()`. + - Tool calls, tool results, thinking and compaction are closed + `details`. Thinking is hidden by default, and unavailable thinking + says so. + - The hostile fixture matches R1, in both assistant text and tool output. + The assertions cover the absence of any element, any `on*` attribute and + any navigation. + - Age is untouched. Reply keeps the board path, and the view polls. +- **§2.3 items 1–7** are in `history-return-flow.test.mjs`, with no + injection and no Refresh. Item 6 is covered with `newer` in place of + `reconcile` (see §3.1). +- **Proxy.** `/api/conversations` and `/api/conversation` are GET-only and + forward their query string unchanged. `/api/board` gets no query. The Host + and Origin checks run first, the JSON-only response rule still holds, and + so does `redirect: 'error'`. The webui serve test pins each of these. +- **§4.** + - The suites and checks are green. + - The screens are present, and I reproduced them at the pins. + - The live check is still open: it needs the commit and a WebUI restart, + as the packet's §6 says. + +## 3. The five choices Dewey asked about + +1. **`newer` instead of `reconcile` for a relaunch: accept.** + - In this code, `reconcile` means "the source refused, polling stopped". + A relaunch refuses nothing. The open file is still accurate, and + polling on it should go on. + - A separate marker with its own action is the better reading of item 6's + intent, which is to keep the file and never switch silently. + - I wrote that line in my R2 review, and the wording was too narrow. + Sage should record the deviation against brief §2.3 item 6, in the + BUILD-LOG entry or as a brief erratum, so the brief and the test agree. +2. **Detection only for the newest readable conversation: accept.** An + older session is an explicit choice from the picker, so it has nothing + newer to warn about. There is one latent edge in the matching; see N1. +3. **The `conv-form` and `conv-receipt` classes: accept.** + - The cause is right: the hidden view comes earlier in the DOM, so a + bare `.reply-form` query found it. + - The four browser tests pass in the overlay. + - Dewey's "forms share the reply-form class" mutant is caught. + - The CSS lists both class pairs, and the submit handler matches + `.reply-form, #conv-form`. +4. **The heading focus ring: accept.** The heading uses `tabindex="-1"` and + the shared `:focus-visible` rule, the same as the inspector title. The + ring shows in the screens only because a synthetic `click()` counts as + keyboard focus. +5. **Darkwing's R2 notes, test-only: accept.** + - Only `packages/control-board/tests/serve.test.mjs` changes under + control-board (`git diff 3a209eea --stat`), so the board's `serve.mjs` + is unchanged. + - The refusal scan now reads every `.mjs` in `packages/conversation/src`, + and the pin covers the whole `REFUSAL_STATUS` object, all 16 codes. + +## 4. Blocking + +**B1. After a reconcile, Reload silently switches the branch.** +- **Where.** The reconcile marker's button is + `{ id: 'reopen', label: 'Reload conversation' }`. The click handler calls + `openConversation(conv.row, conv.id)`, which reads `convURL({ id })` with + no branch, so the server returns the default branch. `openConversation` + resets the markers, and `apply()` raises `branch` only when + `view.defaultBranch !== view.branch`. On the default branch, that is + never. +- **Probe A.** I opened a view, then a fork made another leaf the default, + and the `branch` marker showed. That part is correct. Next I did a + same-inode rewrite that keeps both branches, which triggered a reconcile, + and clicked Reload. + - Result: turns `[["User","QUESTION"],["Assistant","FORK_ANSWER"]]` and + no markers. + - The reader was on `MAIN_ANSWER`'s branch. They now see the fork's + branch, and nothing on screen says so. +- **Why it blocks.** + - The branch marker's own text promises "This view stays on the branch it + opened". + - Test 2's assertion is named "no silent switch". + - Brief §2.1 says nothing switches silently. +- **It is reachable in normal use, not only on a rewrite.** Cursors are held + in board memory with a 10-minute TTL (`reader.mjs:204`), so either of + these produces the reconcile: + - a board restart (`cursor-unknown`); + - Pause held for more than ten minutes (`cursor-expired`). +- **Suggested fix.** + - Reload reopens with the branch it was on: `convURL({ id, branch })`. + - If the board answers `unknown-branch`, open the default and show a + marker that says the old branch is gone. + - Add a test in probe A's shape: fork, a refusal, Reload, then assert the + open branch's text or a marker. Also run a mutant that drops the branch + from Reload. + - The "Open the latest branch" button shares the `reopen` id. It should + keep going to the default, so the two buttons need different ids. + +## 5. Nonblocking + +**N1. The board and the catalogue disagree on "newest".** +- **The mismatch.** The board row's `sessionId` comes from the newest file + by mtime (`scan.mjs:49–58`). The catalogue sorts by the last entry's + timestamp and puts `null` last (`reader.mjs:262`). + `openConversation` takes `readable[0]` as "the board's session" and, for + the "Open the newest session" action, again as "the newest". +- **Probe B.** A relaunch file holding only its header: + - `newer` shows. Clicking "Open the newest session" reopens the **old** + file, clears the marker and records the new `sessionId`. + - After a message then lands in the new file, the view is still on the + old file and has no marker. It never returns. +- **Why it doesn't block.** Real Pi doesn't write a header-only file. Its + `_persist` creates the file with `wx` only once an assistant message + exists (`session-manager.js:739–766`), so header, user and assistant land + together. The new file's last timestamp is then newer, and the two rules + agree. +- **Cheap guard.** After the `newest` action, if `choice.conversation` + equals the conversation that was open, keep the marker and say the newer + session isn't readable yet. + +**N2. Raw bidi controls in the source.** +- **Where.** `app.js:88` builds `BIDI` from raw U+202A, U+202E, U+2066 and + U+2069 characters inside the regex literal. `conversation.test.mjs`'s + `HOSTILE` string holds a raw U+202E. +- **Why it matters.** They behave correctly, but a raw override in a diff is + exactly what a reviewer can't see; this is the Trojan Source pattern. +- **Fix.** Write `/[‪-‮⁦-⁩]/g` and `'‮evil'`. The + behaviour is identical. + +**N3. `inert()` coverage.** +- **What's missing.** It leaves out: + - LRM, RLM and ALM (U+200E, U+200F, U+061C); + - the C1 controls U+0080–U+009F, which include the single-byte CSI + U+009B. +- **Why it's minor.** In a browser, the marks can only nudge neutral + characters next to them, and C1 has no effect. Take it or leave it. If + taken, the fixture gains one of each. + +## 6. Scratch + +- **Worktree.** `/tmp/fb-console-wt` stays in place for the delta re-review. + Its only additions are the probe file + `packages/webui/tests/zz-filbert-probe.test.mjs` and the symlinked + `node_modules`, and the ten pins still verify. Screens are in + `/tmp/fb-console-ev`. +- **Nothing live was touched.** I didn't use the shared tree's served WebUI + or any live process. +- No commit or push. + +## Revision 2: delta re-review + +Candidate: packet `CONSOLE.md` `d06de6a7…72d2`, and +`evidence/console/r2-delta.patch` `c1d65628…5866`. Three files changed: + +- `app.js` `3c7f2f4c…0d6e` +- `conversation.test.mjs` `52c2c663…a4a5` +- `README.md` `5a1a4de7…acc4` + +The other seven pins are unchanged from revision 1. + +**Chain.** In my overlay, the patch applies in reverse to revision 1's ten +pins (all ten verify). Applied forward again, it gives the three revision 2 +hashes. In both the overlay and the shared tree, all ten revision 2 pins +verify. + +**Runs.** Suites 188/188: Dewey's 186, plus my probes A and B, kept for the +run. The chat-00, chat-01 and chat-01c checks exit 0. + +**B1: fixed.** +- The reconcile button is now `reload` and reopens with + `{ branch: c.branch }`. "Open the latest branch" is now `latest`, which + reopens without a branch. `newest` passes `from` and `sessionId`. +- Probe A, rerun: after the rewrite, Reload shows + `[QUESTION, MAIN_ANSWER]` with the `branch` marker. Revision 1 showed the + fork with no marker. +- On `unknown-branch`, the view falls back to the default and raises + `gone`. The fallback runs only when a branch was asked for, and the second + fetch checks the generation. +- The fork test now covers: + - Reload keeping `main`; + - `latest` taking the fork; + - a fork rewritten away, giving `gone` with no reconcile marker. +- Dewey's four new mutants for this (reload drops its branch, latest keeps + the open branch, gone not reopened, gone reopened silently) are all + caught. + +**N1: taken.** +- `newerUnread` is set when `newest` lands on the conversation already open. + The view then keeps the old `sessionId`, so `newer` stays and says the new + history isn't readable yet. +- Probe B, rerun: the marker survives the click and a later poll. +- Dewey's test 3 adds the second click once the new file has an entry, and + that click opens it. + +**N2: taken.** A scan of `app.js` and both conversation test files finds no +raw C1, LRM, RLM, ALM, bidi override or isolate, U+2028, U+2029 or ESC +characters. + +**N3: taken.** +- `UNSEEN` covers U+0080–U+009F, U+061C, U+200E, U+200F, U+202A–U+202E and + U+2066–U+2069, printed as `[U+XXXX]` and padded to four digits. +- The fixture asserts `[U+009B]31mCSI [U+200E]mark [U+202E]evil`, and the + page check rejects all four raw characters. + +**Nit, no action needed.** Test 4's declaration lost a space +(`reply',{ timeout`). + +**Screens.** I didn't regenerate revision 2's screens. Test 1 asserts the +new visible text at the pins, and that overflow check passed in my run. + +**Verdict: approve** revision 2. These are the exact ten files: + +| File | sha256 | +|---|---| +| `packages/webui/README.md` | `5a1a4de7…acc4` | +| `packages/webui/src/public/app.js` | `3c7f2f4c…0d6e` | +| `packages/webui/src/public/index.html` | `b972e2f7` (unchanged) | +| `packages/webui/src/public/live.css` | `8747b83d` (unchanged) | +| `packages/webui/src/serve.mjs` | `1187a98f` (unchanged) | +| `packages/webui/tests/serve.test.mjs` | `0477f66d` (unchanged) | +| `packages/webui/tests/conversation.test.mjs` | `52c2c663…a4a5` | +| `packages/webui/tests/history-fixture.mjs` | `b312c8a1` (unchanged) | +| `packages/webui/tests/history-return-flow.test.mjs` | `0918aeea` (unchanged) | +| `packages/control-board/tests/serve.test.mjs` | `105d87ec` (unchanged) | + +**Still open, and not part of this verdict:** +- the brief §4 live check, after the commit and a WebUI restart; +- Sage recording the `newer` deviation from §2.3 item 6. + +**Scratch.** I removed the probe file and verified the pins, then removed +the worktree `/tmp/fb-console-wt`. No commit or push. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index d974cab6..a9649a55 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -403,3 +403,8 @@ are never rewritten or removed; corrections are new entries. 2026-09-26T21:42:17Z | Sage (T3 Claude Code, thread 1ef1e4f8) | #1509 row 25 live check | Jason's button approval passed (DEC-010 request 2, 21:38:58Z). Approver validation gap sent to the SetSpark lead (ac03938d), who does not own shared-signals; owner and DEC-009 go to Jason. Lead decision 19. 2026-09-26T21:54:38Z | Filbert (T3 Claude Code, thread 9cb9731e) | #1506 Gate F follow-up (notes 1-3) code review | Manifest 382f5bb0 (three pins, clean a4d38a3d worktree): approve. Ledger 51/51; rethrow mutation killed; CLI non-SQLite path prints generic message, exit 1. Live diagnostic 15th traced (shape only): content-engine agent header with spaces in Sage thread, counted human. Review 6fd693b6. Reported to Darkwing and Sage. No commit or push. 2026-09-26T21:57:51Z | Sage (T3 Claude Code, thread 1ef1e4f8) | #1506 Gate F follow-up commit | Darkwing notes 1-3 (manifest 382f5bb0, pins and patch verified), Filbert approve 6fd693b6. Ledger 51/51, eight suites green on the index. Header-role gap noted in DEFERRED. +2026-09-26T22:05:18Z | Sage (T3 Claude Code, thread 1ef1e4f8) | pkill recovery | Claude sessions dropped 22:02:32Z. Darkwing (A1, two untracked files) and Dewey (Console mutation pass) resumed with state; Filbert idle, Rocko (codex) and SetSpark lead unaffected or self-recovered. Discord connector 954332 and board 1042473 still up. +2026-09-26T22:23:02Z | Filbert (T3 Claude Code, thread 9cb9731e) | CHAT-02 Console review, packet 24b046af (#1507 row 5) | changes requested: B1 Reload after reconcile silently switches branch (probe reproduced); N1–N3 nonblocking; 185/185, chat checks 0; review agents/filbert/work/chat-02-console-review-2026-09-26.md +2026-09-26T22:51:14Z | Filbert (T3 Claude Code, thread 9cb9731e) | CHAT-02 Console revision 2 delta re-review, packet d06de6a7 (#1507 row 5) | approved: B1 fixed (probe A rerun keeps branch), N1–N3 taken; 186/186 plus probes, chat checks 0; ten pins in review file +2026-09-26T22:51:39Z | Dewey (T3 Claude Code, thread 84fb346c) | CHAT-02 Console (#1507 row 5), revisions 1 and 2 | approved by Filbert (review 160dd68d): packet CONSOLE.md d06de6a7, ten pins; B1 Reload-branch fix and N1–N3 taken; 186/186 on overlay 3a209eea and shared tree, chat checks 0, mutants 17/17; uncommitted, handed to Sage for commit; live check waits on commit and WebUI restart +2026-09-26T23:05:11Z | Sage (T3 Claude Code, thread 1ef1e4f8) | #1507 CHAT-02 Console commit | Dewey r2 d06de6a7, Filbert approve 160dd68d; ten pins verified; 186/186 serial, eight suites and chat checks green on the index. §2.3 item 6 deviation (newer, not reconcile) recorded. WebUI restart and live check next. diff --git a/docs/plans/DEFERRED.md b/docs/plans/DEFERRED.md index 3ea426f3..51672057 100644 --- a/docs/plans/DEFERRED.md +++ b/docs/plans/DEFERRED.md @@ -154,6 +154,13 @@ at every gate. Started 2026-09-12 during the control board MVP. Sage asked the SetSpark lead to use a one-word role. The rule stays as it is. (2026-09-26, #1506) +- **A nested `node --test` passes even when its tests fail.** A child + `node --test` inherits NODE_TEST_CONTEXT from its parent and exits 0 + whatever its results. Darkwing found it building queue A1 and fixed that + path with `env -u NODE_TEST_CONTEXT`, plus a test. Other suites that + start `node --test` from inside a test run have not been checked, so + they may be hiding failures. (2026-09-26, #1508) + ## Queue Moved to `docs/plans/QUEUE.md` on 2026-09-13. This file holds only gaps. diff --git a/packages/control-board/tests/serve.test.mjs b/packages/control-board/tests/serve.test.mjs index e1f5e18a..6ab004d8 100644 --- a/packages/control-board/tests/serve.test.mjs +++ b/packages/control-board/tests/serve.test.mjs @@ -1104,12 +1104,21 @@ test("conversation routes (F16): a foreign Host, a wrong port and a cross-origin }); test("every refusal code the reader can raise has an HTTP status", () => { - const src = ["reader.mjs", "pi.mjs", "safe-fs.mjs"].map((f) => readFileSync(join(pkgRoot, "..", "conversation", "src", f), "utf8")).join("\n"); + // Every source file in the reader package, so a refusal added in a new file is seen too. + const dir = join(pkgRoot, "..", "conversation", "src"); + const src = readdirSync(dir).filter((f) => f.endsWith(".mjs")).map((f) => readFileSync(join(dir, f), "utf8")).join("\n"); const codes = new Set([...src.matchAll(/new Refusal\(\s*"([a-z-]+)"/g)].map((m) => m[1])); codes.add(UNSUPPORTED_HARNESS); // raised by value, as a root's unsupportedReason assert.ok(codes.size >= 15, [...codes].join(" ")); assert.deepEqual([...codes].filter((c) => !(c in REFUSAL_STATUS)), []); assert.deepEqual(Object.keys(REFUSAL_STATUS).filter((c) => !codes.has(c)), [], "no stale entries"); + // The statuses themselves, so a changed value fails here even where no route test reaches it. + assert.deepEqual(REFUSAL_STATUS, { + "unknown-conversation": 404, "unknown-branch": 404, unavailable: 404, + "cursor-unknown": 409, "cursor-expired": 409, "cursor-foreign": 409, "source-replaced": 409, "incomplete-header": 409, + "unsafe-path": 403, "foreign-project": 403, unreadable: 403, "unknown-actor": 403, + "unsupported-harness": 422, "not-a-pi-session": 422, "too-large": 422, "unsupported-purpose": 422, + }); }); test("conversation routes: catalogue, first page, next page and follow over HTTP; refusals map to 4xx with their code; nothing is written", async () => { diff --git a/packages/webui/README.md b/packages/webui/README.md index ea12352d..7af161b0 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -38,6 +38,19 @@ bytes. Do not expose either unauthenticated server through a public proxy. with a warning. There are no automatic action retries. - Palette and appearance use the existing brand tokens and persist in this browser when local storage is available. No settings screen is added. +- History opens a read-only conversation view for a seat, from its Waiting + card, its table row or its inspector (#1507, CHAT-02). It shows the whole + branch with nothing clipped. Tool calls, tool results and thinking start + collapsed. Session text is always shown as text: Markdown stays as source, + and terminal controls, bidi controls and marks show as visible symbols. Reply in the view + uses the same board reply path as the inspector. +- The view checks for new entries on the same ten-second refresh, and Pause + stops it. It never switches on its own. A fork, a newer session for the seat + or a rewritten file each shows a marker with a button to open the other + history. Reloading after a rewrite keeps the branch the view was on; if that + branch is gone, the view opens the latest one and says so. Session lists + older sessions for the seat. Only repository Pi seats + have history; other harnesses say so. Drafts and receipts stay in page memory, including across refresh, inspector changes and a stale registration. Reloading or closing the page loses them. @@ -47,11 +60,13 @@ delivery may be unknown: inspect the seat before sending again. ## Data and boundaries -GET `/api/board` and POST `/api/seen` and `/api/reply` proxy only the existing -board paths. POST bytes and upstream status/JSON are preserved. GET `/api/config` -returns the configured board URL for the page's error message. No scanner, -registration, session reader or transport is implemented here. No control-board, -seat, fleet, comms or root package files are changed. +GET `/api/board`, `/api/conversations` and `/api/conversation`, and POST +`/api/seen` and `/api/reply` proxy only those board paths. Only the two +conversation routes carry their query string; the board validates it. POST +bytes and upstream status/JSON are preserved. GET `/api/config` returns the +configured board URL for the page's error message. No scanner, registration, +session reader or transport is implemented here. The session reader is +`packages/conversation`, served by the board. Console's shared CSS, Console CSS, brand.js and local Manrope fonts were copied unchanged from `agents/dewey/work/wui/`. Font license and source URLs accompany @@ -67,6 +82,7 @@ or mockup session hierarchy is presented as live data. node --test packages/webui/tests/ node --test packages/control-board/tests/ packages/seat/tests/ packages/ledger/tests/ packages/mosaic/tests/ WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/browser.test.mjs +WEBUI_EVIDENCE=/tmp/webui-evidence node --test packages/webui/tests/conversation.test.mjs ``` Node's test runner and installed `/usr/bin/chromium` are required. Set `CHROMIUM` @@ -83,6 +99,13 @@ on 330 rendered samples across ten palettes and three modes. Layouts are checked at 320, 390, 768, 1440 and 2560px. Horizontal scrolling is intentional within the dense table; the page itself must not overflow. +Conversation tests run the real board routes over temporary repository-layout +session files. They cover full-length answers, collapsed tools and thinking, +hostile content rendered inert, malformed-line and reconcile markers, forks, and +the return flow: a send from the view, a tool call and a delayed result while a +draft is typed, a peer message, a 4.5-million-character answer split into +continuation parts, and a relaunch mid-turn. + No root CI workflow is configured for this package. These local tests are not a claim of CI, deployment, live-seat delivery or user acceptance. diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js index b4246cfc..b862a985 100644 --- a/packages/webui/src/public/app.js +++ b/packages/webui/src/public/app.js @@ -52,14 +52,15 @@ if (!['waiting', 'error'].includes(r.state) || !r.lastActivity) return ''; return ``; } + const historyButton = r => ``; const openButton = r => ``; function cards(rows) { if (!rows.length) return '

Nothing here.

'; - return ``; + return ``; } function table(rows) { if (!rows.length) return '

No sessions match these filters.

'; - return `
${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => ``).join('')}${rows.map(r => ``).join('')}
${h}
${openButton(r)}${esc(r.project)}${badge(r.state)}${connectorStatus(r)}${r.seen ? 'seen' : ''}${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}${esc(r.model || 'unknown')}${esc(r.provider)}${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}${relaunchNotice(r) ? esc(relaunchNotice(r)) : activity(r) ? `${esc(activity(r))}${activity(r) === r.lastActivity ? '' : `${esc(r.lastActivity)}`}` : 'unknown'}
`; + return `
${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => ``).join('')}${rows.map(r => ``).join('')}
${h}
${openButton(r)}${esc(r.project)}${historyButton(r)}${badge(r.state)}${connectorStatus(r)}${r.seen ? 'seen' : ''}${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}${esc(r.model || 'unknown')}${esc(r.provider)}${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}${relaunchNotice(r) ? esc(relaunchNotice(r)) : activity(r) ? `${esc(activity(r))}${activity(r) === r.lastActivity ? '' : `${esc(r.lastActivity)}`}` : 'unknown'}
`; } function registered(r) { const reg = r.registered; @@ -77,16 +78,183 @@ if (r.relaunchedAt) fields.unshift(['Current activity', relaunchNotice(r)]); if (r.connector) fields.push(['Connector', `${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'}; owner ${r.connector.ownerState}`]); const receipt = receipts.get(selected); - $('inspection').innerHTML = `
${fields.map(([k, v]) => `
${k}
${esc(v)}
`).join('')}${pending(r) ? `
Reply
Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.
${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message
` : `
${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}
`}
${esc(r.lastAssistantText || 'No assistant text yet.')}
${r.lastError ? `
${r.relaunchedAt ? 'Historical last error' : 'Last error'}
${esc(r.lastError)}
` : ''}
${seenButton(r)}${canReply(r) ? `
` : `

${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}

`}${receipt ? `

${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}

` : ''}`; + $('inspection').innerHTML = `
${fields.map(([k, v]) => `
${k}
${esc(v)}
`).join('')}${pending(r) ? `
Reply
Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.
${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message
` : `
${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}
`}
${esc(r.lastAssistantText || 'No assistant text yet.')}
${r.lastError ? `
${r.relaunchedAt ? 'Historical last error' : 'Last error'}
${esc(r.lastError)}
` : ''}
${historyButton(r)}${seenButton(r)}${canReply(r) ? `
` : `

${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}

`}${receipt ? `

${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}

` : ''}`; } + // Conversation view (#1507, CHAT-02): the full read-only history from the board's + // /api/conversations and /api/conversation routes. Session content is set with + // textContent only, so it never creates an element, handler or navigation. + // Markdown shows as its source text. New entries arrive by polling a follow cursor. + let conv = null, convGen = 0; + const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g, UNSEEN = /[\u0080-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g; + // C0 controls and DEL show as control pictures. C1 controls and bidi controls and marks have no picture + // and show as [U+XXXX]. Newlines and tabs stay. + const inert = v => String(v).replace(CONTROL, c => c === '\u007f' ? '␡' : String.fromCharCode(0x2400 + c.charCodeAt(0))).replace(UNSEEN, c => `[U+${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}]`); + const node = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = inert(text); return e; }; + const ROLES = { user: 'User', assistant: 'Assistant', tool: 'Tool', notice: 'Notice', compaction: 'Compaction' }; + // The CHAT-01 field that holds each block type's string. Fragments of one block concatenate in order. + const FIELDS = { text: 'text', thinking: 'text', 'tool-call': 'argumentsText', 'tool-result': 'text', compaction: 'summary' }; + const convURL = params => '/api/conversation?' + new URLSearchParams(params); + async function getJSON(path) { + const res = await fetch(path, { cache: 'no-store' }); + let body; try { body = await res.json(); } catch { body = { error: `HTTP ${res.status}` }; } + return { ok: res.ok, body }; + } + function blockNode(b) { + if (b.type === 'text') return node('div', 'conv-text', b.value); + if (b.type === 'attachment') return node('p', 'small muted', 'Attachment not shown.'); + if (!FIELDS[b.type]) return node('p', 'small muted', `A ${b.type} block is not shown.`); + const summary = b.type === 'thinking' ? (b.visibility === 'unavailable' ? 'Thinking (not available)' : 'Thinking') + : b.type === 'tool-call' ? `Tool call: ${b.name}` : b.type === 'tool-result' ? (b.isError ? 'Tool result (error)' : 'Tool result') : 'Earlier context was compacted. Summary'; + const d = node('details', `conv-${b.type}`); d.append(node('summary', '', summary), node('pre', 'conv-pre', b.value)); + return d; + } + function drawMessage(m) { + const li = node('li', `turn conv-turn conv-${m.role}`), time = node('time', '', m.createdAt); + time.dateTime = m.createdAt; li.append(node('span', 'who', ROLES[m.role] || m.role), time); + const open = m.el ? [...m.el.querySelectorAll('details')].map(d => d.open) : []; + let i = 0; + for (const b of m.blocks) if (b) { const n = blockNode(b); if (n.tagName === 'DETAILS') n.open = open[i++] ?? false; li.append(n); } + if (m.el) m.el.replaceWith(li); else $('conv-log').append(li); + m.el = li; + } + function addEntries(entries) { + const touched = new Set(); + for (const e of entries) { + let m = conv.messages.get(e.message); + if (!m) { m = { role: e.role, createdAt: e.createdAt, blocks: [], el: null }; conv.messages.set(e.message, m); } + for (const c of e.content) { const b = m.blocks[c.block] ??= { ...c, value: '' }; if (FIELDS[c.type]) b.value += c[FIELDS[c.type]]; } + touched.add(m); + } + for (const m of touched) drawMessage(m); + return touched.size; + } + function drawMarkers() { + const box = $('conv-markers'); box.replaceChildren(); + for (const [kind, m] of conv.markers) { + const d = node('div', 'notice notice-attn conv-marker'); d.dataset.marker = kind; d.append(node('p', '', m.text)); + if (m.action) { const b = node('button', 'btn', m.action.label); b.type = 'button'; b.dataset.convAction = m.action.id; d.append(b); } + box.append(d); + } + } + function marker(kind, text, action) { if (conv.markers.get(kind)?.text === text) return; conv.markers.set(kind, { text, action }); drawMarkers(); announce(text); } + function unmark(kind) { if (conv.markers.delete(kind)) drawMarkers(); } + function convStatus(text) { + $('conv-status').textContent = text ?? `${conv.messages.size} messages · ${!conv.follow ? 'not checking for new entries' : paused ? 'checking for new entries paused' : 'checks for new entries every 10 seconds'}`; + } + function apply(body) { + conv.branch = body.page.branch; + const added = addEntries(body.page.entries); + const v = body.view; + if (v.incomplete) marker('incomplete', 'The last line of this session is still being written. It shows here once it is complete.'); else unmark('incomplete'); + if (v.defaultBranch !== v.branch) marker('branch', 'This conversation continued on another branch. This view stays on the branch it opened.', { id: 'latest', label: 'Open the latest branch' }); + return added; + } + // A refusal with reconcile keeps everything already shown and stops polling. Nothing switches silently. + function refused(res) { + const code = res.body.refusal?.code; + conv.follow = null; + if (res.body.refusal?.reconcile) marker('reconcile', `This view is out of date (${code}): ${res.body.error}. The history below is what was loaded before.`, { id: 'reload', label: 'Reload conversation' }), convStatus(); + else convStatus(`History unavailable: ${res.body.error || 'unknown error'}${code ? ` (${code})` : ''}.`); + } + const nearEnd = () => $('conv-log').getBoundingClientRect().bottom <= innerHeight + 300; + // Reads pages from `res` until the snapshot ends. Returns false when the view changed or a page was refused. + async function readPages(c, gen, res, onPage) { + for (;;) { + if (gen !== convGen) return false; + if (!res.ok) { refused(res); return false; } + onPage(apply(res.body)); + if (!res.body.cursor) { c.follow = res.body.follow?.id ?? null; return true; } + res = await getJSON(convURL({ id: c.id, branch: c.branch, cursor: res.body.cursor.id })); + } + } + function showConversation(on) { + $('conversation').hidden = !on; $('board-view').hidden = on; + document.body.classList.toggle('has-conversation', on); + } + function convForm() { + if (!conv) return; + const r = row(conv.row), can = !!r && !!canReply(r), receipt = receipts.get(conv.row); + $('conv-form').hidden = !can; $('conv-no-reply').hidden = can; + $('conv-no-reply').textContent = !r ? 'This session is no longer in the board scan.' : r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'; + if (r) $('conv-reply-label').textContent = `Reply to ${r.agent}`; + $('conv-send').disabled = sending.has(conv.row); $('conv-send').textContent = sending.has(conv.row) ? 'Sending…' : 'Send'; + $('conv-receipt').hidden = !receipt; + if (receipt) { $('conv-receipt').className = `conv-receipt ${receipt.delivered ? '' : 'failed'}`; $('conv-receipt').textContent = receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`; } + // The board's newest file for this seat changed after the view opened: a relaunch. The view keeps its file. + if (conv.sessionId && r?.sessionId && r.sessionId !== conv.sessionId) marker('newer', conv.newerUnread ? 'A newer session started for this seat, but its history is not readable yet. This view stays on the session you opened.' : 'A newer session started for this seat. This view stays on the session you opened.', { id: 'newest', label: 'Open the newest session' }); + } + // opts.branch reopens that branch (Reload). opts.from and opts.sessionId come from "Open the newest session". + async function openConversation(rowKey, pick = null, returnTo = conv?.returnFocus, opts = {}) { + const gen = ++convGen, r = row(rowKey), sameRow = conv?.row === rowKey; + conv = { row: rowKey, id: null, branch: null, follow: null, messages: new Map(), markers: new Map(), sessionId: null, loading: true, polling: false, returnFocus: returnTo }; + selected = null; render(); showConversation(true); + $('conv-title').textContent = r ? `${r.agent} conversation` : 'Conversation'; + $('conv-meta').textContent = ''; $('conv-log').replaceChildren(); drawMarkers(); convForm(); + if (!sameRow) $('conv-reply').value = drafts.get(rowKey) || ''; + convStatus('Loading history…'); + const slash = rowKey.lastIndexOf('/'), project = r?.project ?? rowKey.slice(0, slash), agent = r?.agent ?? rowKey.slice(slash + 1); + const c = conv; + try { + const cat = await getJSON('/api/conversations'); + if (gen !== convGen) return; + if (!cat.ok) { convStatus(`History unavailable: ${cat.body.error || 'the board refused the catalogue'}.`); return; } + const mine = cat.body.conversations.filter(x => x.project === project && x.seat === agent); + const readable = mine.filter(x => x.availability === 'available'); + const choice = mine.find(x => x.conversation === pick) ?? readable[0] ?? mine[0]; + $('conv-pick').replaceChildren(...mine.map(x => { const o = node('option', '', `${x.title || 'Untitled'} · ${x.lastActivityAt || x.conversationCreatedAt || 'no activity'}${x.availability === 'available' ? '' : ` (${x.unsupportedReason || x.refusal?.code || x.availability})`}`); o.value = x.conversation; return o; })); + $('conv-pick').disabled = mine.length < 2; + if (!choice) { convStatus('No readable history for this seat. Only repository Pi seats have history here.'); return; } + $('conv-pick').value = c.id = choice.conversation; + $('conv-meta').textContent = `${project} · ${choice.title || 'Untitled'} · started ${choice.conversationCreatedAt || 'unknown'}`; + if (choice.availability !== 'available') { convStatus(choice.unsupportedReason === 'unsupported-harness' ? `History for the ${choice.harness} harness is not available yet.` : `History unavailable (${choice.refusal?.code || choice.availability}).`); return; } + // Relaunch detection only makes sense for the newest session, the one the board row shows. If "newest" + // lands on the file already open, the board's newer file is not readable yet: keep the marker. + c.newerUnread = !!opts.from && opts.from === choice.conversation; + if (choice === readable[0]) c.sessionId = c.newerUnread ? opts.sessionId : r?.sessionId ?? null; + let first = await getJSON(convURL(opts.branch ? { id: c.id, branch: opts.branch } : { id: c.id })); + // The branch this view was on is gone: open the default branch and say so. + if (!first.ok && opts.branch && first.body.refusal?.code === 'unknown-branch') { + first = await getJSON(convURL({ id: c.id })); + if (gen !== convGen) return; + if (first.ok) marker('gone', 'The branch this view was on is no longer in the session. This view shows the latest branch.'); + } + const done = await readPages(c, gen, first, () => convStatus(`Loading history… ${c.messages.size} messages`)); + if (gen !== convGen) return; + if (done) convStatus(); + $('conv-log').lastElementChild?.scrollIntoView({ block: 'end' }); + convForm(); + } catch (err) { if (gen === convGen) convStatus(`History unavailable: ${err.message}.`); } + finally { c.loading = false; } + } + async function pollConversation() { + const c = conv, gen = convGen; + if (!c || c.loading || c.polling || !c.follow) return; + c.polling = true; + try { + const atEnd = nearEnd(); + let added = 0; + const done = await readPages(c, gen, await getJSON(convURL({ id: c.id, branch: c.branch, cursor: c.follow })), n => { added += n; }); + if (gen !== convGen) return; + if (done) convStatus(); + if (added && atEnd) $('conv-log').lastElementChild?.scrollIntoView({ block: 'end' }); + } catch (err) { if (gen === convGen) convStatus(`Could not check for new entries: ${err.message}. The view keeps what it has; the next check tries again.`); } + finally { c.polling = false; } + } + function closeConversation() { + const back = conv?.returnFocus; convGen++; conv = null; showConversation(false); render(); + if (back) restore(back); + if (document.activeElement === document.body) $('main').focus(); + } + $('conv-back').onclick = closeConversation; + $('conv-pick').onchange = e => { if (conv) openConversation(conv.row, e.target.value); }; // Restore by data attribute equality, never interpolate API ids into selectors. function focusSnapshot() { const a = document.activeElement; - return { id: a?.id, open: a?.dataset.open, seen: a?.dataset.seen, project: a?.dataset.project, container: a?.closest('[id]')?.id, start: a?.selectionStart, end: a?.selectionEnd }; + return { id: a?.id, open: a?.dataset.open, seen: a?.dataset.seen, history: a?.dataset.history, project: a?.dataset.project, container: a?.closest('[id]')?.id, start: a?.selectionStart, end: a?.selectionEnd }; } function restore(f) { let el = f.id ? $(f.id) : null; - for (const attr of ['open', 'seen', 'project']) if (f[attr] !== undefined) { + for (const attr of ['open', 'seen', 'history', 'project']) if (f[attr] !== undefined) { el = [...document.querySelectorAll(`[data-${attr}]`)].find(e => e.dataset[attr] === f[attr] && e.closest('[id]')?.id === f.container); } if (el && !el.closest('[hidden]')) { el.focus({ preventScroll: true }); if (f.start != null) el.setSelectionRange(f.start, f.end); } @@ -107,7 +275,7 @@ $('sessions').innerHTML = table(visible); $('footer').textContent = Object.entries(data.counts || {}).map(([s, n]) => `${s} ${n}`).join(' · ') || 'No sessions'; $('status').textContent = `Scanned ${data.generatedAt || 'unknown'} · ${paused ? 'auto-refresh paused' : 'refresh every 10s'}`; - inspect(); restore(focus); + inspect(); convForm(); if (conv) convStatus(); restore(focus); } async function api(path, value) { const res = await fetch(path, value === undefined ? { cache: 'no-store' } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) }); @@ -126,7 +294,7 @@ async function refresh() { if (busy) return; busy = true; $('refresh').disabled = true; - try { accept(await api('/api/board')); } catch (err) { error(err); } + try { try { accept(await api('/api/board')); } catch (err) { error(err); } await pollConversation(); } finally { busy = false; $('refresh').disabled = false; schedule(); } } $('refresh').onclick = refresh; @@ -135,10 +303,15 @@ function close() { selected = null; render(); if (returnFocus) restore(returnFocus); } $('close').onclick = close; document.addEventListener('click', async e => { + const history = e.target.closest('[data-history]'); + if (history) { openConversation(history.dataset.history, null, { history: history.dataset.history, container: history.closest('[id]')?.id }); $('conv-title').focus(); return; } + const action = e.target.closest('[data-conv-action]'); + // Reload keeps the open branch; "Open the latest branch" takes the default; "Open the newest session" takes the newest file. + if (action && conv) { const c = conv, a = action.dataset.convAction; openConversation(c.row, a === 'newest' ? null : c.id, undefined, a === 'reload' ? { branch: c.branch } : a === 'newest' ? { from: c.id, sessionId: c.sessionId } : {}); $('conv-title').focus(); return; } const open = e.target.closest('[data-open]'); - if (open) { returnFocus = focusSnapshot(); selected = open.dataset.open; render(); $('inspector-title').focus(); announce(`Inspecting ${row(selected)?.agent}`); return; } + if (open) { if (conv) closeConversation(); returnFocus = focusSnapshot(); selected = open.dataset.open; render(); $('inspector-title').focus(); announce(`Inspecting ${row(selected)?.agent}`); return; } const projectButton = e.target.closest('[data-project]'); - if (projectButton) { project = projectButton.dataset.project || null; render(); return; } + if (projectButton) { if (conv) closeConversation(); project = projectButton.dataset.project || null; render(); return; } const seen = e.target.closest('[data-seen]'); if (!seen || busy) return; const r = row(seen.dataset.seen); if (!r) return; @@ -147,18 +320,20 @@ catch (err) { error(err); seen.disabled = false; } finally { busy = false; schedule(); } }); - document.addEventListener('input', e => { if (e.target.id === 'reply') drafts.set(selected, e.target.value); }); + document.addEventListener('input', e => { if (e.target.id === 'reply') drafts.set(selected, e.target.value); if (e.target.id === 'conv-reply' && conv) drafts.set(conv.row, e.target.value); }); document.addEventListener('submit', async e => { - if (!e.target.matches('.reply-form')) return; + if (!e.target.matches('.reply-form, #conv-form')) return; e.preventDefault(); - const id = selected, text = $('reply').value; + // The inspector form is rebuilt from drafts on render; the conversation form is not rebuilt, so its box is cleared here. + const inView = e.target.id === 'conv-form', id = inView ? conv?.row : selected, box = inView ? $('conv-reply') : $('reply'), text = box.value; + if (!id) return; if (!text.trim() || sending.has(id)) return; drafts.set(id, text); sending.add(id); render(); try { const result = await api('/api/reply', { agent: id, text }); receipts.set(id, result); if (result.delivered) awaiting.set(id, result.sentAt); // Do not discard text typed while the request was in flight. - if (result.delivered && drafts.get(id) === text) drafts.delete(id); + if (result.delivered && drafts.get(id) === text) { drafts.delete(id); if (inView && box.value === text) box.value = ''; } if (result.delivered && !paused) await refresh(); } catch (err) { receipts.set(id, { error: err.message + ' Delivery may be unknown; check the seat before sending again.' }); } finally { sending.delete(id); render(); announce(receipts.get(id)?.delivered ? 'Reply delivered' : 'Reply failed; draft kept'); } diff --git a/packages/webui/src/public/index.html b/packages/webui/src/public/index.html index 4e5a662c..10bdc61d 100644 --- a/packages/webui/src/public/index.html +++ b/packages/webui/src/public/index.html @@ -11,9 +11,12 @@

Control board

Loading board…

-

Waiting on you 0

Loading sessions…

+ +

Waiting on you 0

Loading sessions…

Seen 0
-

All sessions 0

Select an agent to inspect. Arrow keys move between agents; Enter opens the inspector. Scroll the table for all columns.

+

All sessions 0

Select an agent to inspect. Arrow keys move between agents; Enter opens the inspector. Scroll the table for all columns.

diff --git a/packages/webui/src/public/live.css b/packages/webui/src/public/live.css index ca0e5ec7..3cd48928 100644 --- a/packages/webui/src/public/live.css +++ b/packages/webui/src/public/live.css @@ -6,6 +6,11 @@ section{margin-bottom:20px}#seen-section{margin-bottom:20px}summary{cursor:pointer}#seen{margin-top:10px} .table-wrap{background:var(--surface);border:1px solid var(--line);border-radius:var(--r)}table.sessions{min-width:1050px}table.sessions td{max-width:270px;overflow-wrap:anywhere}table.sessions .task{min-width:190px}.source{display:block;color:var(--muted);font-size:.75rem}.session-open{font:inherit;font-weight:600;background:none;border:0;color:var(--action);padding:3px;cursor:pointer;text-align:left;overflow-wrap:anywhere} .waiting{grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr))}.wait-item .session-open{font-size:1rem}.wait-item p{margin-top:6px}.wait-item .actions{justify-content:space-between}.wait-item .preview{white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden} -.inspector .kv{grid-template-columns:1fr}.inspector .kv dd{margin-bottom:10px}.last-text,.receipt{white-space:pre-wrap;overflow-wrap:anywhere}.last-text{font:inherit;font-size:.9rem;margin:0}.receipt{font-size:.85rem;margin-top:8px}.receipt.failed{color:var(--danger)}.reply-form{display:grid;gap:8px;margin-top:14px}.reply-form .btn{justify-self:start}.inspector-head{flex-wrap:wrap}#inspection{min-width:0}.badge{white-space:nowrap}.foot>*{min-width:0;overflow-wrap:anywhere} +.inspector .kv{grid-template-columns:1fr}.inspector .kv dd{margin-bottom:10px}.last-text,.receipt,.conv-receipt{white-space:pre-wrap;overflow-wrap:anywhere}.last-text{font:inherit;font-size:.9rem;margin:0}.receipt,.conv-receipt{font-size:.85rem;margin-top:8px}.receipt.failed,.conv-receipt.failed{color:var(--danger)}.reply-form,.conv-form{display:grid;gap:8px;margin-top:14px}.reply-form .btn,.conv-form .btn{justify-self:start}.inspector-head{flex-wrap:wrap}#inspection{min-width:0}.badge{white-space:nowrap}.foot>*{min-width:0;overflow-wrap:anywhere} @media(max-width:959px){.tree #projects{flex-basis:100%;width:100%}.tree-list{display:flex;flex-wrap:wrap}.tree-list li{max-width:100%}.tree .small{flex-basis:100%;margin:0}.inspector{max-height:75vh}.cmdbar{position:static}} @media(max-width:479px){.cmd-right{margin-left:0;gap:6px}.cmd-right label{flex:1 1 110px}.cmd-right select{max-width:100%;width:100%}.kv{grid-template-columns:1fr}.content{padding:12px}.tree{padding:12px}.page-head>h2{flex-basis:100%}} +/* Conversation view (#1507, CHAT-02). Text wraps; nothing in it scrolls the page sideways. */ +.conversation{min-width:0}.conversation .page-head{flex-wrap:wrap;gap:10px}.conversation .page-head>div:first-child{min-width:0;overflow-wrap:anywhere}.conv-actions{display:flex;gap:8px;align-items:end;flex-wrap:wrap;max-width:100%}.conv-actions label{display:grid;gap:2px;font-size:.75rem;min-width:0;max-width:100%}.conv-actions select{max-width:min(100%,360px);min-height:36px} +.conv-log .turn{max-width:100%;min-width:0}.conv-turn time{display:block;font-size:.72rem;color:var(--muted);margin-bottom:4px}.conv-user{border-left:3px solid var(--action)}.conv-notice,.conv-compaction{background:none;border:1px dashed var(--border)} +.conv-text,.conv-pre{white-space:pre-wrap;overflow-wrap:anywhere;margin:0}.conv-pre{font-family:var(--mono);font-size:.82rem;max-height:60vh;overflow:auto;margin-top:6px}.conv-turn details{margin-top:6px}.conv-turn summary{font-size:.85rem;color:var(--muted);cursor:pointer} +.conv-marker{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.conv-marker p{margin:0;flex:1 1 220px;overflow-wrap:anywhere}.conv-open{margin-left:6px;padding:2px 8px;min-height:0;font-size:.8rem}#conv-form{max-width:72ch} diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 792e27f8..0dc3fc2a 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -49,15 +49,17 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF res.setHeader('x-content-type-options', 'nosniff'); res.setHeader('referrer-policy', 'no-referrer'); res.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"); - let path; + let path, search; try { const authority = new URL(`http://${req.headers.host}`); if (!isLoopback(authority.hostname.replace(/^\[|\]$/g, '')) || Number(authority.port || 80) !== server.address().port) return json(res, 403, { error: 'non-local Host refused' }); - path = new URL(req.url, 'http://localhost').pathname; + ({ pathname: path, search } = new URL(req.url, 'http://localhost')); } catch { return json(res, 400, { error: 'invalid URL' }); } // No CORS. JSON content type and same-origin checks keep browser forms out. if (req.headers.origin && req.headers.origin !== `http://${req.headers.host}`) return json(res, 403, { error: 'cross-origin request refused' }); - const allowed = path === '/api/board' ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null; + // The conversation routes (#1507, CHAT-02) keep their query; the board validates it. + const conversation = path === '/api/conversations' || path === '/api/conversation'; + const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null; if (allowed) { if (req.method !== allowed) return json(res, 405, { error: 'method not allowed' }); let bytes; @@ -65,7 +67,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF try { bytes = await body(req); } catch (err) { return json(res, 400, { error: err.message }); } } try { - const response = await fetch(upstream + path, { + const response = await fetch(upstream + path + (conversation ? search : ''), { method: allowed, headers: bytes ? { 'content-type': 'application/json' } : {}, body: bytes, redirect: 'error', signal: AbortSignal.timeout(timeout), }); diff --git a/packages/webui/tests/conversation.test.mjs b/packages/webui/tests/conversation.test.mjs new file mode 100644 index 00000000..388918d8 --- /dev/null +++ b/packages/webui/tests/conversation.test.mjs @@ -0,0 +1,195 @@ +// Conversation view (#1507, CHAT-02 brief §2.2): full branch history through +// the real board routes and the real WebUI. The hostile-render fixture (R1) +// must stay inert text: no element, handler or navigation from session content. +// WEBUI_EVIDENCE= also saves full-page screenshots at 320 and 1440 in the +// light and dark modes. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { browser } from './browser.mjs'; +import { historyFixture, session, user, assistant, toolResult, at, waiter, turns } from './history-fixture.mjs'; + +const HOSTILE = ' [click](javascript:window.injected=3) link \u001b[31mRED\u001b[0m \u001b]8;;http://example.invalid\u0007osc\u001b]8;;\u0007 \u009b31mCSI \u200emark \u202eevil'; +const LONG = 'This answer is longer than the board summary. '.repeat(40) + 'LONG_END'; + +test('conversation view: full history, collapsed tools, hidden thinking, inert hostile content, malformed and reconcile markers', { timeout: 120000 }, async () => { + const f = await historyFixture(); + let b; + try { + const file = join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl'); + const log = session(file, f.projectRoot); + log.add(user('Show me the file'), at(-300)); + log.add(assistant([{ type: 'thinking', thinking: 'SECRET_THOUGHT' }, { type: 'text', text: 'Reading it.' }, { type: 'toolCall', id: 'call_1|fc_2', name: 'read', arguments: { path: 'README.md' } }], 'toolUse'), at(-290)); + log.add(toolResult('call_1|fc_2', HOSTILE), at(-280)); + log.raw('{"type":"message","id":'); + log.add(assistant('Here: ' + HOSTILE), at(-270)); + log.add(user('And the summary?'), at(-260)); + log.add(assistant(LONG), at(-250)); + + b = await browser(); await b.viewport(1440, 1000); + const wait = waiter(b); + await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")'); + const href = await b.evaluate('location.href'); + await b.evaluate('document.querySelector("table.sessions [data-history]").click()'); + await wait('document.querySelector("#conv-status").textContent.startsWith("7 messages")'); + assert.equal(await b.evaluate('document.querySelector("#board-view").hidden'), true); + assert.equal(await b.evaluate('document.activeElement.id'), 'conv-title'); + assert.match(await b.evaluate('document.querySelector("#conv-meta").textContent'), /^repo · Show me the file · started /); + + assert.deepEqual((await turns(b)).map(t => t[0]), ['User', 'Assistant', 'Tool', 'Notice', 'Assistant', 'User', 'Assistant']); + assert.match(await b.evaluate('document.querySelectorAll("#conv-log > li")[3].textContent'), /Line 5 could not be read/); + // The long answer in full: the board's 240-character summary is not used. + assert.equal(await b.evaluate('[...document.querySelectorAll("#conv-log .conv-text")].at(-1).textContent'), LONG); + // Thinking and tools collapsed; their contents are not rendered visible. + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#conv-log details")].map(d => [d.className, d.querySelector("summary").textContent, d.open, d.querySelector("pre").checkVisibility()])'), [ + ['conv-thinking', 'Thinking', false, false], + ['conv-tool-call', 'Tool call: read', false, false], + ['conv-tool-result', 'Tool result', false, false], + ]); + assert.equal(await b.evaluate('document.querySelector(".conv-tool-call pre").textContent'), '{"path":"README.md"}'); + + // Hostile content: shown as text, controls made visible, nothing active created. + await b.evaluate('document.querySelectorAll("#conv-log details").forEach(d => d.open = true)'); + const shown = await b.evaluate('[...document.querySelectorAll("#conv-log .conv-text")].find(e => e.textContent.startsWith("Here: ")).textContent'); + assert.ok(shown.startsWith('Here: [click](javascript:window.injected=3) link ␛[31mRED'), shown); + assert.ok(shown.includes('␛]8;;http://example.invalid␇osc') && shown.endsWith('[U+009B]31mCSI [U+200E]mark [U+202E]evil'), shown); + assert.equal(await b.evaluate('document.querySelector(".conv-tool-result pre").textContent'), shown.slice(6)); + assert.equal(await b.evaluate('document.querySelectorAll("#conversation script, #conversation img, #conversation a, #conversation iframe, #conversation object, #conversation embed, #conversation svg, #conversation style, #conversation link").length'), 0); + assert.equal(await b.evaluate('[...document.querySelectorAll("*")].some(e => [...e.attributes].some(a => a.name.startsWith("on")))'), false); + assert.equal(await b.evaluate('/[\\u001b\\u009b\\u200e\\u202e]/.test(document.body.textContent)'), false); + await b.evaluate('[...document.querySelectorAll("#conv-log .conv-text")].find(e => e.textContent.startsWith("Here: ")).click()'); + assert.equal(await b.evaluate('typeof window.injected'), 'undefined'); + assert.equal(await b.evaluate('location.href'), href); + + // A same-inode rewrite of the history: the next check refuses, the view keeps what it showed. + writeFileSync(file, readFileSync(file, 'utf8').replace('Show me the file', 'Show me the FILE')); + await b.evaluate('document.querySelector("#refresh").click()'); + await wait('document.querySelector("[data-marker=reconcile]")'); + assert.match(await b.evaluate('document.querySelector("[data-marker=reconcile]").textContent'), /out of date \(source-replaced\).*loaded before\.Reload conversation$/); + assert.equal(await b.evaluate('document.querySelectorAll("#conv-log > li").length'), 7); + assert.match(await b.evaluate('document.querySelector("#conv-log").textContent'), /Show me the file/); + assert.match(await b.evaluate('document.querySelector("#conv-status").textContent'), /not checking for new entries/); + + // Evidence shows thinking as a reader first sees it (closed), from the top of the page. + await b.evaluate('document.querySelector(".conv-thinking").open = false; scrollTo(0, 0)'); + for (const width of [320, 1440]) for (const mode of ['light', 'dark']) { + await b.viewport(width, 1000); + await b.evaluate(`(() => { const m = document.querySelector("#mode"); m.value = ${JSON.stringify(mode)}; m.dispatchEvent(new Event("change")); })()`); + assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true, `no horizontal overflow at ${width} ${mode}`); + if (process.env.WEBUI_EVIDENCE) { + const { data } = await b.call('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true }); + writeFileSync(join(process.env.WEBUI_EVIDENCE, `conversation-${width}-${mode}.png`), Buffer.from(data, 'base64')); + } + } + + // Reload takes a fresh snapshot of the rewritten file; Back returns focus to the History button. + await b.evaluate('document.querySelector("[data-conv-action=reload]").click()'); + await wait('document.querySelector("#conv-log").textContent.includes("Show me the FILE")'); + assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null); + await b.evaluate('document.querySelector("#conv-back").click()'); + assert.equal(await b.evaluate('document.querySelector("#conversation").hidden'), true); + assert.equal(await b.evaluate('document.activeElement.dataset.history'), 'repo/fixture'); + // The inspector opens the same view. + await b.evaluate('document.querySelector("table.sessions [data-open]").click()'); + await wait('document.querySelector("#inspection [data-history]")'); + await b.evaluate('document.querySelector("#inspection [data-history]").click()'); + await wait('!document.querySelector("#conversation").hidden && document.querySelector("#conv-status").textContent.startsWith("7 messages")'); + assert.match(await b.evaluate('document.querySelector("#conv-log").textContent'), /Show me the FILE/); + } finally { if (b) await b.close(); await f.close(); } +}); + +test('conversation view: a fork keeps the open branch, says so, and opens the new one on request', { timeout: 60000 }, async () => { + const f = await historyFixture(); + let b; + try { + const file = join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl'); + const log = session(file, f.projectRoot); + const first = log.add(user('Question'), at(-120)); + log.add(assistant('MAIN_ANSWER'), at(-110)); + b = await browser(); await b.viewport(1440, 1000); + const wait = waiter(b); + await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")'); + await b.evaluate('document.querySelector("table.sessions [data-history]").click()'); + await wait('document.querySelector("#conv-status").textContent.startsWith("2 messages")'); + // Pi forks from the first entry; the fork is now its default leaf, on branch b.fork-1. + log.raw(JSON.stringify({ type: 'message', id: 'fork-1', parentId: first, timestamp: at(0), message: assistant('FORK_ANSWER') })); + await b.evaluate('document.querySelector("#refresh").click()'); + await wait('document.querySelector("[data-marker=branch]")'); + assert.match(await b.evaluate('document.querySelector("[data-marker=branch]").textContent'), /continued on another branch\. This view stays on the branch it opened\.Open the latest branch$/); + const text = await b.evaluate('document.querySelector("#conv-log").textContent'); + assert.ok(text.includes('MAIN_ANSWER') && !text.includes('FORK_ANSWER'), 'no silent switch'); + // The open branch grows; the next poll still follows it, not the default branch. + log.add(user('MAIN_MORE'), at(0)); + await b.evaluate('document.querySelector("#refresh").click()'); + await wait('document.querySelector("#conv-log").textContent.includes("MAIN_MORE") || !!document.querySelector("[data-marker=reconcile]") || document.querySelector("#conv-status").textContent.includes("unavailable")'); + assert.deepEqual(await turns(b), [['User', 'Question'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]); + assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null); + // The fork becomes the default leaf again. A same-inode rewrite then refuses the next check. + log.raw(JSON.stringify({ type: 'message', id: 'fork-2', parentId: 'fork-1', timestamp: at(0), message: user('FORK_MORE') })); + writeFileSync(file, readFileSync(file, 'utf8').replace('"Question"', '"QUESTION"')); + await b.evaluate('document.querySelector("#refresh").click()'); + await wait('document.querySelector("[data-marker=reconcile]")'); + // Reload stays on the branch the view was on, and still says the conversation went elsewhere. + await b.evaluate('document.querySelector("[data-conv-action=reload]").click()'); + await wait('document.querySelector("#conv-log").textContent.includes("QUESTION") && !document.querySelector("[data-marker=reconcile]")'); + await wait('document.querySelector("[data-marker=branch]")'); + assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]); + // "Open the latest branch" takes the default. + await b.evaluate('document.querySelector("[data-conv-action=latest]").click()'); + await wait('document.querySelector("#conv-log").textContent.includes("FORK_MORE")'); + assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'FORK_ANSWER'], ['User', 'FORK_MORE']]); + assert.equal(await b.evaluate('document.querySelector("[data-marker=branch]")'), null); + // The fork is rewritten away. Reload cannot keep a branch that is gone, so it opens the default and says so. + writeFileSync(file, readFileSync(file, 'utf8').split('\n').filter(l => !l.includes('"fork-')).join('\n')); + await b.evaluate('document.querySelector("#refresh").click()'); + await wait('document.querySelector("[data-marker=reconcile]")'); + await b.evaluate('document.querySelector("[data-conv-action=reload]").click()'); + await wait('document.querySelector("[data-marker=gone]")'); + assert.match(await b.evaluate('document.querySelector("[data-marker=gone]").textContent'), /^The branch this view was on is no longer in the session\. This view shows the latest branch\.$/); + assert.deepEqual(await turns(b), [['User', 'QUESTION'], ['Assistant', 'MAIN_ANSWER'], ['User', 'MAIN_MORE']]); + assert.equal(await b.evaluate('document.querySelector("[data-marker=reconcile]")'), null); + } finally { if (b) await b.close(); await f.close(); } +}); + +test('conversation view: a newer session with no readable history keeps the marker', { timeout: 60000 }, async () => { + const f = await historyFixture(); + let b; + try { + const log = session(join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl'), f.projectRoot); + log.add(user('Question'), at(-120)); + log.add(assistant('OLD_ANSWER'), at(-110)); + b = await browser(); await b.viewport(1440, 1000); + const wait = waiter(b); + await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")'); + await b.evaluate('document.querySelector("table.sessions [data-history]").click()'); + await wait('document.querySelector("#conv-status").textContent.startsWith("2 messages")'); + // The board takes the newest file by mtime; the catalogue orders by last entry, so a header-only file sorts last. + const log2 = session(join(f.sessionsDir, '2026-09-26T11-00-00_s2.jsonl'), f.projectRoot, { id: 'sess-2', timestamp: at(0) }); + await b.evaluate('document.querySelector("#refresh").click()'); + await wait('document.querySelector("[data-marker=newer]")'); + await b.evaluate('document.querySelector("[data-conv-action=newest]").click()'); + await wait('/not readable yet/.test(document.querySelector("[data-marker=newer]")?.textContent)'); + assert.match(await b.evaluate('document.querySelector("#conv-log").textContent'), /OLD_ANSWER/); + // Once the new file has entries, the same button opens it. + log2.add(user('NEW_QUESTION'), at(1)); + await b.evaluate('document.querySelector("[data-conv-action=newest]").click()'); + await wait('document.querySelector("#conv-log").textContent.includes("NEW_QUESTION")'); + assert.equal(await b.evaluate('document.querySelector("[data-marker=newer]")'), null); + } finally { if (b) await b.close(); await f.close(); } +}); + +test('conversation view: seats without history say so and offer no reply',{ timeout: 60000 }, async () => { + const f = await historyFixture(); + let b; + try { + // No session file yet: the board still lists the registered seat. + b = await browser(); await b.viewport(390, 900); + const wait = waiter(b); + await b.navigate(f.base); await wait('document.querySelector("table.sessions [data-history]")'); + await b.evaluate('document.querySelector("table.sessions [data-history]").click()'); + await wait('document.querySelector("#conv-status").textContent.includes("No readable history")'); + assert.equal(await b.evaluate('document.querySelectorAll("#conv-log > li").length'), 0); + assert.equal(await b.evaluate('document.querySelector("#conv-pick").disabled'), true); + } finally { if (b) await b.close(); await f.close(); } +}); diff --git a/packages/webui/tests/history-fixture.mjs b/packages/webui/tests/history-fixture.mjs new file mode 100644 index 00000000..2120c3e6 --- /dev/null +++ b/packages/webui/tests/history-fixture.mjs @@ -0,0 +1,51 @@ +// Repository-layout fixture for the conversation view (#1507, CHAT-02). The +// board's history reader reads only /.pi/state//sessions, +// with the project named after the root directory and a Pi header whose cwd is +// inside the project. Real board, real routes, real WebUI; temporary data only. +import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { startServer as startBoard } from '../../control-board/src/serve.mjs'; +import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs'; +import { startServer } from '../src/serve.mjs'; +import { close } from './fixture.mjs'; + +export const at = s => new Date(Date.now() + s * 1000).toISOString(); + +// Appends Pi entries to one session file, each the child of the one before. +export function session(file, cwd, { id = 'sess-1', timestamp = at(-600) } = {}) { + let n = 0, parent = null; + writeFileSync(file, JSON.stringify({ type: 'session', version: 3, id, timestamp, cwd }) + '\n'); + return { + add(message, timestamp = at(0)) { + const entry = `${id}-e${++n}`; + appendFileSync(file, JSON.stringify({ type: 'message', id: entry, parentId: parent, timestamp, message }) + '\n'); + parent = entry; + return entry; + }, + raw(text) { appendFileSync(file, text + '\n'); }, + }; +} +export const user = text => ({ role: 'user', content: [{ type: 'text', text }] }); +export const assistant = (content, stopReason = 'stop') => ({ role: 'assistant', stopReason, content: typeof content === 'string' ? [{ type: 'text', text: content }] : content }); +export const toolResult = (toolCallId, text, isError = false) => ({ role: 'toolResult', toolCallId, toolName: 'read', content: [{ type: 'text', text }], isError }); + +export async function historyFixture({ seat = 'fixture', project = 'repo' } = {}) { + const root = mkdtempSync(join(tmpdir(), 'webui-history-')); + const projectRoot = join(root, project), sessionsDir = join(projectRoot, '.pi', 'state', seat, 'sessions'); + mkdirSync(sessionsDir, { recursive: true }); + const seatsDir = join(root, 'seats'), captures = []; + writeRegistration(seatsDir, makeRegistration({ resolved: { seat, project, sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: projectRoot }, task: 'Fixed task', tmux: { session: seat, socket: null }, pid: process.pid, now: () => new Date(Date.now() - 3600000) })); + const board = await startBoard({ port: 0, specs: [{ agent: seat, project, sessionsDir, tmux: { session: seat } }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true, + exec: (f, args) => { captures.push(args); return { status: 0, stdout: '', stderr: '' }; } }); + const web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` }); + return { root, projectRoot, sessionsDir, board, web, base: `http://127.0.0.1:${web.address().port}`, captures, + async close() { await close(web); await close(board); rmSync(root, { recursive: true, force: true }); }, + }; +} + +// Browser helpers shared by the history tests. +export function waiter(b) { + return (expression, ms = 25000) => b.evaluate(`(async()=>{for(const end=Date.now()+${ms};Date.now()setTimeout(r,100))}throw Error('timeout: '+${JSON.stringify(expression)})})()`); +} +export const turns = b => b.evaluate('[...document.querySelectorAll("#conv-log > li")].map(li => [li.querySelector(".who").textContent, li.querySelector(".conv-text")?.textContent ?? [...li.querySelectorAll("summary")].map(s => s.textContent).join(" | ")])'); diff --git a/packages/webui/tests/history-return-flow.test.mjs b/packages/webui/tests/history-return-flow.test.mjs new file mode 100644 index 00000000..2fcb6067 --- /dev/null +++ b/packages/webui/tests/history-return-flow.test.mjs @@ -0,0 +1,99 @@ +// CHAT-02 return-flow regression (#1507, brief §2.3). Real board, real +// conversation routes, real WebUI; nothing is injected into the page and the +// Refresh button is never used, so every new entry arrives by polling. +// 1 path, 2 exactness, 3 continuation, 4 thread order, 5 interleaving, +// 6 relaunch mid-turn, 7 a delayed tool result with a draft being typed. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { join } from 'node:path'; +import { browser } from './browser.mjs'; +import { historyFixture, session, user, assistant, toolResult, at, waiter, turns } from './history-fixture.mjs'; + +const PEER = '[from: darkwing (7525c8e4) -> to: fixture (00000000)] peer note that lands mid-turn'; +const ANSWER = 'A'.repeat(250) + 'MID_SENTINEL' + ' and the rest of the answer'.repeat(20) + ' END_SENTINEL'; +const BIG = 'BIG_START ' + Array.from({ length: 900000 }, (_, i) => (i % 1679616).toString(36).padStart(4, '0')).join('-') + ' BIG_END'; + +test('return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch', { timeout: 240000 }, async () => { + const f = await historyFixture(); + let b; + try { + const file = join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl'); + const log = session(file, f.projectRoot); + log.add(user('Start'), at(-120)); + log.add(assistant('Input needed: OLD_ANSWER'), at(-90)); + + b = await browser(); await b.viewport(1440, 1000); + const wait = waiter(b); + const draft = () => b.evaluate('(() => { const t = document.querySelector("#conv-reply"); return [t.value, t.selectionStart, document.activeElement === t]; })()'); + // The seat is waiting on Jason, so the view opens from its card. + await b.navigate(f.base); await wait('document.querySelector("#waiting [data-history]")'); + await b.evaluate('document.querySelector("#waiting [data-history]").click()'); + await wait('document.querySelector("#conv-status").textContent.startsWith("2 messages")'); + const opened = await b.evaluate('document.querySelector("#conv-pick").value'); + + // 1. Send from the conversation view, through the board's reply path. + await b.evaluate('(() => { const t = document.querySelector("#conv-reply"); t.value = "REPLY_FROM_JASON"; t.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector("#conv-form").requestSubmit(); })()'); + await wait('document.querySelector("#conv-receipt").textContent.includes("delivered")'); + const sent = f.captures.at(-1).at(-1); + assert.match(sent, /^REPLY_FROM_JASON\n/); + assert.equal(await b.evaluate('document.querySelector("#conv-reply").value'), '', 'delivered draft cleared'); + + // The seat takes the message and calls a tool. No answer yet. + log.add(user(sent), at(0)); + log.add(assistant([{ type: 'toolCall', id: 'call-1', name: 'read', arguments: { path: 'x' } }], 'toolUse'), at(1)); + await wait('document.querySelector("#conv-log .conv-tool-call")'); + // 7. A draft typed while the seat works. The tool result lands only after that poll. + await b.evaluate('(() => { const t = document.querySelector("#conv-reply"); t.focus(); t.value = "NEXT_DRAFT"; t.setSelectionRange(4, 4); t.dispatchEvent(new Event("input", { bubbles: true })); })()'); + log.add(toolResult('call-1', 'file body'), at(2)); + await wait('document.querySelector("#conv-log .conv-tool-result")'); + assert.deepEqual(await draft(), ['NEXT_DRAFT', 4, true], 'draft and caret survive the poll'); + + // 5. A peer agent-send lands before the final answer. 2. The answer has sentinels past 240 and at the end. + log.add(user(PEER), at(3)); + log.add(assistant(ANSWER), at(4)); + await wait('document.querySelector("#conv-log").textContent.includes("END_SENTINEL")'); + // 4. Thread order: sent message, collapsed tool call and result, peer message, answer. + assert.deepEqual(await turns(b), [ + ['User', 'Start'], ['Assistant', 'Input needed: OLD_ANSWER'], ['User', sent], ['Assistant', 'Tool call: read'], + ['Tool', 'Tool result'], ['User', PEER], ['Assistant', ANSWER], + ]); + const text = await b.evaluate('document.querySelector("#conv-log").textContent'); + assert.equal(text.split('MID_SENTINEL').length, 2, 'answer shown once'); + assert.equal(text.includes('…'), false, 'nothing clipped'); + assert.equal(await b.evaluate('document.querySelectorAll("#conv-log details[open]").length'), 0, 'tools stay collapsed'); + assert.deepEqual(await draft(), ['NEXT_DRAFT', 4, true]); + + // 3. An answer long enough to split into continuation parts reassembles exactly and in order. + const bigId = log.add(assistant(BIG), at(5)); + await wait('document.querySelector("#conv-log").textContent.endsWith("BIG_END")', 45000); + const hash = await b.evaluate('crypto.subtle.digest("SHA-256", new TextEncoder().encode([...document.querySelectorAll("#conv-log .conv-text")].at(-1).textContent)).then(d => [...new Uint8Array(d)].map(x => x.toString(16).padStart(2, "0")).join(""))'); + assert.equal(hash, createHash('sha256').update(BIG).digest('hex')); + const parts = []; + let res = await (await fetch(`${f.base}/api/conversation?id=${opened}`)).json(); + for (;;) { + parts.push(...res.page.entries.filter(e => e.nativeEntry === bigId)); + if (!res.cursor) break; + res = await (await fetch(`${f.base}/api/conversation?id=${opened}&branch=${res.page.branch}&cursor=${res.cursor.id}`)).json(); + } + assert.ok(parts.length >= 2, `continuation parts: ${parts.length}`); + assert.deepEqual(parts.map(p => [p.part, p.lastPart]), parts.map((_, i) => [i, i === parts.length - 1])); + assert.deepEqual(await draft(), ['NEXT_DRAFT', 4, true]); + + // 6. Relaunch mid-turn: a new session file appears. The view keeps its file and says so. + const log2 = session(join(f.sessionsDir, '2026-09-26T11-00-00_s2.jsonl'), f.projectRoot, { id: 'sess-2', timestamp: at(6) }); + log2.add(user('NEW_SESSION_MESSAGE'), at(7)); + await wait('document.querySelector("[data-marker=newer]")'); + assert.match(await b.evaluate('document.querySelector("[data-marker=newer]").textContent'), /newer session started.*stays on the session you opened/); + assert.equal(await b.evaluate('document.querySelector("#conv-pick").value'), opened); + const after = await b.evaluate('document.querySelector("#conv-log").textContent'); + assert.ok(after.includes('END_SENTINEL') && !after.includes('NEW_SESSION_MESSAGE'), 'no silent switch'); + assert.deepEqual(await draft(), ['NEXT_DRAFT', 4, true]); + // Opening the newest session is Jason's choice; the draft for this seat stays. + await b.evaluate('document.querySelector("[data-conv-action=newest]").click()'); + await wait('document.querySelector("#conv-log").textContent.includes("NEW_SESSION_MESSAGE")'); + assert.notEqual(await b.evaluate('document.querySelector("#conv-pick").value'), opened); + assert.equal(await b.evaluate('document.querySelector("[data-marker=newer]")'), null); + assert.equal(await b.evaluate('document.querySelector("#conv-reply").value'), 'NEXT_DRAFT'); + } finally { if (b) await b.close(); await f.close(); } +}); diff --git a/packages/webui/tests/serve.test.mjs b/packages/webui/tests/serve.test.mjs index 5811af8a..a3a21517 100644 --- a/packages/webui/tests/serve.test.mjs +++ b/packages/webui/tests/serve.test.mjs @@ -54,6 +54,7 @@ test('proxy preserves exact request bytes, status and receipt, rejects forms and let body = ''; for await (const c of req) body += c; requests.push({ url: req.url, method: req.method, body }); if (req.url === '/api/board') { res.writeHead(302, { location: 'http://192.0.2.1/' }); return res.end('{}'); } + if (req.url.startsWith('/api/conversation')) { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{"error":"fixture unknown ","refusal":{"code":"unknown-branch","reconcile":true}}'); } res.writeHead(409, { 'content-type': 'application/json' }); res.end('{"error":"fixture refusal "}'); }); await new Promise(r => upstream.listen(0, '127.0.0.1', r)); @@ -73,6 +74,21 @@ test('proxy preserves exact request bytes, status and receipt, rejects forms and assert.equal(requests.length, 2); assert.equal((await fetch(base + '/api/board')).status, 502); assert.equal(requests.length, 3); + // CHAT-02 conversation routes: GET only, the query passes unchanged, the board's status and body come back as sent. + for (const path of ['/api/conversations', '/api/conversation?id=pi-0a&branch=b.e5&cursor=c-1', '/api/conversation?id=x&id=y&unknown=%3C']) { + const res = await fetch(base + path); + assert.equal(res.status, 404, path); + assert.equal(await res.text(), '{"error":"fixture unknown ","refusal":{"code":"unknown-branch","reconcile":true}}'); + assert.deepEqual(requests.at(-1), { url: path, method: 'GET', body: '' }); + } + const before = requests.length; + assert.equal((await post(base, '/api/conversation?id=x', '{}')).status, 405); + assert.equal((await post(base, '/api/conversations', '{}')).status, 405); + assert.equal((await fetch(base + '/api/conversation?id=x', { headers: { origin: 'https://evil.example' } })).status, 403); + assert.equal(requests.length, before, 'refused before the board'); + // Only the conversation routes carry a query upstream. + await fetch(base + '/api/board?x=1'); + assert.equal(requests.at(-1).url, '/api/board'); } finally { await close(web); await close(upstream); } });