fix(desktop): reconcile orphan thinking after reconnect #1002

This commit is contained in:
程序员阿江(Relakkes) 2026-07-11 16:57:26 +08:00
parent d9db7caea0
commit 78ef57d63d
2 changed files with 98 additions and 2 deletions

View File

@ -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: {

View File

@ -129,6 +129,7 @@ export type PerSessionState = {
backgroundAgentTasks?: Record<string, BackgroundAgentTask>
stoppingBackgroundTaskIds?: Record<string, boolean>
suppressNextTaskNotificationResponse?: boolean
replaceHistoryOnCompletion?: boolean
activeGoal?: ActiveGoalState | null
elapsedTimer: ReturnType<typeof setInterval> | 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<ChatStore>((set, get) => ({
chatState: 'thinking',
elapsedSeconds: 0,
suppressNextTaskNotificationResponse: false,
replaceHistoryOnCompletion: false,
streamingText: '',
streamingResponseChars: 0,
statusVerb: isMemberSession ? '' : randomSpinnerVerb(),
@ -1669,6 +1690,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
.filter((message) => message.id !== messageId),
...(pendingText.trim() ? { streamingText: '' } : {}),
suppressNextTaskNotificationResponse: false,
replaceHistoryOnCompletion: false,
}
}),
}))
@ -1692,6 +1714,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
apiRetry: null,
streamingFallback: null,
suppressNextTaskNotificationResponse: false,
replaceHistoryOnCompletion: false,
queuedUserMessages: [],
})) }))
},
@ -1765,6 +1788,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
apiRetry: null,
streamingFallback: null,
statusVerb: '',
replaceHistoryOnCompletion: true,
}
})
useTabStore.getState().updateTabStatus(sessionId, 'running')
@ -2388,9 +2412,14 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((set, get) => ({
...(pendingText.trim() ? { streamingText: '' } : {}),
activeThinkingId: null,
suppressNextTaskNotificationResponse: false,
replaceHistoryOnCompletion: false,
}
})
break