diff --git a/.changeset/fix-transcript-heal-duplicate-lines.md b/.changeset/fix-transcript-heal-duplicate-lines.md new file mode 100644 index 00000000000..9045765e3be --- /dev/null +++ b/.changeset/fix-transcript-heal-duplicate-lines.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix assistant messages in the web transcript showing duplicated or fragmented text lines. 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', 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( [