Repository navigation
fix(claude-code): stop emitting assistant roles on CC stdin (#5711) #5816
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,8 +4,16 @@ | |
| //! like: | ||
| //! { "type":"user", "message":{"role":"user","content":[{"type":"text","text":"..."}]} } | ||
| //! | ||
| //! 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'` otherwise (#5711) — | ||
| //! `type: "user"` on the envelope is not enough. Prior assistant turns | ||
| //! therefore cannot be replayed as themselves; they are folded into a | ||
| //! labelled transcript block carried by a `user` row. | ||
| //! | ||
| //! v1 piping policy: | ||
| //! - On a *new* CC session: send every history `ChatMessage` so claude | ||
| //! - On a *new* CC session: send the full prior conversation as one | ||
| //! transcript `user` row, then the latest user turn verbatim, so claude | ||
| //! has full context (system message is conveyed via | ||
| //! `--append-system-prompt`, not stdin). | ||
| //! - On a `--resume` of an existing CC session: claude already has prior | ||
|
|
@@ -31,26 +39,69 @@ pub fn build_stdin(messages: &[ChatMessage], is_new_session: bool) -> Vec<u8> { | |
| .collect() | ||
| }; | ||
|
|
||
| for msg in to_emit { | ||
| let role = match msg.role.as_str() { | ||
| "user" => "user", | ||
| "assistant" => "assistant", | ||
| // The trailing user turn is the actual prompt and is sent verbatim. | ||
| // Everything before it is context, and has to reach the CLI as `user` | ||
| // rows, so it goes as one labelled transcript block rather than as | ||
| // rewritten turns. | ||
| // Only a trailing *user* turn is the prompt. If the conversation ends on | ||
| // an assistant turn (e.g. the user switched provider mid-thread), all of | ||
| // it is context and none of it is a fresh instruction. | ||
| let split = match to_emit.last() { | ||
| Some(last) if last.role == "user" => to_emit.len() - 1, | ||
| _ => to_emit.len(), | ||
| }; | ||
| let (history, latest) = to_emit.split_at(split); | ||
|
|
||
| if let Some(transcript) = render_transcript(history) { | ||
| push_json_line(&mut out, &user_row(&transcript)); | ||
| } | ||
| for msg in latest { | ||
| push_json_line(&mut out, &user_row(&msg.content)); | ||
| } | ||
|
|
||
| out.into_bytes() | ||
| } | ||
|
|
||
| /// One stdin row. `role` is always `"user"` — see the module docs. | ||
| fn user_row(text: &str) -> Value { | ||
| json!({ | ||
| "type": "user", | ||
| "message": { | ||
| "role": "user", | ||
| "content": [{"type": "text", "text": text}], | ||
| }, | ||
| }) | ||
| } | ||
|
|
||
| /// Fold prior turns into a single labelled transcript, or `None` when there | ||
| /// is nothing to carry. | ||
| /// | ||
| /// Labelling matters: without it the model receives what looks like several | ||
| /// consecutive user messages and can read its own past replies as fresh | ||
| /// instructions. | ||
| 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", | ||
|
Comment on lines
+82
to
+87
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 AGENTS.md reference: AGENTS.md:L487-L494 Useful? React with 👍 / 👎.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 More to the point: this PR does not introduce a second implementation. Relocating stream-json framing into |
||
| // CC stdin doesn't accept `system` or `tool` rows. The system | ||
| // prompt is plumbed via `--append-system-prompt`; tool roles | ||
| // belong to the harness, not the CLI's input format. | ||
| _ => continue, | ||
| }; | ||
| let line = json!({ | ||
| "type": "user", | ||
| "message": { | ||
| "role": role, | ||
| "content": [{"type": "text", "text": msg.content}], | ||
| }, | ||
| }); | ||
| push_json_line(&mut out, &line); | ||
| body.push_str(speaker); | ||
| body.push_str(": "); | ||
| body.push_str(&msg.content); | ||
| body.push('\n'); | ||
| } | ||
|
|
||
| out.into_bytes() | ||
| if body.is_empty() { | ||
| return None; | ||
| } | ||
| Some(format!( | ||
| "Earlier conversation, for context only — do not answer it again:\n\n{}", | ||
| body.trim_end() | ||
| )) | ||
| } | ||
|
|
||
| fn push_json_line(buf: &mut String, v: &Value) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a new-session history ends with an assistant turn,
latestis empty but this still emits the transcript as arole=userrow. Because the driver aborts only whenbuild_stdinreturns 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 👍 / 👎.
There was a problem hiding this comment.
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 deliverbail atdriver.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:maintoday 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.no input messages to deliveris strictly worse for that user than sending the conversation across as labelled context.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.