From d4605794e85e08ba0a07c7334b346b29d9394f3f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 12:13:21 -0600 Subject: [PATCH] feat(TASK-042): Responses reasoning-item replay, strict:false, cross-client portability Add ThinkingBlock::Reasoning to carry OpenAI Responses reasoning items verbatim. The Responses body builder now requests reasoning.encrypted_content, marks tool defs strict:false, and replays each round's reasoning items ahead of the assistant text and function calls. Both extraction paths (non-streaming and streaming) capture reasoning output items. Claude and Bedrock replay paths drop the OpenAI-only variant so mid-session model switches keep working. --- src/client/bedrock.rs | 49 +++++-- src/client/claude.rs | 36 ++++- src/client/message.rs | 78 ++++++++++- src/client/openai.rs | 308 +++++++++++++++++++++++++++++++++++++----- 4 files changed, 424 insertions(+), 47 deletions(-) diff --git a/src/client/bedrock.rs b/src/client/bedrock.rs index cabcb42..a8da844 100644 --- a/src/client/bedrock.rs +++ b/src/client/bedrock.rs @@ -429,7 +429,9 @@ fn build_chat_completions_body( let mut user_parts = vec![]; for (index, tool_result) in tool_results.iter().enumerate() { for block in &tool_result.thinking { - assistant_parts.push(converse_reasoning_block(block)); + if let Some(part) = converse_reasoning_block(block) { + assistant_parts.push(part); + } } let round_text = if index == 0 && !text.is_empty() { Some(text.as_str()) @@ -498,7 +500,9 @@ fn build_chat_completions_body( chunk_ids.insert(id); } for block in &tool_result.thinking { - assistant_parts.push(converse_reasoning_block(block)); + if let Some(part) = converse_reasoning_block(block) { + assistant_parts.push(part); + } } let round_text = if index == 0 && !text.is_empty() { Some(text.as_str()) @@ -663,23 +667,26 @@ fn bedrock_tool_result_content(output: &Value) -> Value { } /// Maps a ThinkingBlock (Anthropic tagged shape) to the Converse -/// reasoningContent union so signed blocks replay verbatim. -fn converse_reasoning_block(block: &ThinkingBlock) -> Value { +/// reasoningContent union so signed blocks replay verbatim. OpenAI Responses +/// reasoning items have no Converse encoding, so they are dropped when the +/// session moves to Bedrock. +fn converse_reasoning_block(block: &ThinkingBlock) -> Option { match block { ThinkingBlock::Thinking { thinking, signature, - } => json!({ + } => Some(json!({ "reasoningContent": { "reasoningText": { "text": thinking, "signature": signature, } } - }), - ThinkingBlock::RedactedThinking { data } => json!({ + })), + ThinkingBlock::RedactedThinking { data } => Some(json!({ "reasoningContent": { "redactedContent": data } - }), + })), + ThinkingBlock::Reasoning { .. } => None, } } @@ -1187,6 +1194,32 @@ mod tests { } } + #[test] + fn skips_openai_reasoning_blocks_in_converse_shape() { + let reasoning = ToolResult { + call: ToolCall::new( + "fs_read".into(), + json!({"path": "x"}), + Some("tool_A".into()), + ), + output: json!("ok"), + text: None, + thinking: vec![ThinkingBlock::Reasoning { + id: "rs_1".into(), + summary: json!([{ "type": "summary_text", "text": "thinking" }]), + encrypted_content: Some("enc123".into()), + }], + }; + + for sequence in [false, true] { + let body = build_body_with(vec![reasoning.clone()], "", sequence); + + let content = body["messages"][1]["content"].as_array().unwrap(); + assert_eq!(content.len(), 1, "body: {body}"); + assert!(content[0].get("toolUse").is_some(), "body: {body}"); + } + } + #[test] fn extract_chat_completions_captures_reasoning_content() { let data = json!({ diff --git a/src/client/claude.rs b/src/client/claude.rs index 2cb67e0..78c7861 100644 --- a/src/client/claude.rs +++ b/src/client/claude.rs @@ -407,7 +407,11 @@ pub fn claude_build_chat_completions_body( let mut user_parts = vec![]; for (index, tool_result) in tool_results.iter().enumerate() { for block in &tool_result.thinking { - assistant_parts.push(json!(block)); + // Responses reasoning items are OpenAI-only; + // drop them when the session moves to Claude. + if !matches!(block, ThinkingBlock::Reasoning { .. }) { + assistant_parts.push(json!(block)); + } } let round_text = if index == 0 && !text.is_empty() { Some(text.as_str()) @@ -475,7 +479,9 @@ pub fn claude_build_chat_completions_body( chunk_ids.insert(id); } for block in &tool_result.thinking { - assistant_parts.push(json!(block)); + if !matches!(block, ThinkingBlock::Reasoning { .. }) { + assistant_parts.push(json!(block)); + } } let round_text = if index == 0 && !text.is_empty() { Some(text.as_str()) @@ -875,6 +881,32 @@ mod tests { assert_eq!(messages.len(), 1, "body: {body}"); } + #[test] + fn skips_openai_reasoning_blocks_in_replay() { + let reasoning = ToolResult { + call: ToolCall::new( + "fs_read".into(), + json!({"path": "x"}), + Some("toolu_A".into()), + ), + output: json!("ok"), + text: None, + thinking: vec![ThinkingBlock::Reasoning { + id: "rs_1".into(), + summary: json!([{ "type": "summary_text", "text": "thinking" }]), + encrypted_content: Some("enc123".into()), + }], + }; + + for sequence in [false, true] { + let body = build_body_with(vec![reasoning.clone()], "", sequence, false); + + let content = body["messages"][1]["content"].as_array().unwrap(); + assert_eq!(content.len(), 1, "body: {body}"); + assert_eq!(content[0]["type"], "tool_use", "body: {body}"); + } + } + #[test] fn prompt_cache_places_breakpoints() { let functions = vec![function_declaration("a"), function_declaration("b")]; diff --git a/src/client/message.rs b/src/client/message.rs index ddad394..87154ff 100644 --- a/src/client/message.rs +++ b/src/client/message.rs @@ -188,15 +188,27 @@ pub struct ImageUrl { pub url: String, } -/// An extended-thinking block returned by Anthropic-protocol models. -/// Serialized to match the API wire format (`type: thinking` / `type: redacted_thinking`) +/// An extended-thinking block returned by Anthropic-protocol models or an +/// OpenAI Responses reasoning item. Serialized to match each API's wire +/// format (`type: thinking` / `type: redacted_thinking` / `type: reasoning`) /// so blocks can be replayed verbatim, signature intact, in subsequent -/// tool-loop rounds as the API requires. +/// tool-loop rounds as the APIs require. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ThinkingBlock { - Thinking { thinking: String, signature: String }, - RedactedThinking { data: String }, + Thinking { + thinking: String, + signature: String, + }, + RedactedThinking { + data: String, + }, + Reasoning { + id: String, + summary: serde_json::Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + encrypted_content: Option, + }, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -263,3 +275,59 @@ pub fn extract_system_message(messages: &mut Vec) -> Option { None } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn old_session_thinking_blocks_still_deserialize() { + let json = json!([ + { "type": "thinking", "thinking": "hmm", "signature": "sig123" }, + { "type": "redacted_thinking", "data": "b64data" }, + ]); + + let blocks: Vec = serde_json::from_value(json).unwrap(); + + assert!( + matches!(&blocks[0], ThinkingBlock::Thinking { thinking, signature } if thinking == "hmm" && signature == "sig123") + ); + assert!( + matches!(&blocks[1], ThinkingBlock::RedactedThinking { data } if data == "b64data") + ); + } + + #[test] + fn reasoning_block_round_trips() { + let json = json!({ + "type": "reasoning", + "id": "rs_1", + "summary": [{ "type": "summary_text", "text": "thinking about it" }], + "encrypted_content": "enc123", + }); + + let block: ThinkingBlock = serde_json::from_value(json.clone()).unwrap(); + + assert!( + matches!(&block, ThinkingBlock::Reasoning { id, encrypted_content, .. } if id == "rs_1" && encrypted_content.as_deref() == Some("enc123")) + ); + assert_eq!(serde_json::to_value(&block).unwrap(), json); + } + + #[test] + fn reasoning_block_without_encrypted_content_round_trips() { + let json = json!({ + "type": "reasoning", + "id": "rs_1", + "summary": [], + }); + + let block: ThinkingBlock = serde_json::from_value(json.clone()).unwrap(); + + assert!( + matches!(&block, ThinkingBlock::Reasoning { encrypted_content, .. } if encrypted_content.is_none()) + ); + assert_eq!(serde_json::to_value(&block).unwrap(), json); + } +} diff --git a/src/client/openai.rs b/src/client/openai.rs index eef437a..6252009 100644 --- a/src/client/openai.rs +++ b/src/client/openai.rs @@ -685,6 +685,11 @@ pub fn openai_build_responses_body(data: ChatCompletionsData, model: &Model) -> tool_result.text.clone() }; let mut items = vec![]; + for block in &tool_result.thinking { + if matches!(block, ThinkingBlock::Reasoning { .. }) { + items.push(json!(block)); + } + } if let Some(round_text) = round_text { items.push(json!({ "role": MessageRole::Assistant, @@ -717,6 +722,7 @@ pub fn openai_build_responses_body(data: ChatCompletionsData, model: &Model) -> "model": &model.real_name(), "input": input, "store": false, + "include": ["reasoning.encrypted_content"], }); if let Some(v) = model.max_tokens_param() { @@ -740,6 +746,7 @@ pub fn openai_build_responses_body(data: ChatCompletionsData, model: &Model) -> .map(|v| { let mut tool = serde_json::to_value(v).unwrap_or_default(); tool["type"] = "function".into(); + tool["strict"] = false.into(); tool }) .collect(); @@ -766,6 +773,7 @@ pub async fn openai_responses_chat_completions( pub fn openai_extract_responses(data: &Value) -> Result { let mut text = String::new(); let mut tool_calls = vec![]; + let mut thinking = vec![]; if let Some(output) = data["output"].as_array() { for item in output { @@ -797,6 +805,11 @@ pub fn openai_extract_responses(data: &Value) -> Result { )); } } + Some("reasoning") => { + if let Some(block) = reasoning_thinking_block(item) { + thinking.push(block); + } + } _ => {} } } @@ -808,10 +821,22 @@ pub fn openai_extract_responses(data: &Value) -> Result { Ok(ChatCompletionsOutput { text, tool_calls, + thinking, ..Default::default() }) } +/// Captures a Responses reasoning item verbatim (summary included) so it can +/// be replayed in later tool-loop rounds; `store: false` makes the replayed +/// `encrypted_content` the model's only access to its prior reasoning. +fn reasoning_thinking_block(item: &Value) -> Option { + Some(ThinkingBlock::Reasoning { + id: item["id"].as_str()?.to_string(), + summary: item["summary"].clone(), + encrypted_content: item["encrypted_content"].as_str().map(|v| v.to_string()), + }) +} + pub async fn openai_responses_streaming( builder: RequestBuilder, handler: &mut SseHandler, @@ -822,48 +847,60 @@ pub async fn openai_responses_streaming( } let data: Value = serde_json::from_str(&message.data)?; debug!("stream-data: {data}"); - - match data["type"].as_str() { - Some("response.output_text.delta") => { - if let Some(delta) = data["delta"].as_str().filter(|v| !v.is_empty()) { - handler.text(delta)?; - } - } - Some("response.output_item.done") => { - let item = &data["item"]; - if item["type"].as_str() == Some("function_call") - && let (Some(name), Some(arguments_str), Some(call_id)) = ( - item["name"].as_str(), - item["arguments"].as_str(), - item["call_id"].as_str(), - ) - { - let arguments: Value = arguments_str.parse().with_context(|| { - format!("Tool call '{name}' has non-JSON arguments '{arguments_str}'") - })?; - handler.tool_call(ToolCall::new( - name.to_string(), - arguments, - Some(call_id.to_string()), - ))?; - } - } - Some("response.completed") => { - return Ok(true); - } - _ => {} - } - Ok(false) + openai_responses_handle_event(&data, handler) }; sse_stream(builder, handle).await } +fn openai_responses_handle_event(data: &Value, handler: &mut SseHandler) -> Result { + match data["type"].as_str() { + Some("response.output_text.delta") => { + if let Some(delta) = data["delta"].as_str().filter(|v| !v.is_empty()) { + handler.text(delta)?; + } + } + Some("response.output_item.done") => { + let item = &data["item"]; + match item["type"].as_str() { + Some("function_call") => { + if let (Some(name), Some(arguments_str), Some(call_id)) = ( + item["name"].as_str(), + item["arguments"].as_str(), + item["call_id"].as_str(), + ) { + let arguments: Value = arguments_str.parse().with_context(|| { + format!("Tool call '{name}' has non-JSON arguments '{arguments_str}'") + })?; + handler.tool_call(ToolCall::new( + name.to_string(), + arguments, + Some(call_id.to_string()), + ))?; + } + } + Some("reasoning") => { + if let Some(block) = reasoning_thinking_block(item) { + handler.thinking_block(block); + } + } + _ => {} + } + } + Some("response.completed") => { + return Ok(true); + } + _ => {} + } + Ok(false) +} + #[cfg(test)] mod tests { use super::*; use crate::client::access_token::set_access_token; use crate::config::AppConfig; + use crate::function::{FunctionDeclaration, ToolResult}; use chrono::Utc; use std::sync::Arc; @@ -907,6 +944,213 @@ mod tests { assert_eq!(messages.len(), 1, "body: {body}"); } + fn reasoning_block(id: &str) -> ThinkingBlock { + ThinkingBlock::Reasoning { + id: id.to_string(), + summary: json!([{ "type": "summary_text", "text": "thinking" }]), + encrypted_content: Some("enc123".to_string()), + } + } + + fn responses_tool_result( + id: &str, + text: Option<&str>, + thinking: Vec, + ) -> ToolResult { + ToolResult { + call: ToolCall::new("fs_read".into(), json!({"path": "x"}), Some(id.into())), + output: json!("ok"), + text: text.map(|t| t.to_string()), + thinking, + } + } + + fn build_responses_body( + tool_results: Vec, + functions: Option>, + ) -> Value { + let data = ChatCompletionsData { + messages: vec![ + Message::new(MessageRole::User, MessageContent::Text("hello".to_string())), + Message::new( + MessageRole::Assistant, + MessageContent::ToolCalls(MessageContentToolCalls { + tool_results, + text: "first round".to_string(), + sequence: false, + }), + ), + ], + temperature: None, + top_p: None, + reasoning_effort: None, + functions, + stream: false, + }; + openai_build_responses_body(data, &Model::new("openai", "gpt-test")) + } + + #[test] + fn responses_body_sets_include_and_strict_false() { + let functions = vec![ + FunctionDeclaration { + name: "a".to_string(), + description: "a description".to_string(), + parameters: Default::default(), + agent: false, + }, + FunctionDeclaration { + name: "b".to_string(), + description: "b description".to_string(), + parameters: Default::default(), + agent: false, + }, + ]; + let body = build_responses_body(vec![], Some(functions)); + + assert_eq!( + body["include"], + json!(["reasoning.encrypted_content"]), + "body: {body}" + ); + let tools = body["tools"].as_array().unwrap(); + assert_eq!(tools.len(), 2, "body: {body}"); + for tool in tools { + assert_eq!(tool["strict"], json!(false), "body: {body}"); + } + } + + #[test] + fn responses_replay_orders_reasoning_first_per_round() { + let body = build_responses_body( + vec![ + responses_tool_result("call_A", None, vec![reasoning_block("rs_1")]), + responses_tool_result("call_B", Some("round two"), vec![reasoning_block("rs_2")]), + ], + None, + ); + + let input = body["input"].as_array().unwrap(); + let types: Vec<_> = input + .iter() + .map(|item| item["type"].as_str().unwrap_or("text")) + .collect(); + assert_eq!( + types, + [ + "text", + "reasoning", + "text", + "function_call", + "function_call_output", + "reasoning", + "text", + "function_call", + "function_call_output", + ], + "body: {body}" + ); + assert_eq!(input[1]["id"], "rs_1", "body: {body}"); + assert_eq!(input[1]["encrypted_content"], "enc123", "body: {body}"); + assert_eq!(input[2]["content"], "first round", "body: {body}"); + assert_eq!(input[3]["call_id"], "call_A", "body: {body}"); + assert_eq!(input[5]["id"], "rs_2", "body: {body}"); + assert_eq!(input[6]["content"], "round two", "body: {body}"); + assert_eq!(input[7]["call_id"], "call_B", "body: {body}"); + } + + #[test] + fn responses_body_skips_anthropic_thinking_blocks() { + let body = build_responses_body( + vec![responses_tool_result( + "call_A", + None, + vec![ + ThinkingBlock::Thinking { + thinking: "hmm".to_string(), + signature: "sig123".to_string(), + }, + ThinkingBlock::RedactedThinking { + data: "b64data".to_string(), + }, + ], + )], + None, + ); + + let input = body["input"].as_array().unwrap(); + assert_eq!(input.len(), 4, "body: {body}"); + assert!( + input.iter().all(|item| !matches!( + item["type"].as_str(), + Some("thinking" | "redacted_thinking" | "reasoning") + )), + "body: {body}" + ); + } + + #[test] + fn extract_responses_captures_reasoning_items() { + let data = json!({ + "output": [ + { + "type": "reasoning", + "id": "rs_1", + "summary": [{ "type": "summary_text", "text": "thinking" }], + "encrypted_content": "enc123", + }, + { "type": "message", "content": [{ "type": "output_text", "text": "answer" }] }, + ] + }); + + let output = openai_extract_responses(&data).unwrap(); + + assert_eq!(output.text, "answer"); + assert_eq!(output.thinking.len(), 1); + match &output.thinking[0] { + ThinkingBlock::Reasoning { + id, + summary, + encrypted_content, + } => { + assert_eq!(id, "rs_1"); + assert_eq!( + summary, + &json!([{ "type": "summary_text", "text": "thinking" }]) + ); + assert_eq!(encrypted_content.as_deref(), Some("enc123")); + } + other => panic!("unexpected block: {other:?}"), + } + } + + #[test] + fn responses_stream_delivers_reasoning_block() { + let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel(); + let abort_signal = crate::utils::create_abort_signal(); + let mut handler = SseHandler::new(sender, abort_signal); + let event = json!({ + "type": "response.output_item.done", + "item": { + "type": "reasoning", + "id": "rs_1", + "summary": [{ "type": "summary_text", "text": "thinking" }], + "encrypted_content": "enc123", + } + }); + + let done = openai_responses_handle_event(&event, &mut handler).unwrap(); + + assert!(!done); + let (_, _, thinking, _) = handler.take(); + assert_eq!(thinking.len(), 1); + assert!(matches!( + &thinking[0], + ThinkingBlock::Reasoning { id, encrypted_content, .. } + if id == "rs_1" && encrypted_content.as_deref() == Some("enc123") + )); + } + fn openai_config(name: &str, auth: Option<&str>, oauth: Option) -> OpenAIConfig { OpenAIConfig { name: Some(name.into()),