fix(desktop): suppress task notification follow-up output (#886)

Keep task-notification-only follow-up turns out of the live desktop transcript so background command completion notices do not render as repeated thinking, assistant text, or completed-duration rows.

Refs: #886
Tested: cd desktop && bun run test -- src/stores/chatStore.test.ts && bun run lint
Confidence: high
Scope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes) 2026-07-01 19:25:55 +08:00
parent 8d12fdbcff
commit dedd51f6f4
2 changed files with 133 additions and 0 deletions

View File

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

View File

@ -113,6 +113,7 @@ export type PerSessionState = {
agentTaskNotifications: Record<string, AgentTaskNotification>
backgroundAgentTasks?: Record<string, BackgroundAgentTask>
pendingCompletedTurnElapsedSeconds?: number | null
suppressNextTaskNotificationResponse?: boolean
activeGoal?: ActiveGoalState | null
elapsedTimer: ReturnType<typeof setInterval> | 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<BackgroundAgentTask, 'taskType' | 'sum
)
}
function shouldSuppressTaskNotificationResponse(session: PerSessionState): boolean {
const lastMessage = session.messages[session.messages.length - 1]
const hasVisibleActiveOutput =
session.streamingText.trim().length > 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<ChatStore>((set, get) => ({
chatState: 'thinking',
elapsedSeconds: 0,
pendingCompletedTurnElapsedSeconds: null,
suppressNextTaskNotificationResponse: false,
streamingText: '',
streamingResponseChars: 0,
statusVerb: isMemberSession ? '' : randomSpinnerVerb(),
@ -1245,6 +1259,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
pendingCompletedTurnElapsedSeconds: hasRunningBackgroundAgents
? session.pendingCompletedTurnElapsedSeconds ?? null
: null,
suppressNextTaskNotificationResponse: false,
elapsedTimer: null,
},
},
@ -1544,6 +1559,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
queuedUserMessages: (currentSession.queuedUserMessages ?? [])
.filter((message) => message.id !== messageId),
...(pendingText.trim() ? { streamingText: '' } : {}),
suppressNextTaskNotificationResponse: false,
}
}),
}))
@ -1567,6 +1583,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
apiRetry: null,
streamingFallback: null,
pendingCompletedTurnElapsedSeconds: null,
suppressNextTaskNotificationResponse: false,
queuedUserMessages: [],
})) }))
},
@ -1668,6 +1685,18 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((set, get) => ({
pendingComputerUsePermission: null,
apiRetry: null,
streamingFallback: null,
suppressNextTaskNotificationResponse: false,
}
})
useTabStore.getState().updateTabStatus(sessionId, 'error')
@ -2279,8 +2348,14 @@ export const useChatStore = create<ChatStore>((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 &&