diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 0692edec..b903a265 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -3982,6 +3982,67 @@ describe('chatStore history mapping', () => { }) }) + it('replaces orphan thinking with authoritative history when a reconnected turn completes', async () => { + vi.mocked(sessionsApi.getMessages).mockClear() + vi.mocked(sessionsApi.getMessages).mockResolvedValue({ + messages: [ + { + id: 'persisted-user', + type: 'user', + timestamp: '2026-07-11T00:00:00.000Z', + content: 'Finish the foreground task', + }, + { + id: 'persisted-assistant', + type: 'assistant', + timestamp: '2026-07-11T00:00:01.000Z', + content: [{ type: 'text', text: 'Foreground task finished.' }], + }, + ], + }) + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession({ + chatState: 'idle', + messages: [], + }), + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'session_state', + turnState: 'running', + }) + await vi.waitFor(() => { + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(2) + }) + + // The task_notification that should suppress this output arrived while + // the renderer was disconnected, so only the late follow-up is observed. + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'thinking', + text: 'orphan background follow-up thinking', + }) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toContainEqual( + expect.objectContaining({ + type: 'thinking', + content: 'orphan background follow-up thinking', + }), + ) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'message_complete', + usage: { input_tokens: 5, output_tokens: 8 }, + }) + + await vi.waitFor(() => { + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).not.toContainEqual( + expect.objectContaining({ type: 'thinking' }), + ) + }) + expect(sessionsApi.getMessages).toHaveBeenCalledTimes(2) + }) + it('keeps the tab running for background agents when reconnect reconciliation finds the foreground idle', () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 1a3e54ec..bfabed47 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -129,6 +129,7 @@ export type PerSessionState = { backgroundAgentTasks?: Record stoppingBackgroundTaskIds?: Record suppressNextTaskNotificationResponse?: boolean + replaceHistoryOnCompletion?: boolean activeGoal?: ActiveGoalState | null elapsedTimer: ReturnType | null composerPrefill?: { @@ -169,6 +170,7 @@ const DEFAULT_SESSION_STATE: PerSessionState = { backgroundAgentTasks: {}, stoppingBackgroundTaskIds: {}, suppressNextTaskNotificationResponse: false, + replaceHistoryOnCompletion: false, activeGoal: null, elapsedTimer: null, composerPrefill: null, @@ -883,6 +885,24 @@ function refreshCompletedTranscriptHistory( }) } +function reconcileCompletedTranscriptHistory( + get: () => ChatStore, + sessionId: string, + replaceHistory: boolean, +): void { + if (!replaceHistory) { + refreshCompletedTranscriptHistory(get, sessionId) + return + } + + const session = get().sessions[sessionId] + if (!session) return + void get().reloadHistory(sessionId, { + messages: session.messages, + backgroundAgentTasks: session.backgroundAgentTasks, + }) +} + function normalizeMemoryEventFiles(data: unknown): MemoryEventFile[] { if (!data || typeof data !== 'object') return [] const writtenPaths = (data as { writtenPaths?: unknown }).writtenPaths @@ -1199,6 +1219,7 @@ export const useChatStore = create((set, get) => ({ chatState: 'thinking', elapsedSeconds: 0, suppressNextTaskNotificationResponse: false, + replaceHistoryOnCompletion: false, streamingText: '', streamingResponseChars: 0, statusVerb: isMemberSession ? '' : randomSpinnerVerb(), @@ -1669,6 +1690,7 @@ export const useChatStore = create((set, get) => ({ .filter((message) => message.id !== messageId), ...(pendingText.trim() ? { streamingText: '' } : {}), suppressNextTaskNotificationResponse: false, + replaceHistoryOnCompletion: false, } }), })) @@ -1692,6 +1714,7 @@ export const useChatStore = create((set, get) => ({ apiRetry: null, streamingFallback: null, suppressNextTaskNotificationResponse: false, + replaceHistoryOnCompletion: false, queuedUserMessages: [], })) })) }, @@ -1765,6 +1788,7 @@ export const useChatStore = create((set, get) => ({ apiRetry: null, streamingFallback: null, statusVerb: '', + replaceHistoryOnCompletion: true, } }) useTabStore.getState().updateTabStatus(sessionId, 'running') @@ -2388,9 +2412,14 @@ export const useChatStore = create((set, get) => ({ streamingText: '', streamingToolInput: '', suppressNextTaskNotificationResponse: false, + replaceHistoryOnCompletion: false, })) useTabStore.getState().updateTabStatus(sessionId, hasRunningBackgroundAgents ? 'running' : 'idle') - refreshCompletedTranscriptHistory(get, sessionId) + reconcileCompletedTranscriptHistory( + get, + sessionId, + session.replaceHistoryOnCompletion === true, + ) for (const queuedMessage of get().sessions[sessionId]?.queuedUserMessages ?? []) { get().sendQueuedUserMessage(sessionId, queuedMessage.id) } @@ -2425,6 +2454,7 @@ export const useChatStore = create((set, get) => ({ elapsedTimer: null, apiRetry: null, streamingFallback: null, + replaceHistoryOnCompletion: false, })) useTabStore.getState().updateTabStatus(sessionId, hasRunningBackgroundAgents ? 'running' : 'idle') const notification = wasAgentRunning && appendedCompletionMessage @@ -2439,7 +2469,11 @@ export const useChatStore = create((set, get) => ({ target: { type: 'session', sessionId }, }) } - refreshCompletedTranscriptHistory(get, sessionId) + reconcileCompletedTranscriptHistory( + get, + sessionId, + session.replaceHistoryOnCompletion === true, + ) for (const queuedMessage of get().sessions[sessionId]?.queuedUserMessages ?? []) { get().sendQueuedUserMessage(sessionId, queuedMessage.id) } @@ -2457,6 +2491,7 @@ export const useChatStore = create((set, get) => ({ ...(pendingText.trim() ? { streamingText: '' } : {}), activeThinkingId: null, suppressNextTaskNotificationResponse: false, + replaceHistoryOnCompletion: false, } }) break