From 36022a0a1a235c1bb3bc9235cb72391cb5c99f75 Mon Sep 17 00:00:00 2001 From: Haoqing Wang <78337154+hqhq1025@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:08:03 +0800 Subject: [PATCH] fix(web): filter system-injected XML tags from rendering as raw text (#387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): filter system-injected XML tags from rendering as raw text Claude Code injects internal messages (, , , ) as user-role messages. The web UI was rendering these as raw XML text visible to users. - Parse and display as agent-event with summary text - Silently drop , , - Add tests covering all injection prefixes and edge cases * fix(web): scope system injection filtering to Claude sessions only Address review feedback: the XML tag filtering was applied at the generic timeline layer, which could incorrectly hide legitimate user messages in Codex/Gemini sessions. - Add isClaudeSession flag threaded from Session.metadata.claudeSessionId - Only filter system-injected tags when isClaudeSession is true - Add tests verifying non-Claude sessions pass through all messages * fix(web): treat all string user output as sidechain to prevent prompt leaks Restores the fix from 3cf96ab that was accidentally reverted in 2205e04. In normalizeUserOutput(), string-content user messages arriving through the agent output path are never real user input (real user text goes through normalizeUserRecord). Previously, non-sidechain string messages were emitted as role:'user', causing subagent prompts and system-injected messages to render as user text in the web UI. Now all string-content user messages in this path are: - with summary → converted to role:'event' - Everything else → marked as sidechain (matched to parent Task tool call by the tracer, or harmlessly skipped by the reducer) This provides a root-level fix that prevents ANY string user message from the agent output path from leaking as visible user text. * ci: retrigger CI * fix(web): remove superseded return-null filter from upstream PR #372 The upstream `return null` filter for and (from PR #372) is now superseded by the comprehensive sidechain upgrade logic. Remove it to avoid short-circuiting the new task-notification → event conversion. * refactor(web): remove reducer-side system injection filtering System-injected messages are now fully handled in normalizeUserOutput() (normalize layer), so the redundant filtering in reduceTimeline() is no longer needed. Removing it also eliminates the risk of accidentally hiding legitimate user messages that happen to start with XML tags. - Remove SYSTEM_INJECTION_PREFIXES, isSystemInjectedMessage, parseTaskNotificationSummary from reducerTimeline.ts - Remove isClaudeSession plumbing from reducer.ts and SessionChat.tsx - Simplify reducerTimeline.test.ts to only test pass-through behavior --- web/src/chat/normalize.test.ts | 91 ++++++++++++++++++++++++++++ web/src/chat/normalizeAgent.ts | 42 ++++++++----- web/src/chat/reducerTimeline.test.ts | 43 +++++++++++++ 3 files changed, 160 insertions(+), 16 deletions(-) create mode 100644 web/src/chat/reducerTimeline.test.ts diff --git a/web/src/chat/normalize.test.ts b/web/src/chat/normalize.test.ts index f6f38a0d..64600669 100644 --- a/web/src/chat/normalize.test.ts +++ b/web/src/chat/normalize.test.ts @@ -104,4 +104,95 @@ describe('normalizeDecryptedMessage', () => { } expect(firstBlock.text).toContain('"foo": "bar"') }) + + it('converts user output to event', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + message: { content: ' Background command stopped ' } + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + + expect(normalized).toMatchObject({ + id: 'msg-1', + role: 'event', + isSidechain: false, + content: { type: 'message', message: 'Background command stopped' } + }) + }) + + it('treats without summary as sidechain (dropped by reducer)', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + uuid: 'u3', + message: { content: ' killed ' } + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + + expect(normalized).toMatchObject({ + role: 'agent', + isSidechain: true, + }) + }) + + it('treats non-sidechain string user output as sidechain', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + isSidechain: false, + uuid: 'u1', + message: { content: 'This is a subagent prompt' } + } + } + }) + + 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 a subagent prompt' + }) + }) + + it('treats user output as sidechain (dropped by reducer)', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + uuid: 'u2', + message: { content: 'Some internal reminder' } + } + } + }) + + const normalized = normalizeDecryptedMessage(message) + + expect(normalized).toMatchObject({ + role: 'agent', + isSidechain: true, + }) + }) }) diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index 18886e98..c9b50f8b 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -109,18 +109,6 @@ function normalizeUserOutput( const messageContent = message.content - // Skip system-injected messages that were logged as type:'user' but are - // not text the human actually typed (task notifications, command caveats, etc.) - if (typeof messageContent === 'string') { - const trimmed = messageContent.trimStart() - if ( - trimmed.startsWith('') || - trimmed.startsWith('') - ) { - return null - } - } - if (isSidechain && typeof messageContent === 'string') { return { id: messageId, @@ -132,15 +120,37 @@ function normalizeUserOutput( } } + // Handle system-injected messages that arrive as type:'user' through + // the agent output path. Real user text goes through normalizeUserRecord. 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: 'user', - isSidechain: false, - content: { type: 'text', text: messageContent }, - meta + role: 'agent', + isSidechain: true, + content: [{ type: 'sidechain', uuid, prompt: messageContent }] } } diff --git a/web/src/chat/reducerTimeline.test.ts b/web/src/chat/reducerTimeline.test.ts new file mode 100644 index 00000000..e572ed45 --- /dev/null +++ b/web/src/chat/reducerTimeline.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { reduceTimeline } from './reducerTimeline' +import type { TracedMessage } from './tracer' + +function makeContext() { + return { + permissionsById: new Map(), + groups: new Map(), + consumedGroupIds: new Set(), + titleChangesByToolUseId: new Map(), + emittedTitleChangeToolUseIds: new Set() + } +} + +function makeUserMessage(text: string, overrides?: Partial): 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', () => { + it('renders 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 XML-like user text (filtering is in normalize layer)', () => { + const text = ' Some task ' + const { blocks } = reduceTimeline([makeUserMessage(text)], makeContext()) + + expect(blocks).toHaveLength(1) + expect(blocks[0].kind).toBe('user-text') + }) +})