From 5eb3a150b619e21143c7f6c61307141f71dd9962 Mon Sep 17 00:00:00 2001 From: qer Date: Mon, 13 Jul 2026 21:14:26 +0800 Subject: [PATCH 1/2] fix(web): dedupe optimistic user message against snapshot resync --- .changeset/fix-web-duplicate-user-message.md | 5 +++ apps/kimi-web/src/api/daemon/eventReducer.ts | 4 +- apps/kimi-web/src/lib/snapshotMessages.ts | 22 ++++++++++- apps/kimi-web/test/lib-logic.test.ts | 41 ++++++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-web-duplicate-user-message.md diff --git a/.changeset/fix-web-duplicate-user-message.md b/.changeset/fix-web-duplicate-user-message.md new file mode 100644 index 0000000000..3a77d2fd3e --- /dev/null +++ b/.changeset/fix-web-duplicate-user-message.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix duplicate user message bubbles when a snapshot resync lands before the message echo, most visible on the first message of a new session. diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts index 667b70f7d8..c90e7e9422 100644 --- a/apps/kimi-web/src/api/daemon/eventReducer.ts +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -124,7 +124,7 @@ function advanceSeq(state: KimiClientState, sessionId: string | undefined, seq: } } -function isOptimisticUserMessage(message: AppMessage): boolean { +export function isOptimisticUserMessage(message: AppMessage): boolean { return ( message.role === 'user' && message.metadata?.[OPTIMISTIC_USER_MESSAGE_METADATA_KEY] === true @@ -164,7 +164,7 @@ function userMessageShape(m: AppMessage): { text: string; media: number } { return { text, media }; } -function sameUserMessageLoosely(a: AppMessage, b: AppMessage): boolean { +export function sameUserMessageLoosely(a: AppMessage, b: AppMessage): boolean { const sa = userMessageShape(a); const sb = userMessageShape(b); return sa.text === sb.text && sa.media === sb.media; diff --git a/apps/kimi-web/src/lib/snapshotMessages.ts b/apps/kimi-web/src/lib/snapshotMessages.ts index 29b301ee6a..dee3bdea75 100644 --- a/apps/kimi-web/src/lib/snapshotMessages.ts +++ b/apps/kimi-web/src/lib/snapshotMessages.ts @@ -6,6 +6,7 @@ // drop the older prefix they already fetched and reset scrollback. Preserve any // loaded messages older than the snapshot window; the snapshot is authoritative // for its own window and replaces anything inside it. +import { isOptimisticUserMessage, sameUserMessageLoosely } from '../api/daemon/eventReducer'; import type { AppMessage } from '../api/types'; export function mergeSnapshotMessages( @@ -18,9 +19,28 @@ export function mergeSnapshotMessages( const earliestSnapshotMs = Date.parse(snapshot[0]!.createdAt); if (Number.isNaN(earliestSnapshotMs)) return snapshot; + // A snapshot can land before the WS echo merges the optimistic user message: + // the optimistic copy's client-side createdAt falls just before the window and + // would be kept as an "older" message next to the snapshot's authoritative one, + // rendering a duplicate bubble the late echo can never clean up. The snapshot + // is authoritative, so drop any optimistic copy it already covers. + const snapshotIds = new Set(snapshot.map((m) => m.id)); + const snapshotUserPromptIds = new Set( + snapshot.filter((m) => m.role === 'user' && m.promptId !== undefined).map((m) => m.promptId), + ); + const snapshotUserMessages = snapshot.filter((m) => m.role === 'user'); + 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 (isOptimisticUserMessage(message)) { + // promptId is stamped only after submitPrompt resolves; before that, fall + // back to the (text, media-count) shape the echo reducer also uses. + if (message.promptId !== undefined && snapshotUserPromptIds.has(message.promptId)) return false; + if (snapshotUserMessages.some((m) => sameUserMessageLoosely(m, message))) return false; + } + return true; }); return older.length > 0 ? [...older, ...snapshot] : snapshot; diff --git a/apps/kimi-web/test/lib-logic.test.ts b/apps/kimi-web/test/lib-logic.test.ts index 1e7f3dc44e..efb5cb6a57 100644 --- a/apps/kimi-web/test/lib-logic.test.ts +++ b/apps/kimi-web/test/lib-logic.test.ts @@ -534,6 +534,47 @@ 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, promptId?: string): AppMessage { + return { + id, + sessionId: 's1', + role: 'user', + content: [{ type: 'text', text }], + createdAt, + promptId, + }; + } + + it('drops an optimistic user message the snapshot already covers by promptId', () => { + const loaded = [optimisticUser('msg_opt_1', '2026-01-02T23:59:59.000Z', 'hello', 'p1')]; + const snapshot = [realUser('msg_9', '2026-01-03T00:00:00.000Z', 'hello', 'p1')]; + expect(mergeSnapshotMessages(loaded, snapshot).map((m) => m.id)).toEqual(['msg_9']); + }); + + it('drops an unstamped optimistic user message matching a snapshot user message shape', () => { + const loaded = [optimisticUser('msg_opt_1', '2026-01-02T23:59:59.000Z', 'hello')]; + 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 the snapshot does not cover', () => { + const loaded = [optimisticUser('msg_opt_1', '2026-01-02T23:59:59.000Z', 'hello', 'p1')]; + const snapshot = [realUser('msg_9', '2026-01-03T00:00:00.000Z', 'world', 'p2')]; + expect(mergeSnapshotMessages(loaded, snapshot).map((m) => m.id)).toEqual(['msg_opt_1', 'msg_9']); + }); }); describe('mergeSnapshotSubagents', () => { From b8493e4d56c3a3199d28d44f2ef4621e192109d0 Mon Sep 17 00:00:00 2001 From: qer Date: Mon, 13 Jul 2026 21:47:42 +0800 Subject: [PATCH 2/2] fix(web): match snapshot messages by identity --- .changeset/fix-web-duplicate-user-message.md | 2 +- apps/kimi-web/src/api/daemon/eventReducer.ts | 4 +-- apps/kimi-web/src/lib/snapshotMessages.ts | 26 ++++++++------------ apps/kimi-web/test/lib-logic.test.ts | 21 ++++++---------- 4 files changed, 20 insertions(+), 33 deletions(-) diff --git a/.changeset/fix-web-duplicate-user-message.md b/.changeset/fix-web-duplicate-user-message.md index 3a77d2fd3e..615fabee18 100644 --- a/.changeset/fix-web-duplicate-user-message.md +++ b/.changeset/fix-web-duplicate-user-message.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -web: Fix duplicate user message bubbles when a snapshot resync lands before the message echo, most visible on the first message of a new session. +web: Fix duplicate user message bubbles after a session snapshot resync. diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts index c90e7e9422..667b70f7d8 100644 --- a/apps/kimi-web/src/api/daemon/eventReducer.ts +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -124,7 +124,7 @@ function advanceSeq(state: KimiClientState, sessionId: string | undefined, seq: } } -export function isOptimisticUserMessage(message: AppMessage): boolean { +function isOptimisticUserMessage(message: AppMessage): boolean { return ( message.role === 'user' && message.metadata?.[OPTIMISTIC_USER_MESSAGE_METADATA_KEY] === true @@ -164,7 +164,7 @@ function userMessageShape(m: AppMessage): { text: string; media: number } { return { text, media }; } -export function sameUserMessageLoosely(a: AppMessage, b: AppMessage): boolean { +function sameUserMessageLoosely(a: AppMessage, b: AppMessage): boolean { const sa = userMessageShape(a); const sb = userMessageShape(b); return sa.text === sb.text && sa.media === sb.media; diff --git a/apps/kimi-web/src/lib/snapshotMessages.ts b/apps/kimi-web/src/lib/snapshotMessages.ts index dee3bdea75..300008dc59 100644 --- a/apps/kimi-web/src/lib/snapshotMessages.ts +++ b/apps/kimi-web/src/lib/snapshotMessages.ts @@ -6,7 +6,6 @@ // drop the older prefix they already fetched and reset scrollback. Preserve any // loaded messages older than the snapshot window; the snapshot is authoritative // for its own window and replaces anything inside it. -import { isOptimisticUserMessage, sameUserMessageLoosely } from '../api/daemon/eventReducer'; import type { AppMessage } from '../api/types'; export function mergeSnapshotMessages( @@ -19,27 +18,22 @@ export function mergeSnapshotMessages( const earliestSnapshotMs = Date.parse(snapshot[0]!.createdAt); if (Number.isNaN(earliestSnapshotMs)) return snapshot; - // A snapshot can land before the WS echo merges the optimistic user message: - // the optimistic copy's client-side createdAt falls just before the window and - // would be kept as an "older" message next to the snapshot's authoritative one, - // rendering a duplicate bubble the late echo can never clean up. The snapshot - // is authoritative, so drop any optimistic copy it already covers. + // 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 snapshotUserPromptIds = new Set( - snapshot.filter((m) => m.role === 'user' && m.promptId !== undefined).map((m) => m.promptId), - ); - const snapshotUserMessages = snapshot.filter((m) => m.role === 'user'); + 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); if (Number.isNaN(createdAtMs) || createdAtMs >= earliestSnapshotMs) return false; if (snapshotIds.has(message.id)) return false; - if (isOptimisticUserMessage(message)) { - // promptId is stamped only after submitPrompt resolves; before that, fall - // back to the (text, media-count) shape the echo reducer also uses. - if (message.promptId !== undefined && snapshotUserPromptIds.has(message.promptId)) return false; - if (snapshotUserMessages.some((m) => sameUserMessageLoosely(m, message))) return false; - } + if ( + message.role === 'user' && + message.promptId !== undefined && + snapshotUserIds.has(message.promptId) + ) return false; return true; }); diff --git a/apps/kimi-web/test/lib-logic.test.ts b/apps/kimi-web/test/lib-logic.test.ts index efb5cb6a57..7562f6cc0c 100644 --- a/apps/kimi-web/test/lib-logic.test.ts +++ b/apps/kimi-web/test/lib-logic.test.ts @@ -535,7 +535,7 @@ describe('mergeSnapshotMessages', () => { expect(mergeSnapshotMessages(snapshot, [])).toEqual([]); }); - function optimisticUser(id: string, createdAt: string, text: string, promptId?: string): AppMessage { + function optimisticUser(id: string, createdAt: string, text: string, promptId: string): AppMessage { return { id, sessionId: 's1', @@ -547,32 +547,25 @@ describe('mergeSnapshotMessages', () => { }; } - function realUser(id: string, createdAt: string, text: string, promptId?: string): AppMessage { + function realUser(id: string, createdAt: string, text: string): AppMessage { return { id, sessionId: 's1', role: 'user', content: [{ type: 'text', text }], createdAt, - promptId, }; } - it('drops an optimistic user message the snapshot already covers by promptId', () => { - const loaded = [optimisticUser('msg_opt_1', '2026-01-02T23:59:59.000Z', 'hello', 'p1')]; - const snapshot = [realUser('msg_9', '2026-01-03T00:00:00.000Z', 'hello', 'p1')]; - expect(mergeSnapshotMessages(loaded, snapshot).map((m) => m.id)).toEqual(['msg_9']); - }); - - it('drops an unstamped optimistic user message matching a snapshot user message shape', () => { - const loaded = [optimisticUser('msg_opt_1', '2026-01-02T23:59:59.000Z', 'hello')]; + 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 the snapshot does not cover', () => { - const loaded = [optimisticUser('msg_opt_1', '2026-01-02T23:59:59.000Z', 'hello', 'p1')]; - const snapshot = [realUser('msg_9', '2026-01-03T00:00:00.000Z', 'world', 'p2')]; + 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']); }); });