From 7d32e1d7ed435cb46378c9983f8782cdd74c761a Mon Sep 17 00:00:00 2001 From: digua Date: Sat, 22 Aug 2026 18:34:20 +0800 Subject: [PATCH] fix(ai): cancel stale conversation restoration Carry one selection generation across the complete session restoration flow so delayed list or history requests cannot override a newer restoration, user selection, or draft. --- src/stores/aiChat.ts | 29 +++++++++++++++++++------ src/stores/aiChatNavigation.test.ts | 33 +++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/stores/aiChat.ts b/src/stores/aiChat.ts index c7fc3fcd..c35b489c 100644 --- a/src/stores/aiChat.ts +++ b/src/stores/aiChat.ts @@ -558,10 +558,13 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { return true } - async function loadAIChat(chatKey: string, aiChatId: string): Promise { + async function loadAIChatForSelection( + chatKey: string, + aiChatId: string, + selectionGeneration: number + ): Promise { const state = getSessionState(chatKey) - if (!state) return false - const selectionGeneration = advanceAIChatSelection(chatKey) + if (!state || !isLatestAIChatSelection(chatKey, selectionGeneration)) return false try { const conversation = await useAIService().getAIChat(aiChatId) @@ -599,6 +602,11 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { } } + async function loadAIChat(chatKey: string, aiChatId: string): Promise { + if (!getSessionState(chatKey)) return false + return loadAIChatForSelection(chatKey, aiChatId, advanceAIChatSelection(chatKey)) + } + function focusAIChat(chatKey: string, aiChatId: string | null): boolean { const state = getSessionState(chatKey) if (!state) return false @@ -635,24 +643,31 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => { } const state = getSessionState(chatKey) if (!state || state.isAIThinking) return + const selectionGeneration = advanceAIChatSelection(chatKey) if (!assistantStore.isLoaded) { await assistantStore.loadAssistants() + if (!isLatestAIChatSelection(chatKey, selectionGeneration)) return } - if (preferredAIChatId && (await loadAIChat(chatKey, preferredAIChatId))) { - return + if (preferredAIChatId) { + if (await loadAIChatForSelection(chatKey, preferredAIChatId, selectionGeneration)) return + if (!isLatestAIChatSelection(chatKey, selectionGeneration)) return } try { const latestAIChat = (await useAIService().getAIChats(state.sessionId))[0] - if (latestAIChat && latestAIChat.id !== preferredAIChatId && (await loadAIChat(chatKey, latestAIChat.id))) { - return + if (!isLatestAIChatSelection(chatKey, selectionGeneration)) return + if (latestAIChat && latestAIChat.id !== preferredAIChatId) { + if (await loadAIChatForSelection(chatKey, latestAIChat.id, selectionGeneration)) return + if (!isLatestAIChatSelection(chatKey, selectionGeneration)) return } } catch (error) { + if (!isLatestAIChatSelection(chatKey, selectionGeneration)) return console.error('[AI] Failed to load the latest conversation:', error) } + if (!isLatestAIChatSelection(chatKey, selectionGeneration)) return if (!state.selectedAssistantId) { const defaultId = getDefaultGeneralAssistantId(state.locale) selectAssistantForSession(chatKey, defaultId) diff --git a/src/stores/aiChatNavigation.test.ts b/src/stores/aiChatNavigation.test.ts index f7431da5..f02f0ce0 100644 --- a/src/stores/aiChatNavigation.test.ts +++ b/src/stores/aiChatNavigation.test.ts @@ -17,14 +17,27 @@ test('restores the latest valid AI chat without leaking stale navigation state', const slowConversation = new Promise>((resolve) => { resolveSlowConversation = resolve }) + let resolveStaleList!: (conversations: ReturnType[]) => void + const staleList = new Promise[]>((resolve) => { + resolveStaleList = resolve + }) + let staleListRequestCount = 0 const aiService = { getAIChat: async (id: string) => { if (id === 'chat-slow') return slowConversation if (id === 'chat-one' || id === 'chat-newest') return makeConversation(id) + if (id === 'chat-restored' || id === 'chat-user-choice') return makeConversation(id, 'session-four') return id === 'chat-latest' ? makeConversation(id, 'session-three') : null }, - getAIChats: async (sessionId: string) => - sessionId === 'session-three' ? [makeConversation('chat-latest', sessionId)] : [], + getAIChats: async (sessionId: string) => { + if (sessionId === 'session-three') return [makeConversation('chat-latest', sessionId)] + if (sessionId === 'session-four') { + staleListRequestCount++ + if (staleListRequestCount === 1) return staleList + return [makeConversation('chat-restored', sessionId)] + } + return [] + }, getMessages: async (aiChatId: string) => [ { id: `message-${aiChatId}`, @@ -120,6 +133,22 @@ test('restores the latest valid AI chat without leaking stale navigation state', assert.equal(third.state.currentAIChatId, 'chat-latest') assert.equal(third.state.messages[0]?.content, 'Saved answer for chat-latest') + const fourth = store.ensureSessionState({ + sessionId: 'session-four', + sessionName: 'Fourth session', + chatType: 'private', + locale: 'zh-CN', + }) + const staleRestore = store.resetToSelectorOnEnter(fourth.chatKey) + await store.resetToSelectorOnEnter(fourth.chatKey) + assert.equal(await store.loadAIChat(fourth.chatKey, 'chat-user-choice'), true) + + resolveStaleList([makeConversation('chat-restored', 'session-four')]) + await staleRestore + + assert.equal(fourth.state.currentAIChatId, 'chat-user-choice') + assert.equal(fourth.state.messages[0]?.content, 'Saved answer for chat-user-choice') + const global = store.ensureGlobalState('zh-CN') store.activeTask = { requestId: 'global-request',