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

Clear all per-session state when a session is archived or removed, so archived sessions no longer leave orphaned data behind.
4 changes: 4 additions & 0 deletions apps/kimi-web/src/api/daemon/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ export class DaemonEventSocket {
/** Unsubscribe from a session's events. */
unsubscribe(sessionId: string): void {
this.subscriptions.delete(sessionId);
// Also cancel a subscribe that was queued before server_hello; otherwise
// onServerHello would merge it back into the active subscription set.
const pendingIdx = this.pendingSubscriptions.findIndex((p) => p.sessionId === sessionId);
if (pendingIdx !== -1) this.pendingSubscriptions.splice(pendingIdx, 1);
if (this.connected && this.ws) {
this.send({
type: 'unsubscribe',
Expand Down
6 changes: 3 additions & 3 deletions apps/kimi-web/src/composables/client/useWorkspaceState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export interface UseWorkspaceStateDeps {
updateSession: (id: string, update: (session: AppSession) => AppSession) => void;
upsertSessionFront: (session: AppSession) => void;
appendSession: (session: AppSession) => void;
removeSession: (id: string) => void;
forgetSession: (id: string) => void;
setActiveSessionId: (id: string | undefined) => void;
/** Update one session's message list via a function of the current list. */
updateSessionMessages: (
Expand Down Expand Up @@ -110,7 +110,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
updateSession,
upsertSessionFront,
appendSession,
removeSession,
forgetSession,
setActiveSessionId,
updateSessionMessages,
nextOptimisticMsgId,
Expand Down Expand Up @@ -1322,7 +1322,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
try {
const api = getKimiWebApi();
await api.archiveSession(id);
removeSession(id);
forgetSession(id);
sideChat.clearSideChatForSession(id);
const { [id]: _removedIds, ...restIds } = rawState.sideChatUserMessageIdsBySession;
void _removedIds;
Expand Down
51 changes: 36 additions & 15 deletions apps/kimi-web/src/composables/useKimiWebClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,40 @@ function removeSessionMessages(sessionId: string): void {
rawState.messagesBySession = rest;
}

// ---------------------------------------------------------------------------
// Session teardown — single place that wipes a session and all its per-session
// sidecar state. Both removal entry points (not-found + archive) go through
// this, so adding a new per-session map only ever needs one new line here.
// ---------------------------------------------------------------------------
function forgetSession(sessionId: string): void {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear queued prompt state in session teardown

When a session is archived while it has an in-flight turn and queued follow-up prompts, this teardown leaves rawState.queuedBySession and inFlightPromptSessions intact. When the archived session later emits its idle status, onSessionIdle still drains rawState.queuedBySession[sid] and calls submitPromptInternal(sid, ...) without checking that the session is still present, so a prompt can be submitted to a session the user just archived. Please clear the prompt queue/in-flight prompt sidecars here along with the other per-session state.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 40defb6forgetSession now also clears inFlightPromptSessions, queuedBySession, promptIdBySession and sendingBySession for the torn-down session, so a queued follow-up can't be submitted to a just-archived session when its turn later goes idle. Thanks for catching this.

// Stop receiving events for this session BEFORE clearing its state: a late or
// buffered event for this id would otherwise be reduced and recreate the very
// per-session maps we are about to delete.
eventConn?.unsubscribe(sessionId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Cancel pending WS subscribes when forgetting sessions

When this runs after subscribe() has queued a session but before the socket receives server_hello, it does not actually stop events for that session: DaemonEventSocket.unsubscribe() deletes subscriptions but leaves pendingSubscriptions, and onServerHello() merges those pending entries back into the active subscription set. In a slow initial WS connection or reconnect, archiving/removing a just-selected session can still subscribe to it and late events can recreate the per-session maps that forgetSession() just cleared; please clear pending subscriptions too or guard events for forgotten IDs.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 02266edDaemonEventSocket.unsubscribe() now also removes the session from pendingSubscriptions, so a subscribe queued before server_hello is properly cancelled and will not be merged back into the active set. The forgetSession unsubscribe is therefore effective in that window too.

Comment on lines +451 to +454

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Prevent late events from recreating forgotten state

When archiving a running session, this only sends unsubscribe before deleting the maps, but DaemonEventSocket still forwards any frames already queued in the browser or sent before the server processes the unsubscribe. Those late messageCreated/status frames then go through applyEvent/onSessionIdle and can recreate messagesBySession, sendingBySession, or unread state for the archived session, so the new teardown is still racy; keep a forgotten-session guard/tombstone in the event path or drop frames for unsubscribed session ids before reducing them.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Acknowledged — this is the buffered/in-flight frame race, which is real but extremely narrow and low-impact: it only triggers when archiving a running session with frames already in the browser queue or sent before the server processes the unsubscribe. The backend stops emitting events once a session is archived, so the residual is only frames already in flight, and any repopulated maps are not rendered (the session is gone from the list) and are cleared again on the next load.

The mitigations already in this PR (clearing per-session state in forgetSession, unsubscribe before teardown, and cancelling pending subscriptions in DaemonEventSocket.unsubscribe) cover the realistic cases. Adding a forgotten-session tombstone in the event path would be extra complexity for a window that is not practically observable, so we are deferring it for now and keeping this PR focused on the teardown fix. We can revisit if it ever shows up in practice.

removeSession(sessionId);
removeSessionMessages(sessionId);
Comment on lines +450 to +456

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Unsubscribe the session before forgetting state

When the session being archived has already been opened, the WebSocket connection remains subscribed here because forgetSession only removes local maps. The socket keeps session subscriptions until unsubscribe() is called, and the event reducer will recreate per-session maps for any later/buffered event for that session id; in an active or running archive flow, closing/turn events can therefore repopulate the orphaned messages/tasks/flags this helper just deleted. Unsubscribe the session as part of this teardown before clearing the sidecars.

Useful? React with 👍 / 👎.

@wbxl2000 wbxl2000 Jun 22, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 134e896forgetSession now calls eventConn?.unsubscribe(sessionId) before clearing the sidecars, so late/buffered events for the removed session cannot be reduced and repopulate the maps. Good catch.

delete rawState.approvalsBySession[sessionId];
delete rawState.questionsBySession[sessionId];
delete rawState.tasksBySession[sessionId];
delete rawState.goalBySession[sessionId];
delete rawState.gitStatusBySession[sessionId];
delete rawState.lastSeqBySession[sessionId];
delete rawState.compactionBySession[sessionId];
delete rawState.messagesLoadingMoreBySession[sessionId];
delete rawState.messagesHasMoreBySession[sessionId];
delete rawState.messagesLoadMoreErrorBySession[sessionId];
delete epochBySession[sessionId];
sessionsKnownEmpty.delete(sessionId);
// In-flight / queued prompt state: drop these too so a queued follow-up
// can't be submitted to a session that was just archived when its turn later
// goes idle (onSessionIdle drains queuedBySession[sid] without re-checking
// that the session still exists).
inFlightPromptSessions.delete(sessionId);
delete rawState.queuedBySession[sessionId];
delete rawState.promptIdBySession[sessionId];
delete rawState.sendingBySession[sessionId];
}

// Models + Providers reactive state and helpers live in
// ./client/useModelProviderState. It is instantiated below (after the
// `activity` computed it depends on) as `modelProvider`.
Expand Down Expand Up @@ -894,20 +928,7 @@ function goalErrorMessage(err: unknown): string | undefined {
}

async function handleSessionNotFound(sessionId: string): Promise<void> {
removeSession(sessionId);
removeSessionMessages(sessionId);
delete rawState.approvalsBySession[sessionId];
delete rawState.questionsBySession[sessionId];
delete rawState.tasksBySession[sessionId];
delete rawState.goalBySession[sessionId];
delete rawState.gitStatusBySession[sessionId];
delete rawState.lastSeqBySession[sessionId];
delete rawState.compactionBySession[sessionId];
delete rawState.messagesLoadingMoreBySession[sessionId];
delete rawState.messagesHasMoreBySession[sessionId];
delete rawState.messagesLoadMoreErrorBySession[sessionId];
delete epochBySession[sessionId];
sessionsKnownEmpty.delete(sessionId);
forgetSession(sessionId);

if (rawState.activeSessionId !== sessionId) return;

Expand Down Expand Up @@ -1835,7 +1856,7 @@ const workspaceState = useWorkspaceState(rawState, {
updateSession,
upsertSessionFront,
appendSession,
removeSession,
forgetSession,
setActiveSessionId,
updateSessionMessages,
nextOptimisticMsgId,
Expand Down
Loading