mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(cli): prevent system-injected messages from appearing as user role (#361)
This commit is contained in:
@@ -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 <task-notification> messages', () => {
|
||||
expect(
|
||||
isExternalUserMessage({
|
||||
...baseUserMsg,
|
||||
message: { role: 'user', content: '<task-notification>\n<task-id>abc123</task-id>\n</task-notification>' },
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for <command-name> messages', () => {
|
||||
expect(
|
||||
isExternalUserMessage({
|
||||
...baseUserMsg,
|
||||
message: { role: 'user', content: '<command-name>/clear</command-name>' },
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for <local-command-caveat> messages', () => {
|
||||
expect(
|
||||
isExternalUserMessage({
|
||||
...baseUserMsg,
|
||||
message: { role: 'user', content: '<local-command-caveat>Caveat: ...</local-command-caveat>' },
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for <system-reminder> messages', () => {
|
||||
expect(
|
||||
isExternalUserMessage({
|
||||
...baseUserMsg,
|
||||
message: { role: 'user', content: '<system-reminder>\nToday is 2026.\n</system-reminder>' },
|
||||
})
|
||||
).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 <task-notification> tag?' },
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for <task-notification> with leading whitespace', () => {
|
||||
expect(
|
||||
isExternalUserMessage({
|
||||
...baseUserMsg,
|
||||
message: { role: 'user', content: ' \n<task-notification>\n<task-id>x</task-id>\n</task-notification>' },
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -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 = [
|
||||
'<task-notification>',
|
||||
'<command-name>',
|
||||
'<local-command-caveat>',
|
||||
'<system-reminder>',
|
||||
]
|
||||
|
||||
/**
|
||||
* 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<RawJSONLines, { type: 'user' }> & { 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: {
|
||||
|
||||
Reference in New Issue
Block a user