Skip to content

fix(cli): prevent system-injected messages from appearing as user role#361

Open
hqhq1025 wants to merge 6 commits intotiann:mainfrom
hqhq1025:fix/filter-meta-and-compact-messages
Open

fix(cli): prevent system-injected messages from appearing as user role#361
hqhq1025 wants to merge 6 commits intotiann:mainfrom
hqhq1025:fix/filter-meta-and-compact-messages

Conversation

@hqhq1025
Copy link
Contributor

Problem

System-injected type:'user' messages like <task-notification> and <system-reminder> were appearing in the HAPI web UI as if the human had typed them.

Root cause

sendClaudeSessionMessage classified a message as role:'user' whenever:

  • type === 'user'
  • message.content is a string
  • isSidechain !== true
  • isMeta !== true

Claude Code injects task notifications (background agent completions) into the conversation log as type:'user' entries — so the model sees them in context. These messages don't have isMeta: true, so they passed through all existing guards and landed in the UI as user messages.

Fix

All genuine user-typed messages — written both by Claude Code's JSONL recorder and by sdkToLogConverter in remote mode — carry userType: 'external'. System-injected messages do not.

Added a userType === 'external' check, extracted into a testable helper isExternalUserMessage().

Changes

  • cli/src/api/apiSession.ts: extract isExternalUserMessage() helper with userType === 'external' guard; use it in sendClaudeSessionMessage
  • cli/src/api/apiSession.test.ts: 6 unit tests covering all branches of isExternalUserMessage

Test plan

  • All 6 new unit tests pass
  • All existing related tests pass (claudeLocalLauncher, OutgoingMessageQueue, sessionScanner)
  • Manually verify: trigger a background task, confirm its completion notification no longer appears as a user message in the web UI

hqhq1025 and others added 3 commits March 24, 2026 09:19
…mote mode

When Claude Code loads a skill, it injects the full skill content as a
user message with isMeta: true. In local mode, this message passes
through all existing filters (it's not a summary, not a system message)
and reaches the web UI where it renders as a regular chat message.

In remote mode, the OutgoingMessageQueue only filtered system-type
messages. Skill injections have type 'user' with isMeta: true and
slipped through.

Also filter isCompactSummary messages at the source for consistency
with the web normalizer, which already checks both fields.

Changes:
- claudeLocalLauncher: add isMeta and isCompactSummary checks
- OutgoingMessageQueue: add isMeta and isCompactSummary checks
- Add tests for both local launcher (6 tests) and queue (4 tests)

Relates to tiann#235
…r role

System-injected messages (e.g. <task-notification>, <system-reminder>) are
written to the JSONL log with type:'user' but no userType:'external'. Without
a userType check, sendClaudeSessionMessage classified them as role:'user',
making them appear in the web UI as if the human had typed them.

Fix: add userType === 'external' guard to isExternalUserMessage() so only
genuine user-typed messages get role:'user'. All real user messages from both
local (JSONL) and remote (SDK) paths already carry userType:'external'.

Adds unit tests for isExternalUserMessage covering: normal user messages,
system-injected messages, isMeta, isSidechain, tool results, and assistant
messages.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Copy link

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Findings

  • None.

Summary

  • Review mode: initial
  • No issues found. Residual risk: behavior now depends on upstream JSONL always setting userType: 'external' for user-typed messages; not validated here.

Testing

  • Not run (automation)

HAPI Bot

Copy link
Owner

@tiann tiann left a comment

Choose a reason for hiding this comment

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

pls fix the typecheck, thank you

Address bot review concern: adds fixture-based tests that assert every
type:'user' string-content message in session JSONL fixtures carries
userType:'external'. This documents and validates the assumption that
isExternalUserMessage() depends on, so future regressions from upstream
Claude Code changes will be caught automatically.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Copy link

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Findings

  • None.

Summary

  • Review mode: follow-up after new commits
  • No issues found. Residual risk: runtime JSONL may diverge from fixtures; guard assumes userType:"external" for real user text.

Testing

  • Not run (automation)

HAPI Bot

The previous approach (checking `userType === 'external'`) was
ineffective — Claude Code stamps ALL user messages (both real and
system-injected) with `userType: 'external'`.  Verified across 35
JSONL files: 1991 user messages all have the same metadata fields.

The only reliable distinguisher is the message content itself.
System-injected messages always start with a well-known XML tag
(`<task-notification>`, `<command-name>`, `<local-command-caveat>`,
`<system-reminder>`), while real user text never does.

Updated isExternalUserMessage() to check content prefixes instead of
metadata fields.  Tests now cover all four injection tag types plus
the false-positive case (user text mentioning tags mid-sentence).

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Copy link

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Findings

  • None.

Summary

  • Review mode: follow-up after new commits
  • No issues found. Residual risk: system-injection detection depends on fixed prefix list; upstream tag changes not validated here.

Testing

  • Not run (automation)

HAPI Bot

Change return type from `boolean` to a type predicate so TypeScript can
narrow the RawJSONLines union inside the true branch, allowing safe
access to `body.message.content` as string.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Copy link

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Findings

  • None.

Summary

  • Review mode: follow-up after new commits
  • No issues found. Residual risk: system-injection filter depends on fixed prefix list; upstream tag changes would bypass.

Testing

  • Not run (automation)

HAPI Bot

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