From 5c7ad53462a2899609d18c5a2388c23731b67b2b Mon Sep 17 00:00:00 2001 From: AI Systems Lab Date: Wed, 22 Jul 2026 23:53:58 +0800 Subject: [PATCH 1/6] test: reproduce chat deletion without cancellation --- tests/chat-store-delete-cancellation.test.cjs | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 tests/chat-store-delete-cancellation.test.cjs diff --git a/tests/chat-store-delete-cancellation.test.cjs b/tests/chat-store-delete-cancellation.test.cjs new file mode 100644 index 0000000..0be3b1e --- /dev/null +++ b/tests/chat-store-delete-cancellation.test.cjs @@ -0,0 +1,240 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const Module = require('node:module'); +const path = require('node:path'); +const test = require('node:test'); +const ts = require('typescript'); + +const chatStorePath = path.join(__dirname, '..', 'src', 'renderer', 'stores', 'chatStore.ts'); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(predicate, message) { + const deadline = Date.now() + 1000; + while (Date.now() < deadline) { + if (predicate()) return; + await sleep(5); + } + assert.fail(message); +} + +async function settlesWithin(promise, timeoutMs) { + let timer; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs); + }); + try { + return await Promise.race([promise.then(() => true), timeout]); + } finally { + clearTimeout(timer); + } +} + +async function withChatStore({ ids, runReactAgent }, run) { + const originalTsLoader = require.extensions['.ts']; + const originalLoad = Module._load; + const originalLocalStorage = global.localStorage; + const deletedConversationIds = []; + let memoryCalls = 0; + + global.localStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }; + + require.extensions['.ts'] = (mod, filename) => { + const source = fs.readFileSync(filename, 'utf8'); + const output = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + }, + }).outputText; + mod._compile(output, filename); + }; + + const model = { + id: 'local-model', + name: 'Local model', + provider: 'ollama', + modelId: 'test-model', + apiKey: '', + baseUrl: 'http://localhost', + }; + const configState = { + models: [model], + activeModelId: model.id, + language: 'zh-CN', + memoryShortTermRounds: 5, + setActiveModel: () => {}, + }; + + const dbAPI = { + getConversations: async () => [], + getMessages: async () => [], + getConfig: async () => null, + setConfig: async () => {}, + upsertConversation: async () => {}, + upsertMessage: async () => {}, + updateConversationTitle: async () => {}, + deleteConversation: async (id) => { + deletedConversationIds.push(id); + }, + deleteMessage: async () => {}, + }; + + const mocks = { + uuid: { v4: () => ids.shift() }, + './configStore': { useConfigStore: { getState: () => configState } }, + './subagentStore': { useSubagentStore: { getState: () => ({ clearTodos: () => {} }) } }, + '../services/reactAgent': { runReactAgent }, + '../services/memoryService': { + consolidateSessionMemory: async () => { + memoryCalls += 1; + }, + }, + '../services/dbAPI': { dbAPI, isElectron: () => true }, + './suggestionsStore': { + useSuggestionsStore: { getState: () => ({ setSuggestions: () => {} }) }, + parseSuggestionsFromMessage: () => [], + }, + '../i18n': { getLocale: () => ({ sidebar: { newConversationTitle: 'New conversation' } }) }, + }; + + Module._load = function loadWithMocks(request, parent, isMain) { + if (parent?.filename === chatStorePath && Object.hasOwn(mocks, request)) { + return mocks[request]; + } + return originalLoad.call(this, request, parent, isMain); + }; + + try { + delete require.cache[chatStorePath]; + const { useChatStore } = require(chatStorePath); + await run({ + useChatStore, + deletedConversationIds, + getMemoryCalls: () => memoryCalls, + }); + } finally { + delete require.cache[chatStorePath]; + Module._load = originalLoad; + if (originalTsLoader) require.extensions['.ts'] = originalTsLoader; + else delete require.extensions['.ts']; + global.localStorage = originalLocalStorage; + } +} + +test('deleting a background run cancels only that conversation', async () => { + const signals = new Map(); + const settledRuns = []; + + async function* runReactAgent(messages, _model, signal) { + const label = messages.at(-1)?.content; + signals.set(label, signal); + if (!signal.aborted) { + await new Promise((resolve) => signal.addEventListener('abort', resolve, { once: true })); + } + settledRuns.push(label); + } + + await withChatStore( + { + ids: ['conv-a', 'user-a', 'assistant-a', 'conv-b', 'user-b', 'assistant-b'], + runReactAgent, + }, + async ({ useChatStore, deletedConversationIds, getMemoryCalls }) => { + const runA = useChatStore.getState().sendMessage('A', []); + await waitFor(() => signals.has('A'), 'conversation A did not start'); + + assert.equal(useChatStore.getState().createConversation(), 'conv-b'); + const runB = useChatStore.getState().sendMessage('B', []); + await waitFor(() => signals.has('B'), 'conversation B did not start'); + + useChatStore.getState().deleteConversation('conv-a'); + const afterDelete = useChatStore.getState(); + const deletionSnapshot = { + aAborted: signals.get('A').aborted, + bAborted: signals.get('B').aborted, + conversations: afterDelete.conversations.map((conversation) => conversation.id), + activeConversationId: afterDelete.activeConversationId, + isStreaming: afterDelete.isStreaming, + streamingConversationIds: afterDelete.streamingConversationIds, + }; + + useChatStore.getState().stopStreaming(); + useChatStore.getState().setActiveConversation('conv-a'); + useChatStore.getState().stopStreaming(); + useChatStore.getState().setActiveConversation('conv-b'); + await Promise.all([runA, runB]); + + assert.deepEqual(deletionSnapshot, { + aAborted: true, + bAborted: false, + conversations: ['conv-b'], + activeConversationId: 'conv-b', + isStreaming: true, + streamingConversationIds: ['conv-b'], + }); + assert.deepEqual(deletedConversationIds, ['conv-a']); + assert.deepEqual(new Set(settledRuns), new Set(['A', 'B'])); + assert.equal(getMemoryCalls(), 0); + + const finalState = useChatStore.getState(); + assert.deepEqual(finalState.conversations.map((conversation) => conversation.id), ['conv-b']); + assert.deepEqual(finalState.streamingConversationIds, []); + assert.equal(finalState.isStreaming, false); + }, + ); +}); + +test('deleting an ask_user run resolves it with the abort sentinel', async () => { + let signalSeen; + let answerSeen; + + async function* runReactAgent(_messages, _model, signal, onAskUser) { + signalSeen = signal; + answerSeen = await onAskUser('ask-1', 'Choose one'); + } + + await withChatStore( + { + ids: ['conv-question', 'user-question', 'assistant-question'], + runReactAgent, + }, + async ({ useChatStore, getMemoryCalls }) => { + const sendPromise = useChatStore.getState().sendMessage('question', []); + await waitFor(() => useChatStore.getState().pendingQuestion != null, 'ask_user did not become pending'); + + useChatStore.getState().deleteConversation('conv-question'); + const settledAfterDelete = await settlesWithin(sendPromise, 75); + const afterDelete = useChatStore.getState(); + const deletionSnapshot = { + signalAborted: signalSeen.aborted, + settledAfterDelete, + answerSeen, + pendingQuestion: afterDelete.pendingQuestion, + isStreaming: afterDelete.isStreaming, + streamingConversationIds: afterDelete.streamingConversationIds, + }; + + if (!settledAfterDelete) { + useChatStore.getState().answerQuestion('__TEST_CLEANUP__'); + useChatStore.getState().setActiveConversation('conv-question'); + useChatStore.getState().stopStreaming(); + await sendPromise; + } + + assert.deepEqual(deletionSnapshot, { + signalAborted: true, + settledAfterDelete: true, + answerSeen: '__ABORT__', + pendingQuestion: null, + isStreaming: false, + streamingConversationIds: [], + }); + assert.equal(getMemoryCalls(), 0); + }, + ); +}); From 3267348a0ff8d631a472209677ba3c97201ef88d Mon Sep 17 00:00:00 2001 From: AI Systems Lab Date: Wed, 22 Jul 2026 23:56:48 +0800 Subject: [PATCH 2/6] test: reproduce deletion before agent startup --- tests/chat-store-delete-cancellation.test.cjs | 47 ++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/tests/chat-store-delete-cancellation.test.cjs b/tests/chat-store-delete-cancellation.test.cjs index 0be3b1e..018cd16 100644 --- a/tests/chat-store-delete-cancellation.test.cjs +++ b/tests/chat-store-delete-cancellation.test.cjs @@ -29,7 +29,7 @@ async function settlesWithin(promise, timeoutMs) { } } -async function withChatStore({ ids, runReactAgent }, run) { +async function withChatStore({ ids, runReactAgent, upsertMessage = async () => {} }, run) { const originalTsLoader = require.extensions['.ts']; const originalLoad = Module._load; const originalLocalStorage = global.localStorage; @@ -76,7 +76,7 @@ async function withChatStore({ ids, runReactAgent }, run) { getConfig: async () => null, setConfig: async () => {}, upsertConversation: async () => {}, - upsertMessage: async () => {}, + upsertMessage, updateConversationTitle: async () => {}, deleteConversation: async (id) => { deletedConversationIds.push(id); @@ -238,3 +238,46 @@ test('deleting an ask_user run resolves it with the abort sentinel', async () => }, ); }); + +test('deleting before agent startup prevents a late run', async () => { + let releaseUserPersistence; + let userPersistenceStarted = false; + let agentCalls = 0; + const userPersistenceGate = new Promise((resolve) => { + releaseUserPersistence = resolve; + }); + + const runReactAgent = () => { + agentCalls += 1; + return (async function* emptyAgentRun() {})(); + }; + + await withChatStore( + { + ids: ['conv-before-start', 'user-before-start', 'assistant-before-start'], + runReactAgent, + upsertMessage: async (message) => { + if (message.id === 'user-before-start') { + userPersistenceStarted = true; + await userPersistenceGate; + } + }, + }, + async ({ useChatStore, deletedConversationIds, getMemoryCalls }) => { + const sendPromise = useChatStore.getState().sendMessage('go', []); + await waitFor(() => userPersistenceStarted, 'user persistence did not start'); + + useChatStore.getState().deleteConversation('conv-before-start'); + releaseUserPersistence(); + await sendPromise; + + const state = useChatStore.getState(); + assert.equal(agentCalls, 0); + assert.equal(getMemoryCalls(), 0); + assert.deepEqual(deletedConversationIds, ['conv-before-start']); + assert.deepEqual(state.conversations, []); + assert.deepEqual(state.streamingConversationIds, []); + assert.equal(state.isStreaming, false); + }, + ); +}); From 8e0120d1b07dbbab309635f153e45dfd314b89b5 Mon Sep 17 00:00:00 2001 From: AI Systems Lab Date: Wed, 22 Jul 2026 23:57:20 +0800 Subject: [PATCH 3/6] fix: cancel active chat runs on deletion --- src/renderer/stores/chatStore.ts | 41 ++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/src/renderer/stores/chatStore.ts b/src/renderer/stores/chatStore.ts index 2695d5c..25032e2 100644 --- a/src/renderer/stores/chatStore.ts +++ b/src/renderer/stores/chatStore.ts @@ -165,6 +165,15 @@ const abortControllers = new Map(); /** Per-conversation ask-user resolvers */ const askUserResolvers = new Map void>(); +function cancelConversationRun(convId: string): void { + const resolver = askUserResolvers.get(convId); + if (resolver) { + resolver('__ABORT__'); + askUserResolvers.delete(convId); + } + abortControllers.get(convId)?.abort(); +} + async function runAgentWithModel( convId: string, assistantMsgId: string, @@ -505,16 +514,28 @@ export const useChatStore = create((set, get) => ({ }, deleteConversation: (id) => { + cancelConversationRun(id); set((s) => { const conversations = s.conversations.filter((c) => c.id !== id); const activeConversationId = s.activeConversationId === id ? (conversations[0]?.id ?? null) : s.activeConversationId; + const streamingConversationIds = s.streamingConversationIds.filter((convId) => convId !== id); + const isStreaming = activeConversationId + ? streamingConversationIds.includes(activeConversationId) + : false; if (activeConversationId) { dbAPI.setConfig('activeConversationId', activeConversationId).catch(console.error); } - return { conversations, activeConversationId }; + return { + conversations, + activeConversationId, + isStreaming, + streamingConversationIds, + pendingQuestion: s.pendingQuestion?.convId === id ? null : s.pendingQuestion, + agentTurnInfo: s.agentTurnInfo?.convId === id ? null : s.agentTurnInfo, + }; }); dbAPI.deleteConversation(id).catch(console.error); }, @@ -594,7 +615,8 @@ export const useChatStore = create((set, get) => ({ } const updatedConv = get().conversations.find((c) => c.id === convId); - const messagesForAgent = updatedConv ? updatedConv.messages.slice(0, -1) : [userMsg]; + if (!updatedConv) return; + const messagesForAgent = updatedConv.messages.slice(0, -1); await runAgentWithModel(convId!, assistantMsg.id, messagesForAgent, set, get); }, @@ -690,17 +712,12 @@ export const useChatStore = create((set, get) => ({ stopStreaming: () => { const { activeConversationId, pendingQuestion } = get(); - // If there's a pending AG2U question, resolve it with __ABORT__ so the agent loop unblocks - if (activeConversationId && pendingQuestion?.convId === activeConversationId) { - const resolver = askUserResolvers.get(activeConversationId); - if (resolver) { - resolver('__ABORT__'); - askUserResolvers.delete(activeConversationId); - } - set({ pendingQuestion: null }); - } if (activeConversationId) { - abortControllers.get(activeConversationId)?.abort(); + cancelConversationRun(activeConversationId); + } + // Clear the active conversation's pending AG2UI question after unblocking its agent. + if (pendingQuestion?.convId === activeConversationId) { + set({ pendingQuestion: null }); } // isStreaming/streamingConversationIds will be cleaned up in runAgentWithModel's finally block }, From cecff7d71c9f65bf8bd4c240013eee83c7eb19c7 Mon Sep 17 00:00:00 2001 From: AI Systems Lab Date: Thu, 23 Jul 2026 00:01:16 +0800 Subject: [PATCH 4/6] test: allow bounded chat deletion settlement --- tests/chat-store-delete-cancellation.test.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/chat-store-delete-cancellation.test.cjs b/tests/chat-store-delete-cancellation.test.cjs index 018cd16..3d9ef1b 100644 --- a/tests/chat-store-delete-cancellation.test.cjs +++ b/tests/chat-store-delete-cancellation.test.cjs @@ -208,7 +208,7 @@ test('deleting an ask_user run resolves it with the abort sentinel', async () => await waitFor(() => useChatStore.getState().pendingQuestion != null, 'ask_user did not become pending'); useChatStore.getState().deleteConversation('conv-question'); - const settledAfterDelete = await settlesWithin(sendPromise, 75); + const settledAfterDelete = await settlesWithin(sendPromise, 500); const afterDelete = useChatStore.getState(); const deletionSnapshot = { signalAborted: signalSeen.aborted, From d1d34e982f1d7f6a79f4ec898ca306c86195a109 Mon Sep 17 00:00:00 2001 From: AI Systems Lab Date: Thu, 23 Jul 2026 00:04:55 +0800 Subject: [PATCH 5/6] test: require clearing deleted active chat pointer --- tests/chat-store-delete-cancellation.test.cjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/chat-store-delete-cancellation.test.cjs b/tests/chat-store-delete-cancellation.test.cjs index 3d9ef1b..4c093fa 100644 --- a/tests/chat-store-delete-cancellation.test.cjs +++ b/tests/chat-store-delete-cancellation.test.cjs @@ -34,6 +34,7 @@ async function withChatStore({ ids, runReactAgent, upsertMessage = async () => { const originalLoad = Module._load; const originalLocalStorage = global.localStorage; const deletedConversationIds = []; + const configWrites = []; let memoryCalls = 0; global.localStorage = { @@ -74,7 +75,9 @@ async function withChatStore({ ids, runReactAgent, upsertMessage = async () => { getConversations: async () => [], getMessages: async () => [], getConfig: async () => null, - setConfig: async () => {}, + setConfig: async (key, value) => { + configWrites.push([key, value]); + }, upsertConversation: async () => {}, upsertMessage, updateConversationTitle: async () => {}, @@ -114,6 +117,7 @@ async function withChatStore({ ids, runReactAgent, upsertMessage = async () => { const { useChatStore } = require(chatStorePath); await run({ useChatStore, + configWrites, deletedConversationIds, getMemoryCalls: () => memoryCalls, }); @@ -263,7 +267,7 @@ test('deleting before agent startup prevents a late run', async () => { } }, }, - async ({ useChatStore, deletedConversationIds, getMemoryCalls }) => { + async ({ useChatStore, configWrites, deletedConversationIds, getMemoryCalls }) => { const sendPromise = useChatStore.getState().sendMessage('go', []); await waitFor(() => userPersistenceStarted, 'user persistence did not start'); @@ -275,6 +279,7 @@ test('deleting before agent startup prevents a late run', async () => { assert.equal(agentCalls, 0); assert.equal(getMemoryCalls(), 0); assert.deepEqual(deletedConversationIds, ['conv-before-start']); + assert.deepEqual(configWrites.at(-1), ['activeConversationId', '']); assert.deepEqual(state.conversations, []); assert.deepEqual(state.streamingConversationIds, []); assert.equal(state.isStreaming, false); From 59a581e79a64d6a8b32092dc651babf57f4b6010 Mon Sep 17 00:00:00 2001 From: AI Systems Lab Date: Thu, 23 Jul 2026 00:05:04 +0800 Subject: [PATCH 6/6] fix: clear persisted active chat on deletion --- src/renderer/stores/chatStore.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/renderer/stores/chatStore.ts b/src/renderer/stores/chatStore.ts index 25032e2..45f6bbb 100644 --- a/src/renderer/stores/chatStore.ts +++ b/src/renderer/stores/chatStore.ts @@ -525,9 +525,7 @@ export const useChatStore = create((set, get) => ({ const isStreaming = activeConversationId ? streamingConversationIds.includes(activeConversationId) : false; - if (activeConversationId) { - dbAPI.setConfig('activeConversationId', activeConversationId).catch(console.error); - } + dbAPI.setConfig('activeConversationId', activeConversationId ?? '').catch(console.error); return { conversations, activeConversationId,