diff --git a/web/src/chat/normalize.test.ts b/web/src/chat/normalize.test.ts index 64600669..9a56c102 100644 --- a/web/src/chat/normalize.test.ts +++ b/web/src/chat/normalize.test.ts @@ -105,13 +105,14 @@ describe('normalizeDecryptedMessage', () => { expect(firstBlock.text).toContain('"foo": "bar"') }) - it('converts user output to event', () => { + it('normalizes user output as sidechain (event extracted by reducer)', () => { const message = makeMessage({ role: 'agent', content: { type: 'output', data: { type: 'user', + uuid: 'u-notif', message: { content: ' Background command stopped ' } } } @@ -119,12 +120,18 @@ describe('normalizeDecryptedMessage', () => { const normalized = normalizeDecryptedMessage(message) + // Normalizer emits as sidechain (preserving uuid for sentinel detection); + // the reducer extracts the summary as an event. expect(normalized).toMatchObject({ - id: 'msg-1', - role: 'event', - isSidechain: false, - content: { type: 'message', message: 'Background command stopped' } + role: 'agent', + isSidechain: true, }) + if (normalized?.role === 'agent') { + expect(normalized.content[0]).toMatchObject({ + type: 'sidechain', + prompt: expect.stringContaining('') + }) + } }) it('treats without summary as sidechain (dropped by reducer)', () => { @@ -195,4 +202,121 @@ describe('normalizeDecryptedMessage', () => { isSidechain: true, }) }) + + it('treats sidechain user output with array content as sidechain', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + uuid: 'u3', + isSidechain: true, + message: { content: [{ type: 'text', text: 'This is an agent prompt in array form' }] } + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + + expect(normalized).toMatchObject({ + role: 'agent', + isSidechain: true, + }) + if (normalized?.role !== 'agent') throw new Error('Expected agent') + expect(normalized.content[0]).toMatchObject({ + type: 'sidechain', + prompt: 'This is an agent prompt in array form' + }) + }) + + it('keeps "No response requested." text in normalized output (filtered later by reducer)', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + uuid: 'a-1', + message: { role: 'assistant', content: 'No response requested.' } + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + // Normalizer preserves the text (uuid/parentUUID needed by tracer); + // the reducer is responsible for suppressing it during rendering. + expect(normalized).not.toBeNull() + expect(normalized?.role).toBe('agent') + if (normalized?.role === 'agent') { + expect(normalized.content).toHaveLength(1) + expect(normalized.content[0]).toMatchObject({ type: 'text', text: 'No response requested.' }) + } + }) + + it('keeps assistant messages with real content', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + uuid: 'a-2', + message: { role: 'assistant', content: 'Here is the answer.' } + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + expect(normalized).not.toBeNull() + expect(normalized?.role).toBe('agent') + }) + + it('propagates parentUuid from assistant output data to text block parentUUID', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + uuid: 'a-3', + parentUuid: 'parent-injected-uuid', + message: { role: 'assistant', content: 'No response requested.' } + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + expect(normalized).not.toBeNull() + if (normalized?.role !== 'agent') throw new Error('Expected agent') + expect(normalized.content).toHaveLength(1) + expect(normalized.content[0]).toMatchObject({ + type: 'text', + text: 'No response requested.', + parentUUID: 'parent-injected-uuid' + }) + }) + + it('sets parentUUID to null when parentUuid is absent in assistant output', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + uuid: 'a-4', + // No parentUuid field + message: { role: 'assistant', content: 'Hello.' } + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + expect(normalized).not.toBeNull() + if (normalized?.role !== 'agent') throw new Error('Expected agent') + expect(normalized.content[0]).toMatchObject({ + type: 'text', + parentUUID: null + }) + }) }) diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index c9b50f8b..b0084b6c 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -116,41 +116,46 @@ function normalizeUserOutput( createdAt, role: 'agent', isSidechain: true, - content: [{ type: 'sidechain', uuid, prompt: messageContent }] + content: [{ type: 'sidechain', uuid, parentUUID, prompt: messageContent }] } } // Handle system-injected messages that arrive as type:'user' through // the agent output path. Real user text goes through normalizeUserRecord. + // + // All string-content user messages here are system-injected (subagent + // prompts, task notifications, system reminders, etc.). Always emit as + // sidechain so the uuid/parentUUID chain is preserved — the reducer uses + // sidechain UUIDs to identify sentinel auto-replies. Task-notification + // summaries are extracted as events by the reducer, not here. if (typeof messageContent === 'string') { - // Convert to a visible event - const trimmed = messageContent.trimStart() - if (trimmed.startsWith('')) { - const summary = trimmed.match(/([\s\S]*?)<\/summary>/)?.[1]?.trim() - if (summary) { - return { - id: messageId, - localId, - createdAt, - role: 'event', - content: { type: 'message', message: summary }, - isSidechain: false, - meta - } - } - } - - // All other string-content user messages in this path are - // system-injected (subagent prompts, system reminders, etc.). - // Treat as sidechain so the tracer can match it to a parent Task - // tool call; unmatched ones are harmlessly skipped by the reducer. return { id: messageId, localId, createdAt, role: 'agent', isSidechain: true, - content: [{ type: 'sidechain', uuid, prompt: messageContent }] + content: [{ type: 'sidechain', uuid, parentUUID, prompt: messageContent }] + } + } + + // Sidechain user messages with array content (e.g. subagent prompts + // that Claude Code serialised as [{type:'text', text:'...'}] instead + // of a plain string). Extract the text and treat as sidechain so the + // tracer can match it to the parent Task tool call. + if (isSidechain && Array.isArray(messageContent)) { + const textParts = messageContent + .filter((b: unknown) => isObject(b) && b.type === 'text' && typeof b.text === 'string') + .map((b: Record) => b.text as string) + if (textParts.length > 0) { + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain: true, + content: [{ type: 'sidechain', uuid, parentUUID, prompt: textParts.join('\n\n') }] + } } } diff --git a/web/src/chat/reducerTimeline.test.ts b/web/src/chat/reducerTimeline.test.ts index e572ed45..f28550fc 100644 --- a/web/src/chat/reducerTimeline.test.ts +++ b/web/src/chat/reducerTimeline.test.ts @@ -24,6 +24,18 @@ function makeUserMessage(text: string, overrides?: Partial): Trac } as TracedMessage } +function makeAgentMessage(text: string, overrides?: Partial): TracedMessage { + return { + id: 'msg-agent-1', + localId: null, + createdAt: 1_700_000_000_000, + role: 'agent', + content: [{ type: 'text', text, uuid: 'u-1', parentUUID: null }], + isSidechain: false, + ...overrides + } as TracedMessage +} + describe('reduceTimeline', () => { it('renders user text as user-text block', () => { const text = 'Hello, this is a normal message' @@ -40,4 +52,138 @@ describe('reduceTimeline', () => { expect(blocks).toHaveLength(1) expect(blocks[0].kind).toBe('user-text') }) + + it('suppresses "No response requested." when parentUUID points to an injected turn', () => { + // Simulate: sidechain message with uuid 'injected-uuid', then sentinel reply pointing to it + const injectedMsg: TracedMessage = { + id: 'msg-injected', + localId: null, + createdAt: 1_700_000_000_000, + role: 'agent', + content: [{ type: 'sidechain', uuid: 'injected-uuid', prompt: '...' }], + isSidechain: true + } as TracedMessage + + const sentinelMsg: TracedMessage = { + id: 'msg-sentinel', + localId: null, + createdAt: 1_700_000_001_000, + role: 'agent', + content: [{ type: 'text', text: 'No response requested.', uuid: 'u-1', parentUUID: 'injected-uuid' }], + isSidechain: false + } as TracedMessage + + const { blocks } = reduceTimeline([injectedMsg, sentinelMsg], makeContext()) + const textBlocks = blocks.filter(b => b.kind === 'agent-text') + expect(textBlocks).toHaveLength(0) + }) + + it('keeps "No response requested." when parentUUID points to a normal turn (not injected)', () => { + // parentUUID points to a normal assistant message, not an injected turn + const normalMsg: TracedMessage = { + id: 'msg-normal', + localId: null, + createdAt: 1_700_000_000_000, + role: 'agent', + content: [{ type: 'text', text: 'Hello!', uuid: 'normal-uuid', parentUUID: null }], + isSidechain: false + } as TracedMessage + + const replyMsg: TracedMessage = { + id: 'msg-reply', + localId: null, + createdAt: 1_700_000_001_000, + role: 'agent', + content: [{ type: 'text', text: 'No response requested.', uuid: 'u-2', parentUUID: 'normal-uuid' }], + isSidechain: false + } as TracedMessage + + const { blocks } = reduceTimeline([normalMsg, replyMsg], makeContext()) + const textBlocks = blocks.filter(b => b.kind === 'agent-text') + // Should be 2: "Hello!" + "No response requested." (not filtered because parent is normal) + expect(textBlocks).toHaveLength(2) + }) + + it('keeps "No response requested." when parentUUID is null (first message)', () => { + const { blocks } = reduceTimeline([makeAgentMessage('No response requested.')], makeContext()) + const textBlocks = blocks.filter(b => b.kind === 'agent-text') + expect(textBlocks).toHaveLength(1) + }) + + it('keeps "No response requested." when message also has other blocks (e.g. tool calls)', () => { + const injectedMsg: TracedMessage = { + id: 'msg-injected', + localId: null, + createdAt: 1_700_000_000_000, + role: 'agent', + content: [{ type: 'sidechain', uuid: 'injected-uuid', prompt: 'system content' }], + isSidechain: true + } as TracedMessage + + const multiMsg: TracedMessage = { + id: 'msg-multi', + localId: null, + createdAt: 1_700_000_001_000, + role: 'agent', + content: [ + { type: 'text', text: 'No response requested.', uuid: 'u-1', parentUUID: 'injected-uuid' }, + { type: 'tool-call', id: 'tc-1', name: 'Bash', input: { command: 'ls' }, description: null, uuid: 'u-1', parentUUID: 'injected-uuid' } + ], + isSidechain: false + } as TracedMessage + + const { blocks } = reduceTimeline([injectedMsg, multiMsg], makeContext()) + const textBlocks = blocks.filter(b => b.kind === 'agent-text') + expect(textBlocks).toHaveLength(1) + }) + + it('keeps normal assistant text blocks', () => { + const { blocks } = reduceTimeline([makeAgentMessage('Here is the answer.')], makeContext()) + + const textBlocks = blocks.filter(b => b.kind === 'agent-text') + expect(textBlocks).toHaveLength(1) + }) + + it('extracts task-notification summary as event from sidechain block', () => { + const msg: TracedMessage = { + id: 'msg-notif', + localId: null, + createdAt: 1_700_000_000_000, + role: 'agent', + content: [{ type: 'sidechain', uuid: 'n-1', prompt: ' Background command stopped ' }], + isSidechain: true + } as TracedMessage + + const { blocks } = reduceTimeline([msg], makeContext()) + const events = blocks.filter(b => b.kind === 'agent-event') + expect(events).toHaveLength(1) + expect((events[0] as any).event.message).toBe('Background command stopped') + }) + + it('suppresses sentinel reply to task-notification (summary path)', () => { + const notifMsg: TracedMessage = { + id: 'msg-notif', + localId: null, + createdAt: 1_700_000_000_000, + role: 'agent', + content: [{ type: 'sidechain', uuid: 'notif-uuid', prompt: ' Done ' }], + isSidechain: true + } as TracedMessage + + const sentinelMsg: TracedMessage = { + id: 'msg-sentinel', + localId: null, + createdAt: 1_700_000_001_000, + role: 'agent', + content: [{ type: 'text', text: 'No response requested.', uuid: 'u-1', parentUUID: 'notif-uuid' }], + isSidechain: false + } as TracedMessage + + const { blocks } = reduceTimeline([notifMsg, sentinelMsg], makeContext()) + const textBlocks = blocks.filter(b => b.kind === 'agent-text') + expect(textBlocks).toHaveLength(0) + // But the event should still be present + const events = blocks.filter(b => b.kind === 'agent-event') + expect(events).toHaveLength(1) + }) }) diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index 1d40714e..e434af97 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -18,6 +18,20 @@ export function reduceTimeline( const toolBlocksById = new Map() let hasReadyEvent = false + // Pre-scan: collect UUIDs of system-injected user turns (sidechain + // prompts, task notifications, system reminders). These are used below + // to identify sentinel auto-replies ("No response requested.") whose + // parentUUID points to one of these injected messages. + const injectedTurnUuids = new Set() + for (const msg of messages) { + if (msg.role !== 'agent' || !msg.isSidechain) continue + for (const c of msg.content) { + if (c.type === 'sidechain') { + injectedTurnUuids.add(c.uuid) + } + } + } + for (const msg of messages) { if (msg.role === 'event') { if (msg.content.type === 'ready') { @@ -92,6 +106,25 @@ export function reduceTimeline( for (let idx = 0; idx < msg.content.length; idx += 1) { const c = msg.content[idx] if (c.type === 'text') { + // Skip "No response requested." — Claude's sentinel auto-response + // to system-injected messages (task notifications, system reminders). + // + // Structural checks to avoid false positives: + // 1. msg.content.length === 1 — no tool calls or reasoning alongside + // 2. c.parentUUID points to a known injected turn UUID (collected + // in pre-scan from sidechain content blocks) + // 3. Exact text match on the known sentinel phrase + if ( + msg.content.length === 1 && + c.parentUUID !== null && + injectedTurnUuids.has(c.parentUUID) + ) { + const trimmedText = c.text.trim() + if (trimmedText === 'No response requested.' || trimmedText === 'No response requested') { + continue + } + } + // Skip text blocks that are just the Task tool prompt (already shown in tool card) if (taskPromptText && c.text.trim() === taskPromptText.trim()) continue @@ -240,7 +273,21 @@ export function reduceTimeline( } if (c.type === 'sidechain') { - // Skip - the prompt is already visible in the parent Task tool call's input + // Extract task-notification summaries as visible events + const trimmedPrompt = c.prompt.trimStart() + if (trimmedPrompt.startsWith('')) { + const summary = trimmedPrompt.match(/([\s\S]*?)<\/summary>/)?.[1]?.trim() + if (summary) { + blocks.push({ + kind: 'agent-event', + id: `${msg.id}:${idx}`, + createdAt: msg.createdAt, + event: { type: 'message', message: summary }, + meta: msg.meta + }) + } + } + // Skip rendering prompt text (already in parent Task tool card or not user-visible) continue } } diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 0cce7591..7261c933 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -65,7 +65,7 @@ export type NormalizedAgentContent = | ToolUse | ToolResult | { type: 'summary'; summary: string } - | { type: 'sidechain'; uuid: string; prompt: string } + | { type: 'sidechain'; uuid: string; parentUUID: string | null; prompt: string } export type NormalizedMessage = ({ role: 'user'