From 18d6b5821950afafdc296d5c4b8135c8a374b941 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 21:31:08 +0800 Subject: [PATCH 1/2] fix(web): fall back to session abort for stale prompts --- .changeset/web-abort-session-fallback.md | 5 +++++ .../src/composables/client/useWorkspaceState.ts | 15 +++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 .changeset/web-abort-session-fallback.md diff --git a/.changeset/web-abort-session-fallback.md b/.changeset/web-abort-session-fallback.md new file mode 100644 index 0000000000..29f1ee1d3a --- /dev/null +++ b/.changeset/web-abort-session-fallback.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix web chat stop actions so stale prompt ids fall back to cancelling the active session. diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index c590e5e4f2..80fbd96ec9 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -1049,15 +1049,22 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta const api = getKimiWebApi(); - // 3. If we have a real id, try the per-prompt abort first. On 40402 fall back - // to session-level abort (the daemon may have restarted or the id is stale). + // 3. If we have a real id, try the per-prompt abort first. If the daemon + // reports the prompt is missing/already completed, clear the stale id and + // fall back to session-level abort for whatever is currently running. if (promptId !== undefined) { try { - await api.abortPrompt(sid, promptId); - return; + const result = await api.abortPrompt(sid, promptId); + if (result.aborted) return; + const nextPromptIds = { ...rawState.promptIdBySession }; + delete nextPromptIds[sid]; + rawState.promptIdBySession = nextPromptIds; } catch (err) { if (isDaemonApiError(err) && err.code === PROMPT_NOT_FOUND_CODE) { // Stale id — try the session-level fallback below. + const nextPromptIds = { ...rawState.promptIdBySession }; + delete nextPromptIds[sid]; + rawState.promptIdBySession = nextPromptIds; } else { pushOperationFailure('abortCurrentPrompt', err, { sessionId: sid }); return; From dd9d80e66dcf83c2a90ff2abcbe3d7bf1ef81667 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 21:40:50 +0800 Subject: [PATCH 2/2] test(web): cover stale prompt abort fallback --- apps/kimi-web/test/workspace-state.test.ts | 154 +++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 apps/kimi-web/test/workspace-state.test.ts diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts new file mode 100644 index 0000000000..b5b7a8466f --- /dev/null +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -0,0 +1,154 @@ +import { computed, ref } from 'vue'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AppSession } from '../src/api/types'; +import { createInitialState } from '../src/api/daemon/eventReducer'; +import { useWorkspaceState, type UseWorkspaceStateDeps } from '../src/composables/client/useWorkspaceState'; +import type { ExtendedState } from '../src/composables/useKimiWebClient'; + +const apiMock = vi.hoisted(() => ({ + abortPrompt: vi.fn(), + abortSession: vi.fn(), +})); + +vi.mock('../src/api', () => ({ + getKimiWebApi: () => apiMock, +})); + +function createSession(): AppSession { + return { + id: 'sess_1', + title: 'Session', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + status: 'running', + archived: false, + currentPromptId: 'prompt_live', + cwd: '/workspace', + model: 'kimi-code', + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalCostUsd: 0, + contextTokens: 0, + contextLimit: 0, + turnCount: 0, + }, + messageCount: 0, + lastSeq: 0, + }; +} + +function createState(): ExtendedState { + return { + ...createInitialState(), + sessions: [createSession()], + activeSessionId: 'sess_1', + connected: true, + serverVersion: '', + workspaceName: 'kimi-web', + connection: 'connected', + permission: 'manual', + thinking: 'high', + planMode: false, + swarmMode: false, + goalMode: false, + loading: false, + sessionLoading: false, + queuedBySession: {}, + gitStatusBySession: {}, + promptIdBySession: { sess_1: 'prompt_stale' }, + sendingBySession: {}, + unreadBySession: {}, + authReady: true, + defaultModel: null, + managedProviderStatus: null, + workspaces: [], + activeWorkspaceId: null, + fsHome: null, + recentRoots: [], + hiddenWorkspaceRoots: [], + availableOpenInApps: [], + config: null, + sideChatMessagesByAgent: {}, + sideChatSendingByAgent: {}, + sideChatUserMessageIdsBySession: {}, + messagesLoadingMoreBySession: {}, + messagesHasMoreBySession: {}, + messagesLoadMoreErrorBySession: {}, + }; +} + +function createDeps(): UseWorkspaceStateDeps { + return { + taskPoller: {}, + sideChat: {}, + modelProvider: {}, + pushOperationFailure: vi.fn(), + activity: computed(() => 'running'), + inFlightPromptSessions: new Set(), + sessionsKnownEmpty: new Set(), + setSessions: vi.fn(), + updateSession: vi.fn(), + upsertSessionFront: vi.fn(), + appendSession: vi.fn(), + forgetSession: vi.fn(), + setActiveSessionId: vi.fn(), + updateSessionMessages: vi.fn(), + nextOptimisticMsgId: () => 'msg_opt_1', + getEventConn: () => null, + syncSessionFromSnapshot: vi.fn(), + subscribeToSessionEvents: vi.fn(), + hasLoadedMessages: vi.fn(), + refreshSessionStatus: vi.fn(), + persistSessionProfile: vi.fn(), + mergedWorkspaces: computed(() => []), + status: computed(() => ({})), + workspaceIdForSession: vi.fn(), + savePermissionToStorage: vi.fn(), + savePlanModeToStorage: vi.fn(), + saveSwarmModeToStorage: vi.fn(), + saveGoalModeToStorage: vi.fn(), + saveUnread: vi.fn(), + saveActiveWorkspaceToStorage: vi.fn(), + saveHiddenWorkspacesToStorage: vi.fn(), + goalErrorMessage: vi.fn(), + basename: (path: string) => path.split('/').at(-1) ?? path, + resetFastMoon: vi.fn(), + initialized: ref(true), + selectedDiffPath: ref(null), + fileDiffLines: ref([]), + fileDiffLoading: ref(false), + } as unknown as UseWorkspaceStateDeps; +} + +describe('useWorkspaceState — abortCurrentPrompt', () => { + beforeEach(() => { + apiMock.abortPrompt.mockReset(); + apiMock.abortSession.mockReset(); + }); + + it('falls back to session abort when the cached prompt id is already completed', async () => { + apiMock.abortPrompt.mockResolvedValue({ aborted: false }); + apiMock.abortSession.mockResolvedValue({ aborted: true }); + const state = createState(); + const workspace = useWorkspaceState(state, createDeps()); + + await workspace.abortCurrentPrompt(); + + expect(apiMock.abortPrompt).toHaveBeenCalledWith('sess_1', 'prompt_stale'); + expect(apiMock.abortSession).toHaveBeenCalledWith('sess_1'); + expect(state.promptIdBySession).toEqual({}); + }); + + it('does not fall back when prompt abort succeeds', async () => { + apiMock.abortPrompt.mockResolvedValue({ aborted: true }); + const workspace = useWorkspaceState(createState(), createDeps()); + + await workspace.abortCurrentPrompt(); + + expect(apiMock.abortPrompt).toHaveBeenCalledWith('sess_1', 'prompt_stale'); + expect(apiMock.abortSession).not.toHaveBeenCalled(); + }); +});