Skip to content

fix(transcript): drop duplicated and fragmented assistant text lines in the web transcript - #3388

Open
StephenZhang-hash wants to merge 2 commits into
MoonshotAI:mainfrom
StephenZhang-hash:fix/transcript-heal-duplicate-frames
Open

fix(transcript): drop duplicated and fragmented assistant text lines in the web transcript#3388
StephenZhang-hash wants to merge 2 commits into
MoonshotAI:mainfrom
StephenZhang-hash:fix/transcript-heal-duplicate-frames

Conversation

@StephenZhang-hash

@StephenZhang-hash StephenZhang-hash commented Aug 31, 2026

Copy link
Copy Markdown

Related Issue

Resolve #3387

Problem

After a turn ends, the transcript heal pass merges the cold (wire-rebuilt) snapshot into the live store. healTurnOps deduped text/thinking frames only by frameId. For steps whose stream persisted one content.part per delta (e.g. OpenAI-compatible reasoning streams that interleave an empty think part per chunk, preventing text-part merging), the cold rebuild produces one text frame per delta: f2 collides with the live consolidated frame and is skipped, but f3…fN have 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-frameId length 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:

  • A longer cold frame that merely extends a shorter live one (the missing-stream-prefix case, covered by the existing 'kind-mismatch' test) is not contained in the live text, so it still heals.
  • Same-frameId handling, 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.upsert ops; assistant content with text parts interleaved by empty think parts → one consolidated text frame. Verified with vitest run test/services/transcript.test.ts (kap-server; the suite's single unrelated failure on my box is git init -b requiring git ≥ 2.28), the @moonshot-ai/transcript suite (82/82), and tsc --noEmit on both packages.

Checklist

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-bot

changeset-bot Bot commented Aug 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 58a8957

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@moonshot-ai/kimi-code Patch

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.
@StephenZhang-hash StephenZhang-hash changed the title fix(kap-server): skip cold frames covered by live frames on turn heal fix(transcript): drop duplicated and fragmented assistant text lines in the web transcript Sep 1, 2026
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.

Web transcript renders duplicated lines: full message plus leftover stream chunks after a turn ends

1 participant