Skip to content

fix(web): normalize Claude response viewer turns - #169

Merged
Ark0N merged 2 commits into
Ark0N:masterfrom
shenlvkang-collab:contrib/claude-response-viewer-normalization
Jul 28, 2026
Merged

fix(web): normalize Claude response viewer turns#169
Ark0N merged 2 commits into
Ark0N:masterfrom
shenlvkang-collab:contrib/claude-response-viewer-normalization

Conversation

@shenlvkang-collab

Copy link
Copy Markdown
Contributor

Summary:\n- Rebind recovered sessions to an unambiguous Claude transcript UUID when persisted mux metadata is stale.\n- Build viewer cards at real human turn boundaries instead of rendering every JSONL content row as a separate message.\n- Deduplicate replayed assistant snapshots while preserving tool, task, skill, compact-summary, and team metadata filtering.\n- Add focused regression coverage for recovered sessions, turn grouping, and repeated streamed fragments.\n\nVerification:\n- test/routes/session-routes-claude-last-response.test.ts: 2 passed\n- typecheck, lint, frontend syntax, public asset checks, format check, lockfile check, and production build passed.\n\nThis branch is based on the current upstream master and contains only the Claude viewer normalization fix plus its changeset.

codeman-local and others added 2 commits July 25, 2026 15:26
Only CLAUDE.md conflicted: master restructured it into the short-rule +
docs/architecture-invariants.md pointer layout while this PR was open.
The response-viewer detail now lives in architecture-invariants, so the
Claude turn-grouping and restored-placeholder rebind notes moved there.
Changeset rewritten to record the measured effect on real transcripts.
@Ark0N

Ark0N commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Thanks, this one is a genuinely good diagnosis. I rebuilt it on current master, verified it against real transcripts rather than just the fixtures, and pushed the conflict resolution to your branch.

The diagnosis is right

The old code treated a Claude transcript as if it were a chat log, one row per message. It isn't: it's an append-only event log where a single logical exchange spans many rows. Tool results are recorded as user rows, assistant output arrives as multiple blocks sharing a message id, and restores replay earlier rows verbatim. Rendering a card per row was always going to produce duplicates and fragments, and the two extra things you caught are the ones that actually matter in practice:

  • Sidechain rows belong to subagents and forks, so the old parser could surface a subagent's output as the main session's "last response". Skipping isSidechain fixes a real correctness bug, not just cosmetics.
  • The restored-<uuid8> rebind. reconcileSessions() recovers a lost mux session with a stale cwd, so lookup by working directory finds nothing and the viewer just shows empty. Recognising that the placeholder still carries the first eight UUID characters, and only rebinding when exactly one candidate matches, is the right level of caution. The candidateIds.size !== 1 guard is what makes it safe.

Verified on real data

Fixtures prove the shape; they don't prove the fix helps on real transcripts or that grouping doesn't silently eat content. So I extracted both parsers verbatim, the master one and yours, and ran them over the 40 largest real transcripts on this machine:

master this PR
cards rendered 3108 621 (-80.0%)
duplicate cards 74 8
assistant text present in master but missing here 0 chars
last-response text differing (non-context=full) 0 of 40 files

The zero-character result is the important one: the grouping merges rows without dropping any assistant output. And the non-full path being byte-identical on every file means the plain last-response behaviour is a pure no-op, so the blast radius really is confined to the viewer.

I checked the 8 residual duplicates individually. All are legitimate: repeated "You've hit your session limit" notices and a repeated continue prompt, each separated by real turns. That is exactly the case your second test pins ("keeps an identical user prompt when it occurs again after an assistant response"), so the parser is behaving correctly, not leaking.

Worst individual case went from 282 cards to 18, and another from 184 (32 of them duplicates) to 68.

One thing worth being aware of

GET /api/sessions/:id/last-response now mutates session state, via adoptClaudeSessionId() plus a persistDockerCaseClaudeSessionId() write for docker cases. A GET with a side effect is normally a smell, and it will fire on every viewer poll for a recovered session.

I left it in, for three reasons: it only triggers on the restored- rebind path (a direct id match returns before it), adoptClaudeSessionId is idempotent so repeat polls converge after the first, and it mirrors what the hook path already does when it adopts a conversation id. Flagging it so it is a known and deliberate property rather than a surprise later.

Housekeeping

  • Merge conflict, CLAUDE.md only, and not your fault: master restructured it while this PR was open, moving deep detail into docs/architecture-invariants.md and leaving a short rule plus pointer behind. The response-viewer paragraph you edited now lives in architecture-invariants, so your Claude turn-grouping and restored-placeholder notes went there instead. Nothing was dropped.
  • Changeset rewritten to record the measured numbers, since it lands in the public CHANGELOG, and to drop the em dashes for repo style.

Verification

  • npm run test:ci: 187 files, 3786 passed, 12 skipped
  • tsc --noEmit, eslint, prettier --check, check:public-assets, check:frontend-syntax: clean
  • Both parsers compared over 40 real ~/.claude/projects transcripts as above

Thanks again. Reading the transcript format correctly instead of patching the symptom is the right instinct, and the sidechain and restored-placeholder catches were the parts that needed someone actually paying attention.

@Ark0N
Ark0N merged commit b586007 into Ark0N:master Jul 28, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Sep 6, 2026
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 shows
1,806 messages instead of 356 and 353 user cards instead of 178, with the
assistant text sequence unchanged row for row and the response without
?context=full byte-identical on all 57 files.

One assistant row IS one whole model message: in that corpus no assistant row
carries more than one content block and no message id carries more than one
text block, so there was nothing to reassemble. Each row becomes its own
message carrying an additive {kind, label, turn}, and the frontend renders a
same-role run inside one turn as badge-less continuation segments — which is
what keeps a p90 of 11 messages per turn from reading as card spam. A numeric
turn gates that rendering, so Codex, the external-CLI pane parser and an older
server keep one badge per card.

A prompt typed while Claude is working is recorded ONLY as an
attachment/queued_command row. Taking it when origin.kind is 'human' and
commandMode is 'prompt' recovers 162 user cards from 163 such rows — one is a
verbatim repeat inside an unanswered user run and is collapsed by the existing
dedup guard — and restores the turn boundary whose absence let the assistant
runs fuse. The CLI's own queue entries are cleanly separable: of 322
queued_command rows, 159 are commandMode 'task-notification' and not one of
them carries an origin key.

This narrows #169 rather than reverting it: sidechain exclusion, the
restored-<uuid8> rebind, replayed-snapshot dedup and synthetic-row filtering
are all unchanged and still asserted.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants