Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-transcript-heal-duplicate-lines.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix assistant messages in the web transcript showing duplicated or fragmented text lines.
11 changes: 11 additions & 0 deletions packages/kap-server/src/services/transcript/transcriptService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {

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 👍 / 👎.

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 });
}
}
Expand Down
53 changes: 53 additions & 0 deletions packages/kap-server/test/services/transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
14 changes: 12 additions & 2 deletions packages/transcript/src/history/groupTurns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? []) {
Expand Down
30 changes: 30 additions & 0 deletions packages/transcript/test/layers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand Down