Skip to content

fix(claude-code): stop emitting assistant roles on CC stdin (#5711) - #5816

Merged
senamakel merged 1 commit into
tinyhumansai:mainfrom
ntdatt812:fix/5711-claude-code-assistant-role
Sep 11, 2026
Merged

senamakel merged 1 commit into
tinyhumansai:mainfrom
ntdatt812:fix/5711-claude-code-assistant-role

Conversation

@ntdatt812

@ntdatt812 ntdatt812 commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

Closes #5711.

Reproduced first-hand

Against Claude Code CLI 2.1.221 (newer than the 2.1.207 in the report), Windows 11, using the issue's minimal input:

$ claude -p --input-format stream-json --output-format stream-json --verbose --model haiku < assistant-role.jsonl
Error: Expected message role 'user', got 'assistant'
EXIT=1

Not one non-hook stdout event was produced — the turn died before any model invocation, which matches the report. The same three lines with every message.role set to "user" exit 0 and produce a normal response, so the constraint is on the inner role, not the type: "user" envelope.

The fix

Prior turns are folded into one labelled transcript row carried as role: "user"; the trailing user turn follows verbatim:

{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Earlier conversation, for context only — do not answer it again:\n\nUser: first user\nAssistant: prior assistant"}]}}
{"type":"user","message":{"role":"user","content":[{"type":"text","text":"<the actual prompt>"}]}}

I piped that exact payload to the real CLI: exit 0, model responded.

The issue offers two strategies; this is "serialize prior transcript context into a user message" rather than "send only the trailing user turn", because the latter silently discards the conversation the user can see on screen.

Why labelled, rather than rewriting each turn into its own bare user row: without labels the model receives several consecutive user messages and can read its own past replies as fresh instructions. Keeping the latest turn as its own verbatim row also means the actual prompt is never reworded — only the context around it is reshaped.

One case the issue does not mention but this repo hits: a history ending on an assistant turn, which is exactly what switching an existing thread to this provider produces. It is now treated as all context and no prompt, instead of sending the assistant's own words to the model as if the user had typed them.

Verification

  • 6 tests in input_builder, 3 of which fail against the previous implementation (new_session_never_emits_an_assistant_role, new_session_carries_prior_turns_as_one_labelled_transcript, a_history_ending_on_an_assistant_turn_is_all_context) — verified by restoring the old builder with the new tests in place: 3 passed; 3 failed.
  • cargo test --lib claude_code — 45 passed.
  • cargo fmt --all — clean.
  • End-to-end against the live CLI as above.

One test helper, assert_every_row_is_a_user_role, parses each emitted line and asserts the invariant directly, so any future row-shaping change is held to the schema rather than to a string match.

Note on --append-system-prompt

The system row is still filtered out and still rides --append-system-prompt; a test pins that it does not leak into the transcript block.

Summary by CodeRabbit

  • Bug Fixes
    • Improved conversation handling when sending prompts to Claude Code.
    • Preserved the latest user message as the active prompt while including earlier conversation context in a consolidated transcript.
    • Ensured submitted conversation entries use a consistent user role for more reliable processing.

@ntdatt812
ntdatt812 requested a review from a team August 27, 2026 01:43

@tinysweeper tinysweeper 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.

tinysweeper found nothing blocking. Approving.

$0.0000 · 0 in / 0 out

@tinysweeper tinysweeper Bot added the priority: p3 Whenever. Cosmetic, a nicety, or a cleanup with no user visible effect. label Aug 27, 2026
@coderabbitai

coderabbitai Bot commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 7d0e6536-acc6-48f1-b653-31c8dbada25b

📥 Commits

Reviewing files that changed from the base of the PR and between 6171799 and 54e3e32.

📒 Files selected for processing (2)
  • src/openhuman/inference/provider/claude_code/input_builder.rs
  • src/openhuman/inference/provider/claude_code/input_builder_tests.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/openhuman/inference/provider/claude_code/input_builder.rs

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.


📝 Walkthrough

Walkthrough

build_stdin now emits only user roles. Prior conversation turns become one labelled transcript row, while a trailing user turn remains verbatim. Tests cover new-session and resume behavior, including histories ending with assistant turns.

Changes

Claude Code input handling

Layer / File(s) Summary
User-role input policy
src/openhuman/inference/provider/claude_code/input_builder.rs
build_stdin separates the latest user turn from prior context. Helper functions emit user rows and render earlier turns as labelled transcript context.
Input behavior regression coverage
src/openhuman/inference/provider/claude_code/input_builder.rs, src/openhuman/inference/provider/claude_code/input_builder_tests.rs
Tests verify user-only roles, transcript folding, assistant-ending histories, single-turn input, and resume behavior.

Estimated code review effort: 2 (Simple) | ~15 minutes

Merge Risk: ⚪ Minimal · up to 54e3e

This localized change reshapes Claude Code input so prior conversation context remains available without emitting unsupported assistant roles; no actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers: al629176, codeghost21, giri-aayush

Poem

A rabbit checks each role in line,
User rows now hop along just fine.
Old turns form one context trail,
The latest prompt stays clear and pale.
Tests guard each input part,
Claude sessions make a fresh start.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: preventing unsupported assistant roles in Claude Code stdin.
Linked Issues check ✅ Passed The implementation satisfies issue #5711. It emits only user roles, folds prior turns into a labelled transcript, preserves the trailing user turn for resume behavior, filters system messages, and add…
Out of Scope Changes check ✅ Passed The code and test changes are directly related to issue #5711. No unrelated or out-of-scope changes are identified.
Docstring Coverage ✅ Passed Docstring coverage is 81.82% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 2 files.
Full details: Linked Issues check

Explanation

The implementation satisfies issue #5711. It emits only user roles, folds prior turns into a labelled transcript, preserves the trailing user turn for resume behavior, filters system messages, and adds regression tests for assistant history and assistant-ending histories.

✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch

Warning

Your free Security trial is over. An organization admin can upgrade to Advanced for continuous pull request security review or dismiss this notice.


Comment @coderabbitai help to get the list of available commands.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 27, 2026

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1357accc30

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +55 to +56
if let Some(transcript) = render_transcript(history) {
push_json_line(&mut out, &user_row(&transcript));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid emitting context when no prompt is present

When a new-session history ends with an assistant turn, latest is empty but this still emits the transcript as a role=user row. Because the driver aborts only when build_stdin returns no bytes, Claude treats that context-only row as a new prompt and can generate an unsolicited duplicate response—the exact provider-switch scenario covered by the new test—despite the comment claiming there is no fresh instruction. Require a trailing user turn before emitting anything.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maintainer triage — not taking this one, and here is the reasoning so the thread is not left silent.

The suggestion is to return an empty payload (and so hit the driver's no input messages to deliver bail at driver.rs:341) when a new-session history ends on an assistant turn. That would be a behaviour change beyond the scope of this fix, and a regression on the case it names:

  • On main today this case never bails — the old builder emitted a row per message, so the payload was always non-empty. It emitted them with "role":"assistant", which is precisely the Claude Code rejects assistant history when starting a new CLI session #5711 crash. This PR keeps the payload non-empty and makes it legal; it does not introduce the "context with no trailing prompt" situation.
  • The scenario is a user switching an existing thread onto this provider. Making that hard-fail with no input messages to deliver is strictly worse for that user than sending the conversation across as labelled context.
  • The emitted row is explicitly prefixed Earlier conversation, for context only — do not answer it again:, so the "treated as a new prompt" framing overstates it. A model asked to continue will produce a turn either way; the question is only whether it does so with the thread's context or with an error.

If the project does want inference invoked on an assistant-terminated history to be a hard error, that is a decision for the harness that builds messages, not for this transport encoder — and it should be its own change with its own test.

Comment on lines +82 to +87
fn render_transcript(history: &[&ChatMessage]) -> Option<String> {
let mut body = String::new();
for msg in history {
let speaker = match msg.role.as_str() {
"user" => "User",
"assistant" => "Assistant",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Move transcript replay into the provider dialect

This introduces a second transcript-replay implementation that independently decides which roles survive and how turns are serialized. That can drift from the active tool dialect—for example, its handling of assistant/tool exchanges—and produce malformed next iterations when the dialect's tool grammar changes. The repository contract explicitly requires transcript replay and tool-call formatting to remain together in tinyagents::harness::tool_calling::dialect, so this Claude-specific behavior should be added or delegated there instead of implemented in the transport builder.

AGENTS.md reference: AGENTS.md:L487-L494

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maintainer triage — not taking this one in this PR.

The AGENTS.md contract cited ("Tool calling lives in tinyagents") governs the tool-calling dialect: how a call is advertised, parsed, rendered back, and replayed, kept together as XmlDialect / PFormatDialect / NativeDialect. input_builder.rs is not a dialect — it is the transport encoder for the Claude Code CLI's --input-format stream-json stdin framing. It chooses no tool grammar, and the CLI executes its own tools rather than round-tripping them through the harness.

More to the point: this PR does not introduce a second implementation. src/openhuman/inference/provider/claude_code/input_builder.rs already exists on main and already decides which roles survive and how turns are serialised — the _ => continue that drops system and tool rows is unchanged by this diff, and so is the module's stated v1 piping policy. What changed is that assistant turns are no longer emitted with "role":"assistant", because the CLI rejects that outright (Error: Expected message role 'user', got 'assistant', exit 1, #5711).

Relocating stream-json framing into tinyagents::harness::tool_calling::dialect would be a cross-repo architectural change to a crate this repo vendors. That may well be worth doing, but it is not a prerequisite for fixing a crash, and holding a one-file bug fix behind it would leave #5711 broken on main. Worth its own issue if the maintainers want it.

@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@tinysweeper

tinysweeper Bot commented Sep 1, 2026

Copy link
Copy Markdown

How this change flows

2 changed behaviours across 2 relationships. The code graph does not know these behaviours yet — normal for newly added code, and a cold index otherwise. 1 further behaviour left out to keep the diagram readable.

flowchart LR
  n0["build_stdin<br/>changed"]:::changed
  n1["resume_pipes_only_last_user_turn<br/>changed"]:::changed
  n1 -->|calls| n0
  n1 -->|tests| n0
  classDef changed fill:#0d4429,stroke:#238636,color:#e6edf3
  classDef impacted fill:#161b22,stroke:#6e7681,color:#c9d1d9
  classDef flagged fill:#5a1e02,stroke:#d93f0b,color:#ffffff
  classDef blocking fill:#67060c,stroke:#f85149,color:#ffffff
Loading

Green: changed behaviour. Grey: surrounding behaviour. Arrows name the call, use, implementation, or test relationship. Orange: has findings. Red: has a finding that blocks the merge.

tinysweeper 0.1.0

…nsai#5711)

Every stdin row must carry `message.role == "user"`. The CLI validates this
before it invokes the model and exits 1 with
`Error: Expected message role 'user', got 'assistant'` — `type: "user"` on the
envelope is not enough. Replaying a prior assistant turn as itself therefore
killed the turn outright on any new CC session with history.

Prior turns are folded into one labelled transcript row instead, and only a
trailing *user* turn is treated as the prompt: a conversation that ends on an
assistant turn (the user switched provider mid-thread) is all context and
carries no fresh instruction. The label matters — without it the model reads
several consecutive user messages and can take its own past replies as new
instructions.

Rebased onto main's sibling-test layout: the tests now live in
input_builder_tests.rs. `new_session_pipes_full_user_history` is gone
deliberately — it asserted the assistant-role rows this change stops emitting.
Its surviving intent, that history reaches a new session, is covered by
`new_session_carries_prior_turns_as_one_labelled_transcript`.
@ntdatt812
ntdatt812 force-pushed the fix/5711-claude-code-assistant-role branch from 54e3e32 to 3c26f14 Compare September 1, 2026 08:49
@senamakel
senamakel merged commit 37d350a into tinyhumansai:main Sep 11, 2026
27 checks passed
senamakel added a commit to HDZTony/openhuman that referenced this pull request Sep 11, 2026
…ode-assistant-role\n\nfix(claude-code): stop emitting assistant roles on CC stdin (tinyhumansai#5711)\n
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: p3 Whenever. Cosmetic, a nicety, or a cleanup with no user visible effect.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Claude Code rejects assistant history when starting a new CLI session

3 participants