From fba81834ded1f234ecd37d4f390ef17aec0580ba 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: Tue, 29 Sep 2026 15:45:10 +0800 Subject: [PATCH] fix: show the model's replies to background task notifications Everything the assistant did between a turn and the next real user message was hidden, so the reply a model gave once a background command finished (and any tool work after it) never reached the chat, live or after a reload, even though it was written to the transcript. Drop that suppression from every session history projection (full, paged, recovery, sub-agent lookup) and from the desktop store (history mapping and the live-stream flag). Only the injected notification prompt stays hidden; the background task cards still come from its notification data. The suppression was added for #886 to quiet replies to stale notifications; the CLI already avoids those at the source when the model has read the task's result. Refs #1389 --- desktop/src/stores/chatStore.test.ts | 387 +++++++++++++++++- desktop/src/stores/chatStore.ts | 98 +---- desktop/src/stores/teamStore.ts | 4 +- docs/desktop/sessions.md | 2 +- docs/en/desktop/sessions.md | 2 +- src/server/__tests__/sessions.test.ts | 103 ++++- .../services/sessionHistoryContext.test.ts | 152 +++---- src/server/services/sessionHistoryContext.ts | 45 +- .../services/sessionHistoryRecovery.test.ts | 6 +- src/server/services/sessionHistoryRecovery.ts | 9 +- src/server/services/sessionService.ts | 51 +-- .../services/subagentRunService.test.ts | 18 +- 12 files changed, 565 insertions(+), 312 deletions(-) diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 0e92cd81..ad9b7ce6 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -3735,7 +3735,158 @@ describe('chatStore history mapping', () => { ]) }) - it('filters task-notification turns and resumes at the next real user message', () => { + it('hides the task-notification message but keeps every model reply that follows it', () => { + const notice = (taskId: string, toolUseId: string, name: string) => + `\n${taskId}\n${toolUseId}\ncompleted\nBackground command "Background sleep then write ${name}" completed (exit code 0)\n` + const messages: MessageEntry[] = [ + { id: 'user-real-1', type: 'user', timestamp: '2026-09-29T06:41:00.000Z', content: '启动两个后台命令' }, + { + id: 'assistant-dispatched', + type: 'assistant', + timestamp: '2026-09-29T06:41:03.000Z', + content: [{ type: 'text', text: '已派发' }], + }, + { id: 'notice-alpha', type: 'user', timestamp: '2026-09-29T06:42:25.000Z', content: notice('bt1', 'call_alpha', 'alpha') }, + { + id: 'assistant-alpha', + type: 'assistant', + timestamp: '2026-09-29T06:42:27.000Z', + content: [{ type: 'text', text: 'alpha 任务完成:ALPHA_DONE' }], + }, + { id: 'notice-bravo', type: 'user', timestamp: '2026-09-29T06:43:55.000Z', content: notice('bt2', 'call_bravo', 'bravo') }, + { + id: 'assistant-bravo', + type: 'assistant', + timestamp: '2026-09-29T06:43:57.000Z', + content: [{ type: 'text', text: 'bravo 任务完成:BRAVO_DONE' }], + }, + { id: 'user-real-2', type: 'user', timestamp: '2026-09-29T06:50:00.000Z', content: '继续' }, + { + id: 'assistant-real-2', + type: 'assistant', + timestamp: '2026-09-29T06:50:02.000Z', + content: [{ type: 'text', text: '好的' }], + }, + ] + + const mapped = mapHistoryMessagesToUiMessages(messages) + + expect(mapped.map((message) => [message.type, 'content' in message ? message.content : undefined])).toEqual([ + ['user_text', '启动两个后台命令'], + ['assistant_text', '已派发'], + ['assistant_text', 'alpha 任务完成:ALPHA_DONE'], + ['assistant_text', 'bravo 任务完成:BRAVO_DONE'], + ['user_text', '继续'], + ['assistant_text', '好的'], + ]) + expect(JSON.stringify(mapped)).not.toContain('') + }) + + it('keeps thinking, tool calls and tool results the model produces after a task notification', () => { + const messages: MessageEntry[] = [ + { id: 'user-real-1', type: 'user', timestamp: '2026-09-29T07:00:00.000Z', content: '构建项目' }, + { + id: 'assistant-started', + type: 'assistant', + timestamp: '2026-09-29T07:00:01.000Z', + content: [{ type: 'text', text: '构建已在后台启动' }], + }, + { + id: 'notice-build', + type: 'user', + timestamp: '2026-09-29T07:03:00.000Z', + content: '\nbuild-task\ntoolu_build\nfailed\nBackground command "npm run build" failed with exit code 2\n', + }, + { + id: 'assistant-thinking', + type: 'assistant', + timestamp: '2026-09-29T07:03:02.000Z', + content: [{ type: 'thinking', thinking: '构建失败,先看日志。' }], + }, + { + id: 'assistant-tool', + type: 'tool_use', + timestamp: '2026-09-29T07:03:03.000Z', + content: [{ type: 'tool_use', id: 'toolu_fix_1', name: 'Bash', input: { command: 'tail -n 20 build.log' } }], + }, + { + id: 'tool-result', + type: 'tool_result', + timestamp: '2026-09-29T07:03:04.000Z', + content: [{ type: 'tool_result', tool_use_id: 'toolu_fix_1', content: 'error TS2322: type mismatch' }], + }, + { + id: 'assistant-fix', + type: 'assistant', + timestamp: '2026-09-29T07:03:06.000Z', + content: [{ type: 'text', text: '类型错误,正在修复。' }], + }, + ] + + const mapped = mapHistoryMessagesToUiMessages(messages) + + expect(mapped.map((message) => message.type)).toEqual([ + 'user_text', + 'assistant_text', + 'thinking', + 'tool_use', + 'tool_result', + 'assistant_text', + ]) + expect(mapped[3]).toMatchObject({ toolUseId: 'toolu_fix_1', toolName: 'Bash' }) + expect(mapped[4]).toMatchObject({ toolUseId: 'toolu_fix_1' }) + expect(mapped[5]).toMatchObject({ content: '类型错误,正在修复。' }) + expect(JSON.stringify(mapped)).not.toContain('') + }) + + it('keeps a notification follow-up turn that is stored as raw assistant and user transcript blocks', () => { + const messages: MessageEntry[] = [ + { + id: 'notice-raw', + type: 'user', + timestamp: '2026-09-29T07:10:00.000Z', + content: '\nraw-task\ntoolu_raw\ncompleted\nBackground command completed\n', + }, + { + id: 'assistant-raw', + type: 'assistant', + timestamp: '2026-09-29T07:10:02.000Z', + content: [ + { type: 'thinking', thinking: '读取输出文件。' }, + { type: 'text', text: '先看一下输出。' }, + { type: 'tool_use', name: 'Read', id: 'toolu_read_1', input: { file_path: '/tmp/out.txt' } }, + ], + }, + { + id: 'user-tool-result', + type: 'user', + timestamp: '2026-09-29T07:10:03.000Z', + content: [{ type: 'tool_result', tool_use_id: 'toolu_read_1', content: 'all green' }], + }, + { + id: 'assistant-raw-final', + type: 'assistant', + timestamp: '2026-09-29T07:10:04.000Z', + content: [{ type: 'text', text: '全部通过。' }], + }, + ] + + const mapped = mapHistoryMessagesToUiMessages(messages) + + expect(mapped.map((message) => message.type)).toEqual([ + 'thinking', + 'assistant_text', + 'tool_use', + 'tool_result', + 'assistant_text', + ]) + expect(mapped[4]).toMatchObject({ content: '全部通过。' }) + expect(JSON.stringify(mapped)).not.toContain('') + }) + + it('keeps even a no-action reply to a stale task notification rather than hiding model output', () => { + // The transcript cannot tell a filler reply from a useful one, so it never + // guesses: only the injected notification prompt is hidden. const messages: MessageEntry[] = [ { id: 'user-real-1', @@ -3781,6 +3932,10 @@ describe('chatStore history mapping', () => { type: 'assistant_text', content: '项目创建好了', }, + { + type: 'assistant_text', + content: '旧后台任务通知,无需处理', + }, { id: 'user-real-2', type: 'user_text', @@ -3788,7 +3943,6 @@ describe('chatStore history mapping', () => { }, ]) expect(JSON.stringify(mapped)).not.toContain('') - expect(JSON.stringify(mapped)).not.toContain('旧后台任务通知') }) it('reconstructs task notifications from transcript XML before filtering it from UI', () => { @@ -12965,8 +13119,8 @@ describe('chatStore history mapping', () => { 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. + // Whatever started this turn (a task_notification, for example) arrived + // while the renderer was disconnected, so only its late thinking is observed. useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'thinking', text: 'orphan background follow-up thinking', @@ -13195,7 +13349,213 @@ describe('chatStore history mapping', () => { ])) }) - it('suppresses assistant output for a task-notification-only follow-up turn', () => { + it('shows the model reply to a background command completion as soon as its turn ends, and keeps it after the transcript reload', async () => { + // Replays a callback turn captured from a real desktop server (issue #1389): + // the task_notification lands while the session is idle, then the CLI starts + // a follow-up turn on its own and answers it. Nothing here comes from the user. + const send = (message: ServerMessage) => + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, message) + vi.mocked(sessionsApi.getFullHistory).mockResolvedValue({ + messages: [ + { + id: 'user-1', + type: 'user', + timestamp: '2026-09-29T06:41:00.000Z', + content: 'Start the background commands', + }, + { + id: 'assistant-dispatched', + type: 'assistant', + timestamp: '2026-09-29T06:41:03.000Z', + content: [{ type: 'text', text: '已派发' }], + }, + { + id: 'assistant-callback', + type: 'assistant', + timestamp: '2026-09-29T06:42:29.000Z', + content: [{ type: 'text', text: 'alpha 任务完成:ALPHA_DONE' }], + }, + ], + }) + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession({ + chatState: 'idle', + messages: [ + { id: 'user-1', type: 'user_text', content: 'Start the background commands', timestamp: 1 }, + { id: 'assistant-dispatched', type: 'assistant_text', content: '已派发', timestamp: 2 }, + ], + }), + }, + }) + + send({ + type: 'system_notification', + subtype: 'task_notification', + data: { + type: 'system', + subtype: 'task_notification', + task_id: 'bt1uz0yz1', + tool_use_id: 'call_00_ddp9AzDwk9lWwuLP9pom8301', + status: 'completed', + output_file: '/tmp/bt1uz0yz1.output', + summary: 'Background command "Background sleep 25 then write alpha" completed (exit code 0)', + }, + }) + send({ + type: 'system_notification', + subtype: 'init', + message: 'Model: deepseek-flash[1m]', + data: { model: 'deepseek-flash[1m]' }, + }) + send({ + type: 'system_notification', + subtype: 'slash_commands', + data: [{ name: 'update-config', description: 'Configure the harness' }], + }) + send({ type: 'status', state: 'thinking', attemptStart: true }) + send({ type: 'status', state: 'thinking', verb: 'Thinking' }) + send({ type: 'content_start', blockType: 'text' }) + for (const text of ['alpha', ' ', '任务', '完成', ':', 'AL', 'P', 'HA', '_D', 'ONE']) { + send({ type: 'content_delta', text }) + } + send({ + type: 'message_complete', + usage: { input_tokens: 249, output_tokens: 11, cache_read_tokens: 30592 }, + timing: { duration_ms: 500, duration_api_ms: 3026, ttft_ms: 0, decode_ms: 0 }, + }) + + // The reply must not wait for a transcript round trip to become visible. + const live = useChatStore.getState().sessions[TEST_SESSION_ID] + expect(live?.chatState).toBe('idle') + expect(live?.messages).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'assistant_text', content: 'alpha 任务完成:ALPHA_DONE' }), + expect.objectContaining({ + type: 'background_task', + task: expect.objectContaining({ taskId: 'bt1uz0yz1', status: 'completed' }), + }), + ])) + // The desktop notification previews the same reply the transcript shows. + expect(notifyDesktopMock).toHaveBeenCalledWith(expect.objectContaining({ + title: 'Claude Code Haha 已完成回复', + body: expect.stringContaining('alpha 任务完成'), + })) + + // The authoritative reload carries the same reply and must not duplicate it. + await vi.waitFor(() => { + expect(sessionsApi.getFullHistory).toHaveBeenCalled() + const settled = useChatStore.getState().sessions[TEST_SESSION_ID] + expect(settled?.historyStatus).toBe('ready') + expect(settled?.messages.filter((message) => message.type === 'assistant_text') + .map((message) => message.type === 'assistant_text' ? message.content : '')) + .toEqual(['已派发', 'alpha 任务完成:ALPHA_DONE']) + }) + }) + + it('shows the thinking and tool work the model performs in response to a background task notification', async () => { + const send = (message: ServerMessage) => + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, message) + const notification = [ + '', + 'build-task', + 'toolu_build', + 'failed', + 'Background command "npm run build" failed with exit code 2', + '', + ].join('\n') + vi.mocked(sessionsApi.getFullHistory).mockResolvedValue({ + messages: [ + { id: 'user-1', type: 'user', timestamp: '2026-09-29T07:00:00.000Z', content: 'Build the project' }, + { + id: 'assistant-started', + type: 'assistant', + timestamp: '2026-09-29T07:00:01.000Z', + content: [{ type: 'text', text: 'Build started in the background' }], + }, + { id: 'notification-user', type: 'user', timestamp: '2026-09-29T07:03:00.000Z', content: notification }, + { + id: 'assistant-thinking', + type: 'assistant', + timestamp: '2026-09-29T07:03:02.000Z', + content: [{ type: 'thinking', thinking: 'The build failed, so I will read its log.' }], + }, + { + id: 'assistant-tool', + type: 'tool_use', + timestamp: '2026-09-29T07:03:03.000Z', + content: [{ type: 'tool_use', id: 'toolu_fix_1', name: 'Bash', input: { command: 'tail -n 20 build.log' } }], + }, + { + id: 'tool-result', + type: 'tool_result', + timestamp: '2026-09-29T07:03:04.000Z', + content: [{ type: 'tool_result', tool_use_id: 'toolu_fix_1', content: 'error TS2322: type mismatch' }], + }, + { + id: 'assistant-fix', + type: 'assistant', + timestamp: '2026-09-29T07:03:06.000Z', + content: [{ type: 'text', text: 'The build fails on a type error; fixing it now.' }], + }, + ], + }) + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession({ + chatState: 'idle', + messages: [ + { id: 'user-1', type: 'user_text', content: 'Build the project', timestamp: 1 }, + { id: 'assistant-started', type: 'assistant_text', content: 'Build started in the background', timestamp: 2 }, + ], + }), + }, + }) + + send({ + type: 'system_notification', + subtype: 'task_notification', + data: { + task_id: 'build-task', + tool_use_id: 'toolu_build', + status: 'failed', + summary: 'Background command "npm run build" failed with exit code 2', + }, + }) + send({ type: 'status', state: 'thinking', attemptStart: true }) + send({ type: 'thinking', text: 'The build failed, so I will read its log.' }) + send({ type: 'content_start', blockType: 'tool_use', toolName: 'Bash', toolUseId: 'toolu_fix_1' }) + send({ + type: 'tool_use_complete', + toolName: 'Bash', + toolUseId: 'toolu_fix_1', + input: { command: 'tail -n 20 build.log' }, + }) + send({ type: 'tool_result', toolUseId: 'toolu_fix_1', content: 'error TS2322: type mismatch', isError: false }) + send({ type: 'content_start', blockType: 'text' }) + send({ type: 'content_delta', text: 'The build fails on a type error; fixing it now.' }) + send({ type: 'message_complete', usage: { input_tokens: 40, output_tokens: 22 } }) + + const expectFollowUpWork = (messages: UIMessage[] | undefined) => { + expect(messages).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'thinking', content: 'The build failed, so I will read its log.' }), + expect.objectContaining({ type: 'tool_use', toolUseId: 'toolu_fix_1', toolName: 'Bash' }), + expect.objectContaining({ type: 'tool_result', toolUseId: 'toolu_fix_1' }), + expect.objectContaining({ type: 'assistant_text', content: 'The build fails on a type error; fixing it now.' }), + ])) + } + expectFollowUpWork(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages) + + await vi.waitFor(() => { + const settled = useChatStore.getState().sessions[TEST_SESSION_ID] + expect(settled?.historyStatus).toBe('ready') + expectFollowUpWork(settled?.messages) + expect(JSON.stringify(settled?.messages)).not.toContain('') + }) + }) + + it('shows the thinking and reply of a follow-up turn started only by a task notification', () => { + // Even a reply that says "nothing more to add" is model output: the store + // cannot tell it from a useful one, so it never hides it. useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ @@ -13243,17 +13603,20 @@ describe('chatStore history mapping', () => { status: 'completed', }, }, + { + type: 'thinking', + content: "The earlier monitoring command has already been handled by subsequent work, so there's nothing more to add here.", + }, + { + type: 'assistant_text', + content: '那是早前的监控命令收尾通知,已被后续的多核压测取代,无需处理。交付已全部完成并验证通过。', + }, ]) - 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('does not suppress foreground skill output when a background task completes', () => { + it('keeps foreground skill output visible when a background task completes mid-turn', () => { useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ @@ -13298,8 +13661,6 @@ describe('chatStore history mapping', () => { }, }) - expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.suppressNextTaskNotificationResponse).not.toBe(true) - useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'content_start', blockType: 'text', diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 64cfa067..d9b65071 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -202,7 +202,6 @@ export type PerSessionState = { historyMutationEpoch?: number /** Changes when a directed child stream starts or settles. */ agentStreamRevision?: number - suppressNextTaskNotificationResponse?: boolean replaceHistoryOnCompletion?: boolean activeGoal?: ActiveGoalState | null activeGoalRevision?: number @@ -256,7 +255,6 @@ const DEFAULT_SESSION_STATE: PerSessionState = { historyBootstrapDisabled: false, historyMutationEpoch: 0, agentStreamRevision: 0, - suppressNextTaskNotificationResponse: false, replaceHistoryOnCompletion: false, activeGoal: null, activeGoalRevision: 0, @@ -1332,15 +1330,6 @@ function isCancellableSubagentTask(task: BackgroundAgentTask): boolean { ) } -function shouldSuppressTaskNotificationResponse(session: PerSessionState): boolean { - if (session.chatState !== 'idle') return false - const lastMessage = session.messages[session.messages.length - 1] - const hasVisibleActiveOutput = - session.streamingText.trim().length > 0 || - Boolean(session.activeToolUseId) - return !hasVisibleActiveOutput && lastMessage?.type !== 'user_text' -} - function mergeRestoredTerminalGoalEvents( messages: UIMessage[], restoredMessages: UIMessage[], @@ -3337,7 +3326,6 @@ export const useChatStore = create((setState, get) => { isPreparingTurn: false, historyMutationEpoch: (session.historyMutationEpoch ?? 0) + 1, elapsedSeconds: 0, - suppressNextTaskNotificationResponse: false, replaceHistoryOnCompletion: false, streamingText: '', streamingResponseChars: 0, @@ -3560,7 +3548,6 @@ export const useChatStore = create((setState, get) => { pendingComputerUsePermissions: {}, apiRetry: null, streamingFallback: null, - suppressNextTaskNotificationResponse: false, stoppingBackgroundTaskIds, stopAllSubagentsRequested: true, elapsedTimer: null, @@ -4502,7 +4489,6 @@ export const useChatStore = create((setState, get) => { queuedUserMessages: (currentSession.queuedUserMessages ?? []) .filter((message) => message.id !== messageId), ...(pendingText.trim() ? { streamingText: '' } : {}), - suppressNextTaskNotificationResponse: false, replaceHistoryOnCompletion: false, } }), @@ -4537,7 +4523,6 @@ export const useChatStore = create((setState, get) => { preHydrationSocketGapPending: false, apiRetry: null, streamingFallback: null, - suppressNextTaskNotificationResponse: false, replaceHistoryOnCompletion: false, queuedUserMessages: [], })) })) @@ -4912,18 +4897,6 @@ export const useChatStore = create((setState, 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 })) - } // The server keeps a stopped-turn fence until it attributes a replay // (or a pure local command's first output) to the replacement turn. // Mirror that boundary instead of clearing the SubAgent stop latch on @@ -5065,10 +5038,6 @@ export const useChatStore = create((setState, get) => { } case 'content_delta': - if (get().sessions[sessionId]?.suppressNextTaskNotificationResponse) { - consumePendingDelta(sessionId) - break - } let receivedLiveDelta = false if (msg.text !== undefined) { if (!get().sessions[sessionId]) break @@ -5129,15 +5098,6 @@ export const useChatStore = create((setState, get) => { break case 'thinking': { - if (get().sessions[sessionId]?.suppressNextTaskNotificationResponse) { - consumePendingDelta(sessionId) - update(() => ({ - streamingText: '', - activeThinkingId: null, - statusVerb: '', - })) - break - } // 重放/空块都不该冒出一个新的「已思考」气泡,也不该把会话拖回 thinking 态 // 或者启动计时器 —— 那正是"打开一个早就结束的会话,它自己开始输出"的观感。 let skippedThinkingBlock = false @@ -5466,39 +5426,6 @@ export const useChatStore = create((setState, get) => { void cliTaskStore.refreshTasks(sessionId) } } - if (session.suppressNextTaskNotificationResponse) { - consumePendingDelta(sessionId) - clearPendingToolInputDelta(sessionId) - if (session.elapsedTimer) clearInterval(session.elapsedTimer) - const hasRunningBackgroundAgents = hasRunningBackgroundTasks(session.backgroundAgentTasks) - update((current) => ({ - tokenUsage: msg.usage, - chatState: 'idle', - activeThinkingId: null, - pendingPermission: null, - pendingPermissions: {}, - pendingComputerUsePermission: null, - pendingComputerUsePermissions: {}, - elapsedTimer: null, - apiRetry: null, - streamingFallback: null, - streamingText: '', - streamingToolInput: '', - suppressNextTaskNotificationResponse: false, - replaceHistoryOnCompletion: false, - historyMutationEpoch: (current.historyMutationEpoch ?? 0) + 1, - })) - useTabStore.getState().updateTabStatus(sessionId, hasRunningBackgroundAgents ? 'running' : 'idle') - reconcileCompletedTranscriptHistory( - get, - sessionId, - session.replaceHistoryOnCompletion === true, - ) - for (const queuedMessage of get().sessions[sessionId]?.queuedUserMessages ?? []) { - get().sendQueuedUserMessage(sessionId, queuedMessage.id) - } - break - } const completedAt = Date.now() const wasAgentRunning = session.chatState !== 'idle' const text = `${session.streamingText}${consumePendingDelta(sessionId)}` @@ -5565,7 +5492,6 @@ export const useChatStore = create((setState, get) => { messages: appendReplayedUserMessage(baseMessages, msg.content, Date.now(), msg.sessionReferences, msg.collaboration), ...(pendingText.trim() ? { streamingText: '' } : {}), activeThinkingId: null, - suppressNextTaskNotificationResponse: false, replaceHistoryOnCompletion: false, stopAllSubagentsRequested: false, historyMutationEpoch: (session.historyMutationEpoch ?? 0) + 1, @@ -5613,7 +5539,6 @@ export const useChatStore = create((setState, get) => { pendingComputerUsePermissions: {}, apiRetry: null, streamingFallback: null, - suppressNextTaskNotificationResponse: false, historyMutationEpoch: (s.historyMutationEpoch ?? 0) + 1, } }) @@ -5977,18 +5902,11 @@ export const useChatStore = create((setState, get) => { hasRunningBackgroundAgentsAfterUpdate = hasRunningBackgroundTasks(backgroundAgentTasks) const task = backgroundAgentTasks[taskEvent.taskId] const accepted = Boolean(task) - const suppressNotificationResponse = - accepted && - (taskEvent.status === 'completed' || - taskEvent.status === 'failed' || - taskEvent.status === 'stopped') && - shouldSuppressTaskNotificationResponse(session) const stoppingBackgroundTaskIds = { ...session.stoppingBackgroundTaskIds } delete stoppingBackgroundTaskIds[taskEvent.taskId] return { ...buildBackgroundTaskSessionUpdate(session, backgroundAgentTasks, task, now), stoppingBackgroundTaskIds, - ...(suppressNotificationResponse ? { suppressNextTaskNotificationResponse: true } : {}), agentTaskNotifications: { ...session.agentTaskNotifications, ...(accepted && @@ -7774,14 +7692,14 @@ export function mapHistoryMessagesToUiMessages( ): UIMessage[] { const includeTeammateMessages = options?.includeTeammateMessages === true const uiMessages: UIMessage[] = [] - let suppressTaskNotificationResponse = false let pendingGoalCommand: { name: string; args: string } | null = null for (const msg of messages) { - if (msg.type === 'user' && isTaskNotificationContent(msg.content)) { - suppressTaskNotificationResponse = true - continue - } + // A task notification is a system-injected prompt: the background task + // cards already stand for it. What the assistant does in the turn that + // answers it (thinking, text, tool calls) is ordinary transcript content + // and stays visible. + if (msg.type === 'user' && isTaskNotificationContent(msg.content)) continue if (msg.type === 'user') { const commandDisplayText = getCommandMetadataDisplayText(msg.content) if (commandDisplayText) { @@ -7792,18 +7710,12 @@ export function mapHistoryMessagesToUiMessages( ...(msg.id ? { transcriptMessageId: msg.id } : {}), timestamp: new Date(msg.timestamp).getTime(), }) - suppressTaskNotificationResponse = false continue } if (shouldHideCommandMetadataContent(msg.content)) { continue } } - if (msg.type === 'user') { - suppressTaskNotificationResponse = false - } else if (suppressTaskNotificationResponse) { - continue - } const timestamp = new Date(msg.timestamp).getTime() if ( diff --git a/desktop/src/stores/teamStore.ts b/desktop/src/stores/teamStore.ts index 7db66e3b..b0e77539 100644 --- a/desktop/src/stores/teamStore.ts +++ b/desktop/src/stores/teamStore.ts @@ -1409,8 +1409,8 @@ export const useTeamStore = create((set, get) => ({ ), }, })) - // Mapping the complete durable transcript preserves suppression state - // across cursor pages (for example task-notification follow-up blocks). + // Mapping the complete durable transcript keeps state that spans entries + // intact across cursor pages (for example a goal command and its output). // Pending local sends are merged back only after that projection. const transcriptMessages = mapHistoryMessagesToUiMessages( mergedEntries, diff --git a/docs/desktop/sessions.md b/docs/desktop/sessions.md index 682475c0..fa15cc6b 100644 --- a/docs/desktop/sessions.md +++ b/docs/desktop/sessions.md @@ -83,7 +83,7 @@ Claude 每完成一轮并修改文件,对话里会出现一张「{n} 个文件 - **任务** — Claude 自己维护的待办清单,顶部显示「任务进度 3/7」。 - **SubAgent** — 它派出去的子 Agent,点进去能看子 Agent 完整的运行记录。 -- **后台任务** — 挂在后台跑的命令和工作流,可以单独停掉某一个。 +- **后台任务** — 挂在后台跑的命令和工作流,可以单独停掉某一个。任务结束时 Claude 会收到通知并接着处理,它的回复照常出现在对话里。 - **团队** — 用到 Agent Team 时,每个成员一行,可以点进去直接给某个成员发消息。 后台跑的子 Agent 的工具活动也会冒泡到这里,不用等它跑完才知道它在干什么。 diff --git a/docs/en/desktop/sessions.md b/docs/en/desktop/sessions.md index 9fdf5bfb..42a05913 100644 --- a/docs/en/desktop/sessions.md +++ b/docs/en/desktop/sessions.md @@ -83,7 +83,7 @@ The first button on the right of the tab bar opens the Activity panel, which lis - **Tasks** — the to-do list Claude maintains for itself, with "Task progress 3/7" at the top. - **SubAgents** — the agents it delegated to. Open one to read its full transcript. -- **Background tasks** — commands and workflows running in the background; each can be stopped individually. +- **Background tasks** — commands and workflows running in the background; each can be stopped individually. When one finishes, Claude is notified and carries on, and its reply appears in the conversation as usual. - **Team** — when an Agent Team is in play, one row per member, and you can message a member directly. Tool activity from background subagents bubbles up here too, so you don't have to wait for one to finish to see what it's doing. diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index a8637fef..f4131051 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -2223,7 +2223,7 @@ describe('SessionService', () => { ]) }) - it('should hide task-notification turns and their automatic responses from history', async () => { + it('should hide the task-notification turn itself but keep everything the assistant does after it', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const firstUserId = crypto.randomUUID() const firstAssistantId = crypto.randomUUID() @@ -2253,7 +2253,7 @@ describe('SessionService', () => { parentUuid: firstAssistantId, }, { - ...makeAssistantEntry('旧后台任务通知,无需处理', taskNotificationId), + ...makeAssistantEntry('后台命令跑完了,我来重启服务', taskNotificationId), uuid: taskAssistantId, }, { @@ -2279,7 +2279,7 @@ describe('SessionService', () => { timestamp: '2026-01-01T00:03:00.000Z', }, { - ...makeAssistantEntry('后台任务触发的工具调用完成', taskToolResultId), + ...makeAssistantEntry('服务已重启', taskToolResultId), uuid: taskAfterToolId, }, { @@ -2298,13 +2298,17 @@ describe('SessionService', () => { expect(messages.map((message) => message.id)).toEqual([ firstUserId, firstAssistantId, + taskAssistantId, + taskToolUseMessageId, + taskToolResultId, + taskAfterToolId, realFollowUpId, realAssistantId, ]) expect(JSON.stringify(messages)).not.toContain('') - expect(JSON.stringify(messages)).not.toContain('旧后台任务通知') - expect(JSON.stringify(messages)).not.toContain('server restarted') - expect(JSON.stringify(messages)).not.toContain('后台任务触发的工具调用完成') + expect(JSON.stringify(messages)).toContain('后台命令跑完了,我来重启服务') + expect(JSON.stringify(messages)).toContain('server restarted') + expect(JSON.stringify(messages)).toContain('服务已重启') expect(taskNotifications).toEqual([ { taskId: 'bg-1', @@ -2316,6 +2320,77 @@ describe('SessionService', () => { ]) }) + it('keeps the model reply to a background-task notification and hides only the notification itself (#1389)', async () => { + // Shapes copied from a real desktop session: a background shell finished, the CLI queued a + // user turn, and DeepSeek answered it with an (empty) thinking block plus text. + const sessionId = 'bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee' + const userId = crypto.randomUUID() + const dispatchedId = crypto.randomUUID() + const notificationId = crypto.randomUUID() + const thinkingId = crypto.randomUUID() + const replyId = crypto.randomUUID() + const secondNotificationId = crypto.randomUUID() + const secondReplyId = crypto.randomUUID() + const notification = (taskId: string) => [ + '', + `${taskId}`, + `call_${taskId}`, + `/tmp/${taskId}.output`, + 'completed', + `Background command "sleep then write ${taskId}" completed (exit code 0)`, + '', + ].join('\n') + + await writeSessionFile('-tmp-notification-reply', sessionId, [ + makeSnapshotEntry(), + { ...makeUserEntry('start two background commands', userId), parentUuid: null }, + { ...makeAssistantEntry('dispatched', userId), uuid: dispatchedId }, + { + type: 'cc-haha-task-notification', + isMeta: true, + taskNotification: { + taskId: 'alpha', + toolUseId: 'call_alpha', + status: 'completed', + summary: 'Background command "sleep then write alpha" completed (exit code 0)', + timestamp: '2026-01-01T00:03:00.000Z', + }, + timestamp: '2026-01-01T00:03:00.000Z', + }, + { ...makeUserEntry(notification('alpha'), notificationId), parentUuid: dispatchedId }, + { + ...makeAssistantEntry('', notificationId), + uuid: thinkingId, + message: { + role: 'assistant', + model: 'deepseek-flash', + id: 'msg_thinking_alpha', + content: [{ type: 'thinking', thinking: '', signature: 'sig' }], + }, + }, + { ...makeAssistantEntry('alpha finished: ALPHA_DONE', thinkingId), uuid: replyId }, + { ...makeUserEntry(notification('bravo'), secondNotificationId), parentUuid: replyId }, + { ...makeAssistantEntry('bravo finished: BRAVO_DONE', secondNotificationId), uuid: secondReplyId }, + ]) + + const messages = await service.getSessionMessages(sessionId) + const rendered = JSON.stringify(messages) + + expect(rendered).not.toContain('') + expect(rendered).toContain('alpha finished: ALPHA_DONE') + expect(rendered).toContain('bravo finished: BRAVO_DONE') + expect(messages.map((message) => message.id)).toEqual(expect.arrayContaining([ + userId, + dispatchedId, + replyId, + secondReplyId, + ])) + // The cards for the finished tasks still come from the notification data, not from the hidden message. + expect((await service.getSessionTaskNotifications(sessionId)).map((item) => item.taskId)).toEqual( + expect.arrayContaining(['alpha', 'bravo']), + ) + }) + it('uses bounded locators for snapshots and task notifications with safe fallback', async () => { const sessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' const projectDir = '-tmp-locator-consumers' @@ -2416,7 +2491,7 @@ describe('SessionService', () => { serveWrongEmptyFingerprint = false const callsBeforeFullHistory = locatorCalls - expect(await indexedService.getSessionMessages(sessionId)).toHaveLength(0) + expect(await indexedService.getSessionMessages(sessionId)).toHaveLength(1) expect(locatorCalls).toBe(callsBeforeFullHistory) mode = 'shadow' @@ -4304,7 +4379,7 @@ describe('Sessions API', () => { '\nbg-1\ntoolu_bg\nfailed\nBackground command failed & stopped\nStack trace & failed assertion\nC:\\Temp\\bg.output\n', crypto.randomUUID(), ), - makeAssistantEntry('internal task response'), + makeAssistantEntry('the background command failed, investigating'), ]) const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/messages`) @@ -4314,11 +4389,15 @@ describe('Sessions API', () => { messages: unknown[] taskNotifications: unknown[] } - expect(body.messages).toHaveLength(2) + expect(body.messages).toHaveLength(3) expect(body.messages[1]).toMatchObject({ type: 'assistant', usage: { input_tokens: 1234, output_tokens: 56 }, }) + expect(body.messages[2]).toMatchObject({ + type: 'assistant', + content: [{ type: 'text', text: 'the background command failed, investigating' }], + }) expect(JSON.stringify(body.messages)).not.toContain('') expect(body.taskNotifications).toEqual([ { @@ -4403,7 +4482,7 @@ describe('Sessions API', () => { }, { type: 'assistant', - message: { role: 'assistant', content: 'Internal notification response' }, + message: { role: 'assistant', content: 'Child shell stopped, continuing the review' }, uuid: crypto.randomUUID(), timestamp: '2026-01-01T00:00:07.000Z', }, @@ -4430,9 +4509,9 @@ describe('Sessions API', () => { prompt: 'Read session routes', source: 'subagent-jsonl', }) - expect(body.messages).toHaveLength(2) + expect(body.messages).toHaveLength(3) expect(JSON.stringify(body.messages)).not.toContain('') - expect(JSON.stringify(body.messages)).not.toContain('Internal notification response') + expect(JSON.stringify(body.messages)).toContain('Child shell stopped, continuing the review') expect(body.taskNotifications).toEqual([{ taskId: 'child-shell-task', toolUseId: 'child-shell.call', diff --git a/src/server/services/sessionHistoryContext.test.ts b/src/server/services/sessionHistoryContext.test.ts index e9da132c..6188cedb 100644 --- a/src/server/services/sessionHistoryContext.test.ts +++ b/src/server/services/sessionHistoryContext.test.ts @@ -10,38 +10,48 @@ beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'history-conte afterEach(async () => { await rm(directory, { recursive: true, force: true }) }) const row = (id: string, fields: object = {}) => JSON.stringify({ uuid: id, ...fields }) const version = async (filePath = file) => { const info = await stat(filePath, { bigint: true }); return `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}` } -const classify = (entry: Record) => ({ notification: entry.notification === true, reset: entry.reset === true, agentToolId: typeof entry.agent === 'string' ? entry.agent : undefined }) +const agentToolId = (entry: Record) => typeof entry.agent === 'string' ? entry.agent : undefined +const messageAgentToolId = (entry: Record) => { + const content = (entry.message as { content?: unknown } | undefined)?.content + const blocks = Array.isArray(content) ? content : [] + return blocks.find(block => block.type === 'tool_use' && ['Agent', 'Task'].includes(block.name))?.id as string | undefined +} -test('subagent visibility keeps unowned sidechains without bypassing cached notification suppression', async () => { +test('root reads hide unowned sidechains while a child transcript keeps them, from one shared scan', async () => { const entries = [ - row('user', { isSidechain: true, reset: true }), - row('notice', { isSidechain: true, notification: true }), - row('hidden', { isSidechain: true, parentUuid: 'notice' }), - row('next', { isSidechain: true, reset: true }), + row('owner', { agent: 'agent-tool' }), + row('owned', { isSidechain: true, parentUuid: 'owner' }), + row('orphan', { isSidechain: true }), + row('root'), ] await writeFile(file, entries.join('\n') + '\n') const offsets = entries.map((_, index) => Buffer.byteLength(entries.slice(0, index).map(value => value + '\n').join(''))) - const options = { filePath: file, sourceVersion: await version(), offsets, classify } + const options = { filePath: file, sourceVersion: await version(), offsets, agentToolId } const root = await readHistoryContexts(options) - expect([...root.contexts.values()].map(context => context.suppressed)).toEqual([true, true, true, true]) + expect([...root.contexts.values()]).toEqual([ + { owner: undefined, hidden: false }, + { owner: 'agent-tool', hidden: false }, + { owner: undefined, hidden: true }, + { owner: undefined, hidden: false }, + ]) const child = await readHistoryContexts({ ...options, includeUnownedSidechains: true }) expect(child.scannedBytes).toBe(0) - expect([...child.contexts.values()].map(context => context.suppressed)).toEqual([false, true, true, false]) - expect([...((await readHistoryContexts(options)).contexts.values())].every(context => context.suppressed)).toBe(true) + expect([...child.contexts.values()].map(context => context.hidden)).toEqual([false, false, false, false]) + expect([...((await readHistoryContexts(options)).contexts.values())].map(context => context.hidden)).toEqual([false, false, true, false]) }) test('indexes EOF records and resumes at their boundary when a newline and new records are appended', async () => { - const first = row('notification', { notification: true }) + '\n' - const partial = row('user', { reset: true }) + const first = row('owner', { agent: 'agent-tool' }) + '\n' + const partial = row('child', { isSidechain: true, parentUuid: 'owner' }) await writeFile(file, first + partial) - const before = await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [0, Buffer.byteLength(first)], classify }) - expect(before.contexts.get(0)?.suppressed).toBe(true) - expect(before.contexts.get(Buffer.byteLength(first))?.suppressed).toBe(false) + const before = await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [0, Buffer.byteLength(first)], agentToolId }) + expect(before.contexts.get(0)).toEqual({ owner: undefined, hidden: false }) + expect(before.contexts.get(Buffer.byteLength(first))).toEqual({ owner: 'agent-tool', hidden: false }) const suffix = '\n' + row('assistant') + '\n' await appendFile(file, suffix) - const after = await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [Buffer.byteLength(first + partial + '\n')], classify }) + const after = await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [Buffer.byteLength(first + partial + '\n')], agentToolId }) expect(after.scannedBytes).toBe(Buffer.byteLength(partial + suffix)) - expect([...after.contexts.values()]).toEqual([{ owner: undefined, suppressed: false }]) + expect([...after.contexts.values()]).toEqual([{ owner: undefined, hidden: false }]) }) test('joins concurrent builds and cancelling one subscriber does not cancel the remaining reader', async () => { @@ -49,118 +59,62 @@ test('joins concurrent builds and cancelling one subscriber does not cancel the const sourceVersion = await version() const controller = new AbortController() let visits = 0 - const observed = (entry: Record) => { visits++; if (visits === 1) controller.abort(); return classify(entry) } - const first = readHistoryContexts({ filePath: file, sourceVersion, offsets: [0], signal: controller.signal, classify: observed }).catch(error => error) - const second = readHistoryContexts({ filePath: file, sourceVersion, offsets: [0], classify: observed }) + const observed = (entry: Record) => { visits++; if (visits === 1) controller.abort(); return agentToolId(entry) } + const first = readHistoryContexts({ filePath: file, sourceVersion, offsets: [0], signal: controller.signal, agentToolId: observed }).catch(error => error) + const second = readHistoryContexts({ filePath: file, sourceVersion, offsets: [0], agentToolId: observed }) expect((await first).name).toBe('AbortError') - expect((await second).contexts.get(0)?.suppressed).toBe(false) + expect((await second).contexts.get(0)?.hidden).toBe(false) expect(visits).toBe(128) - expect((await readHistoryContexts({ filePath: file, sourceVersion, offsets: [0], classify })).scannedBytes).toBe(0) + expect((await readHistoryContexts({ filePath: file, sourceVersion, offsets: [0], agentToolId })).scannedBytes).toBe(0) }) test('replacing a transcript invalidates old scalar state and rejects stale page identities', async () => { - await writeFile(file, row('notice', { notification: true }) + '\n') + await writeFile(file, row('side', { isSidechain: true }) + '\n') const oldVersion = await version() - await readHistoryContexts({ filePath: file, sourceVersion: oldVersion, offsets: [0], classify }) + expect((await readHistoryContexts({ filePath: file, sourceVersion: oldVersion, offsets: [0], agentToolId })).contexts.get(0)?.hidden).toBe(true) await writeFile(file + '.new', row('new') + '\n') await rename(file + '.new', file) - const fresh = await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [0], classify }) - expect(fresh.contexts.get(0)?.suppressed).toBe(false) - await expect(readHistoryContexts({ filePath: file, sourceVersion: oldVersion, offsets: [0], classify })).rejects.toMatchObject({ statusCode: 409 }) + const fresh = await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [0], agentToolId }) + expect(fresh.contexts.get(0)?.hidden).toBe(false) + await expect(readHistoryContexts({ filePath: file, sourceVersion: oldVersion, offsets: [0], agentToolId })).rejects.toMatchObject({ statusCode: 409 }) }) test('bounds queued file builds and cleans a fully cancelled scan for subsequent retry', async () => { const files = Array.from({ length: 6 }, (_, index) => join(directory, `${index}.jsonl`)) await Promise.all(files.map(filePath => writeFile(filePath, row('one') + '\n' + row('two') + '\n'))) const versions = await Promise.all(files.map(filePath => version(filePath))) - const requests = files.slice(0, 5).map((filePath, index) => readHistoryContexts({ filePath, sourceVersion: versions[index]!, offsets: [0], classify })) - await expect(readHistoryContexts({ filePath: files[5]!, sourceVersion: versions[5]!, offsets: [0], classify })).rejects.toMatchObject({ statusCode: 429 }) + const requests = files.slice(0, 5).map((filePath, index) => readHistoryContexts({ filePath, sourceVersion: versions[index]!, offsets: [0], agentToolId })) + await expect(readHistoryContexts({ filePath: files[5]!, sourceVersion: versions[5]!, offsets: [0], agentToolId })).rejects.toMatchObject({ statusCode: 429 }) await Promise.all(requests) const controller = new AbortController() - await expect(readHistoryContexts({ filePath: files[5]!, sourceVersion: versions[5]!, offsets: [0], signal: controller.signal, classify: entry => { controller.abort(); return classify(entry) } })).rejects.toThrow() - expect((await readHistoryContexts({ filePath: files[5]!, sourceVersion: versions[5]!, offsets: [0], classify })).contexts.get(0)?.suppressed).toBe(false) + await expect(readHistoryContexts({ filePath: files[5]!, sourceVersion: versions[5]!, offsets: [0], signal: controller.signal, agentToolId: entry => { controller.abort(); return agentToolId(entry) } })).rejects.toThrow() + expect((await readHistoryContexts({ filePath: files[5]!, sourceVersion: versions[5]!, offsets: [0], agentToolId })).contexts.get(0)?.hidden).toBe(false) }) test('rebuilds scalar context after an in-place rewrite grows beyond the cached snapshot', async () => { - await writeFile(file, row('notice', { notification: true }) + '\n') - await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [0], classify }) - await writeFile(file, row('replacement', { reset: true }) + '\n' + row('more-records') + '\n') - const rebuilt = await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [0], classify }) - expect(rebuilt.contexts.get(0)?.suppressed).toBe(false) + await writeFile(file, row('side', { isSidechain: true }) + '\n') + await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [0], agentToolId }) + await writeFile(file, row('replacement') + '\n' + row('more-records') + '\n') + const rebuilt = await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [0], agentToolId }) + expect(rebuilt.contexts.get(0)?.hidden).toBe(false) expect(rebuilt.scannedBytes).toBe(Number((await stat(file)).size)) }) -const classifyMessage = (entry: Record) => { - const message = entry.message as { role?: string; content?: unknown } - const content = message?.content - const blocks = Array.isArray(content) ? content : [] - const texts = typeof content === 'string' ? [content] : blocks.filter(block => block.type === 'text').map(block => block.text) - const user = message?.role === 'user' && !entry.isMeta - return { - notification: user && texts.length > 0 && texts.every(text => //.test(text)), - reset: user && !blocks.some(block => block.type === 'tool_result'), - agentToolId: blocks.find(block => block.type === 'tool_use' && ['Agent', 'Task'].includes(block.name))?.id, - } -} - -test('an oversized ordinary image resets notification suppression and preserves following replies', async () => { - const notice = row('notice', { type: 'user', message: { role: 'user', content: 'done' } }) + '\n' - const image = row('image', { type: 'user', message: { role: 'user', content: [{ type: 'image', source: { type: 'base64', data: 'A'.repeat(9 * 1024 * 1024) } }] } }) + '\n' - const answer = row('answer', { type: 'assistant', message: { role: 'assistant', content: 'Visible reply' } }) + '\n' - await writeFile(file, notice + image + answer) - const answerOffset = Buffer.byteLength(notice + image) - const contexts = await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [0, Buffer.byteLength(notice), answerOffset], classify: classifyMessage }) - expect(contexts.contexts.get(0)?.suppressed).toBe(true) - expect(contexts.contexts.get(Buffer.byteLength(notice))?.suppressed).toBe(false) - expect(contexts.contexts.get(answerOffset)?.suppressed).toBe(false) - expect((await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [answerOffset], classify: classifyMessage })).scannedBytes).toBe(0) -}) - test('oversized Agent input preserves the sidechain owner for later small records', async () => { const parent = row('parent', { type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id: 'agent-tool', name: 'Agent', input: { prompt: 'x'.repeat(9 * 1024 * 1024) } }] } }) + '\n' const child = row('child', { type: 'assistant', parentUuid: 'parent', isSidechain: true, message: { role: 'assistant', content: 'Child result' } }) + '\n' await writeFile(file, parent + child) const offset = Buffer.byteLength(parent) - const contexts = await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [offset], classify: classifyMessage }) - expect(contexts.contexts.get(offset)).toEqual({ owner: 'agent-tool', suppressed: false }) + const contexts = await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [offset], agentToolId: messageAgentToolId }) + expect(contexts.contexts.get(offset)).toEqual({ owner: 'agent-tool', hidden: false }) }) -test('an oversized tool result does not reset preceding notification suppression', async () => { - const notice = row('notice', { type: 'user', message: { role: 'user', content: 'done' } }) + '\n' - const result = row('result', { type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tool', content: 'x'.repeat(9 * 1024 * 1024) }] } }) + '\n' - const answer = row('answer', { type: 'assistant', message: { role: 'assistant', content: 'Hidden notification response' } }) + '\n' - await writeFile(file, notice + result + answer) - const offset = Buffer.byteLength(notice + result) - expect((await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [offset], classify: classifyMessage })).contexts.get(offset)?.suppressed).toBe(true) -}) - -test('a notification beyond a projected text prefix remains suppressed until an explicit user reset', async () => { - const longText = row('long', { type: 'user', message: { role: 'user', content: 'x'.repeat(9 * 1024 * 1024) + 'done' } }) + '\n' - const hidden = row('hidden', { type: 'assistant', message: { role: 'assistant', content: 'Notification response' } }) + '\n' - const reset = row('reset', { type: 'user', message: { role: 'user', content: 'New request' } }) + '\n' - const visible = row('visible', { type: 'assistant', message: { role: 'assistant', content: 'Normal reply' } }) + '\n' - await writeFile(file, longText + hidden + reset + visible) - const hiddenOffset = Buffer.byteLength(longText) - const visibleOffset = Buffer.byteLength(longText + hidden + reset) - const contexts = await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [hiddenOffset, visibleOffset], classify: classifyMessage }) - expect(contexts.contexts.get(hiddenOffset)?.suppressed).toBe(true) - expect(contexts.contexts.get(visibleOffset)?.suppressed).toBe(false) -}) - -test('malformed oversized records preserve uncertainty rather than exposing subsequent notification replies', async () => { +test('a malformed oversized record does not disturb the records after it', async () => { const malformed = '{"message":{"content":"' + 'x'.repeat(9 * 1024 * 1024) + '\n' - const reply = row('reply', { type: 'assistant', message: { role: 'assistant', content: 'Unknown context' } }) + '\n' + const reply = row('reply', { type: 'assistant', message: { role: 'assistant', content: 'Still visible' } }) + '\n' await writeFile(file, malformed + reply) - const contexts = await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [Buffer.byteLength(malformed)], classify: classifyMessage }) - expect(contexts.contexts.get(Buffer.byteLength(malformed))?.suppressed).toBe(true) -}) - -test('ordinary long text inside the existing semantic limit remains a complete reset', async () => { - const notice = row('notice', { type: 'user', message: { role: 'user', content: 'done' } }) + '\n' - const request = row('request', { type: 'user', message: { role: 'user', content: 'x'.repeat(60 * 1024) } }) + '\n' - const answer = row('answer', { type: 'assistant', message: { role: 'assistant', content: 'Visible reply' } }) + '\n' - await writeFile(file, notice + request + answer) - const offset = Buffer.byteLength(notice + request) - expect((await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [offset], classify: classifyMessage })).contexts.get(offset)?.suppressed).toBe(false) + const offset = Buffer.byteLength(malformed) + const contexts = await readHistoryContexts({ filePath: file, sourceVersion: await version(), offsets: [offset], agentToolId: messageAgentToolId }) + expect(contexts.contexts.get(offset)).toEqual({ owner: undefined, hidden: false }) }) diff --git a/src/server/services/sessionHistoryContext.ts b/src/server/services/sessionHistoryContext.ts index 91e2fa3a..3dd4f532 100644 --- a/src/server/services/sessionHistoryContext.ts +++ b/src/server/services/sessionHistoryContext.ts @@ -5,11 +5,12 @@ import { rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { withHistoryReadBudget } from './boundedSessionHistory.js' -import { isSessionMetadataTextTruncated, streamSessionMetadata } from './sessionMetadataReader.js' +import { streamSessionMetadata } from './sessionMetadataReader.js' import { ApiError } from '../middleware/errorHandler.js' -type Context = { owner?: string; suppressed: boolean } -type Cache = { database: Database; directory: string; identity: string; size: number; mtime: string; offset: number; suppressed: boolean | null; fingerprint?: string } +/** `hidden` marks an unowned sidechain record that a root-only view must not show. */ +type Context = { owner?: string; hidden: boolean } +type Cache = { database: Database; directory: string; identity: string; size: number; mtime: string; offset: number; fingerprint?: string } type Flight = { promise: Promise; controller: AbortController; users: number } const cache = new Map() const flights = new Map() @@ -42,7 +43,7 @@ export async function readHistoryContexts(options: { offsets: number[] signal?: AbortSignal includeUnownedSidechains?: boolean - classify: (entry: Record) => { notification: boolean; reset: boolean; agentToolId?: string } + agentToolId: (entry: Record) => string | undefined }): Promise<{ contexts: Map; scannedBytes: number }> { if (options.signal?.aborted) throw options.signal.reason ?? new DOMException('Aborted', 'AbortError') const [dev, ino, size, mtime] = options.sourceVersion.split(':') @@ -73,8 +74,8 @@ export async function readHistoryContexts(options: { } const directory = await mkdtemp(join(tmpdir(), 'claude-history-context-')) const database = new Database(join(directory, 'context.sqlite')) - database.exec('PRAGMA journal_mode=OFF; PRAGMA cache_size=-512; PRAGMA temp_store=FILE; CREATE TABLE parents (id TEXT PRIMARY KEY, chain TEXT); CREATE TABLE context (offset INTEGER PRIMARY KEY, owner TEXT, suppressed INTEGER, unowned_sidechain INTEGER)') - state = { database, directory, identity, size: 0, mtime: '', offset: 0, suppressed: false } + database.exec('PRAGMA journal_mode=OFF; PRAGMA cache_size=-512; PRAGMA temp_store=FILE; CREATE TABLE parents (id TEXT PRIMARY KEY, chain TEXT); CREATE TABLE context (offset INTEGER PRIMARY KEY, owner TEXT, unowned_sidechain INTEGER)') + state = { database, directory, identity, size: 0, mtime: '', offset: 0 } cache.set(options.filePath, state) } cache.delete(options.filePath) @@ -82,39 +83,27 @@ export async function readHistoryContexts(options: { if (state.size >= targetSize) return const getParent = state.database.query('SELECT chain FROM parents WHERE id = ?') const saveParent = state.database.query('INSERT OR REPLACE INTO parents VALUES (?, ?)') - const saveContext = state.database.query('INSERT OR REPLACE INTO context VALUES (?, ?, ?, ?)') + const saveContext = state.database.query('INSERT OR REPLACE INTO context VALUES (?, ?, ?)') const originalOffset = state.offset - let suppressed = state.suppressed - let completeSuppression = suppressed try { const fingerprint = await sourceAnchors(options.filePath, targetSize, signal) state.database.exec('BEGIN') - const result = await streamSessionMetadata(options.filePath, (entry, completeLine, offset) => { - const classification = options.classify(entry) + const result = await streamSessionMetadata(options.filePath, (entry, _completeLine, offset) => { const inherited = typeof entry.parentUuid === 'string' ? (getParent.get(entry.parentUuid) as { chain?: string } | null)?.chain : undefined const explicit = typeof entry.parent_tool_use_id === 'string' && entry.parent_tool_use_id ? entry.parent_tool_use_id : undefined const owner = explicit ?? (entry.isSidechain === true ? inherited : undefined) - const chain = classification.agentToolId ?? inherited + const chain = options.agentToolId(entry) ?? inherited if (typeof entry.uuid === 'string') saveParent.run(entry.uuid, chain ?? null) - const message = entry.message as { role?: unknown } | undefined - // A bounded text preview cannot prove whether a user record contains a - // task notification beyond its prefix. Keep uncertainty fail-closed; - // images and tool payloads do not affect this textual classification. - if (message?.role === 'user' && !entry.isMeta && isSessionMetadataTextTruncated(entry)) suppressed = null - else if (classification.notification) suppressed = true - else if (classification.reset) suppressed = false - // Keep root-only ownership filtering separate from notification state: - // a dedicated child transcript legitimately lacks its parent's Agent call. - saveContext.run(offset, owner ?? null, suppressed !== false ? 1 : 0, entry.isSidechain === true && !owner ? 1 : 0) - if (completeLine) completeSuppression = suppressed - }, signal, { startOffset: originalOffset, endOffset: targetSize, onSkipped: () => { suppressed = null; completeSuppression = null } }) + // Root-only ownership filtering: a dedicated child transcript legitimately + // lacks its parent's Agent call, so the flag is kept per record. + saveContext.run(offset, owner ?? null, entry.isSidechain === true && !owner ? 1 : 0) + }, signal, { startOffset: originalOffset, endOffset: targetSize }) if (fingerprint !== await sourceAnchors(options.filePath, targetSize, signal)) throw new ApiError(409, 'History was rewritten during context scan', 'HISTORY_CHANGED') state.database.exec('COMMIT') state.fingerprint = fingerprint state.size = targetSize state.mtime = mtime! state.offset = result.nextOffset - state.suppressed = completeSuppression scannedBytes += result.scannedBytes } catch (error) { try { state.database.exec('ROLLBACK') } catch { /* Validation may fail before BEGIN. */ } @@ -162,12 +151,12 @@ export async function readHistoryContexts(options: { }) } const state = cache.get(options.filePath)! - const query = state.database.query('SELECT owner, suppressed, unowned_sidechain FROM context WHERE offset = ?') + const query = state.database.query('SELECT owner, unowned_sidechain FROM context WHERE offset = ?') const contexts = new Map() for (const offset of options.offsets) { - const row = query.get(offset) as { owner: string | null; suppressed: number; unowned_sidechain: number } | null + const row = query.get(offset) as { owner: string | null; unowned_sidechain: number } | null if (!row) throw new ApiError(409, 'History context is unavailable; reload the page', 'HISTORY_CHANGED') - contexts.set(offset, { owner: row.owner ?? undefined, suppressed: row.suppressed === 1 || (!options.includeUnownedSidechains && row.unowned_sidechain === 1) }) + contexts.set(offset, { owner: row.owner ?? undefined, hidden: !options.includeUnownedSidechains && row.unowned_sidechain === 1 }) } return { contexts, scannedBytes } } diff --git a/src/server/services/sessionHistoryRecovery.test.ts b/src/server/services/sessionHistoryRecovery.test.ts index e2f5c256..df5c637f 100644 --- a/src/server/services/sessionHistoryRecovery.test.ts +++ b/src/server/services/sessionHistoryRecovery.test.ts @@ -104,16 +104,16 @@ test('launch metadata, title, work directory and metadata appends never material }) -test('history pages preserve cross-page notification suppression and sidechain ownership, and index only appended bytes', async () => { +test('history pages hide the notification turn but keep its reply, preserve sidechain ownership, and index only appended bytes', async () => { const notification = 'taskagentcompleted' await writeFile(file, [ entry('assistant', 'owner', [{ type: 'tool_use', id: 'agent', name: 'Agent', input: {} }]), entry('assistant', 'child', 'child response', { isSidechain: true, parentUuid: 'owner' }), entry('user', 'notice', notification), - entry('assistant', 'hidden', 'internal notification response'), + entry('assistant', 'reply', 'the agent finished, here is the result'), ].map(value => JSON.stringify(value)).join('\n') + '\n') const latest = await service.getSessionHistoryPage(id, { limit: 1 }) - expect(latest.messages).toEqual([]) + expect(latest.messages).toMatchObject([{ id: 'reply' }]) expect(latest.page.contextScanBytes).toBeGreaterThan(0) const noticePage = await service.getSessionHistoryPage(id, { limit: 1, cursor: latest.page.nextCursor! }) expect(noticePage.messages).toEqual([]) diff --git a/src/server/services/sessionHistoryRecovery.ts b/src/server/services/sessionHistoryRecovery.ts index 5e754922..da12c36d 100644 --- a/src/server/services/sessionHistoryRecovery.ts +++ b/src/server/services/sessionHistoryRecovery.ts @@ -56,7 +56,6 @@ export async function recoverBoundedSessionHistory(options: { let goalBase: Evidence | undefined let goalStatus: Evidence | undefined let omitted = 0 - let suppressTaskNotificationResponse = false const completeness = { goal: true, todos: true, activity: true, usage: true, workspace: true } const usage = { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 } const saveActivity = (evidence: Evidence, category: 'activity' | 'workspace' = 'activity') => { @@ -80,11 +79,9 @@ export async function recoverBoundedSessionHistory(options: { saveNotice.run(JSON.stringify([notice.ownerAgentId ?? null, notice.toolUseId]), ordinal, json) } const rawMessage = entry.message as { role?: string; content?: unknown } | undefined - const notificationUser = rawMessage?.role === 'user' && notifications.length > 0 - const hasToolResult = Array.isArray(rawMessage?.content) && rawMessage.content.some((block: any) => block?.type === 'tool_result') - if (notificationUser) { suppressTaskNotificationResponse = true; return } - if (rawMessage?.role === 'user' && !hasToolResult) suppressTaskNotificationResponse = false - else if (suppressTaskNotificationResponse) return + // The queued notification turn is plumbing (its data was saved above); the + // assistant's response to it is ordinary conversation and is kept. + if (rawMessage?.role === 'user' && notifications.length > 0) return const message = options.toMessage(entry, owner) if (!message) return if (message.usage && (!message.usageKey || usageKey.run(message.usageKey).changes > 0)) { diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 1f6889cf..c2fcea1d 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -1973,17 +1973,6 @@ export class SessionService { ) } - private isToolResultContent(content: unknown): boolean { - return ( - Array.isArray(content) && - content.some((block) => - block && - typeof block === 'object' && - (block as Record).type === 'tool_result' - ) - ) - } - private isTaskNotificationContent(content: unknown): boolean { const textBlocks = this.extractTextBlocks(content) return ( @@ -3990,19 +3979,11 @@ export class SessionService { offsets: result.entries.map(item => item.byteStart), signal, includeUnownedSidechains, - classify: raw => { - const entry = raw as RawEntry - const user = entry.message?.role === 'user' && !entry.isMeta - return { - notification: user && this.isTaskNotificationContent(entry.message?.content), - reset: user && !this.isToolResultContent(entry.message?.content), - agentToolId: this.extractAgentToolUseId(entry), - } - }, + agentToolId: raw => this.extractAgentToolUseId(raw as RawEntry), }) const visibleEntries = result.entries.flatMap(item => { const state = context.contexts.get(item.byteStart)! - if (state.suppressed && !this.isGoalLocalCommandEntry(item.entry as RawEntry)) return [] + if (state.hidden && !this.isGoalLocalCommandEntry(item.entry as RawEntry)) return [] return [{ ...item.entry, ...(state.owner ? { parent_tool_use_id: state.owner } : {}) } as RawEntry] }) return { entries: visibleEntries, contextScanBytes: context.scannedBytes } @@ -4152,18 +4133,13 @@ export class SessionService { const taskNotifications: SessionTaskNotification[] = [] let bytes = 0 let incomplete = false - let suppressTaskNotificationResponse = false const scan = await streamBoundedHistory(filePath, raw => { const entry = raw as RawEntry const message = raw.message as { role?: string; content?: unknown } | undefined - if (!entry.isMeta && message?.role === 'user') { - if (this.isTaskNotificationContent(message.content)) suppressTaskNotificationResponse = true - else if (!this.isToolResultContent(message.content)) suppressTaskNotificationResponse = false - } const content = Array.isArray(message?.content) ? message.content.filter((block: any) => block?.type === 'tool_use' ? ids.has(block.id) : block?.type === 'tool_result' && ids.has(block.tool_use_id)) : [] const notices = this.taskNotificationsFromEntries([entry]).filter(notice => ids.has(notice.toolUseId)) - const selected = content.length && !suppressTaskNotificationResponse + const selected = content.length ? displayPreview({ ...entry, message: { ...message, content } }) : undefined if (selected?.bodyTruncated) incomplete = true const selectedBytes = (selected ? Buffer.byteLength(JSON.stringify(selected)) : 0) + @@ -5238,7 +5214,6 @@ export class SessionService { const messages: MessageEntry[] = [] const entriesByUuid = new Map() const parentToolUseIdCache = new Map() - let suppressTaskNotificationResponse = false for (const entry of entries) { if (typeof entry.uuid === 'string' && entry.uuid.length > 0) { @@ -5261,23 +5236,9 @@ export class SessionService { // message that must render as an ordinary user-position bubble. if (entry.isMeta && !parseSessionCollaborationEnvelope(entry.message.content)) continue - const isTaskNotification = - entry.message.role === 'user' && - this.isTaskNotificationContent(entry.message.content) - if (isTaskNotification) { - suppressTaskNotificationResponse = true - continue - } - - if ( - entry.message.role === 'user' && - !this.isToolResultContent(entry.message.content) - ) { - suppressTaskNotificationResponse = false - } else if (suppressTaskNotificationResponse) { - continue - } - + // The queued turn is system plumbing and is hidden here (the + // Activity cards come from its notification data). What the assistant does in + // response is ordinary conversation, so it is never dropped with it. if (this.shouldHideTranscriptEntry(entry)) continue // Skip non-transcript entry types diff --git a/src/server/services/subagentRunService.test.ts b/src/server/services/subagentRunService.test.ts index f1dc0c2f..798c8f62 100644 --- a/src/server/services/subagentRunService.test.ts +++ b/src/server/services/subagentRunService.test.ts @@ -587,7 +587,7 @@ describe('getSubagentRunByTool', () => { ) }) - it('returns child shell notifications without exposing notification turns or their response', async () => { + it('returns child shell notifications while hiding the notification turn but keeping the response to it', async () => { await setupTmpConfigDir() const sessionId = '12121212-bbbb-cccc-dddd-eeeeeeeeeeee' const projectDir = '-tmp-subagent-run' @@ -639,7 +639,7 @@ describe('getSubagentRunByTool', () => { type: 'assistant', message: { role: 'assistant', - content: [{ type: 'text', text: 'Internal notification response' }], + content: [{ type: 'text', text: 'The child shell was stopped, continuing the review' }], }, uuid: 'child-notification-response', timestamp: '2026-01-01T00:00:08.000Z', @@ -648,9 +648,9 @@ describe('getSubagentRunByTool', () => { const result = await getSubagentRunByTool(sessionId, toolUseId) - expect(result?.messages).toHaveLength(2) + expect(result?.messages).toHaveLength(3) expect(JSON.stringify(result?.messages)).not.toContain('') - expect(JSON.stringify(result?.messages)).not.toContain('Internal notification response') + expect(JSON.stringify(result?.messages)).toContain('The child shell was stopped, continuing the review') expect(result?.taskNotifications).toEqual([{ taskId: 'shell-task-1', toolUseId: 'shell.call:0', @@ -659,7 +659,7 @@ describe('getSubagentRunByTool', () => { outputFile: '/tmp/shell-task-1.output', timestamp: '2026-01-01T00:00:07.000Z', }]) - expect(result?.updatedAt).toBe('2026-01-01T00:00:07.000Z') + expect(result?.updatedAt).toBe('2026-01-01T00:00:08.000Z') }) it('uses the live task id to resolve a running one-shot SubAgent transcript', async () => { @@ -1677,7 +1677,7 @@ describe('getSubagentRunByAgentId', () => { expect(result?.canSendMessage).toBe(false) }) - it('returns workflow-agent task notifications beside its filtered transcript', async () => { + it('returns workflow-agent task notifications beside its transcript without the notification turn', async () => { await setupTmpConfigDir() const sessionId = '90909090-bbbb-cccc-dddd-ffffffffffff' const projectDir = '-tmp-workflow-agent' @@ -1702,7 +1702,7 @@ describe('getSubagentRunByAgentId', () => { }, { type: 'assistant', - message: { role: 'assistant', content: 'Internal notification response' }, + message: { role: 'assistant', content: 'The workflow check passed, moving on' }, uuid: 'wf-notification-response', timestamp: '2026-01-01T00:00:07.000Z', }, @@ -1710,7 +1710,7 @@ describe('getSubagentRunByAgentId', () => { const result = await getSubagentRunByAgentId(sessionId, agentId) - expect(result?.messages.map(message => message.id)).toEqual(['wf-assistant']) + expect(result?.messages.map(message => message.id)).toEqual(['wf-assistant', 'wf-notification-response']) expect(result?.taskNotifications).toEqual([{ taskId: 'wf-shell-task', toolUseId: 'wf-shell-tool', @@ -1718,7 +1718,7 @@ describe('getSubagentRunByAgentId', () => { summary: 'Workflow check passed', timestamp: '2026-01-01T00:00:06.000Z', }]) - expect(result?.updatedAt).toBe('2026-01-01T00:00:06.000Z') + expect(result?.updatedAt).toBe('2026-01-01T00:00:07.000Z') }) it('returns null when no transcript exists for that agent', async () => {