From 78ffd13d19d5f879b592324de69c203fda820907 Mon Sep 17 00:00:00 2001 From: twtrubiks Date: Wed, 14 Oct 2026 18:39:47 +0800 Subject: [PATCH] fix(cli): stop duplicating thinking blocks in streamed responses push_output_block recorded a thinking event at content_block_start (an empty placeholder) and the stream loop recorded the full block again at content_block_stop. The empty duplicate was replayed on the next request and rejected with 400 "each thinking block must contain thinking". Only emit the event from the non-streaming path. --- rust/crates/rusty-claude-cli/src/main.rs | 46 ++++++++++++++++++++---- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 934fce0a..730b3437 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -14466,7 +14466,7 @@ fn push_output_block( out: &mut (impl Write + ?Sized), events: &mut Vec, pending_tool: &mut Option<(String, String, String)>, - streaming_tool_input: bool, + streaming: bool, block_has_thinking_summary: &mut bool, ) -> Result<(), RuntimeError> { match block { @@ -14483,7 +14483,7 @@ fn push_output_block( // During streaming, the initial content_block_start has an empty input ({}). // The real input arrives via input_json_delta events. In // non-streaming responses, preserve a legitimate empty object. - let initial_input = if streaming_tool_input + let initial_input = if streaming && input.is_object() && input.as_object().is_some_and(serde_json::Map::is_empty) { @@ -14498,10 +14498,16 @@ fn push_output_block( signature, } => { render_thinking_block_summary(out, Some(thinking.chars().count()), false)?; - events.push(AssistantEvent::Thinking { - thinking, - signature, - }); + // During streaming, content_block_start only carries an empty + // placeholder; the full block (thinking + signature) is emitted on + // content_block_stop. Pushing it here too would replay an empty + // thinking block that the API rejects with 400 on the next turn. + if !streaming { + events.push(AssistantEvent::Thinking { + thinking, + signature, + }); + } *block_has_thinking_summary = true; } OutputContentBlock::RedactedThinking { .. } => { @@ -19772,6 +19778,34 @@ UU conflicted.rs", ); } + #[test] + fn push_output_block_defers_thinking_event_while_streaming() { + let mut out = Vec::new(); + let mut events = Vec::new(); + let mut pending_tool = None; + let mut block_has_thinking_summary = false; + + push_output_block( + OutputContentBlock::Thinking { + thinking: String::new(), + signature: Some(String::new()), + }, + &mut out, + &mut events, + &mut pending_tool, + true, + &mut block_has_thinking_summary, + ) + .expect("thinking block start should render"); + + // The full thinking block is emitted on content_block_stop; emitting the + // empty placeholder here would duplicate it in the session history. + assert!(events.is_empty()); + assert!(block_has_thinking_summary); + let rendered = String::from_utf8(out).expect("utf8"); + assert!(rendered.contains("▶ Thinking (0 chars hidden)")); + } + #[test] fn response_to_events_preserves_empty_object_json_input_outside_streaming() { let mut out = Vec::new();