Skip to content

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

Merged
tiann merged 1 commit into
tiann:mainfrom
hqhq1025:fix/skill-injection-user-message
Jul 28, 2026
Merged

fix(cli): mark synthetic SDK user messages as meta#1209
tiann merged 1 commit into
tiann:mainfrom
hqhq1025:fix/skill-injection-user-message

Conversation

@hqhq1025

Copy link
Copy Markdown
Collaborator

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 isSynthetic instead — 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's case 'user' copied only message, so the flag was silently discarded and the emitted line had neither isMeta nor isSynthetic. Every downstream guard then let it through:

  • OutgoingMessageQueue.ts:124 — checks !logMessage.isMeta, which is undefined, so it forwards.
  • apiSession.ts:88isExternalUserMessage checks isMeta, then falls back to a prefix allowlist (<system-reminder>, <task-notification>, …). A skill body is bare markdown starting with Base directory for this skill:, so it matches nothing and is classified as genuine human input.
  • The web UI receives role:'user' and renders a user bubble.

This is why local mode is unaffected: claudeLocalLauncher.ts:29 sees isMeta on the JSONL and drops the message, covered by claudeLocalLauncher.test.ts:167 ('filters out isMeta messages (e.g. skill injections)'). The SDK path simply never had an equivalent.

Fix

Normalize isSynthetic (and a passthrough isMeta) onto the converted line, restoring parity with the transcript path. No new filtering logic is introduced — the three existing isMeta guards start working as they were already written to.

This also covers compact continuation summaries, which carry the same isSynthetic flag over stream-json and were leaking through remote mode for the same reason.

Tests

Three cases added to sdkToLogConverter.test.ts: isSynthetic maps to isMeta, an explicit isMeta survives, and a genuine user message stays unflagged.

  • bun run typecheck in cli/ passes.
  • bun test in cli/: 1054 pass / 110 fail. The same 110 fail on main at 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-376 turns any non-sidechain user message whose content is entirely text blocks into role:'user'. That branch is the backstop this bug slipped past, and its stated premise — "isExternalUserMessage rejects array content" — is not accurate, since extractRawUserTextContent handles arrays. It has no test coverage. Worth tightening separately; with this fix the message no longer reaches the web layer at all.

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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@tiann
tiann merged commit 31a7f67 into tiann:main Jul 28, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants