fix: show the model's replies to background task notifications

Everything the assistant did between a <task-notification> 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
This commit is contained in:
程序员阿江(Relakkes)
2026-09-29 15:45:10 +08:00
parent 2ec845d084
commit fba81834de
12 changed files with 565 additions and 312 deletions
+374 -13
View File
@@ -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) =>
`<task-notification>\n<task-id>${taskId}</task-id>\n<tool-use-id>${toolUseId}</tool-use-id>\n<status>completed</status>\n<summary>Background command "Background sleep then write ${name}" completed (exit code 0)</summary>\n</task-notification>`
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('<task-notification>')
})
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: '<task-notification>\n<task-id>build-task</task-id>\n<tool-use-id>toolu_build</tool-use-id>\n<status>failed</status>\n<summary>Background command "npm run build" failed with exit code 2</summary>\n</task-notification>',
},
{
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('<task-notification>')
})
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: '<task-notification>\n<task-id>raw-task</task-id>\n<tool-use-id>toolu_raw</tool-use-id>\n<status>completed</status>\n<summary>Background command completed</summary>\n</task-notification>',
},
{
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('<task-notification>')
})
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('<task-notification>')
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 = [
'<task-notification>',
'<task-id>build-task</task-id>',
'<tool-use-id>toolu_build</tool-use-id>',
'<status>failed</status>',
'<summary>Background command "npm run build" failed with exit code 2</summary>',
'</task-notification>',
].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('<task-notification>')
})
})
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',
+5 -93
View File
@@ -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<ChatStore>((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<ChatStore>((setState, get) => {
pendingComputerUsePermissions: {},
apiRetry: null,
streamingFallback: null,
suppressNextTaskNotificationResponse: false,
stoppingBackgroundTaskIds,
stopAllSubagentsRequested: true,
elapsedTimer: null,
@@ -4502,7 +4489,6 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((setState, get) => {
preHydrationSocketGapPending: false,
apiRetry: null,
streamingFallback: null,
suppressNextTaskNotificationResponse: false,
replaceHistoryOnCompletion: false,
queuedUserMessages: [],
})) }))
@@ -4912,18 +4897,6 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((setState, get) => {
pendingComputerUsePermissions: {},
apiRetry: null,
streamingFallback: null,
suppressNextTaskNotificationResponse: false,
historyMutationEpoch: (s.historyMutationEpoch ?? 0) + 1,
}
})
@@ -5977,18 +5902,11 @@ export const useChatStore = create<ChatStore>((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 (
+2 -2
View File
@@ -1409,8 +1409,8 @@ export const useTeamStore = create<TeamStore>((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,
+1 -1
View File
@@ -83,7 +83,7 @@ Claude 每完成一轮并修改文件,对话里会出现一张「{n} 个文件
- **任务** — Claude 自己维护的待办清单,顶部显示「任务进度 3/7」。
- **SubAgent** — 它派出去的子 Agent,点进去能看子 Agent 完整的运行记录。
- **后台任务** — 挂在后台跑的命令和工作流,可以单独停掉某一个。
- **后台任务** — 挂在后台跑的命令和工作流,可以单独停掉某一个。任务结束时 Claude 会收到通知并接着处理,它的回复照常出现在对话里。
- **团队** — 用到 Agent Team 时,每个成员一行,可以点进去直接给某个成员发消息。
后台跑的子 Agent 的工具活动也会冒泡到这里,不用等它跑完才知道它在干什么。
+1 -1
View File
@@ -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.
+91 -12
View File
@@ -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('<task-notification>')
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
// <task-notification> 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) => [
'<task-notification>',
`<task-id>${taskId}</task-id>`,
`<tool-use-id>call_${taskId}</tool-use-id>`,
`<output-file>/tmp/${taskId}.output</output-file>`,
'<status>completed</status>',
`<summary>Background command "sleep then write ${taskId}" completed (exit code 0)</summary>`,
'</task-notification>',
].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('<task-notification>')
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', () => {
'<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>failed</status>\n<summary>Background command failed &amp; stopped</summary>\n<result>Stack trace &amp; failed assertion</result>\n<output-file>C:\\Temp\\bg.output</output-file>\n</task-notification>',
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('<task-notification>')
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('<task-notification>')
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',
@@ -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<string, unknown>) => ({ notification: entry.notification === true, reset: entry.reset === true, agentToolId: typeof entry.agent === 'string' ? entry.agent : undefined })
const agentToolId = (entry: Record<string, unknown>) => typeof entry.agent === 'string' ? entry.agent : undefined
const messageAgentToolId = (entry: Record<string, unknown>) => {
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<string, unknown>) => { 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<string, unknown>) => { 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<string, unknown>) => {
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 => /<task-notification>/.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: '<task-notification>done</task-notification>' } }) + '\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: '<task-notification>done</task-notification>' } }) + '\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) + '<task-notification>done</task-notification>' } }) + '\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: '<task-notification>done</task-notification>' } }) + '\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 })
})
+17 -28
View File
@@ -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<void>; controller: AbortController; users: number }
const cache = new Map<string, Cache>()
const flights = new Map<string, Flight>()
@@ -42,7 +43,7 @@ export async function readHistoryContexts(options: {
offsets: number[]
signal?: AbortSignal
includeUnownedSidechains?: boolean
classify: (entry: Record<string, unknown>) => { notification: boolean; reset: boolean; agentToolId?: string }
agentToolId: (entry: Record<string, unknown>) => string | undefined
}): Promise<{ contexts: Map<number, Context>; 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<number, Context>()
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 }
}
@@ -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 = '<task-notification><task-id>task</task-id><tool-use-id>agent</tool-use-id><status>completed</status></task-notification>'
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([])
@@ -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)) {
+6 -45
View File
@@ -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<string, unknown>).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<string, RawEntry>()
const parentToolUseIdCache = new Map<string, string | undefined>()
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 <task-notification> 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
@@ -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('<task-notification>')
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 () => {