diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 5896f703..0d794e52 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -3437,6 +3437,64 @@ describe('chatStore history mapping', () => { ])) }) + it('suppresses assistant output for a task-notification-only follow-up turn', () => { + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession({ + chatState: 'idle', + elapsedSeconds: 718, + }), + }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'system_notification', + subtype: 'task_notification', + data: { + task_id: 'butp7dybq', + tool_use_id: 'toolu_bdrk_01SvH8CKoRoBcv1T1Gr9jWT3', + status: 'completed', + summary: 'Background command "1000 客户端压测并采样服务端内存" completed (exit code 0)', + output_file: '/tmp/butp7dybq.output', + }, + }) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'thinking', + text: "The earlier monitoring command has already been handled by subsequent work, so there's nothing more to add here.", + }) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'content_start', + blockType: 'text', + }) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'content_delta', + text: '那是早前的监控命令收尾通知,已被后续的多核压测取代,无需处理。交付已全部完成并验证通过。', + }) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'message_complete', + usage: { input_tokens: 12, output_tokens: 34 }, + }) + + const session = useChatStore.getState().sessions[TEST_SESSION_ID] + expect(session?.messages).toMatchObject([ + { + type: 'background_task', + task: { + taskId: 'butp7dybq', + toolUseId: 'toolu_bdrk_01SvH8CKoRoBcv1T1Gr9jWT3', + status: 'completed', + }, + }, + ]) + expect(session?.messages).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'thinking' }), + expect.objectContaining({ type: 'assistant_text' }), + expect.objectContaining({ type: 'system', content: 'Completed in 11m 58s' }), + ])) + expect(session?.chatState).toBe('idle') + expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'idle') + }) + it('flushes a delayed completion before a new user turn while background tasks keep running', () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 35b9d810..f0d816d3 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -113,6 +113,7 @@ export type PerSessionState = { agentTaskNotifications: Record backgroundAgentTasks?: Record pendingCompletedTurnElapsedSeconds?: number | null + suppressNextTaskNotificationResponse?: boolean activeGoal?: ActiveGoalState | null elapsedTimer: ReturnType | null composerPrefill?: { @@ -150,6 +151,7 @@ const DEFAULT_SESSION_STATE: PerSessionState = { agentTaskNotifications: {}, backgroundAgentTasks: {}, pendingCompletedTurnElapsedSeconds: null, + suppressNextTaskNotificationResponse: false, activeGoal: null, elapsedTimer: null, composerPrefill: null, @@ -677,6 +679,17 @@ function isAgentBackgroundTask(task: Pick 0 || + Boolean(session.activeToolUseId) || + session.chatState === 'streaming' || + session.chatState === 'tool_executing' || + session.chatState === 'permission_pending' + return !hasVisibleActiveOutput && lastMessage?.type !== 'user_text' +} + function mergeRestoredTerminalGoalEvents( messages: UIMessage[], restoredMessages: UIMessage[], @@ -1132,6 +1145,7 @@ export const useChatStore = create((set, get) => ({ chatState: 'thinking', elapsedSeconds: 0, pendingCompletedTurnElapsedSeconds: null, + suppressNextTaskNotificationResponse: false, streamingText: '', streamingResponseChars: 0, statusVerb: isMemberSession ? '' : randomSpinnerVerb(), @@ -1245,6 +1259,7 @@ export const useChatStore = create((set, get) => ({ pendingCompletedTurnElapsedSeconds: hasRunningBackgroundAgents ? session.pendingCompletedTurnElapsedSeconds ?? null : null, + suppressNextTaskNotificationResponse: false, elapsedTimer: null, }, }, @@ -1544,6 +1559,7 @@ export const useChatStore = create((set, get) => ({ queuedUserMessages: (currentSession.queuedUserMessages ?? []) .filter((message) => message.id !== messageId), ...(pendingText.trim() ? { streamingText: '' } : {}), + suppressNextTaskNotificationResponse: false, } }), })) @@ -1567,6 +1583,7 @@ export const useChatStore = create((set, get) => ({ apiRetry: null, streamingFallback: null, pendingCompletedTurnElapsedSeconds: null, + suppressNextTaskNotificationResponse: false, queuedUserMessages: [], })) })) }, @@ -1668,6 +1685,18 @@ export const useChatStore = create((set, get) => ({ case 'content_start': { const session = get().sessions[sessionId] if (!session) break + if (session.suppressNextTaskNotificationResponse && msg.blockType === 'text') { + consumePendingDelta(sessionId) + update(() => ({ + streamingText: '', + activeThinkingId: null, + statusVerb: '', + })) + break + } + if (session.suppressNextTaskNotificationResponse) { + update(() => ({ suppressNextTaskNotificationResponse: false })) + } const pendingText = `${session.streamingText}${consumePendingDelta(sessionId)}` if (msg.blockType !== 'text' && pendingText.trim()) { update((s) => ({ @@ -1759,6 +1788,10 @@ export const useChatStore = create((set, get) => ({ } case 'content_delta': + if (get().sessions[sessionId]?.suppressNextTaskNotificationResponse) { + consumePendingDelta(sessionId) + break + } let receivedLiveDelta = false if (msg.text !== undefined) { if (!get().sessions[sessionId]) break @@ -1818,6 +1851,15 @@ export const useChatStore = create((set, get) => ({ break case 'thinking': + if (get().sessions[sessionId]?.suppressNextTaskNotificationResponse) { + consumePendingDelta(sessionId) + update(() => ({ + streamingText: '', + activeThinkingId: null, + statusVerb: '', + })) + break + } update((s) => { const pendingText = `${s.streamingText}${consumePendingDelta(sessionId)}` const base = pendingText.trim() @@ -1981,6 +2023,31 @@ export const useChatStore = create((set, get) => ({ case 'message_complete': { const session = get().sessions[sessionId] if (!session) break + if (session.suppressNextTaskNotificationResponse) { + consumePendingDelta(sessionId) + clearPendingToolInputDelta(sessionId) + if (session.elapsedTimer) clearInterval(session.elapsedTimer) + const hasRunningBackgroundAgents = hasRunningBackgroundTasks(session.backgroundAgentTasks) + update(() => ({ + tokenUsage: msg.usage, + chatState: 'idle', + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + elapsedTimer: null, + apiRetry: null, + streamingFallback: null, + streamingText: '', + streamingToolInput: '', + suppressNextTaskNotificationResponse: false, + })) + useTabStore.getState().updateTabStatus(sessionId, hasRunningBackgroundAgents ? 'running' : 'idle') + refreshCompletedTranscriptHistory(get, sessionId) + for (const queuedMessage of get().sessions[sessionId]?.queuedUserMessages ?? []) { + get().sendQueuedUserMessage(sessionId, queuedMessage.id) + } + break + } const completedAt = Date.now() const wasAgentRunning = session.chatState !== 'idle' const hasQueuedUserMessages = (session.queuedUserMessages?.length ?? 0) > 0 @@ -2046,6 +2113,7 @@ export const useChatStore = create((set, get) => ({ messages: appendReplayedUserMessage(baseMessages, msg.content, Date.now()), ...(pendingText.trim() ? { streamingText: '' } : {}), activeThinkingId: null, + suppressNextTaskNotificationResponse: false, } }) break @@ -2080,6 +2148,7 @@ export const useChatStore = create((set, get) => ({ pendingComputerUsePermission: null, apiRetry: null, streamingFallback: null, + suppressNextTaskNotificationResponse: false, } }) useTabStore.getState().updateTabStatus(sessionId, 'error') @@ -2279,8 +2348,14 @@ export const useChatStore = create((set, get) => ({ shouldUpdateIdleTabStatus = session.chatState === 'idle' hasRunningBackgroundAgentsAfterUpdate = hasRunningBackgroundTasks(backgroundAgentTasks) const task = backgroundAgentTasks[taskEvent.taskId] + const suppressNotificationResponse = + (taskEvent.status === 'completed' || + taskEvent.status === 'failed' || + taskEvent.status === 'stopped') && + shouldSuppressTaskNotificationResponse(session) return { ...buildBackgroundTaskSessionUpdate(session, backgroundAgentTasks, task, now), + ...(suppressNotificationResponse ? { suppressNextTaskNotificationResponse: true } : {}), agentTaskNotifications: { ...session.agentTaskNotifications, ...(toolUseId &&