diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index 84d080ff48107e7173226df81a419b90603d82fd..6b6312e48176c93fbfb12f97e26c7943c6cbf89a 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -182,6 +182,7 @@ impl DbThread { crate::Message::Agent(AgentMessage { content, tool_results, + reasoning_details: None, }) } language_model::Role::System => { diff --git a/crates/agent/src/edit_agent.rs b/crates/agent/src/edit_agent.rs index 2ecf3429d46540ea309052e833c3e40ea2a53cb5..e5b1d1e3871ecb0070f60f5f382196482e24963a 100644 --- a/crates/agent/src/edit_agent.rs +++ b/crates/agent/src/edit_agent.rs @@ -703,6 +703,7 @@ impl EditAgent { role: Role::User, content: vec![MessageContent::Text(prompt)], cache: false, + reasoning_details: None, }); // Include tools in the request so that we can take advantage of diff --git a/crates/agent/src/edit_agent/evals.rs b/crates/agent/src/edit_agent/evals.rs index ddb9052b84b986229720efa89b9e912452411d86..81dce33d0394b5757be4934031f31b6f17233e9c 100644 --- a/crates/agent/src/edit_agent/evals.rs +++ b/crates/agent/src/edit_agent/evals.rs @@ -1081,6 +1081,7 @@ fn message( role, content: contents.into_iter().collect(), cache: false, + reasoning_details: None, } } @@ -1268,6 +1269,7 @@ impl EvalAssertion { role: Role::User, content: vec![prompt.into()], cache: false, + reasoning_details: None, }], thinking_allowed: true, ..Default::default() @@ -1594,6 +1596,7 @@ impl EditAgentTest { role: Role::System, content: vec![MessageContent::Text(system_prompt)], cache: true, + reasoning_details: None, }] .into_iter() .chain(eval.conversation) diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index f43cbed952afd434c4262da486ce11dffa40a5c8..efba471f1a927446aa96b1c1426c60b42b725b89 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -215,7 +215,8 @@ async fn test_prompt_caching(cx: &mut TestAppContext) { vec![LanguageModelRequestMessage { role: Role::User, content: vec!["Message 1".into()], - cache: true + cache: true, + reasoning_details: None, }] ); fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::Text( @@ -239,17 +240,20 @@ async fn test_prompt_caching(cx: &mut TestAppContext) { LanguageModelRequestMessage { role: Role::User, content: vec!["Message 1".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::Assistant, content: vec!["Response to Message 1".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, content: vec!["Message 2".into()], - cache: true + cache: true, + reasoning_details: None, } ] ); @@ -295,37 +299,44 @@ async fn test_prompt_caching(cx: &mut TestAppContext) { LanguageModelRequestMessage { role: Role::User, content: vec!["Message 1".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::Assistant, content: vec!["Response to Message 1".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, content: vec!["Message 2".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::Assistant, content: vec!["Response to Message 2".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, content: vec!["Use the echo tool".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::Assistant, content: vec![MessageContent::ToolUse(tool_use)], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, content: vec![MessageContent::ToolResult(tool_result)], - cache: true + cache: true, + reasoning_details: None, } ] ); @@ -648,17 +659,20 @@ async fn test_resume_after_tool_use_limit(cx: &mut TestAppContext) { LanguageModelRequestMessage { role: Role::User, content: vec!["abc".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::Assistant, content: vec![MessageContent::ToolUse(tool_use.clone())], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, content: vec![MessageContent::ToolResult(tool_result.clone())], - cache: true + cache: true, + reasoning_details: None, }, ] ); @@ -682,22 +696,26 @@ async fn test_resume_after_tool_use_limit(cx: &mut TestAppContext) { LanguageModelRequestMessage { role: Role::User, content: vec!["abc".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::Assistant, content: vec![MessageContent::ToolUse(tool_use)], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, content: vec![MessageContent::ToolResult(tool_result)], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, content: vec!["Continue where you left off".into()], - cache: true + cache: true, + reasoning_details: None, } ] ); @@ -769,22 +787,26 @@ async fn test_send_after_tool_use_limit(cx: &mut TestAppContext) { LanguageModelRequestMessage { role: Role::User, content: vec!["abc".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::Assistant, content: vec![MessageContent::ToolUse(tool_use)], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, content: vec![MessageContent::ToolResult(tool_result)], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, content: vec!["ghi".into()], - cache: true + cache: true, + reasoning_details: None, } ] ); @@ -1827,7 +1849,8 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) { LanguageModelRequestMessage { role: Role::User, content: vec!["Hey!".into()], - cache: true + cache: true, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::Assistant, @@ -1835,7 +1858,8 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) { MessageContent::Text("Hi!".into()), MessageContent::ToolUse(echo_tool_use.clone()) ], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, @@ -1846,7 +1870,8 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) { content: "test".into(), output: Some("test".into()) })], - cache: false + cache: false, + reasoning_details: None, }, ], ); @@ -2244,12 +2269,14 @@ async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) { LanguageModelRequestMessage { role: Role::User, content: vec!["Call the echo tool!".into()], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::Assistant, content: vec![language_model::MessageContent::ToolUse(tool_use_1.clone())], - cache: false + cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, @@ -2262,7 +2289,8 @@ async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) { output: Some("test".into()) } )], - cache: true + cache: true, + reasoning_details: None, }, ] ); @@ -2276,7 +2304,8 @@ async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) { thread.last_message(), Some(Message::Agent(AgentMessage { content: vec![AgentMessageContent::Text("Done".into())], - tool_results: IndexMap::default() + tool_results: IndexMap::default(), + reasoning_details: None, })) ); }) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 928b60eee4bc3ccdf296e8ba7f4f0bdc49cb9fa3..294c96b3ecb7800ab5b5f62749d335682efebd60 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -113,6 +113,7 @@ impl Message { role: Role::User, content: vec!["Continue where you left off".into()], cache: false, + reasoning_details: None, }], } } @@ -177,6 +178,7 @@ impl UserMessage { role: Role::User, content: Vec::with_capacity(self.content.len()), cache: false, + reasoning_details: None, }; const OPEN_CONTEXT: &str = "\n\ @@ -444,6 +446,7 @@ impl AgentMessage { role: Role::Assistant, content: Vec::with_capacity(self.content.len()), cache: false, + reasoning_details: self.reasoning_details.clone(), }; for chunk in &self.content { match chunk { @@ -479,6 +482,7 @@ impl AgentMessage { role: Role::User, content: Vec::new(), cache: false, + reasoning_details: None, }; for tool_result in self.tool_results.values() { @@ -508,6 +512,7 @@ impl AgentMessage { pub struct AgentMessage { pub content: Vec, pub tool_results: IndexMap, + pub reasoning_details: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -1398,6 +1403,18 @@ impl Thread { self.handle_thinking_event(text, signature, event_stream, cx) } RedactedThinking { data } => self.handle_redacted_thinking_event(data, cx), + ReasoningDetails(details) => { + let last_message = self.pending_message(); + // Store the last non-empty reasoning_details (overwrites earlier ones) + // This ensures we keep the encrypted reasoning with signatures, not the early text reasoning + if let serde_json::Value::Array(ref arr) = details { + if !arr.is_empty() { + last_message.reasoning_details = Some(details); + } + } else { + last_message.reasoning_details = Some(details); + } + } ToolUse(tool_use) => { return Ok(self.handle_tool_use_event(tool_use, event_stream, cx)); } @@ -1673,6 +1690,7 @@ impl Thread { role: Role::User, content: vec![SUMMARIZE_THREAD_DETAILED_PROMPT.into()], cache: false, + reasoning_details: None, }); let task = cx @@ -1737,6 +1755,7 @@ impl Thread { role: Role::User, content: vec![SUMMARIZE_THREAD_PROMPT.into()], cache: false, + reasoning_details: None, }); self.pending_title_generation = Some(cx.spawn(async move |this, cx| { let mut title = String::new(); @@ -1984,6 +2003,7 @@ impl Thread { role: Role::System, content: vec![system_prompt.into()], cache: false, + reasoning_details: None, }]; for message in &self.messages { messages.extend(message.to_request()); diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index ba52b0298d37211626b6baf6aae1fb3da0be6372..647437770604b766ab054cb66fc6c8e154402ab7 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -423,6 +423,7 @@ impl CodegenAlternative { role: Role::User, content: Vec::new(), cache: false, + reasoning_details: None, }; if let Some(context) = context_task.await { diff --git a/crates/agent_ui/src/terminal_inline_assistant.rs b/crates/agent_ui/src/terminal_inline_assistant.rs index c6da11a35af22c4052cd580e58c896e19a1faf78..43ea697bece318699f350259a0e2e38d1a4f4d8d 100644 --- a/crates/agent_ui/src/terminal_inline_assistant.rs +++ b/crates/agent_ui/src/terminal_inline_assistant.rs @@ -262,6 +262,7 @@ impl TerminalInlineAssistant { role: Role::User, content: vec![], cache: false, + reasoning_details: None, }; if let Some(context) = load_context_task.await { diff --git a/crates/assistant_text_thread/src/text_thread.rs b/crates/assistant_text_thread/src/text_thread.rs index 9f065e9ca7a1daf933c1313dd1d5f092cbed2771..a50e410ab7d1bd1eb34ba367dfbfd36a7b2ec826 100644 --- a/crates/assistant_text_thread/src/text_thread.rs +++ b/crates/assistant_text_thread/src/text_thread.rs @@ -1417,6 +1417,7 @@ impl TextThread { role: Role::User, content: vec!["Respond only with OK, nothing else.".into()], cache: false, + reasoning_details: None, }); req }; @@ -2085,6 +2086,11 @@ impl TextThread { ); } LanguageModelCompletionEvent::StartMessage { .. } => {} + LanguageModelCompletionEvent::ReasoningDetails(_) => { + // ReasoningDetails are metadata (signatures, encrypted data, format info) + // used for request/response validation, not UI content. + // The displayable thinking text is already handled by the Thinking event. + } LanguageModelCompletionEvent::Stop(reason) => { stop_reason = reason; } @@ -2308,6 +2314,7 @@ impl TextThread { role: message.role, content: Vec::new(), cache: message.cache.as_ref().is_some_and(|cache| cache.is_anchor), + reasoning_details: None, }; while let Some(content) = contents.peek() { @@ -2679,6 +2686,7 @@ impl TextThread { role: Role::User, content: vec![SUMMARIZE_THREAD_PROMPT.into()], cache: false, + reasoning_details: None, }); // If there is no summary, it is set with `done: false` so that "Loading Summary…" can diff --git a/crates/copilot/src/copilot_chat.rs b/crates/copilot/src/copilot_chat.rs index 5d22760942dbbcfd72f1dacb83c249a08f2fe72a..d4051701f72331bf5fc25fcd634002f0206ba529 100644 --- a/crates/copilot/src/copilot_chat.rs +++ b/crates/copilot/src/copilot_chat.rs @@ -353,6 +353,8 @@ pub enum ToolCallContent { pub struct FunctionContent { pub name: String, pub arguments: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thought_signature: Option, } #[derive(Deserialize, Debug)] @@ -396,6 +398,7 @@ pub struct ToolCallChunk { pub struct FunctionChunk { pub name: Option, pub arguments: Option, + pub thought_signature: Option, } #[derive(Deserialize)] diff --git a/crates/copilot/src/copilot_responses.rs b/crates/copilot/src/copilot_responses.rs index c1e066208823dcab34a32096cfa447dd0ec9592f..938577e224bcf4af440c3bd646cd1910ec1fbd13 100644 --- a/crates/copilot/src/copilot_responses.rs +++ b/crates/copilot/src/copilot_responses.rs @@ -127,6 +127,8 @@ pub enum ResponseInputItem { arguments: String, #[serde(skip_serializing_if = "Option::is_none")] status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + thought_signature: Option, }, FunctionCallOutput { call_id: String, @@ -251,6 +253,8 @@ pub enum ResponseOutputItem { arguments: String, #[serde(skip_serializing_if = "Option::is_none")] status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + thought_signature: Option, }, Reasoning { id: String, diff --git a/crates/eval/src/instance.rs b/crates/eval/src/instance.rs index 075a1a5cea1da782d40778befeb04bf2e6bac316..99a8af053609b98efe29a179964a38137c4ba021 100644 --- a/crates/eval/src/instance.rs +++ b/crates/eval/src/instance.rs @@ -553,6 +553,7 @@ impl ExampleInstance { role: Role::User, content: vec![MessageContent::Text(to_prompt(assertion.description))], cache: false, + reasoning_details: None, }], temperature: None, tools: Vec::new(), @@ -1255,7 +1256,8 @@ pub fn response_events_to_markdown( | LanguageModelCompletionEvent::StartMessage { .. } | LanguageModelCompletionEvent::UsageUpdated { .. } | LanguageModelCompletionEvent::Queued { .. } - | LanguageModelCompletionEvent::Started, + | LanguageModelCompletionEvent::Started + | LanguageModelCompletionEvent::ReasoningDetails(_), ) => {} Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { json_parse_error, .. @@ -1341,6 +1343,7 @@ impl ThreadDialog { Ok(LanguageModelCompletionEvent::UsageUpdate(_)) | Ok(LanguageModelCompletionEvent::RedactedThinking { .. }) | Ok(LanguageModelCompletionEvent::StartMessage { .. }) + | Ok(LanguageModelCompletionEvent::ReasoningDetails(_)) | Ok(LanguageModelCompletionEvent::Stop(_)) | Ok(LanguageModelCompletionEvent::Queued { .. }) | Ok(LanguageModelCompletionEvent::Started) @@ -1372,6 +1375,7 @@ impl ThreadDialog { role: Role::Assistant, content, cache: false, + reasoning_details: None, }) } else { None diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index e810672c69b9ed602ddf76c2ca1f1035b958cd26..a6c6113a33b61cd16f007b6d2d818e42ad2a191e 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -2051,6 +2051,7 @@ impl GitPanel { role: Role::User, content: vec![content.into()], cache: false, + reasoning_details: None, }], tools: Vec::new(), tool_choice: None, diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 3322409c09399b3ec957d8288b45e1833b77c106..c9b6391136da1a2b2e9a2ae470229179615a865a 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -98,6 +98,7 @@ pub enum LanguageModelCompletionEvent { StartMessage { message_id: String, }, + ReasoningDetails(serde_json::Value), UsageUpdate(TokenUsage), } @@ -680,6 +681,7 @@ pub trait LanguageModel: Send + Sync { Ok(LanguageModelCompletionEvent::Text(text)) => Some(Ok(text)), Ok(LanguageModelCompletionEvent::Thinking { .. }) => None, Ok(LanguageModelCompletionEvent::RedactedThinking { .. }) => None, + Ok(LanguageModelCompletionEvent::ReasoningDetails(_)) => None, Ok(LanguageModelCompletionEvent::Stop(_)) => None, Ok(LanguageModelCompletionEvent::ToolUse(_)) => None, Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { @@ -1034,8 +1036,8 @@ mod tests { let original = LanguageModelToolUse { id: LanguageModelToolUseId::from("no_sig_id"), name: "no_sig_tool".into(), - raw_input: json!({"key": "value"}).to_string(), - input: json!({"key": "value"}), + raw_input: json!({"arg": "value"}).to_string(), + input: json!({"arg": "value"}), is_input_complete: true, thought_signature: None, }; diff --git a/crates/language_model/src/request.rs b/crates/language_model/src/request.rs index d0f7789e40dd71ada8dcae2712cefcef966ad52f..d97d87bdc95c443aeaf3f2b5578bf7f0c1ef322a 100644 --- a/crates/language_model/src/request.rs +++ b/crates/language_model/src/request.rs @@ -357,6 +357,8 @@ pub struct LanguageModelRequestMessage { pub role: Role, pub content: Vec, pub cache: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_details: Option, } impl LanguageModelRequestMessage { diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 2491e8277a8b2632f6835af13736c23e94966c4c..1affe38a08d22e2aaed8c1207513ce41a13b8e59 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -989,6 +989,7 @@ mod tests { MessageContent::Image(language_model::LanguageModelImage::empty()), ], cache: true, + reasoning_details: None, }], thread_id: None, prompt_id: None, diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index f62b899318ae56452509f8d9e7cca05f8859cf27..1d0410c0cfff5f0f757120c9b91432593c8c1053 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -361,6 +361,7 @@ pub fn map_to_language_model_completion_events( id: String, name: String, arguments: String, + thought_signature: Option, } struct State { @@ -418,6 +419,11 @@ pub fn map_to_language_model_completion_events( if let Some(arguments) = function.arguments.clone() { entry.arguments.push_str(&arguments); } + + if let Some(thought_signature) = function.thought_signature.clone() + { + entry.thought_signature = Some(thought_signature); + } } } @@ -458,7 +464,7 @@ pub fn map_to_language_model_completion_events( is_input_complete: true, input, raw_input: tool_call.arguments, - thought_signature: None, + thought_signature: tool_call.thought_signature, }, )), Err(error) => Ok( @@ -550,6 +556,7 @@ impl CopilotResponsesEventMapper { call_id, name, arguments, + thought_signature, .. } => { let mut events = Vec::new(); @@ -561,7 +568,7 @@ impl CopilotResponsesEventMapper { is_input_complete: true, input, raw_input: arguments.clone(), - thought_signature: None, + thought_signature, }, ))), Err(error) => { @@ -776,6 +783,7 @@ fn into_copilot_chat( function: copilot::copilot_chat::FunctionContent { name: tool_use.name.to_string(), arguments: serde_json::to_string(&tool_use.input)?, + thought_signature: tool_use.thought_signature.clone(), }, }, }); @@ -950,6 +958,7 @@ fn into_copilot_responses( name: tool_use.name.to_string(), arguments: tool_use.raw_input.clone(), status: None, + thought_signature: tool_use.thought_signature.clone(), }); } } @@ -1122,6 +1131,7 @@ mod tests { name: "do_it".into(), arguments: "{\"x\":1}".into(), status: None, + thought_signature: None, }, }]; @@ -1147,6 +1157,7 @@ mod tests { name: "do_it".into(), arguments: "{not json}".into(), status: None, + thought_signature: None, }, }]; @@ -1250,6 +1261,7 @@ mod tests { name: "do_it".into(), arguments: "{}".into(), status: None, + thought_signature: None, }, }, responses::StreamEvent::Completed { diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index 68b6f976418b2125027e5800527f73cc49e5a1bb..c5a5affcd3d9e8c34f6306f86cb5348f86397892 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -1094,6 +1094,7 @@ mod tests { role: Role::Assistant, content: vec![MessageContent::ToolUse(tool_use)], cache: false, + reasoning_details: None, }], ..Default::default() }, @@ -1130,6 +1131,7 @@ mod tests { role: Role::Assistant, content: vec![MessageContent::ToolUse(tool_use)], cache: false, + reasoning_details: None, }], ..Default::default() }, @@ -1162,6 +1164,7 @@ mod tests { role: Role::Assistant, content: vec![MessageContent::ToolUse(tool_use)], cache: false, + reasoning_details: None, }], ..Default::default() }, @@ -1218,6 +1221,7 @@ mod tests { role: Role::Assistant, content: vec![MessageContent::ToolUse(tool_use)], cache: false, + reasoning_details: None, }], ..Default::default() }, diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index 0c45913bea83e32c508daa6c6579ecd0382b3dc0..8372a8c95e579f1d860fd9bb25656731ee2c7e50 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -1025,11 +1025,13 @@ mod tests { role: Role::System, content: vec![MessageContent::Text("System prompt".into())], cache: false, + reasoning_details: None, }, LanguageModelRequestMessage { role: Role::User, content: vec![MessageContent::Text("Hello".into())], cache: false, + reasoning_details: None, }, ], temperature: Some(0.5), @@ -1064,6 +1066,7 @@ mod tests { }), ], cache: false, + reasoning_details: None, }], tools: vec![], tool_choice: None, diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index ee62522882c214dfa1384f75ced6eba46c9ec35f..9d828d188586b92e3f47a1345e070f33af380d48 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -882,6 +882,7 @@ mod tests { role: Role::User, content: vec![MessageContent::Text("message".into())], cache: false, + reasoning_details: None, }], tools: vec![], tool_choice: None, diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index c98ee02efd7b7af32ea6c649f29eef685753ba7d..7b10ebf963033603ede691fa72d2fa523bcdbab9 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -393,6 +393,7 @@ pub fn into_open_router( ) -> open_router::Request { let mut messages = Vec::new(); for message in request.messages { + let reasoning_details = message.reasoning_details.clone(); for content in message.content { match content { MessageContent::Text(text) => add_message_content_part( @@ -419,18 +420,26 @@ pub fn into_open_router( name: tool_use.name.to_string(), arguments: serde_json::to_string(&tool_use.input) .unwrap_or_default(), + thought_signature: tool_use.thought_signature.clone(), }, }, }; - if let Some(open_router::RequestMessage::Assistant { tool_calls, .. }) = - messages.last_mut() + if let Some(open_router::RequestMessage::Assistant { + tool_calls, + reasoning_details: existing_reasoning, + .. + }) = messages.last_mut() { tool_calls.push(tool_call); + if existing_reasoning.is_none() && reasoning_details.is_some() { + *existing_reasoning = reasoning_details.clone(); + } } else { messages.push(open_router::RequestMessage::Assistant { content: None, tool_calls: vec![tool_call], + reasoning_details: reasoning_details.clone(), }); } } @@ -529,6 +538,7 @@ fn add_message_content_part( Role::Assistant => open_router::RequestMessage::Assistant { content: Some(open_router::MessageContent::from(vec![new_part])), tool_calls: Vec::new(), + reasoning_details: None, }, Role::System => open_router::RequestMessage::System { content: open_router::MessageContent::from(vec![new_part]), @@ -540,12 +550,14 @@ fn add_message_content_part( pub struct OpenRouterEventMapper { tool_calls_by_index: HashMap, + reasoning_details: Option, } impl OpenRouterEventMapper { pub fn new() -> Self { Self { tool_calls_by_index: HashMap::default(), + reasoning_details: None, } } @@ -577,6 +589,15 @@ impl OpenRouterEventMapper { }; let mut events = Vec::new(); + + if let Some(details) = choice.delta.reasoning_details.clone() { + // Emit reasoning_details immediately + events.push(Ok(LanguageModelCompletionEvent::ReasoningDetails( + details.clone(), + ))); + self.reasoning_details = Some(details); + } + if let Some(reasoning) = choice.delta.reasoning.clone() { events.push(Ok(LanguageModelCompletionEvent::Thinking { text: reasoning, @@ -608,6 +629,10 @@ impl OpenRouterEventMapper { if let Some(arguments) = function.arguments.clone() { entry.arguments.push_str(&arguments); } + + if let Some(signature) = function.thought_signature.clone() { + entry.thought_signature = Some(signature); + } } } } @@ -623,6 +648,7 @@ impl OpenRouterEventMapper { match choice.finish_reason.as_deref() { Some("stop") => { + // Don't emit reasoning_details here - already emitted immediately when captured events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn))); } Some("tool_calls") => { @@ -635,7 +661,7 @@ impl OpenRouterEventMapper { is_input_complete: true, input, raw_input: tool_call.arguments.clone(), - thought_signature: None, + thought_signature: tool_call.thought_signature.clone(), }, )), Err(error) => Ok(LanguageModelCompletionEvent::ToolUseJsonParseError { @@ -647,10 +673,12 @@ impl OpenRouterEventMapper { } })); + // Don't emit reasoning_details here - already emitted immediately when captured events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::ToolUse))); } Some(stop_reason) => { log::error!("Unexpected OpenRouter stop_reason: {stop_reason:?}",); + // Don't emit reasoning_details here - already emitted immediately when captured events.push(Ok(LanguageModelCompletionEvent::Stop(StopReason::EndTurn))); } None => {} @@ -665,6 +693,7 @@ struct RawToolCall { id: String, name: String, arguments: String, + thought_signature: Option, } pub fn count_open_router_tokens( @@ -832,3 +861,235 @@ impl Render for ConfigurationView { } } } + +#[cfg(test)] +mod tests { + use super::*; + + use open_router::{ChoiceDelta, FunctionChunk, ResponseMessageDelta, ToolCallChunk}; + + #[gpui::test] + async fn test_reasoning_details_preservation_with_tool_calls() { + // This test verifies that reasoning_details are properly captured and preserved + // when a model uses tool calling with reasoning/thinking tokens. + // + // The key regression this prevents: + // - OpenRouter sends multiple reasoning_details updates during streaming + // - First with actual content (encrypted reasoning data) + // - Then with empty array on completion + // - We must NOT overwrite the real data with the empty array + + let mut mapper = OpenRouterEventMapper::new(); + + // Simulate the streaming events as they come from OpenRouter/Gemini + let events = vec![ + // Event 1: Initial reasoning details with text + ResponseStreamEvent { + id: Some("response_123".into()), + created: 1234567890, + model: "google/gemini-3-pro-preview".into(), + choices: vec![ChoiceDelta { + index: 0, + delta: ResponseMessageDelta { + role: None, + content: None, + reasoning: None, + tool_calls: None, + reasoning_details: Some(serde_json::json!([ + { + "type": "reasoning.text", + "text": "Let me analyze this request...", + "format": "google-gemini-v1", + "index": 0 + } + ])), + }, + finish_reason: None, + }], + usage: None, + }, + // Event 2: More reasoning details + ResponseStreamEvent { + id: Some("response_123".into()), + created: 1234567890, + model: "google/gemini-3-pro-preview".into(), + choices: vec![ChoiceDelta { + index: 0, + delta: ResponseMessageDelta { + role: None, + content: None, + reasoning: None, + tool_calls: None, + reasoning_details: Some(serde_json::json!([ + { + "type": "reasoning.encrypted", + "data": "EtgDCtUDAdHtim9OF5jm4aeZSBAtl/randomized123", + "format": "google-gemini-v1", + "index": 0, + "id": "tool_call_abc123" + } + ])), + }, + finish_reason: None, + }], + usage: None, + }, + // Event 3: Tool call starts + ResponseStreamEvent { + id: Some("response_123".into()), + created: 1234567890, + model: "google/gemini-3-pro-preview".into(), + choices: vec![ChoiceDelta { + index: 0, + delta: ResponseMessageDelta { + role: None, + content: None, + reasoning: None, + tool_calls: Some(vec![ToolCallChunk { + index: 0, + id: Some("tool_call_abc123".into()), + function: Some(FunctionChunk { + name: Some("list_directory".into()), + arguments: Some("{\"path\":\"test\"}".into()), + thought_signature: Some("sha256:test_signature_xyz789".into()), + }), + }]), + reasoning_details: None, + }, + finish_reason: None, + }], + usage: None, + }, + // Event 4: Empty reasoning_details on tool_calls finish + // This is the critical event - we must not overwrite with this empty array! + ResponseStreamEvent { + id: Some("response_123".into()), + created: 1234567890, + model: "google/gemini-3-pro-preview".into(), + choices: vec![ChoiceDelta { + index: 0, + delta: ResponseMessageDelta { + role: None, + content: None, + reasoning: None, + tool_calls: None, + reasoning_details: Some(serde_json::json!([])), + }, + finish_reason: Some("tool_calls".into()), + }], + usage: None, + }, + ]; + + // Process all events + let mut collected_events = Vec::new(); + for event in events { + let mapped = mapper.map_event(event); + collected_events.extend(mapped); + } + + // Verify we got the expected events + let mut has_tool_use = false; + let mut reasoning_details_events = Vec::new(); + let mut thought_signature_value = None; + + for event_result in collected_events { + match event_result { + Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => { + has_tool_use = true; + assert_eq!(tool_use.id.to_string(), "tool_call_abc123"); + assert_eq!(tool_use.name.as_ref(), "list_directory"); + thought_signature_value = tool_use.thought_signature.clone(); + } + Ok(LanguageModelCompletionEvent::ReasoningDetails(details)) => { + reasoning_details_events.push(details); + } + _ => {} + } + } + + // Assertions + assert!(has_tool_use, "Should have emitted ToolUse event"); + assert!( + !reasoning_details_events.is_empty(), + "Should have emitted ReasoningDetails events" + ); + + // We should have received multiple reasoning_details events (text, encrypted, empty) + // The agent layer is responsible for keeping only the first non-empty one + assert!( + reasoning_details_events.len() >= 2, + "Should have multiple reasoning_details events from streaming" + ); + + // Verify at least one contains the encrypted data + let has_encrypted = reasoning_details_events.iter().any(|details| { + if let serde_json::Value::Array(arr) = details { + arr.iter().any(|item| { + item["type"] == "reasoning.encrypted" + && item["data"] + .as_str() + .map_or(false, |s| s.contains("EtgDCtUDAdHtim9OF5jm4aeZSBAtl")) + }) + } else { + false + } + }); + assert!( + has_encrypted, + "Should have at least one reasoning_details with encrypted data" + ); + + // Verify thought_signature was captured + assert!( + thought_signature_value.is_some(), + "Tool use should have thought_signature" + ); + assert_eq!( + thought_signature_value.unwrap(), + "sha256:test_signature_xyz789" + ); + } + + #[gpui::test] + async fn test_agent_prevents_empty_reasoning_details_overwrite() { + // This test verifies that the agent layer prevents empty reasoning_details + // from overwriting non-empty ones, even though the mapper emits all events. + + // Simulate what the agent does when it receives multiple ReasoningDetails events + let mut agent_reasoning_details: Option = None; + + let events = vec![ + // First event: non-empty reasoning_details + serde_json::json!([ + { + "type": "reasoning.encrypted", + "data": "real_data_here", + "format": "google-gemini-v1" + } + ]), + // Second event: empty array (should not overwrite) + serde_json::json!([]), + ]; + + for details in events { + // This mimics the agent's logic: only store if we don't already have it + if agent_reasoning_details.is_none() { + agent_reasoning_details = Some(details); + } + } + + // Verify the agent kept the first non-empty reasoning_details + assert!(agent_reasoning_details.is_some()); + let final_details = agent_reasoning_details.unwrap(); + if let serde_json::Value::Array(arr) = &final_details { + assert!( + !arr.is_empty(), + "Agent should have kept the non-empty reasoning_details" + ); + assert_eq!(arr[0]["data"], "real_data_here"); + } else { + panic!("Expected array"); + } + } +} diff --git a/crates/open_router/src/open_router.rs b/crates/open_router/src/open_router.rs index 0081c877756dab46433481ac58f2180877e7667f..57ff9558c261194136b84f0e96a4936a183a15b5 100644 --- a/crates/open_router/src/open_router.rs +++ b/crates/open_router/src/open_router.rs @@ -215,6 +215,8 @@ pub enum RequestMessage { content: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] tool_calls: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_details: Option, }, User { content: MessageContent, @@ -341,6 +343,8 @@ pub enum ToolCallContent { pub struct FunctionContent { pub name: String, pub arguments: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thought_signature: Option, } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] @@ -350,6 +354,8 @@ pub struct ResponseMessageDelta { pub reasoning: Option, #[serde(default, skip_serializing_if = "is_none_or_empty")] pub tool_calls: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_details: Option, } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] @@ -363,6 +369,8 @@ pub struct ToolCallChunk { pub struct FunctionChunk { pub name: Option, pub arguments: Option, + #[serde(default)] + pub thought_signature: Option, } #[derive(Serialize, Deserialize, Debug)] diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs index b5b664f6e5c91e2a4f3760b3ad34c3b055bb2df7..09b7e0b539cde7371b97ef092fbd8f904b241c13 100644 --- a/crates/rules_library/src/rules_library.rs +++ b/crates/rules_library/src/rules_library.rs @@ -1072,6 +1072,7 @@ impl RulesLibrary { role: Role::System, content: vec![body.to_string().into()], cache: false, + reasoning_details: None, }], tools: Vec::new(), tool_choice: None,