fix(cli): mark synthetic SDK user messages as meta (#1209)

Claude Code injects its own user-role turns for skill bodies and compact
continuation summaries. The on-disk transcript flags them `isMeta`, which
claudeLocalLauncher drops before they ever reach the web UI. Over
stream-json the same event is flagged `isSynthetic` instead, and
sdkToLogConverter copied only `message`, dropping the flag entirely.

With no `isMeta` on the converted line, every downstream guard let it
through: OutgoingMessageQueue forwarded it, isExternalUserMessage
classified it as genuine human input (its XML-prefix allowlist does not
match a bare-markdown skill body), and the web UI rendered the full skill
document as a user bubble.

Normalize `isSynthetic` to `isMeta` in the converter so the SDK path
carries the same signal as the transcript path and the existing filters
fire. Fixes skill injections appearing as user messages in remote mode.
This commit is contained in:
Haoqing Wang
2026-07-28 20:45:28 +08:00
committed by GitHub
parent 83fc9cde32
commit 31a7f67e66
3 changed files with 63 additions and 0 deletions
+7
View File
@@ -17,6 +17,13 @@ export interface SDKMessage {
export interface SDKUserMessage extends SDKMessage {
type: 'user'
parent_tool_use_id?: string
/**
* Set by Claude Code on user-role messages it injects itself (skill bodies,
* compact continuation summaries) rather than relaying from the human. The
* on-disk transcript spells the same thing `isMeta`.
*/
isSynthetic?: boolean
isMeta?: boolean
message: {
role: 'user'
content: string | Array<{
@@ -69,6 +69,52 @@ describe('SDKToLogConverter', () => {
expect(logMessage?.type).toBe('user')
expect((logMessage as any).message.content).toHaveLength(2)
})
it('should mark synthetic user messages as meta', () => {
// Skill injections arrive over stream-json as plain-text user messages
// flagged with isSynthetic. The on-disk transcript flags the same event
// with isMeta, which is what every downstream filter looks for.
const sdkMessage: SDKUserMessage = {
type: 'user',
isSynthetic: true,
message: {
role: 'user',
content: [
{ type: 'text', text: 'Base directory for this skill: /home/user/.claude/skills/foo' }
]
}
}
const logMessage = converter.convert(sdkMessage)
expect(logMessage?.type).toBe('user')
expect(logMessage?.isMeta).toBe(true)
})
it('should preserve isMeta on user messages', () => {
const sdkMessage: SDKUserMessage = {
type: 'user',
isMeta: true,
message: {
role: 'user',
content: 'injected context'
}
}
expect(converter.convert(sdkMessage)?.isMeta).toBe(true)
})
it('should not mark genuine user messages as meta', () => {
const sdkMessage: SDKUserMessage = {
type: 'user',
message: {
role: 'user',
content: 'Hello Claude'
}
}
expect(converter.convert(sdkMessage)?.isMeta).toBeUndefined()
})
})
describe('Assistant messages', () => {
+10
View File
@@ -271,6 +271,16 @@ export class SDKToLogConverter {
message: userMsg.message
}
// Claude Code injects its own user-role turns (skill bodies, compact
// continuation summaries). Over stream-json they are flagged
// `isSynthetic`, while the on-disk transcript the local launcher reads
// flags them `isMeta`. Normalize to `isMeta` so both paths hit the same
// downstream filters — otherwise the injected text reaches the web UI
// and is rendered as if the human had typed it.
if (userMsg.isSynthetic === true || userMsg.isMeta === true) {
logMessage.isMeta = true
}
// Check if this is a tool result and add mode if available
if (Array.isArray(userMsg.message.content)) {
for (const content of userMsg.message.content) {