fix(claude-code): stop emitting assistant roles on CC stdin (#5711) - #5816
fix(claude-code): stop emitting assistant roles on CC stdin (#5711)#5816ntdatt812 wants to merge 1 commit into
Conversation
…nsai#5711) Starting a new Claude Code session from an existing conversation died before the model ran. `build_stdin` wrapped every history row as `type: "user"` but preserved `message.role: "assistant"` for prior assistant turns, and the CLI validates that inner role: Error: Expected message role 'user', got 'assistant' (exit 1) Reproduced first-hand against Claude Code CLI 2.1.221 on Windows 11 with the issue's minimal input: exit 1, and not one non-hook stdout event, so the turn died before any model invocation. The same three lines with every role set to `user` exit 0 and produce a normal response. Prior turns are now folded into a single labelled transcript row carried as `role: "user"`, and the trailing user turn is sent verbatim after it: {"type":"user","message":{"role":"user","content":[{"type":"text", "text":"Earlier conversation, for context only — do not answer it again:\n\nUser: …\nAssistant: …"}]}} {"type":"user","message":{"role":"user","content":[{"type":"text", "text":"<the actual prompt>"}]}} Piped that exact payload to the real CLI: exit 0, model responded. Why a labelled transcript rather than rewriting each turn into its own `user` row: without the 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 means the actual prompt is never reworded. A conversation ending on an assistant turn — which is exactly what switching an existing thread to this provider produces — is treated as all context and no prompt, rather than sending the assistant's own words as the user's. Tests: 6 in `input_builder`, 3 of which fail against the previous implementation. `cargo test --lib claude_code` 45 passed.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review. 📝 WalkthroughWalkthrough
ChangesClaude Code input handling
Estimated code review effort: 2 (Simple) | ~15 minutes Merge Risk: ⚪ Minimal · up to The change reshapes Claude Code input so prior context remains available while every emitted message uses the accepted user role; no actionable merge-blocking risk remains after normal checks and review. Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Full details: Linked Issues checkExplanation The changes satisfy issue Warning Your free Security trial is over. An organization admin can activate billing to continue. Comment |
There was a problem hiding this comment.
💡 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".
| if let Some(transcript) = render_transcript(history) { | ||
| push_json_line(&mut out, &user_row(&transcript)); |
There was a problem hiding this comment.
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 👍 / 👎.
| 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", |
There was a problem hiding this comment.
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 👍 / 👎.
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:
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.roleset to"user"exit 0 and produce a normal response, so the constraint is on the inner role, not thetype: "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
userrow: 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
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.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-promptThe 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