diff --git a/.changeset/fix-web-reopen-reconcile.md b/.changeset/fix-web-reopen-reconcile.md new file mode 100644 index 0000000000..fd5eb32821 --- /dev/null +++ b/.changeset/fix-web-reopen-reconcile.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix the end of a reply staying missing after reopening a session. diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 00add89630..50e07ea3ea 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -972,10 +972,9 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta const result = await syncSessionFromSnapshot(sessionId); if (result === 'not-found') return; } else { - // Re-open: if the session was evicted from the subscription cap since - // last open, reopenSession rebuilds it from a snapshot (the kept cursor - // may have skipped per-session events); otherwise it re-subscribes from - // the tracked cursor and the daemon replays any missed durable events. + // Re-open: rebuild from a fresh snapshot rather than resuming from the + // tracked cursor — the daemon only replays durable events, so volatile + // streamed deltas lost to a WS hiccup would otherwise stay missing. const result = await reopenSession(sessionId); if (result === 'not-found') return; } diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 9e149433e6..123bda2599 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -13,6 +13,7 @@ import { type WorkspaceSortMode, } from '../lib/workspaceOrder'; import { mergeWorkspaces } from '../lib/mergeWorkspaces'; +import { mergeSnapshotMessages } from '../lib/snapshotMessages'; import { createCoalescedAsyncRunner } from '../lib/snapshotSync'; import { loadUnread, @@ -1137,7 +1138,12 @@ async function syncSessionFromSnapshot(sessionId: string): Promise { - if (sessionsWithStaleCursor.has(sessionId)) { - return syncSessionFromSnapshot(sessionId); - } - subscribeToSessionEvents(sessionId); - return 'ok'; + return syncSessionFromSnapshot(sessionId); } // --------------------------------------------------------------------------- diff --git a/apps/kimi-web/src/lib/snapshotMessages.ts b/apps/kimi-web/src/lib/snapshotMessages.ts new file mode 100644 index 0000000000..29b301ee6a --- /dev/null +++ b/apps/kimi-web/src/lib/snapshotMessages.ts @@ -0,0 +1,27 @@ +// apps/kimi-web/src/lib/snapshotMessages.ts +// Merge an authoritative snapshot tail into already-loaded messages. +// +// The session snapshot returns only the most recent bounded page. After a user +// has loaded older pages, replacing the whole message array with that tail would +// 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 type { AppMessage } from '../api/types'; + +export function mergeSnapshotMessages( + loaded: AppMessage[], + snapshot: AppMessage[], +): AppMessage[] { + if (snapshot.length === 0) return snapshot; + if (loaded.length === 0) return snapshot; + + const earliestSnapshotMs = Date.parse(snapshot[0]!.createdAt); + if (Number.isNaN(earliestSnapshotMs)) return snapshot; + + const older = loaded.filter((message) => { + const createdAtMs = Date.parse(message.createdAt); + return !Number.isNaN(createdAtMs) && createdAtMs < earliestSnapshotMs; + }); + + 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 d04269b014..00ca0f0132 100644 --- a/apps/kimi-web/test/lib-logic.test.ts +++ b/apps/kimi-web/test/lib-logic.test.ts @@ -8,6 +8,7 @@ import { parseDiff } from '../src/lib/parseDiff'; import { buildDiffLines } from '../src/lib/diffLines'; import { buildEditDiffLines } from '../src/lib/toolDiff'; import { createCoalescedAsyncRunner } from '../src/lib/snapshotSync'; +import { mergeSnapshotMessages } from '../src/lib/snapshotMessages'; import { normalizeToolName, toolSummary } from '../src/lib/toolMeta'; import { coerceThinkingForModel, @@ -17,7 +18,7 @@ import { modelThinkingAvailability, segmentsFor, } from '../src/lib/modelThinking'; -import type { AppModel } from '../src/api/types'; +import type { AppMessage, AppModel } from '../src/api/types'; import { resolveToolRenderer } from '../src/components/chat/tool-calls/toolRegistry'; import AgentTool from '../src/components/chat/tool-calls/AgentTool.vue'; import EditTool from '../src/components/chat/tool-calls/EditTool.vue'; @@ -369,3 +370,42 @@ describe('modelThinking', () => { }); }); }); + +describe('mergeSnapshotMessages', () => { + function msg(id: string, createdAt: string): AppMessage { + return { id, sessionId: 's1', role: 'assistant', content: [], createdAt }; + } + + it('keeps loaded messages older than the snapshot window', () => { + const loaded = [ + msg('old-1', '2026-01-01T00:00:00.000Z'), + msg('old-2', '2026-01-02T00:00:00.000Z'), + msg('recent-live', '2026-01-03T00:00:00.000Z'), + ]; + const snapshot = [ + msg('m0', '2026-01-03T00:00:00.000Z'), + msg('m1', '2026-01-04T00:00:00.000Z'), + ]; + expect(mergeSnapshotMessages(loaded, snapshot).map((m) => m.id)).toEqual([ + 'old-1', + 'old-2', + 'm0', + 'm1', + ]); + }); + + it('returns the snapshot when there is no older loaded prefix', () => { + const loaded = [msg('recent-live', '2026-01-03T00:00:00.000Z')]; + const snapshot = [ + msg('m0', '2026-01-03T00:00:00.000Z'), + msg('m1', '2026-01-04T00:00:00.000Z'), + ]; + expect(mergeSnapshotMessages(loaded, snapshot)).toBe(snapshot); + }); + + it('returns the snapshot when either side is empty', () => { + const snapshot = [msg('m0', '2026-01-03T00:00:00.000Z')]; + expect(mergeSnapshotMessages([], snapshot)).toBe(snapshot); + expect(mergeSnapshotMessages(snapshot, [])).toEqual([]); + }); +});