From 3c26f145fa1499f0eeee15754020148fbfb4029a Mon Sep 17 00:00:00 2001 From: Nguyen Thanh Dat Date: Tue, 1 Sep 2026 13:49:05 +0700 Subject: [PATCH] fix(claude-code): stop emitting assistant roles on CC stdin (#5711) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- .../provider/claude_code/input_builder.rs | 81 +++++++++++++--- .../claude_code/input_builder_tests.rs | 95 +++++++++++++++++-- 2 files changed, 154 insertions(+), 22 deletions(-) diff --git a/src/openhuman/inference/provider/claude_code/input_builder.rs b/src/openhuman/inference/provider/claude_code/input_builder.rs index 82f40a19194..25a7d90b636 100644 --- a/src/openhuman/inference/provider/claude_code/input_builder.rs +++ b/src/openhuman/inference/provider/claude_code/input_builder.rs @@ -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 { .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 { + let mut body = String::new(); + for msg in history { + let speaker = match msg.role.as_str() { + "user" => "User", + "assistant" => "Assistant", // 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) { diff --git a/src/openhuman/inference/provider/claude_code/input_builder_tests.rs b/src/openhuman/inference/provider/claude_code/input_builder_tests.rs index e5af2a1f70c..ba1d4188b7a 100644 --- a/src/openhuman/inference/provider/claude_code/input_builder_tests.rs +++ b/src/openhuman/inference/provider/claude_code/input_builder_tests.rs @@ -9,21 +9,101 @@ fn msg(role: &str, content: &str) -> ChatMessage { } } +/// Every row's `message.role` must be `"user"`. The CLI validates this +/// before invoking the model and exits 1 with +/// `Expected message role 'user', got 'assistant'` otherwise (#5711). +/// Verified against Claude Code CLI 2.1.221. +fn assert_every_row_is_a_user_role(payload: &str) { + for (i, line) in payload.lines().enumerate() { + let row: Value = serde_json::from_str(line).unwrap_or_else(|e| { + panic!( + "row {i} is not valid JSON: {e} +{line}" + ) + }); + assert_eq!( + row["message"]["role"], "user", + "row {i} must carry role=user, the only role CC stdin accepts +{line}" + ); + assert_eq!(row["type"], "user", "row {i} envelope type"); + } +} + #[test] -fn new_session_pipes_full_user_history() { +fn new_session_never_emits_an_assistant_role() { + let history = vec![ + msg("system", "you are helpful"), + msg("user", "first user"), + msg("assistant", "prior assistant"), + msg("user", "latest user"), + ]; + let s = String::from_utf8(build_stdin(&history, true)).unwrap(); + assert_every_row_is_a_user_role(&s); + assert!( + !s.contains("\"role\":\"assistant\""), + "an assistant role row is what the CLI rejects: +{s}" + ); +} + +#[test] +fn new_session_carries_prior_turns_as_one_labelled_transcript() { let history = vec![ msg("system", "you are helpful"), msg("user", "hi"), msg("assistant", "hello"), msg("user", "how are you?"), ]; - let bytes = build_stdin(&history, true); - let s = String::from_utf8(bytes).unwrap(); + let s = String::from_utf8(build_stdin(&history, true)).unwrap(); let lines: Vec<_> = s.lines().collect(); - assert_eq!(lines.len(), 3); // system filtered out - assert!(lines[0].contains("\"hi\"")); - assert!(lines[1].contains("\"hello\"")); - assert!(lines[2].contains("how are you")); + + // One transcript row + the latest user turn. The system row is still + // filtered out — it rides `--append-system-prompt`. + assert_eq!( + lines.len(), + 2, + "got: +{s}" + ); + assert!(lines[0].contains("User: hi")); + assert!(lines[0].contains("Assistant: hello")); + assert!( + !lines[0].contains("you are helpful"), + "the system message must not leak into the transcript" + ); + + // The prompt itself is passed through untouched, not folded in. + let latest: Value = serde_json::from_str(lines[1]).unwrap(); + assert_eq!(latest["message"]["content"][0]["text"], "how are you?"); +} + +#[test] +fn a_history_ending_on_an_assistant_turn_is_all_context() { + // Switching an existing conversation to the CC provider can leave the + // assistant speaking last; none of it is a fresh instruction. + let history = vec![msg("user", "hi"), msg("assistant", "hello")]; + let s = String::from_utf8(build_stdin(&history, true)).unwrap(); + let lines: Vec<_> = s.lines().collect(); + assert_eq!( + lines.len(), + 1, + "got: +{s}" + ); + assert_every_row_is_a_user_role(&s); + assert!(lines[0].contains("User: hi")); + assert!(lines[0].contains("Assistant: hello")); +} + +#[test] +fn a_single_user_turn_is_sent_verbatim_with_no_transcript() { + let history = vec![msg("user", "just this")]; + let s = String::from_utf8(build_stdin(&history, true)).unwrap(); + let lines: Vec<_> = s.lines().collect(); + assert_eq!(lines.len(), 1); + let row: Value = serde_json::from_str(lines[0]).unwrap(); + assert_eq!(row["message"]["content"][0]["text"], "just this"); } #[test] @@ -38,6 +118,7 @@ fn resume_pipes_only_last_user_turn() { let lines: Vec<_> = s.lines().collect(); assert_eq!(lines.len(), 1); assert!(lines[0].contains("\"follow-up\"")); + assert_every_row_is_a_user_role(&s); } #[test]