fix(cli): mark synthetic SDK user messages as meta - #1209
Merged
Conversation
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.
There was a problem hiding this comment.
Review mode: initial
No findings. The change is narrowly scoped and the new tests cover the intended flag normalization. Residual risk: I could not run bun test here because bun is not available in this environment, so the added unit tests were not executed locally.
HAPI Bot
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
In remote mode, invoking a skill renders the entire skill document as a user message bubble in the web UI, as if the human had pasted it into the chat. The tool card itself renders correctly; the injected body shows up right after it.
The cause is a field-name mismatch between the two transcript sources, and a converter that drops the flag.
Claude Code injects its own user-role turns (skill bodies, compact continuation summaries). The on-disk JSONL flags them
isMeta:{"type":"user","isMeta":true,"sourceToolUseID":"toolu_01…", "message":{"role":"user","content":[{"type":"text","text":"Base directory for this skill: …"}]}}The stream-json the SDK path consumes flags the same event
isSyntheticinstead — captured from a real session:{"type":"user","message":{"role":"user","content":[{"type":"text", "text":"Base directory for this skill: /home/…/skills/brainstorming"}]}, "parent_tool_use_id":null,"isSynthetic":true}sdkToLogConverter'scase 'user'copied onlymessage, so the flag was silently discarded and the emitted line had neitherisMetanorisSynthetic. Every downstream guard then let it through:OutgoingMessageQueue.ts:124— checks!logMessage.isMeta, which isundefined, so it forwards.apiSession.ts:88—isExternalUserMessagechecksisMeta, then falls back to a prefix allowlist (<system-reminder>,<task-notification>, …). A skill body is bare markdown starting withBase directory for this skill:, so it matches nothing and is classified as genuine human input.role:'user'and renders a user bubble.This is why local mode is unaffected:
claudeLocalLauncher.ts:29seesisMetaon the JSONL and drops the message, covered byclaudeLocalLauncher.test.ts:167('filters out isMeta messages (e.g. skill injections)'). The SDK path simply never had an equivalent.Fix
Normalize
isSynthetic(and a passthroughisMeta) onto the converted line, restoring parity with the transcript path. No new filtering logic is introduced — the three existingisMetaguards start working as they were already written to.This also covers compact continuation summaries, which carry the same
isSyntheticflag over stream-json and were leaking through remote mode for the same reason.Tests
Three cases added to
sdkToLogConverter.test.ts:isSyntheticmaps toisMeta, an explicitisMetasurvives, and a genuine user message stays unflagged.bun run typecheckincli/passes.bun testincli/: 1054 pass / 110 fail. The same 110 fail onmainat this commit (1051 pass there — the delta is exactly the three new tests), so no regression.Follow-up (not in this PR)
web/src/chat/normalizeAgent.ts:360-376turns any non-sidechain user message whose content is entirely text blocks intorole:'user'. That branch is the backstop this bug slipped past, and its stated premise — "isExternalUserMessage rejects array content" — is not accurate, sinceextractRawUserTextContenthandles arrays. It has no test coverage. Worth tightening separately; with this fix the message no longer reaches the web layer at all.