Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions web/src/chat/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export type LatestUsage = {

export function reduceChatBlocks(
normalized: NormalizedMessage[],
agentState: AgentState | null | undefined
agentState: AgentState | null | undefined,
options?: { isClaudeSession?: boolean }
): { blocks: ChatBlock[]; hasReadyEvent: boolean; latestUsage: LatestUsage | null } {
const permissionsById = getPermissions(agentState)
const toolIdsInMessages = collectToolIdsFromMessages(normalized)
Expand All @@ -43,7 +44,7 @@ export function reduceChatBlocks(

const consumedGroupIds = new Set<string>()
const emittedTitleChangeToolUseIds = new Set<string>()
const reducerContext = { permissionsById, groups, consumedGroupIds, titleChangesByToolUseId, emittedTitleChangeToolUseIds }
const reducerContext = { permissionsById, groups, consumedGroupIds, titleChangesByToolUseId, emittedTitleChangeToolUseIds, isClaudeSession: options?.isClaudeSession }
const rootResult = reduceTimeline(root, reducerContext)
let hasReadyEvent = rootResult.hasReadyEvent

Expand Down
109 changes: 109 additions & 0 deletions web/src/chat/reducerTimeline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import { reduceTimeline } from './reducerTimeline'
import type { TracedMessage } from './tracer'

function makeContext(overrides?: { isClaudeSession?: boolean }) {
return {
permissionsById: new Map(),
groups: new Map(),
consumedGroupIds: new Set<string>(),
titleChangesByToolUseId: new Map(),
emittedTitleChangeToolUseIds: new Set<string>(),
isClaudeSession: overrides?.isClaudeSession ?? true
}
}

function makeUserMessage(text: string, overrides?: Partial<TracedMessage>): TracedMessage {
return {
id: 'msg-1',
localId: null,
createdAt: 1_700_000_000_000,
role: 'user',
content: { type: 'text', text },
isSidechain: false,
...overrides
} as TracedMessage
}

describe('reduceTimeline – system injection filtering', () => {
it('converts <task-notification> with summary to agent-event', () => {
const text = `<task-notification> <task-id>abc</task-id> <status>killed</status> <summary>Background command "Download benchmarks" was stopped</summary> </task-notification>`
const { blocks } = reduceTimeline([makeUserMessage(text)], makeContext())

expect(blocks).toHaveLength(1)
expect(blocks[0].kind).toBe('agent-event')
if (blocks[0].kind === 'agent-event') {
expect(blocks[0].event).toEqual({
type: 'message',
message: 'Background command "Download benchmarks" was stopped'
})
}
})

it('silently drops <task-notification> without summary', () => {
const text = `<task-notification> <task-id>abc</task-id> <status>killed</status> </task-notification>`
const { blocks } = reduceTimeline([makeUserMessage(text)], makeContext())

expect(blocks).toHaveLength(0)
})

it('silently drops <task-notification> with empty summary', () => {
const text = `<task-notification> <summary></summary> <status>completed</status> </task-notification>`
const { blocks } = reduceTimeline([makeUserMessage(text)], makeContext())

expect(blocks).toHaveLength(0)
})

it('handles <task-notification> with leading whitespace', () => {
const text = ` \n <task-notification> <summary>Task done</summary> </task-notification>`
const { blocks } = reduceTimeline([makeUserMessage(text)], makeContext())

expect(blocks).toHaveLength(1)
expect(blocks[0].kind).toBe('agent-event')
})

it('hides <system-reminder> messages', () => {
const text = `<system-reminder>\nSome internal reminder\n</system-reminder>`
const { blocks } = reduceTimeline([makeUserMessage(text)], makeContext())

expect(blocks).toHaveLength(0)
})

it('hides <command-name> messages', () => {
const text = `<command-name>commit</command-name>`
const { blocks } = reduceTimeline([makeUserMessage(text)], makeContext())

expect(blocks).toHaveLength(0)
})

it('hides <local-command-caveat> messages', () => {
const text = `<local-command-caveat>some caveat</local-command-caveat>`
const { blocks } = reduceTimeline([makeUserMessage(text)], makeContext())

expect(blocks).toHaveLength(0)
})

it('passes through normal user text as user-text block', () => {
const text = 'Hello, this is a normal message'
const { blocks } = reduceTimeline([makeUserMessage(text)], makeContext())

expect(blocks).toHaveLength(1)
expect(blocks[0].kind).toBe('user-text')
})

it('does not filter system tags in non-Claude sessions', () => {
const text = `<task-notification> <summary>Some task</summary> </task-notification>`
const { blocks } = reduceTimeline([makeUserMessage(text)], makeContext({ isClaudeSession: false }))

expect(blocks).toHaveLength(1)
expect(blocks[0].kind).toBe('user-text')
})

it('does not filter <system-reminder> in non-Claude sessions', () => {
const text = `<system-reminder>Some reminder</system-reminder>`
const { blocks } = reduceTimeline([makeUserMessage(text)], makeContext({ isClaudeSession: false }))

expect(blocks).toHaveLength(1)
expect(blocks[0].kind).toBe('user-text')
})
})
42 changes: 42 additions & 0 deletions web/src/chat/reducerTimeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ import { createCliOutputBlock, isCliOutputText, mergeCliOutputBlocks } from '@/c
import { parseMessageAsEvent } from '@/chat/reducerEvents'
import { ensureToolBlock, extractTitleFromChangeTitleInput, isChangeTitleToolName, type PermissionEntry } from '@/chat/reducerTools'

/**
* XML tags that Claude Code injects as user-role messages.
* These must be filtered out or converted before rendering in the web UI.
* Mirrors SYSTEM_INJECTION_PREFIXES in cli/src/api/apiSession.ts.
*/
const SYSTEM_INJECTION_PREFIXES = [
'<task-notification>',
'<command-name>',
'<local-command-caveat>',
'<system-reminder>',
]

function isSystemInjectedMessage(text: string): boolean {
const trimmed = text.trimStart()
return SYSTEM_INJECTION_PREFIXES.some(p => trimmed.startsWith(p))
}

function parseTaskNotificationSummary(text: string): string | null {
const trimmed = text.trimStart()
if (!trimmed.startsWith('<task-notification>')) return null
const summary = trimmed.match(/<summary>([\s\S]*?)<\/summary>/)?.[1]?.trim()
// Return null for missing/empty summary so isSystemInjectedMessage silently drops it
return summary || null
}

export function reduceTimeline(
messages: TracedMessage[],
context: {
Expand All @@ -12,6 +37,7 @@ export function reduceTimeline(
consumedGroupIds: Set<string>
titleChangesByToolUseId: Map<string, string>
emittedTitleChangeToolUseIds: Set<string>
isClaudeSession?: boolean
}
): { blocks: ChatBlock[]; toolBlocksById: Map<string, ToolCallBlock>; hasReadyEvent: boolean } {
const blocks: ChatBlock[] = []
Expand Down Expand Up @@ -47,6 +73,22 @@ export function reduceTimeline(
}

if (msg.role === 'user') {
if (context.isClaudeSession) {
const taskSummary = parseTaskNotificationSummary(msg.content.text)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] parseTaskNotificationSummary() is wired into the msg.role === 'user' reducer path, but real Claude injections never reach this branch. sendClaudeSessionMessage() stores non-external Claude XML as role: 'agent' / type: 'output' (cli/src/api/apiSession.ts:364), and normalizeUserOutput() still returns null for <task-notification> before reduction (web/src/chat/normalizeAgent.ts:117). That means the new agent-event behavior is unreachable in production, while the added test only covers a synthetic traced role: 'user' message (web/src/chat/reducerTimeline.test.ts:29).

Suggested fix:

if (typeof messageContent === 'string') {
    const trimmed = messageContent.trimStart()
    if (trimmed.startsWith('<task-notification>')) {
        const summary = trimmed.match(/<summary>([\s\S]*?)<\/summary>/)?.[1]?.trim()
        if (!summary) return null
        return {
            id: messageId,
            localId,
            createdAt,
            role: 'event',
            content: { type: 'message', message: summary },
            isSidechain: false,
            meta
        }
    }
    if (
        trimmed.startsWith('<system-reminder>') ||
        trimmed.startsWith('<command-name>') ||
        trimmed.startsWith('<local-command-caveat>')
    ) {
        return null
    }
}

if (taskSummary) {
blocks.push({
kind: 'agent-event',
id: msg.id,
createdAt: msg.createdAt,
event: { type: 'message', message: taskSummary },
meta: msg.meta
})
continue
}
if (isSystemInjectedMessage(msg.content.text)) {
continue
}
}
if (isCliOutputText(msg.content.text, msg.meta)) {
blocks.push(createCliOutputBlock({
id: msg.id,
Expand Down
6 changes: 4 additions & 2 deletions web/src/components/SessionChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,11 @@ export function SessionChat(props: {
return normalized
}, [props.messages])

const isClaudeSession = Boolean(props.session.metadata?.claudeSessionId)

const reduced = useMemo(
() => reduceChatBlocks(normalizedMessages, props.session.agentState),
[normalizedMessages, props.session.agentState]
() => reduceChatBlocks(normalizedMessages, props.session.agentState, { isClaudeSession }),
[normalizedMessages, props.session.agentState, isClaudeSession]
)
const reconciled = useMemo(
() => reconcileChatBlocks(reduced.blocks, blocksByIdRef.current),
Expand Down
Loading