From 279f75815ecd7d84f9baed8901de4ae3b64d65d3 Mon Sep 17 00:00:00 2001 From: Haoqing Wang <78337154+hqhq1025@users.noreply.github.com> Date: Thu, 26 Mar 2026 08:06:30 +0800 Subject: [PATCH] fix(cli): prevent system-injected messages from appearing as user role (#361) --- cli/src/api/apiSession.test.ts | 98 ++++++++++++++++++++++++++++++++++ cli/src/api/apiSession.ts | 36 ++++++++++++- 2 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 cli/src/api/apiSession.test.ts diff --git a/cli/src/api/apiSession.test.ts b/cli/src/api/apiSession.test.ts new file mode 100644 index 00000000..06a6bbc9 --- /dev/null +++ b/cli/src/api/apiSession.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { isExternalUserMessage } from './apiSession' + +describe('isExternalUserMessage', () => { + const baseUserMsg = { + type: 'user' as const, + uuid: 'test-uuid', + userType: 'external' as const, + isSidechain: false, + message: { role: 'user', content: 'hello' }, + } + + it('returns true for a real user text message', () => { + expect(isExternalUserMessage(baseUserMsg)).toBe(true) + }) + + it('returns false when isMeta is true (skill injections)', () => { + expect(isExternalUserMessage({ ...baseUserMsg, isMeta: true })).toBe(false) + }) + + it('returns false when isSidechain is true', () => { + expect(isExternalUserMessage({ ...baseUserMsg, isSidechain: true })).toBe(false) + }) + + it('returns false when content is an array (tool results)', () => { + expect( + isExternalUserMessage({ + ...baseUserMsg, + message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'x', content: 'y' }] }, + } as never) + ).toBe(false) + }) + + it('returns false for assistant messages', () => { + expect( + isExternalUserMessage({ + type: 'assistant', + uuid: 'test-uuid', + message: { role: 'assistant', content: 'hi' }, + } as never) + ).toBe(false) + }) + + // System-injected content detection + it('returns false for messages', () => { + expect( + isExternalUserMessage({ + ...baseUserMsg, + message: { role: 'user', content: '\nabc123\n' }, + }) + ).toBe(false) + }) + + it('returns false for messages', () => { + expect( + isExternalUserMessage({ + ...baseUserMsg, + message: { role: 'user', content: '/clear' }, + }) + ).toBe(false) + }) + + it('returns false for messages', () => { + expect( + isExternalUserMessage({ + ...baseUserMsg, + message: { role: 'user', content: 'Caveat: ...' }, + }) + ).toBe(false) + }) + + it('returns false for messages', () => { + expect( + isExternalUserMessage({ + ...baseUserMsg, + message: { role: 'user', content: '\nToday is 2026.\n' }, + }) + ).toBe(false) + }) + + it('returns true for user text that mentions XML-like strings but is not injected', () => { + expect( + isExternalUserMessage({ + ...baseUserMsg, + message: { role: 'user', content: 'How do I use the tag?' }, + }) + ).toBe(true) + }) + + it('returns false for with leading whitespace', () => { + expect( + isExternalUserMessage({ + ...baseUserMsg, + message: { role: 'user', content: ' \n\nx\n' }, + }) + ).toBe(false) + }) +}) diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 5fbf9f6d..12aff384 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -35,6 +35,40 @@ import { cleanupUploadDir } from '../modules/common/handlers/uploads' import { TerminalManager } from '@/terminal/TerminalManager' import { applyVersionedAck } from './versionedUpdate' +/** + * XML tags that Claude Code injects as `type:'user'` messages. + * These are internal bookkeeping, not text the human actually typed. + */ +const SYSTEM_INJECTION_PREFIXES = [ + '', + '', + '', + '', +] + +/** + * Returns true if a JSONL message should be classified as a user-role message + * (i.e., text typed by a real human) rather than an agent-role message. + * + * Claude Code injects system messages (task notifications, command caveats, …) + * into the JSONL log as `type:'user'` entries so the model sees them in + * context. All metadata fields (`userType`, `isMeta`, …) are identical to + * genuine user messages, so the only reliable signal is the message content + * itself: injected messages always start with a well-known XML tag. + */ +export function isExternalUserMessage(body: RawJSONLines): body is Extract & { message: { content: string } } { + if (body.type !== 'user') return false + if (typeof body.message.content !== 'string') return false + if (body.isSidechain === true) return false + if (body.isMeta === true) return false + + const trimmed = body.message.content.trimStart() + for (const prefix of SYSTEM_INJECTION_PREFIXES) { + if (trimmed.startsWith(prefix)) return false + } + return true +} + export class ApiSessionClient extends EventEmitter { private readonly token: string readonly sessionId: string @@ -330,7 +364,7 @@ export class ApiSessionClient extends EventEmitter { sendClaudeSessionMessage(body: RawJSONLines): void { let content: MessageContent - if (body.type === 'user' && typeof body.message.content === 'string' && body.isSidechain !== true && body.isMeta !== true) { + if (isExternalUserMessage(body)) { content = { role: 'user', content: {