diff --git a/.changeset/fix-claude-response-viewer-per-message.md b/.changeset/fix-claude-response-viewer-per-message.md new file mode 100644 index 000000000..50b2ef5b5 --- /dev/null +++ b/.changeset/fix-claude-response-viewer-per-message.md @@ -0,0 +1,15 @@ +--- +"aicodeman": patch +--- + +fix(web): render one Claude response-viewer message per model message + +The Claude reader concatenated every assistant row between two human prompts into one +card, fusing up to 74 distinct model messages into a single card, and it never read the +attachment rows that hold a prompt typed while the agent was working. Measured over 57 +real transcripts on 2026-09-01, the viewer now shows 1,806 messages instead of 356 and +353 user cards instead of 178, recovering the user's own words from 162 absorbed +prompts, with the assistant text sequence unchanged row for row and the response without +`?context=full` byte-identical on all 57 files. A same-speaker run inside one turn +renders as continuation segments under one badge, and the header reports turns as well +as messages instead of claiming a 1,566-row session was "6 messages". diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index e34e7cd54..c7bd485e3 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -340,7 +340,7 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se ⚠️ **`Session.lastSubmitAt` is persisted state, not a runtime counter.** `start()` reassigns `_claudeSessionId = resumeSessionId || id` on every launch — including the re-attach path for a mux session that survived the restart — so a recovered pane always points the viewer at its *launch* conversation, even when the CLI moved on via `/clear` hours earlier. The submit anchor is the only thing that can correct that without user input, so it round-trips through `SessionState.lastSubmitAt` and is restored in `restoreMuxSessions()`. Drop it from `toState()` and recovered panes silently show the pre-`/clear` transcript until the user types again. Restoring a *stale* anchor is safe: the resolver's staleness guard rejects any candidate transcript older than the one the pane is currently on, which is exactly the shape of a respawn into a fresh conversation. -⚠️ **Claude transcripts are grouped at real human-turn boundaries, not per JSONL row.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots, and multi-block assistant output. Rendering a card per row was the bug: it produced duplicate and truncated cards that looked like the viewer had lost the response. The grouping walks to the next genuine user turn and dedups replayed assistant snapshots while preserving the tool/task/skill/compact/team metadata filtering. Related: a recovered `restored-` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices. +⚠️ **The Claude viewer emits one message per model message and groups them with `turn`; it never concatenates them.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots. Rendering a card per *row* was the original bug (#169) — but the fix overshot to one card per *human turn*, which fused up to 74 distinct model messages into a single card and reported it as one message. One assistant row IS one whole model message: measured across a real `~/.claude/projects` (CLI 2.1.220-2.1.251) no assistant row carries more than one content block and no `message.id` carries more than one text block, so there was never anything to reassemble, and no adjacent pair of assistant rows continues a table, a list, or an open code fence. Each row is therefore its own message carrying `{kind, label, role, text, timestamp, turn}`; the frontend renders a same-role run inside one `turn` as badge-less continuation segments (`.rv-msg-cont`), which is what keeps a p90 of 11 messages per turn from reading as card spam. ⚠️ **A prompt typed while Claude is working is recorded ONLY as an `attachment/queued_command` row** — the CLI never re-emits it as a `user` row — so reading only `user` rows lost 162 of 353 user cards on that corpus AND lost the turn boundary each one carries, which is what let an assistant run fuse in the first place. Take it only when `attachment.origin.kind === 'human'` and `commandMode === 'prompt'`; the CLI's own queue entries (`commandMode: 'task-notification'`) carry no `origin` key at all. The shape is not a documented CLI contract, so every field check must fail closed. ⚠️ **`data.text` (no `?context=full`) is frozen on the last assistant row and must never be derived from `messages.at(-1)`** — agent pollers hash it (`skills/codeman/preamble.sh`), and the last message can be the user's own queued prompt. Replayed assistant snapshots are still deduped, and the tool/task/skill/compact/team metadata filtering is unchanged. Related: a recovered `restored-` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`, `test/response-viewer-turn-segments.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices. **File Viewer button** (header, 1.4.1) is **shown by default on desktop** since `211f3c0` (post-1.8.0): toggle under App Settings → Header & Panels → Header buttons → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set, fallback default `true`). Purely client-side like the response viewer: the template now ships the button VISIBLE (no `--hidden` class) and `applyHeaderVisibilitySettings()` toggles the `btn-file-viewer--hidden` marker class after settings load; phones still hide it via mobile.css. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The same commit set the **default desktop header** to WS/CPU/MEM + File Viewer + gear: the token-count chip (`showTokenCount`, no settings-UI toggle) and the lifecycle-log button (`showLifecycleLog`) both default **OFF** now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Header & Panels → Scheduling); cron jobs themselves are unaffected. ### Session list layout (header strip vs. left sidebar) diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index c9ab7d437..8a52be316 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -283,6 +283,7 @@ than into an existing checkout. | create a session in an arbitrary directory (no case, **no PTY**, id at `.data.session.id`) | `POST /api/v1/sessions`, then `POST /api/v1/sessions/:id/interactive` or `.../shell` to start it, see [Starting a worker](#starting-a-worker) | | send input | `POST /api/v1/sessions/:id/input` | | **read a worker's answer** (claude/codex/deepseek) | `GET /api/v1/sessions/:id/last-response` → `.data.{text,timestamp}`, clean transcript text, no TUI noise. ⚠️ **Poll it**, see [symptom 7](#7-last-response-returns-an-empty-string-right-after-stop) | +| read the whole conversation | `GET /api/v1/sessions/:id/last-response?context=full` → `.data.messages[]`. ⚠️ **Only `{role,text}` is present for every mode.** `kind`/`label` come from claude (`prompt`/`response`), deepseek and the pane parser (which also emit `status`/`tool`) but NOT from codex; `timestamp` from claude and codex but not deepseek/pane; `turn` and `queued:true` (a prompt typed while the agent was working) from claude only. `.data.text` is unchanged by `context=full` — it stays the last assistant message, never `messages[-1]` | | read terminal (tail is in **BYTES**, raw ANSI) | `GET /api/v1/sessions/:id/terminal?tail=3000` → `.data.terminalBuffer`, for *diagnosis* (unsubmitted prompt?), not for reading answers | | full tmux scrollback (context bomb; post-mortems only) | `GET /api/v1/sessions/:id/terminal?full=1` | | background agents, one session | `GET /api/v1/sessions/:id/subagents` | diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index fa09d565a..4b93d0696 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -391,7 +391,17 @@ done printf '%s\n' "$TXT" ``` -`.data` is `{text, timestamp}`. ⚠️ **On a hook-less workspace this reads the PREVIOUS +`.data` is `{text, timestamp}`. Add `?context=full` for the whole conversation in +`.data.messages[]`. ⚠️ **The four readers do not emit the same fields — only `{role, text}` +is guaranteed.** `kind`/`label` come from claude (`prompt`/`response`), deepseek and the pane +parser (the last two also emit `status`/`tool`), but **not** from codex; `timestamp` comes +from claude and codex but not from deepseek or the pane parser. A claude worker additionally +carries `turn` (a run of same-speaker messages inside one `turn` is one utterance split into +segments, not separate exchanges) and `queued: true` on a prompt the user typed while the +agent was still working. Filter on `role`, not on `kind`, unless you know the mode. +`.data.text` does not change under `context=full`: it stays the +last **assistant** message, so never read it as `messages[-1]`, which can be a prompt. +⚠️ **On a hook-less workspace this reads the PREVIOUS turn.** `last-response` returns whatever the transcript last flushed, so it is only as correct as your end-of-turn signal: pair it with a `stop` signal or a marker, never with a bare `idle` ([§5.1](#51-where-to-spawn)). ⚠️ **Poll it, do not read it once.** `text` is written diff --git a/src/web/public/app.js b/src/web/public/app.js index d256f436d..421643b24 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2183,15 +2183,26 @@ class CodemanApp { } /** Build one response-viewer message so the brief and full views share markup and CSS. */ - _buildResponseViewerMessage(text, role, agentLabel) { + _buildResponseViewerMessage(text, role, agentLabel, meta) { const div = document.createElement('div'); const isUser = role === 'user'; div.className = 'rv-message ' + (isUser ? 'rv-msg-user' : 'rv-msg-assistant'); - - const roleBadge = document.createElement('div'); - roleBadge.className = 'rv-role ' + (isUser ? 'rv-role-user' : 'rv-role-assistant'); - roleBadge.textContent = isUser ? 'You' : agentLabel; - div.appendChild(roleBadge); + // Consecutive messages from one speaker inside one turn are segments of a + // single utterance: one badge, a hairline seam. Claude emits a median of 3 + // messages per turn (p90 11, max 51), so a badge per message would be the + // card spam the old concatenation was introduced to avoid. `meta` is + // optional so the brief view's 3-argument call keeps its exact shape. + const continuation = !!(meta && meta.continuation); + if (continuation) div.classList.add('rv-msg-cont'); + if (meta && meta.kind) div.dataset.kind = meta.kind; + if (meta && meta.queued) div.dataset.queued = '1'; + + if (!continuation) { + const roleBadge = document.createElement('div'); + roleBadge.className = 'rv-role ' + (isUser ? 'rv-role-user' : 'rv-role-assistant'); + roleBadge.textContent = isUser ? 'You' : agentLabel; + div.appendChild(roleBadge); + } const renderedText = document.createElement('div'); renderedText.className = 'rv-text'; @@ -2351,19 +2362,49 @@ class CodemanApp { if (!body) return; if (messages.length === 0) { - body.textContent = 'No conversation history available'; + // Never destroy what the eye button already rendered: the brief view has + // a terminal-buffer fallback (see toggleResponseViewer) that this + // endpoint does not, so an empty full-context result must not wipe a + // real answer the user is reading. + // ⚠️ Idempotent, because More deliberately stays live here: the branch + // returns before the button is hidden so a transcript that appears a + // moment later can still be loaded, and appending would then stack a + // second identical notice on every retry. + // `:scope >` keeps the lookup off model-rendered markdown inside .rv-text. + let notice = body.querySelector(':scope > .rv-notice'); + if (!notice) { + notice = document.createElement('div'); + notice.className = 'rv-notice'; + body.appendChild(notice); + } + const emptyText = 'No full conversation history available for this session'; + notice.textContent = window.codemanT?.(emptyText) || emptyText; return; } // Render conversation thread const agentLabel = this._getResponseViewerAgentLabel(); body.innerHTML = ''; + let previous = null; for (const msg of messages) { - body.appendChild(this._buildResponseViewerMessage(msg.text, msg.role, agentLabel)); + // ⚠️ A numeric `turn` is REQUIRED, never same-role adjacency alone. + // Only the Claude reader emits turns; Codex and the external-CLI pane + // parser emit adjacent assistant/response blocks with no turn at all, and + // an older server emits none either — all three must keep rendering one + // badged card per message exactly as they do today. + const continuation = + !!previous && previous.role === msg.role && typeof msg.turn === 'number' && previous.turn === msg.turn; + body.appendChild(this._buildResponseViewerMessage(msg.text, msg.role, agentLabel, { ...msg, continuation })); + previous = msg; } this._bindResponseViewerInteractions(body); - if (title) title.textContent = `Conversation (${messages.length} messages)`; + const turns = new Set(messages.filter((msg) => typeof msg.turn === 'number').map((msg) => msg.turn)).size; + if (title) { + title.textContent = turns + ? `Conversation (${messages.length} messages, ${turns} turns)` + : `Conversation (${messages.length} messages)`; + } if (moreBtn) moreBtn.style.display = 'none'; // Scroll to bottom (latest message) body.scrollTop = body.scrollHeight; diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index fbdcc2962..207b64232 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -86,6 +86,7 @@ 'Instance count': '实例数量', 'No response yet': '暂无回复', 'No response yet — send a message in this session first.': '暂无回复,请先在此会话中发送一条消息。', + 'No full conversation history available for this session': '此会话没有可显示的完整对话历史', 'Last Response': '最近一次回复', More: '更多', 'Codeman version': '{name}版本', diff --git a/src/web/public/styles.css b/src/web/public/styles.css index a517b3c0b..d7563e392 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -12600,6 +12600,43 @@ kbd { background: color-mix(in srgb, var(--green) 12%, transparent); } +/* Consecutive messages from one speaker inside one turn are segments of a + single utterance, not separate cards: no repeated badge, a hairline seam. + The role accent survives because the colour rules above match on BOTH + :has(.rv-role-*) and .rv-msg-* — a badge-less continuation still hits the + class arm. Do not drop either arm. */ +.rv-message.rv-msg-cont { + margin-top: -18px; + border-top: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; + padding-top: 0; +} + +.rv-message.rv-msg-cont > .rv-text { + border-top: 1px solid var(--border); + padding-top: 12px; +} + +.rv-message:has(+ .rv-msg-cont) { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + padding-bottom: 0; +} + +/* A prompt the user typed while the agent was working (absorbed mid-turn). + A pseudo-element, not a text node, so the i18n MutationObserver cannot + rewrite it. */ +.rv-message[data-queued='1'] .rv-role::after { + content: ' ⏱'; +} + +.rv-notice { + opacity: 0.7; + font-style: italic; + margin-top: 12px; +} + /* Markdown rendered content inside response viewer. Prose uses a proportional font for readability; code keeps monospace. */ .rv-text, diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 9c9e7b408..8ab5e2a94 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -1926,9 +1926,13 @@ export function registerSessionRoutes( } interface ClaudeResponseMessage { + kind: 'prompt' | 'response'; + label: 'Prompt' | 'Response'; role: 'user' | 'assistant'; text: string; timestamp?: string; + turn: number; + queued?: boolean; } interface ClaudeTranscriptEntry { @@ -1938,6 +1942,19 @@ export function registerSessionRoutes( isSidechain?: boolean; isCompactSummary?: boolean; message?: { content?: unknown }; + // A prompt typed while Claude is working is absorbed mid-turn and recorded + // ONLY here — the CLI never re-emits it as a `user` row. Every field stays + // optional and unvalidated: `queued_command` is not a documented CLI + // contract, so a missing/renamed field must mean "skip", which is also what + // the CLI's own non-human queue entries (commandMode 'task-notification', + // no `origin` key) require. Shape observed on Claude Code 2.1.220-2.1.251. + attachment?: { + type?: string; + prompt?: string; + commandMode?: string; + timestamp?: string; + origin?: { kind?: string }; + }; } function extractClaudeText(content: unknown, separator: string): string { @@ -1963,10 +1980,17 @@ export function registerSessionRoutes( } /** - * Claude writes one logical turn as many JSONL rows: text, thinking and tool - * blocks share message ids, while tool results are represented as user rows. - * Build viewer cards from real user boundaries instead of treating every row - * as a separate chat message. + * Claude writes an append-only event log: tool results arrive as user rows, + * thinking/tool_use rows carry no text, and a prompt typed while the agent is + * working is only ever recorded as an `attachment/queued_command` row. But one + * assistant row IS one whole model message: measured across ~/.claude/projects + * (CLI 2.1.220-2.1.251) no assistant row carries more than one content block + * and no message id carries more than one text block, so there is nothing to + * reassemble. Emit one card per row and group them with `turn` instead of + * concatenating a human turn's replies into a single card (#169), which fused + * up to 74 distinct model messages into one card. Splitting is safe for + * markdown: no adjacent pair of assistant text rows in the corpus continues a + * table, a list, or an open code fence. */ function parseClaudeResponseTranscript( content: string, @@ -1975,8 +1999,23 @@ export function registerSessionRoutes( let lastText = ''; let lastTimestamp = ''; const messages: ClaudeResponseMessage[] = []; - let currentUserFragments = new Set(); - let currentAssistantFragments = new Set(); + // #169's replay guards, kept: they now SKIP a duplicated row instead of + // concatenating it into the previous card. + const currentUserFragments = new Set(); + const currentAssistantFragments = new Set(); + // Turn 0 is reserved for anything emitted before the first human prompt. + let turn = 0; + + const pushUserMessage = (text: string, timestamp: string | undefined, queued: boolean): void => { + // A run of consecutive human inputs (a mid-turn queued burst) is ONE turn, + // so the viewer renders it under one badge instead of one badge per line. + if (messages.at(-1)?.role !== 'user') turn += 1; + const message: ClaudeResponseMessage = { kind: 'prompt', label: 'Prompt', role: 'user', text, timestamp, turn }; + if (queued) message.queued = true; + messages.push(message); + currentUserFragments.add(text); + currentAssistantFragments.clear(); + }; for (const line of content.split('\n')) { if (!line) continue; @@ -1990,25 +2029,37 @@ export function registerSessionRoutes( // rows include repeated image dimensions and other UI-generated context. if (entry.isSidechain) continue; + // A prompt typed while Claude is working is absorbed mid-turn and lives + // ONLY in an attachment row, so it was lost outright. `origin.kind` and + // `commandMode` separate the human's queue entries from the CLI's own: + // measured over 57 real transcripts on 2026-09-01, 322 queued_command rows + // split 163 `prompt`/`human` and 159 `task-notification`, and not one of + // those 159 carries an `origin` key. The 163 human rows become 162 user + // cards here — one is a verbatim repeat inside a still-unanswered user run + // and is collapsed by the dedup guard below — out of 353 user cards total. + if (entry.type === 'attachment') { + if (!full) continue; + const queued = entry.attachment; + if (!queued || queued.type !== 'queued_command') continue; + if (queued.origin?.kind !== 'human' || queued.commandMode !== 'prompt') continue; + const text = typeof queued.prompt === 'string' ? queued.prompt.trim() : ''; + if (!text || isClaudeSyntheticUserMessage(entry, text)) continue; + if (currentUserFragments.has(text)) continue; + pushUserMessage(text, queued.timestamp || entry.timestamp, true); + continue; + } + if (entry.type === 'user') { const text = extractClaudeText(entry.message?.content, '\n').trim(); // A tool_result block has no text block and naturally drops out here. if (!text || isClaudeSyntheticUserMessage(entry, text)) continue; if (!full) continue; - - const previous = messages.at(-1); - if (previous?.role === 'user') { - // Claude can replay the initial user row while restoring a transcript. - // Only collapse duplicates within the same unanswered user turn; the - // same prompt after an assistant response remains a legitimate turn. - if (currentUserFragments.has(text)) continue; - previous.text += `\n\n${text}`; - currentUserFragments.add(text); - } else { - messages.push({ role: 'user', text, timestamp: entry.timestamp }); - currentUserFragments = new Set([text]); - } - currentAssistantFragments.clear(); + // Claude replays the initial user row while restoring a transcript, and + // a CLI that also wrote an absorbed prompt as a user row would double it. + // Both collapse here. The same prompt sent again AFTER a reply is a + // legitimate second turn, because that reply cleared the set. + if (currentUserFragments.has(text)) continue; + pushUserMessage(text, entry.timestamp, false); continue; } @@ -2019,18 +2070,10 @@ export function registerSessionRoutes( lastTimestamp = entry.timestamp || ''; if (!full) continue; - const previous = messages.at(-1); - if (previous?.role === 'assistant') { - // Replayed snapshots sometimes repeat an identical text block. Distinct - // progress/final blocks are kept, but remain inside one Claude card. - if (currentAssistantFragments.has(text)) continue; - previous.text += `\n\n${text}`; - previous.timestamp = entry.timestamp || previous.timestamp; - currentAssistantFragments.add(text); - } else { - messages.push({ role: 'assistant', text, timestamp: entry.timestamp }); - currentAssistantFragments = new Set([text]); - } + // Replayed snapshots repeat an identical text block inside one turn. + if (currentAssistantFragments.has(text)) continue; + messages.push({ kind: 'response', label: 'Response', role: 'assistant', text, timestamp: entry.timestamp, turn }); + currentAssistantFragments.add(text); currentUserFragments.clear(); } diff --git a/test/frontend-public-tooling.test.ts b/test/frontend-public-tooling.test.ts index 945754acf..6785409d2 100644 --- a/test/frontend-public-tooling.test.ts +++ b/test/frontend-public-tooling.test.ts @@ -24,7 +24,15 @@ describe('frontend public asset tooling', () => { const appJs = readFileSync(resolve(repoRoot, 'src/web/public/app.js'), 'utf8'); expect(appJs).toContain("body.appendChild(this._buildResponseViewerMessage(lastResponse, 'assistant'"); - expect(appJs).toContain('body.appendChild(this._buildResponseViewerMessage(msg.text, msg.role, agentLabel));'); + expect(appJs).toContain( + 'body.appendChild(this._buildResponseViewerMessage(msg.text, msg.role, agentLabel, { ...msg, continuation }));' + ); + // ⚠️ A numeric `turn` gates continuation rendering. Only the Claude reader + // emits turns; Codex, the external-CLI pane parser and an older server emit + // adjacent same-role messages with none, and must keep one badge per card. + expect(appJs).toContain( + "!!previous && previous.role === msg.role && typeof msg.turn === 'number' && previous.turn === msg.turn" + ); expect(appJs).toContain("div.className = 'rv-message ' + (isUser ? 'rv-msg-user' : 'rv-msg-assistant');"); expect(appJs).toContain("renderedText.className = 'rv-text';"); }); diff --git a/test/response-viewer-turn-segments.test.ts b/test/response-viewer-turn-segments.test.ts new file mode 100644 index 000000000..ed8526186 --- /dev/null +++ b/test/response-viewer-turn-segments.test.ts @@ -0,0 +1,149 @@ +/** + * @fileoverview Response-viewer turn segmentation (`CodemanApp._buildResponseViewerMessage`). + * + * The server now emits one message per model message instead of concatenating a + * human turn's replies into one card, so a long autonomous run arrives as tens + * of messages rather than one 12,000-character block. Rendered naively that is + * card spam — the measured distribution is p50 3 messages per turn, p90 11, + * max 51, with 58% of messages under 80 characters. So consecutive messages + * from one speaker inside one `turn` render as SEGMENTS of one card: no + * repeated role badge, a hairline seam. + * + * Pinned here because the badge suppression is the only thing standing between + * the server change and a wall of 51 "Claude" badges: + * + * 1. A continuation carries `rv-msg-cont` and has NO `.rv-role` child, while + * keeping its role class so the CSS accent survives (the colour rules match + * on both `:has(.rv-role-*)` and `.rv-msg-*` — only the class arm hits here). + * 2. A queued prompt is marked in the DOM, not in text, so the i18n + * MutationObserver cannot rewrite the marker. + * 3. The 4th argument is genuinely optional: the brief view's 3-argument call + * still renders a badge. + * + * Loaded via `vm` with a jsdom document injected (same technique as + * response-viewer-file-links.test.ts). + * Port: N/A + */ +import { readFileSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { describe, expect, it, vi } from 'vitest'; + +const dom = new JSDOM(''); +const { document, NodeFilter } = dom.window; + +interface MessageBuilder { + _buildResponseViewerMessage(text: string, role: string, agentLabel: string, meta?: unknown): HTMLElement; + loadFullContext(): Promise; + activeSessionId?: string; +} + +function loadCodemanAppClass() { + const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); + const context = vm.createContext({ + console, + performance, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + HTMLCanvasElement: class HTMLCanvasElement {}, + fetch: vi.fn(), + document, + NodeFilter, + localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + MobileDetection: {}, + }); + vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context); + // The context is returned too: app.js closes over the context's own `fetch`, so a + // test that drives loadFullContext has to replace THAT binding, not globalThis'. + return { CodemanApp: (context as { __CodemanApp: { prototype: MessageBuilder } }).__CodemanApp, context }; +} + +const { CodemanApp, context: appContext } = loadCodemanAppClass(); + +function build(text: string, role: string, meta?: unknown): HTMLElement { + const app = Object.create(CodemanApp.prototype) as MessageBuilder; + return app._buildResponseViewerMessage(text, role, 'Claude', meta); +} + +describe('response viewer turn segmentation', () => { + it('renders a continuation without a repeated role badge but keeps its role class', () => { + const div = build('second half of the same turn', 'assistant', { continuation: true, kind: 'response', turn: 3 }); + + expect(div.classList.contains('rv-msg-cont')).toBe(true); + expect(div.classList.contains('rv-msg-assistant')).toBe(true); + expect(div.querySelector('.rv-role')).toBeNull(); + expect(div.querySelector('.rv-text')).not.toBeNull(); + expect(div.dataset.kind).toBe('response'); + }); + + it('marks a prompt the user queued mid-turn in the DOM, not in the text', () => { + const div = build('actually use PowerShell', 'user', { + continuation: false, + kind: 'prompt', + queued: true, + turn: 2, + }); + + expect(div.dataset.queued).toBe('1'); + expect(div.dataset.kind).toBe('prompt'); + const badge = div.querySelector('.rv-role'); + expect(badge).not.toBeNull(); + expect(badge!.classList.contains('rv-role-user')).toBe(true); + // The marker is a CSS pseudo-element, so the badge text stays translatable. + expect(badge!.textContent).toBe('You'); + }); + + it('still renders a badge for the brief view, which passes no meta', () => { + const div = build('the last response', 'assistant'); + + expect(div.classList.contains('rv-msg-cont')).toBe(false); + expect(div.querySelector('.rv-role')!.textContent).toBe('Claude'); + expect(div.dataset.kind).toBeUndefined(); + expect(div.dataset.queued).toBeUndefined(); + }); +}); + +/** + * The empty-state branch deliberately does NOT wipe the body — the brief view has + * a terminal-buffer fallback this endpoint does not — and deliberately leaves the + * More button live so a transcript that appears a moment later can still be + * loaded. Both together mean the notice must be idempotent: without that, every + * retry stacks another identical line. Upstream got this for free because it + * assigned `body.textContent`. + */ +describe('response viewer empty full-context state', () => { + it('reuses one notice across repeated More clicks and keeps the brief card', async () => { + const body = document.createElement('div'); + body.id = 'responseViewerBody'; + const title = document.createElement('div'); + title.id = 'responseViewerTitle'; + const more = document.createElement('button'); + more.id = 'responseViewerMore'; + document.body.append(body, title, more); + body.textContent = 'No response yet — send a message in this session first.'; + + const app = Object.create(CodemanApp.prototype) as MessageBuilder; + app.activeSessionId = 's1'; + (appContext as { fetch: unknown }).fetch = vi.fn(async () => ({ + json: async () => ({ data: { messages: [] } }), + })); + + await app.loadFullContext(); + await app.loadFullContext(); + await app.loadFullContext(); + + expect(body.querySelectorAll('.rv-notice')).toHaveLength(1); + expect(body.textContent).toContain('No response yet'); + // More stays clickable: it is the only retry path once a transcript lands. + expect(more.style.display).toBe(''); + + document.body.innerHTML = ''; + }); +}); diff --git a/test/routes/session-routes-claude-last-response.test.ts b/test/routes/session-routes-claude-last-response.test.ts index f9cd8bc24..3980d1361 100644 --- a/test/routes/session-routes-claude-last-response.test.ts +++ b/test/routes/session-routes-claude-last-response.test.ts @@ -60,6 +60,24 @@ const assistantEntry = (text: string, timestamp: string) => ({ message: { content: [{ type: 'text', text }] }, }); +/** + * A prompt typed while Claude is working. Shape copied from a real CLI 2.1.251 + * row: the CLI's own queue entries carry commandMode 'task-notification' and no + * `origin` key at all, which is what separates them from the human's. + */ +const queuedEntry = (prompt: string, timestamp: string, kind: 'human' | 'task-notification' = 'human') => ({ + type: 'attachment', + timestamp, + attachment: { + type: 'queued_command', + prompt, + source_uuid: `src-${timestamp}`, + commandMode: kind === 'human' ? 'prompt' : 'task-notification', + ...(kind === 'human' ? { origin: { kind: 'human' } } : {}), + timestamp, + }, +}); + describe('GET /api/sessions/:id/last-response (claude)', () => { let harness: LocalHarness; let testHome: string; @@ -93,7 +111,7 @@ describe('GET /api/sessions/:id/last-response (claude)', () => { return { response, body: JSON.parse(response.body) }; } - it('recovers a placeholder tmux session by UUID prefix and groups JSONL fragments into turns', async () => { + it('recovers a placeholder tmux session by UUID prefix and renders one message per model message', async () => { const restoredId = 'restored-40568a29'; const conversationId = '40568a29-d4eb-4eb6-b671-8401428e4f39'; const session = harness.ctx._session as typeof harness.ctx._session & { @@ -135,15 +153,60 @@ describe('GET /api/sessions/:id/last-response (claude)', () => { expect(full.body.data).toEqual({ text: 'Second half.', timestamp: '2026-07-21T00:00:06Z', + // #169's guarantees all still hold and this array proves them: the replayed + // 'first prompt' row, the replayed 'Checking the files.' snapshot, the + // sidechain row and all five synthetic rows are absent. Only the GROUPING + // UNIT narrows, from one card per human turn to one card per model + // message, carried by `turn` instead of by a '\n\n' joiner. messages: [ - { role: 'user', text: 'first prompt', timestamp: '2026-07-21T00:00:00Z' }, { + kind: 'prompt', + label: 'Prompt', + role: 'user', + text: 'first prompt', + timestamp: '2026-07-21T00:00:00Z', + turn: 1, + }, + { + kind: 'response', + label: 'Response', + role: 'assistant', + text: 'Checking the files.', + timestamp: '2026-07-21T00:00:01Z', + turn: 1, + }, + { + kind: 'response', + label: 'Response', role: 'assistant', - text: 'Checking the files.\n\nThe first result is ready.', + text: 'The first result is ready.', timestamp: '2026-07-21T00:00:03Z', + turn: 1, + }, + { + kind: 'prompt', + label: 'Prompt', + role: 'user', + text: 'second prompt', + timestamp: '2026-07-21T00:00:00Z', + turn: 2, + }, + { + kind: 'response', + label: 'Response', + role: 'assistant', + text: 'First half.', + timestamp: '2026-07-21T00:00:04Z', + turn: 2, + }, + { + kind: 'response', + label: 'Response', + role: 'assistant', + text: 'Second half.', + timestamp: '2026-07-21T00:00:06Z', + turn: 2, }, - { role: 'user', text: 'second prompt', timestamp: '2026-07-21T00:00:00Z' }, - { role: 'assistant', text: 'First half.\n\nSecond half.', timestamp: '2026-07-21T00:00:06Z' }, ], }); expect(session.adoptClaudeSessionId).toHaveBeenCalledWith(conversationId); @@ -175,6 +238,137 @@ describe('GET /api/sessions/:id/last-response (claude)', () => { ['assistant', 'Second answer.'], ]); }); + + /** + * A prompt typed while Claude is working is absorbed mid-turn and recorded + * ONLY as an attachment row — 160 of the 347 user cards across a real + * ~/.claude/projects. Reading only `user` rows lost them outright AND lost the + * turn boundary they carry, which is what let an assistant run fuse. + */ + it('surfaces a prompt the user queued while Claude was working', async () => { + const sessionId = harness.ctx._session.id; + const session = harness.ctx._session as typeof harness.ctx._session & { + claudeSessionId: string; + adoptClaudeSessionId: ReturnType; + }; + session.claudeSessionId = sessionId; + session.adoptClaudeSessionId = vi.fn(); + writeTranscript(sessionId, [ + userEntry('start the job'), + assistantEntry('Working on it.', '2026-07-21T00:00:01Z'), + queuedEntry('actually use PowerShell', '2026-07-21T00:00:02Z'), + queuedEntry('background agent finished', '2026-07-21T00:00:03Z', 'task-notification'), + // The most common attachment subtype; it carries no prompt/origin at all. + { type: 'attachment', attachment: { type: 'total_tokens_reminder', tokens: 1 } }, + assistantEntry('Switched to PowerShell.', '2026-07-21T00:00:04Z'), + ]); + + const { body } = await getLastResponse(sessionId, true); + const messages = body.data.messages as Array<{ role: string; text: string; turn: number; queued?: boolean }>; + expect(messages.map((message) => [message.role, message.text, message.turn])).toEqual([ + ['user', 'start the job', 1], + ['assistant', 'Working on it.', 1], + ['user', 'actually use PowerShell', 2], + ['assistant', 'Switched to PowerShell.', 2], + ]); + expect(messages[2].queued).toBe(true); + expect(messages[0].queued).toBeUndefined(); + }); + + /** + * Mostly forward insurance. A queued prompt re-emitted as a `user` row AFTER + * its attachment row — the shape that would double-render — is not observed on + * CLI 2.1.220-2.1.251 (0 of 163 measured 2026-09-01). The only exact-text + * collisions are three occurrences of the same one-character nudge in a single + * transcript, and the guard fires on one of them, which is why 163 human + * queued rows yield 162 cards. The guard exists so a CLI that starts writing + * both rows does not double every absorbed prompt. + */ + it('renders an absorbed prompt once when the CLI also writes it as a user row', async () => { + const sessionId = harness.ctx._session.id; + const session = harness.ctx._session as typeof harness.ctx._session & { + claudeSessionId: string; + adoptClaudeSessionId: ReturnType; + }; + session.claudeSessionId = sessionId; + session.adoptClaudeSessionId = vi.fn(); + writeTranscript(sessionId, [ + userEntry('go'), + assistantEntry('OK.', '2026-07-21T00:00:01Z'), + queuedEntry('switch to PowerShell', '2026-07-21T00:00:02Z'), + userEntry('switch to PowerShell'), + assistantEntry('Done.', '2026-07-21T00:00:03Z'), + ]); + + const { body } = await getLastResponse(sessionId, true); + const messages = body.data.messages as Array<{ role: string; text: string; queued?: boolean }>; + const absorbed = messages.filter((message) => message.role === 'user' && message.text === 'switch to PowerShell'); + expect(absorbed).toHaveLength(1); + expect(absorbed[0].queued).toBe(true); + }); + + /** + * The brief response is what agent pollers hash (skills/codeman/preamble.sh + * last_text()). It must stay the last assistant row and must NEVER be derived + * from messages.at(-1), which can be the user's own queued prompt. + */ + it('keeps the brief response on the last assistant row while a turn is in flight', async () => { + const sessionId = harness.ctx._session.id; + const session = harness.ctx._session as typeof harness.ctx._session & { + claudeSessionId: string; + adoptClaudeSessionId: ReturnType; + }; + session.claudeSessionId = sessionId; + session.adoptClaudeSessionId = vi.fn(); + writeTranscript(sessionId, [ + userEntry('go'), + assistantEntry('Let me look.', '2026-07-21T00:00:01Z'), + { type: 'assistant', message: { content: [{ type: 'tool_use', id: 'x' }] } }, + { type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'x' }] } }, + ]); + + const brief = await getLastResponse(sessionId); + expect(brief.body.data).toEqual({ text: 'Let me look.', timestamp: '2026-07-21T00:00:01Z' }); + + const { body } = await getLastResponse(sessionId, true); + const messages = body.data.messages as Array<{ role: string; text: string }>; + expect(messages.at(-1)).toMatchObject({ role: 'assistant', text: 'Let me look.' }); + expect(body.data.text).toBe('Let me look.'); + }); + + /** + * A multi-line paste absorbed mid-turn arrives as N queued rows within a few + * hundred milliseconds (observed: 5 rows inside ~360ms). They are one turn, so + * the viewer renders them under one badge instead of N. + */ + it('groups a burst of queued prompts into one turn', async () => { + const sessionId = harness.ctx._session.id; + const session = harness.ctx._session as typeof harness.ctx._session & { + claudeSessionId: string; + adoptClaudeSessionId: ReturnType; + }; + session.claudeSessionId = sessionId; + session.adoptClaudeSessionId = vi.fn(); + writeTranscript(sessionId, [ + userEntry('go'), + assistantEntry('OK.', '2026-07-21T00:00:01Z'), + queuedEntry('one more thing', '2026-07-21T00:00:02.100Z'), + queuedEntry('and the requirements are', '2026-07-21T00:00:02.360Z'), + queuedEntry('finally, keep it fast', '2026-07-21T00:00:02.480Z'), + assistantEntry('Understood.', '2026-07-21T00:00:05Z'), + ]); + + const { body } = await getLastResponse(sessionId, true); + const messages = body.data.messages as Array<{ role: string; turn: number }>; + expect(messages.map((message) => [message.role, message.turn])).toEqual([ + ['user', 1], + ['assistant', 1], + ['user', 2], + ['user', 2], + ['user', 2], + ['assistant', 2], + ]); + }); }); /**