From e2e302e1b37e23cb50547f2a519d8e0763f8733a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 16 Aug 2026 01:40:56 +0800 Subject: [PATCH] fix(desktop): reconcile stale H5 task state #1214 --- desktop/src/stores/chatStore.test.ts | 43 ++++++++++++++++++ desktop/src/stores/chatStore.ts | 44 ++++++++++++++++++- desktop/src/types/chat.ts | 6 ++- src/server/__tests__/conversations.test.ts | 1 + .../__tests__/websocket-handler.test.ts | 32 +++++++++++++- src/server/ws/events.ts | 6 ++- src/server/ws/handler.ts | 3 ++ 7 files changed, 130 insertions(+), 5 deletions(-) diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 4f700377..10fe1a94 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -6757,6 +6757,7 @@ describe('chatStore history mapping', () => { useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'session_state', turnState: 'idle', + activeBackgroundTaskIds: [], }) await vi.waitFor(() => { @@ -6797,6 +6798,7 @@ describe('chatStore history mapping', () => { useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'session_state', turnState: 'idle', + activeBackgroundTaskIds: ['agent-task-1'], }) await vi.waitFor(() => { @@ -6809,6 +6811,47 @@ describe('chatStore history mapping', () => { ).toBe('running') }) + it('settles a stale H5 SubAgent when reconnect says no background task is still active', async () => { + vi.mocked(sessionsApi.getMessages).mockClear() + vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({ + messages: [], + taskNotifications: [], + }) + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession({ + chatState: 'idle', + backgroundAgentTasks: { + 'agent-task-missed-terminal': { + taskId: 'agent-task-missed-terminal', + toolUseId: 'agent-tool-missed-terminal', + status: 'running', + taskType: 'local_agent', + description: 'Finished while H5 was offline', + startedAt: 1, + updatedAt: 2, + }, + }, + }), + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'session_state', + turnState: 'idle', + activeBackgroundTaskIds: [], + }) + + await vi.waitFor(() => { + expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID) + expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'idle') + }) + expect( + useChatStore.getState().sessions[TEST_SESSION_ID] + ?.backgroundAgentTasks?.['agent-task-missed-terminal']?.status, + ).toBe('completed') + }) + it('does not let an older persisted terminal overwrite a new lifecycle with the same Agent id', async () => { vi.mocked(sessionsApi.getMessages).mockClear() vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({ diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 4f025285..54614600 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -2756,9 +2756,23 @@ export const useChatStore = create((set, get) => ({ break case 'session_state': { - const session = get().sessions[sessionId] + let session = get().sessions[sessionId] if (!session) break + let settledInactiveBackgroundTasks = false + if (msg.activeBackgroundTaskIds !== undefined) { + const reconciled = reconcileBackgroundAgentTasksWithActiveSnapshot( + session.backgroundAgentTasks ?? {}, + msg.activeBackgroundTaskIds, + Date.now(), + ) + settledInactiveBackgroundTasks = reconciled.changed + if (reconciled.changed) { + update(() => ({ backgroundAgentTasks: reconciled.tasks })) + session = { ...session, backgroundAgentTasks: reconciled.tasks } + } + } + if (msg.turnState === 'running') { // Raw deltas are not replayable across a socket gap. Discard the // uncommitted attempt instead of appending new deltas (or a missed @@ -2807,7 +2821,14 @@ export const useChatStore = create((set, get) => ({ } if (session.chatState === 'idle') { + if (settledInactiveBackgroundTasks) { + useTabStore.getState().updateTabStatus( + sessionId, + hasRunningBackgroundTasks(session.backgroundAgentTasks) ? 'running' : 'idle', + ) + } if ( + settledInactiveBackgroundTasks || hasRunningSubagentTasks(session.backgroundAgentTasks) || session.stopAllSubagentsRequested ) { @@ -4523,6 +4544,27 @@ function upsertBackgroundAgentTask( } } +function reconcileBackgroundAgentTasksWithActiveSnapshot( + current: Record, + activeTaskIds: string[], + now: number, +): { tasks: Record; changed: boolean } { + const active = new Set(activeTaskIds) + let changed = false + const tasks = Object.fromEntries( + Object.entries(current).map(([key, task]) => { + const remainsActive = active.has(task.taskId) || + Boolean(task.toolUseId && active.has(task.toolUseId)) + if (task.status !== 'running' || remainsActive) return [key, task] + changed = true + // The snapshot proves only that the task settled. History reconciliation + // below replaces this fallback with failed/stopped when that bookend exists. + return [key, { ...task, status: 'completed' as const, updatedAt: now }] + }), + ) + return { tasks: changed ? tasks : current, changed } +} + function hasTerminalTaskPayloadChanged( existing: BackgroundAgentTask, event: Partial & Pick, diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index 91de385d..9d2c7383 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -83,7 +83,11 @@ export type UIAttachment = { export type ServerMessage = | { type: 'connected'; sessionId: string } - | { type: 'session_state'; turnState: 'running' | 'idle' } + | { + type: 'session_state' + turnState: 'running' | 'idle' + activeBackgroundTaskIds?: string[] + } | { type: 'agent_run_event' runAgentId: string diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index ad95066e..22f8e441 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -5936,6 +5936,7 @@ describe('WebSocket Chat Integration', () => { expect(reconnectMessages).toContainEqual({ type: 'session_state', turnState: 'idle', + activeBackgroundTaskIds: [], }) expect(reconnectMessages.some((message) => message.type === 'message_complete')).toBe(false) } finally { diff --git a/src/server/__tests__/websocket-handler.test.ts b/src/server/__tests__/websocket-handler.test.ts index 52f019cb..d2b80b78 100644 --- a/src/server/__tests__/websocket-handler.test.ts +++ b/src/server/__tests__/websocket-handler.test.ts @@ -569,7 +569,7 @@ describe('WebSocket handler session isolation', () => { expect(ws.sent.map((payload) => JSON.parse(payload))).toEqual([ { type: 'status', state: 'idle' }, - { type: 'session_state', turnState: 'idle' }, + { type: 'session_state', turnState: 'idle', activeBackgroundTaskIds: [] }, ]) }) @@ -659,7 +659,11 @@ describe('WebSocket handler session isolation', () => { type: 'message_complete', usage: { input_tokens: 12, output_tokens: 3 }, }, - { type: 'session_state', turnState: 'idle' }, + { + type: 'session_state', + turnState: 'idle', + activeBackgroundTaskIds: ['background-task-1'], + }, ]) }) @@ -972,6 +976,7 @@ describe('WebSocket handler session isolation', () => { expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({ type: 'session_state', turnState: 'idle', + activeBackgroundTaskIds: ['agent-task-1'], }) __markActiveTurnForTests(sessionId) @@ -994,6 +999,24 @@ describe('WebSocket handler session isolation', () => { expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({ type: 'session_state', turnState: 'running', + activeBackgroundTaskIds: ['agent-task-1'], + }) + + outputCallbacks[0]?.({ + type: 'system', + subtype: 'task_notification', + task_id: 'agent-task-1', + tool_use_id: 'agent-tool-1', + status: 'completed', + summary: 'Playwright checks passed', + task_type: 'local_agent', + }) + ws.sent.length = 0 + handleWebSocket.message(ws, JSON.stringify({ type: 'sync_state' })) + expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({ + type: 'session_state', + turnState: 'running', + activeBackgroundTaskIds: [], }) }) @@ -2948,6 +2971,7 @@ describe('WebSocket handler session isolation', () => { expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({ type: 'session_state', turnState: 'idle', + activeBackgroundTaskIds: [], }) }) @@ -4471,6 +4495,7 @@ describe('WebSocket handler session isolation', () => { expect(runningSocket.sent.map((payload) => JSON.parse(payload))).toContainEqual({ type: 'session_state', turnState: 'running', + activeBackgroundTaskIds: [], }) const idleSessionId = `sync-idle-${crypto.randomUUID()}` @@ -4482,6 +4507,7 @@ describe('WebSocket handler session isolation', () => { expect(idleSocket.sent.map((payload) => JSON.parse(payload))).toContainEqual({ type: 'session_state', turnState: 'idle', + activeBackgroundTaskIds: [], }) }) @@ -4515,6 +4541,7 @@ describe('WebSocket handler session isolation', () => { expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({ type: 'session_state', turnState: 'idle', + activeBackgroundTaskIds: [], }) }) @@ -4558,6 +4585,7 @@ describe('WebSocket handler session isolation', () => { expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({ type: 'session_state', turnState: 'running', + activeBackgroundTaskIds: [], }) }) }) diff --git a/src/server/ws/events.ts b/src/server/ws/events.ts index daa4d118..9b9ddf5e 100644 --- a/src/server/ws/events.ts +++ b/src/server/ws/events.ts @@ -57,7 +57,11 @@ export const RUNTIME_CONFIG_APPLIED_EVENT = 'runtime_config_applied' as const export type ServerMessage = | { type: 'connected'; sessionId: string } - | { type: 'session_state'; turnState: 'running' | 'idle' } + | { + type: 'session_state' + turnState: 'running' | 'idle' + activeBackgroundTaskIds?: string[] + } | { type: 'agent_run_event' runAgentId: string diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 6a55cdc1..6100b1b5 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -676,6 +676,9 @@ export const handleWebSocket = { turnState: hasLiveUserTurnForClient(ws.data.sessionId) ? 'running' : 'idle', + activeBackgroundTaskIds: [ + ...(activeBackgroundTaskIds.get(ws.data.sessionId) ?? []), + ], }) break