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-duplicate-user-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Fix duplicate user message bubbles after a session snapshot resync.
16 changes: 15 additions & 1 deletion apps/kimi-web/src/lib/snapshotMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,23 @@ export function mergeSnapshotMessages(
const earliestSnapshotMs = Date.parse(snapshot[0]!.createdAt);
if (Number.isNaN(earliestSnapshotMs)) return snapshot;

// The optimistic bubble keeps its client-side id to avoid remounting, while
// submitPrompt stamps the authoritative v2 user-message id into promptId.
// Match that identity against the snapshot instead of guessing from content:
// repeated prompts are distinct messages even when their text/media is equal.
const snapshotIds = new Set(snapshot.map((m) => m.id));
const snapshotUserIds = new Set(snapshot.filter((m) => m.role === 'user').map((m) => m.id));

const older = loaded.filter((message) => {
const createdAtMs = Date.parse(message.createdAt);
return !Number.isNaN(createdAtMs) && createdAtMs < earliestSnapshotMs;
if (Number.isNaN(createdAtMs) || createdAtMs >= earliestSnapshotMs) return false;
if (snapshotIds.has(message.id)) return false;
if (
message.role === 'user' &&
message.promptId !== undefined &&
snapshotUserIds.has(message.promptId)
) return false;
return true;
});

return older.length > 0 ? [...older, ...snapshot] : snapshot;
Expand Down
34 changes: 34 additions & 0 deletions apps/kimi-web/test/lib-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,40 @@ describe('mergeSnapshotMessages', () => {
expect(mergeSnapshotMessages([], snapshot)).toBe(snapshot);
expect(mergeSnapshotMessages(snapshot, [])).toEqual([]);
});

function optimisticUser(id: string, createdAt: string, text: string, promptId: string): AppMessage {
return {
id,
sessionId: 's1',
role: 'user',
content: [{ type: 'text', text }],
createdAt,
promptId,
metadata: { 'kimiWeb.optimisticUserMessage': true },
};
}

function realUser(id: string, createdAt: string, text: string): AppMessage {
return {
id,
sessionId: 's1',
role: 'user',
content: [{ type: 'text', text }],
createdAt,
};
}

it('drops an optimistic user message when its promptId is the snapshot message id', () => {
const loaded = [optimisticUser('msg_opt_1', '2026-01-02T23:59:59.000Z', 'hello', 'msg_9')];
const snapshot = [realUser('msg_9', '2026-01-03T00:00:00.000Z', 'hello')];
expect(mergeSnapshotMessages(loaded, snapshot).map((m) => m.id)).toEqual(['msg_9']);
});

it('keeps an optimistic user message when a different snapshot message repeats its content', () => {
const loaded = [optimisticUser('msg_opt_1', '2026-01-02T23:59:59.000Z', 'hello', 'msg_8')];
const snapshot = [realUser('msg_9', '2026-01-03T00:00:00.000Z', 'hello')];
expect(mergeSnapshotMessages(loaded, snapshot).map((m) => m.id)).toEqual(['msg_opt_1', 'msg_9']);
});
});

describe('mergeSnapshotSubagents', () => {
Expand Down
Loading