Skip to content
Merged
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-web-resync-step-blob.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Fix a running multi-step turn rendering a duplicated wall of text after the page reconnects or refreshes mid-turn.
22 changes: 15 additions & 7 deletions apps/kimi-web/src/api/daemon/agentEventProjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,9 @@ interface SessionState {
// Assistant message tracking
currentAssistantMsgId: string | undefined;

// Per-turn accumulated stream lengths — aligned against the wire `offset`
// on volatile delta frames (v2 sync protocol) to skip duplicates and
// detect gaps after a snapshot seed.
// Per-step accumulated stream lengths — aligned against the (step-relative)
// wire `offset` on volatile delta frames (v2 sync protocol) to skip
// duplicates and detect gaps after a snapshot seed.
turnTextLen: number;
turnThinkLen: number;

Expand Down Expand Up @@ -500,9 +500,10 @@ export interface AgentProjector {
/**
* Seed mid-turn state from a session snapshot's `in_flight_turn` (v2 sync):
* resets per-session state, builds the partially-streamed assistant message
* (thinking + text + running tool_use parts), and returns the messageCreated
* AppEvent to apply to the reducer. Live deltas continue appending; their
* wire `offset` aligns against the seeded text so the overlap window around
* (thinking + text + running tool_use parts — the current step only; earlier
* steps arrive via the transcript), and returns the messageCreated AppEvent
* to apply to the reducer. Live deltas continue appending; their wire
* `offset` aligns against the seeded text so the overlap window around
* snapshot/subscribe is exact. Session status is NOT seeded here — the REST
* snapshot's `session.status` is the authoritative value.
*/
Expand Down Expand Up @@ -573,6 +574,7 @@ export function createAgentProjector(): AgentProjector {
s.toolStartTimes.set(tool.toolCallId, Date.now());
}
s.currentAssistantMsgId = msg.id;
// Seeded step-relative lengths; the next turn.step.started resets both.
s.turnTextLen = turn.assistantText.length;
s.turnThinkLen = turn.thinkingText.length;

Expand Down Expand Up @@ -706,7 +708,7 @@ export function createAgentProjector(): AgentProjector {
if (turnId !== undefined) {
s.turnPromptId.set(turnId, existingPromptId);
}
// Fresh turn → fresh per-turn stream offsets.
// Fresh turn → fresh step stream offsets.
s.turnTextLen = 0;
s.turnThinkLen = 0;
break;
Expand All @@ -725,6 +727,12 @@ export function createAgentProjector(): AgentProjector {
if (turnId !== undefined) s.turnPromptId.set(turnId, promptId);
}

// Fresh step → fresh stream offsets: the server's delta `offset` is
// step-relative, so without this reset every delta from step 2 on is
// silently skipped or misread as a gap.
s.turnTextLen = 0;
s.turnThinkLen = 0;
Comment thread
wbxl2000 marked this conversation as resolved.
Comment thread
wbxl2000 marked this conversation as resolved.

// Create a new pending assistant message
const msg = startAssistantMessage(s, sessionId, promptId);
s.currentAssistantMsgId = msg.id;
Expand Down
101 changes: 90 additions & 11 deletions apps/kimi-web/src/composables/messagesToTurns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,13 +347,14 @@ interface Group {
/** Client-side measured duration from turn.started to turn.ended (ms). */
durationMs?: number;
/**
* Content signatures already folded into this group, used to drop a duplicate
* assistant message. The same logical reply can reach us under two different
* ids — e.g. the streamed copy plus the persisted copy after a reload — and
* since both share the promptId they'd otherwise merge and render the text +
* tool cards twice. Dedupe by exact content so a turn shows each reply once.
* Normalized signatures already folded into this group, used to drop a
* duplicate assistant message. The same logical reply can reach us under two
* different ids — e.g. the streamed copy plus the persisted copy after a
* reload — and since both share the promptId they'd otherwise merge and
* render the text + tool cards twice. Dedupe by normalized content (see
* `contentSig` / `covers`) so a turn shows each reply once.
*/
seenSigs: Set<string>;
foldedSigs: ContentSig[];
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -485,6 +486,57 @@ function parsePlanSavedPath(output: string[] | undefined): string | undefined {
return undefined;
}

/**
* Normalize an assistant message's content for duplicate detection. The same
* logical reply reaches us as both a persisted transcript message and a
* streamed copy (live deltas, or a resync seed from `in_flight_turn`), and the
* two differ in ways that must not defeat the dedup: the persisted thinking
* part may carry a provider `signature`, the seeded tool card may carry
* progress `outputLines`, and the seeded copy concatenates each stream into a
* single part instead of keeping the model's part boundaries. Reduce to the
* concatenated stream text plus sorted tool-call ids — a toolCallId is unique
* per call, so identical id sets mean the same logical message.
*/
interface ContentSig {
text: string;
thinking: string;
toolIds: string[];
rest: string[];
}

function contentSig(content: AppMessage['content']): ContentSig {
let text = '';
let thinking = '';
const toolIds: string[] = [];
const rest: string[] = [];
for (const c of content) {
if (c.type === 'text') text += c.text;
else if (c.type === 'thinking') thinking += c.thinking;
else if (c.type === 'toolUse') toolIds.push(c.toolCallId);
else rest.push(JSON.stringify(c));
}
toolIds.sort();
rest.sort();
return { text, thinking, toolIds, rest };
}

/**
* Whether an already-folded message's signature fully covers `incoming` —
* i.e. `incoming` is a duplicate of it. Subset, not equality: a resync seed
* carries only the still-running tools of a parallel batch (finished ones
* left `running_tools`), so its id set is a strict subset of the persisted
* message's. Empty text/thinking in `incoming` adds nothing and counts as
* covered.
*/
function covers(folded: ContentSig, incoming: ContentSig): boolean {
if (incoming.text !== '' && incoming.text !== folded.text) return false;
if (incoming.thinking !== '' && incoming.thinking !== folded.thinking) return false;
return (
incoming.toolIds.every((id) => folded.toolIds.includes(id)) &&
incoming.rest.every((j) => folded.rest.includes(j))
);
}

export function messagesToTurns(
messages: AppMessage[],
approvals: AppApprovalRequest[],
Expand Down Expand Up @@ -614,6 +666,28 @@ export function messagesToTurns(
}
}

/**
* Fold the volatile extras of a dropped duplicate into the group: a resync
* seed's tool cards carry live progress (`outputLines` from
* `in_flight_turn.running_tools[].last_progress`) that the persisted copy
* lacks — without this, a mid-tool refresh blanks the card's latest output
* until the next progress frame. Never overwrite output a tool result
* already settled.
*/
function mergeVolatileExtras(g: Group, content: AppMessage['content']): void {
for (const c of content) {
if (c.type !== 'toolUse' || !c.outputLines?.length) continue;
const idx = g.tools.findIndex((t) => t.id === c.toolCallId);
if (idx === -1) continue;
const tool = g.tools[idx]!;
if (tool.output !== undefined) continue;
const updated: ToolCall = { ...tool, output: c.outputLines };
g.tools[idx] = updated;
const blk = g.blocks.find((b) => b.kind === 'tool' && b.tool.id === c.toolCallId);
if (blk && blk.kind === 'tool') blk.tool = updated;
}
}

function resolveMediaUrl(
c: AppMessage['content'][number],
): { url: string; kind: 'image' | 'video'; fileId?: string } | undefined {
Expand Down Expand Up @@ -769,7 +843,7 @@ export function messagesToTurns(
blocks: [],
approval: undefined,
approvalId: undefined,
seenSigs: new Set<string>(),
foldedSigs: [],
durationMs: msg.durationMs,
};
} else if (pendingGroup !== null && pendingGroup.promptId === undefined && pid !== undefined) {
Expand All @@ -781,10 +855,15 @@ export function messagesToTurns(

// Drop an assistant message whose content was already folded into this group
// (a duplicate streamed-vs-persisted copy sharing the promptId), so the turn
// doesn't render the same text + tools twice.
const sig = JSON.stringify(msg.content);
if (group.promptId !== undefined && group.seenSigs.has(sig)) continue;
group.seenSigs.add(sig);
// doesn't render the same text + tools twice. The duplicate can still carry
// volatile extras the persisted copy lacks (tool progress), so merge those
// into the existing cards before dropping it.
const sig = contentSig(msg.content);
if (group.promptId !== undefined && group.foldedSigs.some((folded) => covers(folded, sig))) {
mergeVolatileExtras(group, msg.content);
continue;
}
group.foldedSigs.push(sig);

absorbContent(group, msg.content);
}
Expand Down
77 changes: 77 additions & 0 deletions apps/kimi-web/test/agent-event-projector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,80 @@ describe('session status single-sourcing', () => {
);
});
});

describe('step-boundary delta alignment', () => {
it('resets stream offsets at step boundaries — a post-step delta ahead of local state signals a gap', () => {
const projector = createAgentProjector();
projector.project('turn.started', { turnId: 1 }, 's1');
projector.project('turn.step.started', { turnId: 1, step: 1 }, 's1');
projector.project('assistant.delta', { turnId: 1, delta: 'step-one text' }, 's1', { offset: 0 });
projector.project('turn.step.completed', { turnId: 1, step: 1 }, 's1');
projector.project('turn.step.started', { turnId: 1, step: 2 }, 's1');

const events = projector.project('assistant.delta', { turnId: 1, delta: 'tail' }, 's1', { offset: 12 });
expect(events).toContainEqual(
expect.objectContaining({ type: 'historyCompacted', reason: 'delta_gap' }),
);
});

it('appends step-2 deltas to the fresh step message at step-relative offsets', () => {
const projector = createAgentProjector();
projector.project('turn.started', { turnId: 1 }, 's1');
projector.project('turn.step.started', { turnId: 1, step: 1 }, 's1');
projector.project('assistant.delta', { turnId: 1, delta: 'step one' }, 's1', { offset: 0 });
projector.project('turn.step.completed', { turnId: 1, step: 1 }, 's1');

const step2 = projector.project('turn.step.started', { turnId: 1, step: 2 }, 's1');
const created = step2.find((e) => e.type === 'messageCreated');
const msgId = (created as { message: { id: string } } | undefined)?.message.id;
expect(msgId).toBeDefined();

// Offset restarts at 0 for the new step and appends to ITS message.
const events = projector.project('assistant.delta', { turnId: 1, delta: 'step two' }, 's1', { offset: 0 });
expect(events).toContainEqual(
expect.objectContaining({
type: 'assistantDelta',
messageId: msgId,
delta: { text: 'step two' },
}),
);
});

it('seeds only the current step and aligns live deltas against the seeded length', () => {
const projector = createAgentProjector();
const seeded = projector.seedInFlight('s1', {
turnId: 7,
promptId: 'pr_1',
thinkingText: 'step two thinking',
assistantText: 'step two partial',
runningTools: [{ toolCallId: 'tc_1', name: 'bash', args: { command: 'ls' } }],
});
const created = seeded.find((e) => e.type === 'messageCreated');
const message = (created as { message: { id: string; content: unknown[] } } | undefined)?.message;
expect(message).toBeDefined();

expect(message!.content).toEqual([
{ type: 'thinking', thinking: 'step two thinking' },
{ type: 'text', text: 'step two partial' },
{ type: 'toolUse', toolCallId: 'tc_1', toolName: 'bash', input: { command: 'ls' } },
]);

const dup = projector.project('assistant.delta', { turnId: 7, delta: 'two part' }, 's1', { offset: 5 });
expect(dup).toEqual([]);

const cont = projector.project(
'assistant.delta',
{ turnId: 7, delta: ' continues' },
's1',
{ offset: 'step two partial'.length },
);
expect(cont).toContainEqual(
expect.objectContaining({
type: 'assistantDelta',
messageId: message!.id,
contentIndex: 3,
delta: { text: ' continues' },
}),
);
});
});
Loading
Loading