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

web: Fix the end of a reply staying missing after reopening a session.
7 changes: 3 additions & 4 deletions apps/kimi-web/src/composables/client/useWorkspaceState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
49 changes: 18 additions & 31 deletions apps/kimi-web/src/composables/useKimiWebClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1137,7 +1138,12 @@ async function syncSessionFromSnapshot(sessionId: string): Promise<SyncSessionRe
? snap.session.model
: s.model,
}));
setSessionMessages(sessionId, snap.messages);
// The snapshot only carries the most recent page; keep any older pages the
// user already loaded so reopening does not reset scrollback.
setSessionMessages(
sessionId,
mergeSnapshotMessages(rawState.messagesBySession[sessionId] ?? [], snap.messages),
);
rawState.messagesHasMoreBySession = {
...rawState.messagesHasMoreBySession,
[sessionId]: snap.hasMoreMessages,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Preserve exhausted pagination after snapshot merge

When a user has already loaded all older messages (messagesHasMoreBySession[sessionId] is false), reopening a long session now merges that full prefix with the latest snapshot but then resets the pagination flag to snap.hasMoreMessages. That flag is only relative to the snapshot's bounded tail, so sessions with more than the snapshot page size will incorrectly show “load older” again and issue an unnecessary empty request before returning to false.

Useful? React with 👍 / 👎.

Expand Down Expand Up @@ -1260,38 +1266,19 @@ function dropWsSubscription(sessionId: string): void {
sessionsWithStaleCursor.delete(sessionId);
}

function subscribeToSessionEvents(sessionId: string): void {
connectEventsIfNeeded();
if (eventConn) {
// Apply any queued streaming deltas before re-subscribing so the transcript
// is current. (These deltas are volatile — never replayed by the server and
// they don't advance lastSeqBySession — but flushing here is cheap and
// future-proofs the cursor if the batching set ever changes.)
enqueueEvent.flush();
const seq = rawState.lastSeqBySession[sessionId] ?? 0;
const epoch = epochBySession[sessionId];
eventConn.subscribe(sessionId, { seq, epoch });
retainWsSubscription(sessionId);
}
}

/** Re-open an already-loaded session. If it was evicted from the subscription
* cap since it was last open, rebuild from a snapshot: resuming from the kept
* cursor would skip the per-session events that arrived while unsubscribed,
* and replaying from seq 0 would make the projector regenerate message ids and
* duplicate the already-loaded transcript. Otherwise just re-subscribe from the
* tracked cursor.
/** Re-open an already-loaded session: always rebuild from a fresh snapshot.
*
* The stale marker is only read here; `syncSessionFromSnapshot` clears it once
* the snapshot succeeds. If the snapshot fails transiently the marker stays, so
* the next re-open retries the snapshot instead of falling back to a cursor
* that may have skipped events while unsubscribed. */
* Volatile `assistant.delta` frames are never journaled or replayed: if a
* transport hiccup covered the tail of a turn while the user was away, the
* local transcript silently lost the model's final text, and a cursor
* resubscribe has nothing to recover it with. Always fetching the authoritative
* snapshot keeps the logic trivially correct (no freshness heuristics, no
* races to reason about); the snapshot is cheap server-side (LRU on the wire
* file). Trade-off: a snapshot GET in flight during a steep local send can
* momentarily overwrite that optimistic message — the user notices immediately
* and the next re-open (or a refresh) reconciles. */
async function reopenSession(sessionId: string): Promise<SyncSessionResult> {
if (sessionsWithStaleCursor.has(sessionId)) {
return syncSessionFromSnapshot(sessionId);
}
subscribeToSessionEvents(sessionId);
return 'ok';
return syncSessionFromSnapshot(sessionId);
Comment thread
wbxl2000 marked this conversation as resolved.
Comment thread
wbxl2000 marked this conversation as resolved.
}

// ---------------------------------------------------------------------------
Expand Down
27 changes: 27 additions & 0 deletions apps/kimi-web/src/lib/snapshotMessages.ts
Original file line number Diff line number Diff line change
@@ -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;
}
42 changes: 41 additions & 1 deletion apps/kimi-web/test/lib-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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([]);
});
});
Loading