mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
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:
@@ -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',
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -83,7 +83,7 @@ Claude 每完成一轮并修改文件,对话里会出现一张「{n} 个文件
|
||||
|
||||
- **任务** — Claude 自己维护的待办清单,顶部显示「任务进度 3/7」。
|
||||
- **SubAgent** — 它派出去的子 Agent,点进去能看子 Agent 完整的运行记录。
|
||||
- **后台任务** — 挂在后台跑的命令和工作流,可以单独停掉某一个。
|
||||
- **后台任务** — 挂在后台跑的命令和工作流,可以单独停掉某一个。任务结束时 Claude 会收到通知并接着处理,它的回复照常出现在对话里。
|
||||
- **团队** — 用到 Agent Team 时,每个成员一行,可以点进去直接给某个成员发消息。
|
||||
|
||||
后台跑的子 Agent 的工具活动也会冒泡到这里,不用等它跑完才知道它在干什么。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 & stopped</summary>\n<result>Stack trace & 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 })
|
||||
})
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user