fix(transcript): drop duplicated and fragmented assistant text lines in the web transcript - #3388
Conversation
healTurnOps deduped snapshot frames against live frames only by frameId. Steps whose stream persisted one content.part per delta (e.g. interleaved empty think parts from OpenAI-compatible reasoning streams) cold-rebuild to one text frame per delta, and every frame beyond the first has no live counterpart, so the post-turn heal appended them on top of the live consolidated frame. The web transcript then rendered the full message followed by the leftover stream chunks as extra lines. Skip a snapshot text/thinking frame when any same-kind, same-role live frame in the step already contains its text, so per-delta leftovers are dropped while genuinely missing or longer cold frames still heal.
🦋 Changeset detectedLatest commit: 58a8957 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 144e97edca
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if ( | ||
| liveStep.frames.some((entry) => { | ||
| if (entry.frameId === frame.frameId || entry.kind !== frame.kind) return false; | ||
| if (entry.text.length < frame.text.length || !entry.text.includes(frame.text)) { |
There was a problem hiding this comment.
Preserve repeated cold chunks during healing
When the live projector misses a repeated delta, this substring check mistakes an earlier occurrence for coverage and drops the missing frame. For example, with cold text frames f1="foo" and f2="foo" but a live f1="foo" that missed the second append, the same-ID check skips cold f1 and includes now skips cold f2, leaving the healed transcript truncated instead of foofoo. Coverage needs to account for ordering and occurrence count rather than testing each cold frame against any substring independently.
Useful? React with 👍 / 👎.
Streams that persist one text part per delta (e.g. interleaved empty think parts from OpenAI-compatible reasoning streams) cold-rebuilt to one frame per part, so reloading a session or restarting the server rendered the message as fragment-per-line. Merge adjacent same-kind frames within a step during the cold rebuild so the snapshot matches the consolidated live view.
Related Issue
Resolve #3387
Problem
After a turn ends, the transcript heal pass merges the cold (wire-rebuilt) snapshot into the live store.
healTurnOpsdeduped text/thinking frames only byframeId. For steps whose stream persisted onecontent.partper delta (e.g. OpenAI-compatible reasoning streams that interleave an emptythinkpart per chunk, preventing text-part merging), the cold rebuild produces one text frame per delta:f2collides with the live consolidated frame and is skipped, butf3…fNhave no live counterpart and get appended on top of it. The web transcript then renders the full message followed by the leftover stream chunks as extra lines; the same merge at attach-time backfill shows it on reopened sessions too.The same fragmentation also escapes the heal path entirely: when a session is cold (server restart, or a session reopened later), the REST transcript route serves the cold snapshot directly with no live store to heal against, so the message renders as one line per stream fragment.
What changed
In
healTurnOps(packages/kap-server/src/services/transcript/transcriptService.ts), after the existing same-frameIdlength check, skip a snapshot text/thinking frame when any other same-kind (and same-role for text) live frame in the step already contains its text (length >=+includes). Every per-delta leftover is a substring of the live consolidated frame, so they all disappear and the ended step renders exactly like an intermediate step (one thinking + one text frame).In
groupMessagesIntoSnapshot(packages/transcript/src/history/groupTurns.ts), coalesce adjacent assistant text frames (and adjacent thinking frames) within a step during the cold rebuild. Per-delta fragments merge back into a single frame, so cold renders match the live consolidated view. Genuinely separate frames are unaffected: they are never adjacent same-kind (text around a tool call has the tool frame in between; user-role notification frames differ by role).Existing heal semantics are preserved:
frameIdhandling, tool-frame healing, and the live-attachment-id preference are untouched.Added regression tests on both paths: live consolidated frame + cold per-delta frames → heal emits zero
frame.upsertops; assistant content with text parts interleaved by empty think parts → one consolidated text frame. Verified withvitest run test/services/transcript.test.ts(kap-server; the suite's single unrelated failure on my box isgit init -brequiring git ≥ 2.28), the@moonshot-ai/transcriptsuite (82/82), andtsc --noEmiton both packages.Checklist
/approve— awaiting approval on Web transcript renders duplicated lines: full message plus leftover stream chunks after a turn ends #3387).gen-changesetsskill, or this PR needs no changeset.gen-docsskill, or this PR needs no doc update.