From 2e942f50cd2d2aff2b96af1c348bf381d1c654e0 Mon Sep 17 00:00:00 2001 From: Raing5Days Date: Sat, 7 Nov 2026 23:42:54 +0800 Subject: [PATCH] fix(flow-chat): dismiss stale ACP sessions on archive --- .../flow-chat-manager/SessionModule.test.ts | 53 +++++++++++++++++++ .../local/LocalSessionDriver.ts | 24 ++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts index 514088c4b6..ca9d1eb4cc 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts @@ -1695,6 +1695,59 @@ describe('SessionModule historical session coordination', () => { expect(flowChatStore.getState().sessions.has(target)).toBe(false); }); + it.each(['local', 'peer-archive'])('removes an empty stale ACP catalog entry after the %s host confirms it is absent', async surface => { + activateSurface(surface); + const session = createSession({ config: { agentType: 'acp:qoder' }, mode: 'acp:qoder', persistedTurnCount: 0 }); + const { context, flowChatStore } = createContext(session, { activeSessionId: session.sessionId }); + sessionApiMocks.archiveSession.mockRejectedValueOnce(new Error('Session metadata not found')); + sessionApiMocks.loadSessionMetadata.mockResolvedValueOnce(null); + await archiveChatSession(context, session.sessionId); + expect(sessionApiMocks.loadSessionMetadata).toHaveBeenCalledWith(session.sessionId, 'workspace-1'); + expect(flowChatStore.getState().sessions.has(session.sessionId)).toBe(false); + }); + + it.each(['exists', 'offline', 'has-turns', 'unknown-count'])('retains an ACP session when the missing-record check is unsafe: %s', async scenario => { + const session = createSession({ + config: { agentType: 'acp:qoder' }, mode: 'acp:qoder', + persistedTurnCount: scenario === 'unknown-count' ? undefined : scenario === 'has-turns' ? 1 : 0, + }); + const { context, flowChatStore } = createContext(session); + sessionApiMocks.archiveSession.mockRejectedValueOnce(new Error('archive failed')); + if (scenario === 'exists') sessionApiMocks.loadSessionMetadata.mockResolvedValueOnce({ sessionId: session.sessionId }); + if (scenario === 'offline') sessionApiMocks.loadSessionMetadata.mockRejectedValueOnce(new Error('Host offline')); + await expect(archiveChatSession(context, session.sessionId)).rejects.toThrow('archive failed'); + expect(flowChatStore.getState().sessions.get(session.sessionId)).toBe(session); + }); + + it('retains an ACP session updated while the missing-record check is pending', async () => { + const session = createSession({ config: { agentType: 'acp:qoder' }, mode: 'acp:qoder', persistedTurnCount: 0 }); + const { context, flowChatStore } = createContext(session); + const pending = createDeferred(); + sessionApiMocks.archiveSession.mockRejectedValueOnce(new Error('archive failed')); + sessionApiMocks.loadSessionMetadata.mockReturnValueOnce(pending.promise); + const archive = archiveChatSession(context, session.sessionId); + await vi.waitFor(() => expect(sessionApiMocks.loadSessionMetadata).toHaveBeenCalled()); + const updated = { ...session, title: 'Updated' }; + flowChatStore.setState((state: any) => ({ ...state, sessions: new Map(state.sessions).set(session.sessionId, updated) })); + pending.resolve(null); + await expect(archive).rejects.toThrow('archive failed'); + expect(flowChatStore.getState().sessions.get(session.sessionId)).toBe(updated); + }); + + it('does not remove an ACP projection when the surface changes during the missing-record check', async () => { + const session = createSession({ config: { agentType: 'acp:qoder' }, mode: 'acp:qoder', persistedTurnCount: 0 }); + const { context, flowChatStore } = createContext(session); + const pending = createDeferred(); + sessionApiMocks.archiveSession.mockRejectedValueOnce(new Error('archive failed')); + sessionApiMocks.loadSessionMetadata.mockReturnValueOnce(pending.promise); + const archive = archiveChatSession(context, session.sessionId); + await vi.waitFor(() => expect(sessionApiMocks.loadSessionMetadata).toHaveBeenCalled()); + activateSurface('peer-other'); + pending.resolve(null); + await expect(archive).rejects.toMatchObject({ isSurfaceChangedError: true }); + expect(flowChatStore.removeSession).not.toHaveBeenCalled(); + }); + it('retains the session and its runtime state when archive fails', async () => { const session = createSession(); const { context, flowChatStore, processingManager } = createContext(session, { activeSessionId: session.sessionId }); diff --git a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts index 43640f3843..5c328ecf5e 100644 --- a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts +++ b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts @@ -2,7 +2,7 @@ import { readModelSelections } from '@/infrastructure/config/services/modelSelec import { readProjectDefaults } from '@/infrastructure/config/services/ProjectDefaultsService'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { supportsModelPools } from '@/infrastructure/api/service-api/ModelPoolAPI'; -import { acpClientIdFromAgentType } from '../../utils/acpSession'; +import { acpClientIdFromAgentType, isAcpFlowSession } from '../../utils/acpSession'; import { hostQueueSupported, hostDialogQueue, queueImageAttachments } from '../../services/hostDialogQueue'; import { promoteAcceptedHostMessage } from '../../services/hostQueueSubmission'; import { requireSessionOwningWorkspaceId, sessionOwningWorkspaceId } from '../../utils/sessionOrdering'; @@ -263,7 +263,27 @@ export const localSessionDriver: SessionDriver = { } if (session.draft?.phase !== 'editing') { - await sessionAPI.archiveSession(sessionId, requireSessionOwningWorkspaceId(session)); + const workspaceId = requireSessionOwningWorkspaceId(session); + try { + await sessionAPI.archiveSession(sessionId, workspaceId); + } catch (error) { + scope.assertCurrent('verify failed session archive'); + // A failed ACP startup may have removed its provisional host record + // after a catalog read projected it. Only discard an untouched empty + // projection after the same host confirms the record is absent. + if (!isAcpFlowSession(session) || session.historyState !== 'metadata-only' + || session.persistedTurnCount !== 0 || session.dialogTurns.length !== 0 + || removal.removedSessionIds.length !== 1) throw error; + let metadata; + try { + metadata = await sessionAPI.loadSessionMetadata(sessionId, workspaceId); + } catch { + scope.assertCurrent('verify failed session archive'); + throw error; + } + scope.assertCurrent('verify failed session archive'); + if (metadata !== null || context.flowChatStore.getState().sessions.get(sessionId) !== session) throw error; + } scope.assertCurrent('complete session archive'); }