diff --git a/cli/src/claude/sdk/types.ts b/cli/src/claude/sdk/types.ts index 927cf130..737ce61f 100644 --- a/cli/src/claude/sdk/types.ts +++ b/cli/src/claude/sdk/types.ts @@ -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<{ diff --git a/cli/src/claude/utils/sdkToLogConverter.test.ts b/cli/src/claude/utils/sdkToLogConverter.test.ts index 08df739a..5f433ca4 100644 --- a/cli/src/claude/utils/sdkToLogConverter.test.ts +++ b/cli/src/claude/utils/sdkToLogConverter.test.ts @@ -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', () => { diff --git a/cli/src/claude/utils/sdkToLogConverter.ts b/cli/src/claude/utils/sdkToLogConverter.ts index a86780fd..cfc14703 100644 --- a/cli/src/claude/utils/sdkToLogConverter.ts +++ b/cli/src/claude/utils/sdkToLogConverter.ts @@ -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) {