From 144e97edca692288dda784c7958555d475434511 Mon Sep 17 00:00:00 2001 From: zhangshida Date: Mon, 31 Aug 2026 14:20:07 +0800 Subject: [PATCH 1/2] fix(kap-server): skip cold frames covered by live frames on turn heal 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. --- .../fix-transcript-heal-duplicate-lines.md | 5 ++ .../services/transcript/transcriptService.ts | 11 ++++ .../test/services/transcript.test.ts | 53 +++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 .changeset/fix-transcript-heal-duplicate-lines.md diff --git a/.changeset/fix-transcript-heal-duplicate-lines.md b/.changeset/fix-transcript-heal-duplicate-lines.md new file mode 100644 index 00000000000..2c74c4e01a2 --- /dev/null +++ b/.changeset/fix-transcript-heal-duplicate-lines.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix duplicated text lines in assistant messages shown in the web transcript after a turn ends. diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index c1bbc4c2f01..e5389030ec7 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -744,6 +744,17 @@ export function healTurnOps( ) { continue; } + 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)) { + return false; + } + return frame.kind !== 'text' || entry.kind !== 'text' || entry.role === frame.role; + }) + ) { + continue; + } ops.push({ op: 'frame.upsert', turnId: snapshotTurn.turnId, stepId: step.stepId, frame }); } } diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 6a129306a0e..7383c091c81 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -3528,6 +3528,59 @@ describe('bindSessionTranscript', () => { expect(frames).toContainEqual(expect.objectContaining({ kind: 'text', frameId: 't0.1.f2', text: 'Hello world' })); }); + it('skips cold per-delta frames already covered by the live consolidated frame', () => { + const full = + "Sure, here's one:\n\nWhy do programmers prefer dark mode?\n\nBecause light attracts bugs."; + const snapshotTurn: TranscriptTurn = { + kind: 'turn', + turnId: 't0', + ordinal: 0, + state: 'completed', + origin: { kind: 'user' }, + steps: [ + { + kind: 'step', + stepId: 't0.1', + turnId: 't0', + ordinal: 1, + state: 'completed', + frames: [ + { kind: 'thinking', frameId: 't0.1.f1', text: 'plan' }, + { kind: 'text', frameId: 't0.1.f2', role: 'assistant', text: 'Sure' }, + { kind: 'text', frameId: 't0.1.f3', role: 'assistant', text: ", here's one" }, + { kind: 'text', frameId: 't0.1.f4', role: 'assistant', text: ':' }, + { kind: 'text', frameId: 't0.1.f5', role: 'assistant', text: '\n\nWhy do programmers' }, + ], + }, + ], + }; + const liveTurn: TranscriptTurn = { + kind: 'turn', + turnId: 't0', + ordinal: 0, + state: 'completed', + origin: { kind: 'user' }, + steps: [ + { + kind: 'step', + stepId: 't0.1', + turnId: 't0', + ordinal: 1, + state: 'completed', + frames: [ + { kind: 'thinking', frameId: 't0.1.f1', text: 'plan' }, + { kind: 'text', frameId: 't0.1.f2', role: 'assistant', text: full }, + ], + }, + ], + }; + + const frames = healTurnOps(snapshotTurn, liveTurn) + .filter((op): op is FrameUpsertOp => op.op === 'frame.upsert') + .map((op) => op.frame); + expect(frames).toHaveLength(0); + }); + it('heals missing tool frames and missed results, keeps richer live ones', () => { const makeTurn = (frames: TranscriptTurn['steps'][number]['frames']): TranscriptTurn => ({ kind: 'turn', From 58a8957bfb5c6f0ffd175c201339f39589320b24 Mon Sep 17 00:00:00 2001 From: zhangshida Date: Tue, 1 Sep 2026 11:25:03 +0800 Subject: [PATCH 2/2] fix(transcript): coalesce fragmented assistant frames on cold rebuild 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. --- .../fix-transcript-heal-duplicate-lines.md | 2 +- packages/transcript/src/history/groupTurns.ts | 14 +++++++-- packages/transcript/test/layers.test.ts | 30 +++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/.changeset/fix-transcript-heal-duplicate-lines.md b/.changeset/fix-transcript-heal-duplicate-lines.md index 2c74c4e01a2..9045765e3be 100644 --- a/.changeset/fix-transcript-heal-duplicate-lines.md +++ b/.changeset/fix-transcript-heal-duplicate-lines.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Fix duplicated text lines in assistant messages shown in the web transcript after a turn ends. +Fix assistant messages in the web transcript showing duplicated or fragmented text lines. diff --git a/packages/transcript/src/history/groupTurns.ts b/packages/transcript/src/history/groupTurns.ts index 57d7a34fa7a..8433f5fd2e8 100644 --- a/packages/transcript/src/history/groupTurns.ts +++ b/packages/transcript/src/history/groupTurns.ts @@ -338,9 +338,19 @@ export function groupMessagesIntoSnapshot( pendingNotificationFrames = []; for (const part of message.content ?? []) { if (part.type === 'text' && 'text' in part && typeof part.text === 'string' && part.text.length > 0) { - step.frames.push({ kind: 'text', frameId: nextFrameId(), role: 'assistant', text: part.text }); + const last = step.frames.at(-1); + if (last !== undefined && last.kind === 'text' && last.role === 'assistant') { + step.frames[step.frames.length - 1] = { ...last, text: last.text + part.text }; + } else { + step.frames.push({ kind: 'text', frameId: nextFrameId(), role: 'assistant', text: part.text }); + } } else if (part.type === 'think' && 'think' in part && typeof part.think === 'string' && part.think.length > 0) { - step.frames.push({ kind: 'thinking', frameId: nextFrameId(), text: part.think }); + const last = step.frames.at(-1); + if (last !== undefined && last.kind === 'thinking') { + step.frames[step.frames.length - 1] = { ...last, text: last.text + part.think }; + } else { + step.frames.push({ kind: 'thinking', frameId: nextFrameId(), text: part.think }); + } } } for (const call of message.toolCalls ?? []) { diff --git a/packages/transcript/test/layers.test.ts b/packages/transcript/test/layers.test.ts index a95fa4bf16a..dbb110565e2 100644 --- a/packages/transcript/test/layers.test.ts +++ b/packages/transcript/test/layers.test.ts @@ -471,6 +471,36 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { expect(marker?.kind === 'marker' && marker.marker).toBe('compaction'); }); + it('coalesces assistant text fragments split by empty think parts into one frame', () => { + const snapshot = groupMessagesIntoSnapshot([ + { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [], origin: { kind: 'user' } }, + { + role: 'assistant', + content: [ + { type: 'think', think: 'plan' }, + { type: 'text', text: 'Why do programmers' }, + { type: 'think', think: '' }, + { type: 'text', text: ' prefer dark mode?' }, + { type: 'think', think: '' }, + { type: 'text', text: ' Because light attracts bugs.' }, + ], + toolCalls: [], + }, + ]); + + const turn = snapshot.items[0]; + if (turn?.kind !== 'turn') throw new Error('expected turn'); + expect(turn.steps).toHaveLength(1); + expect(turn.steps[0]?.frames).toEqual([ + expect.objectContaining({ kind: 'thinking', text: 'plan' }), + expect.objectContaining({ + kind: 'text', + role: 'assistant', + text: 'Why do programmers prefer dark mode? Because light attracts bugs.', + }), + ]); + }); + it('folds task-notification user messages into the current turn instead of opening their own', () => { const snapshot = groupMessagesIntoSnapshot( [