diff --git a/crates/copilot/src/copilot_chat.rs b/crates/copilot/src/copilot_chat.rs index d4051701f72331bf5fc25fcd634002f0206ba529..52a3631791ecaf4e1f7b2bc935be37816f2b25de 100644 --- a/crates/copilot/src/copilot_chat.rs +++ b/crates/copilot/src/copilot_chat.rs @@ -294,6 +294,10 @@ pub enum ChatMessage { content: ChatMessageContent, #[serde(default, skip_serializing_if = "Vec::is_empty")] tool_calls: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_opaque: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_text: Option, }, User { content: ChatMessageContent, @@ -386,6 +390,8 @@ pub struct ResponseDelta { pub role: Option, #[serde(default)] pub tool_calls: Vec, + pub reasoning_opaque: Option, + pub reasoning_text: Option, } #[derive(Deserialize, Debug, Eq, PartialEq)] pub struct ToolCallChunk { @@ -786,13 +792,13 @@ async fn stream_completion( is_user_initiated: bool, ) -> Result>> { let is_vision_request = request.messages.iter().any(|message| match message { - ChatMessage::User { content } - | ChatMessage::Assistant { content, .. } - | ChatMessage::Tool { content, .. } => { - matches!(content, ChatMessageContent::Multipart(parts) if parts.iter().any(|part| matches!(part, ChatMessagePart::Image { .. }))) - } - _ => false, - }); + ChatMessage::User { content } + | ChatMessage::Assistant { content, .. } + | ChatMessage::Tool { content, .. } => { + matches!(content, ChatMessageContent::Multipart(parts) if parts.iter().any(|part| matches!(part, ChatMessagePart::Image { .. }))) + } + _ => false, + }); let request_initiator = if is_user_initiated { "user" } else { "agent" }; diff --git a/crates/copilot/src/copilot_responses.rs b/crates/copilot/src/copilot_responses.rs index 938577e224bcf4af440c3bd646cd1910ec1fbd13..2da2eb394b5fc5ba88c8dd3007df394a2dbc15bf 100644 --- a/crates/copilot/src/copilot_responses.rs +++ b/crates/copilot/src/copilot_responses.rs @@ -313,7 +313,8 @@ pub async fn stream_response( }; let is_streaming = request.stream; - let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?; + let json = serde_json::to_string(&request)?; + let request = request_builder.body(AsyncBody::from(json))?; let mut response = client.send(request).await?; if !response.status().is_success() { diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 1d0410c0cfff5f0f757120c9b91432593c8c1053..92ac342a39ff04ae42f5b01b5777a5d16563c37f 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -367,12 +367,16 @@ pub fn map_to_language_model_completion_events( struct State { events: Pin>>>, tool_calls_by_index: HashMap, + reasoning_opaque: Option, + reasoning_text: Option, } futures::stream::unfold( State { events, tool_calls_by_index: HashMap::default(), + reasoning_opaque: None, + reasoning_text: None, }, move |mut state| async move { if let Some(event) = state.events.next().await { @@ -403,6 +407,14 @@ pub fn map_to_language_model_completion_events( events.push(Ok(LanguageModelCompletionEvent::Text(content))); } + // Capture reasoning data from the delta (e.g. for Gemini 3) + if let Some(opaque) = delta.reasoning_opaque.clone() { + state.reasoning_opaque = Some(opaque); + } + if let Some(text) = delta.reasoning_text.clone() { + state.reasoning_text = Some(text); + } + for (index, tool_call) in delta.tool_calls.iter().enumerate() { let tool_index = tool_call.index.unwrap_or(index); let entry = state.tool_calls_by_index.entry(tool_index).or_default(); @@ -445,6 +457,32 @@ pub fn map_to_language_model_completion_events( ))); } Some("tool_calls") => { + // Gemini 3 models send reasoning_opaque/reasoning_text that must + // be preserved and sent back in subsequent requests. Emit as + // ReasoningDetails so the agent stores it in the message. + if state.reasoning_opaque.is_some() + || state.reasoning_text.is_some() + { + let mut details = serde_json::Map::new(); + if let Some(opaque) = state.reasoning_opaque.take() { + details.insert( + "reasoning_opaque".to_string(), + serde_json::Value::String(opaque), + ); + } + if let Some(text) = state.reasoning_text.take() { + details.insert( + "reasoning_text".to_string(), + serde_json::Value::String(text), + ); + } + events.push(Ok( + LanguageModelCompletionEvent::ReasoningDetails( + serde_json::Value::Object(details), + ), + )); + } + events.extend(state.tool_calls_by_index.drain().map( |(_, tool_call)| { // The model can output an empty string @@ -807,6 +845,22 @@ fn into_copilot_chat( buffer }; + // Extract reasoning_opaque and reasoning_text from reasoning_details + let (reasoning_opaque, reasoning_text) = + if let Some(details) = &message.reasoning_details { + let opaque = details + .get("reasoning_opaque") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let text = details + .get("reasoning_text") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + (opaque, text) + } else { + (None, None) + }; + messages.push(ChatMessage::Assistant { content: if text_content.is_empty() { ChatMessageContent::empty() @@ -814,6 +868,8 @@ fn into_copilot_chat( text_content.into() }, tool_calls, + reasoning_opaque, + reasoning_text, }); } Role::System => messages.push(ChatMessage::System { @@ -1317,6 +1373,106 @@ mod tests { other => panic!("expected HttpResponseError, got {:?}", other), } } + + #[test] + fn chat_completions_stream_maps_reasoning_data() { + use copilot::copilot_chat::ResponseEvent; + + let events = vec![ + ResponseEvent { + choices: vec![copilot::copilot_chat::ResponseChoice { + index: Some(0), + finish_reason: None, + delta: Some(copilot::copilot_chat::ResponseDelta { + content: None, + role: Some(copilot::copilot_chat::Role::Assistant), + tool_calls: vec![copilot::copilot_chat::ToolCallChunk { + index: Some(0), + id: Some("call_abc123".to_string()), + function: Some(copilot::copilot_chat::FunctionChunk { + name: Some("list_directory".to_string()), + arguments: Some("{\"path\":\"test\"}".to_string()), + thought_signature: None, + }), + }], + reasoning_opaque: Some("encrypted_reasoning_token_xyz".to_string()), + reasoning_text: Some("Let me check the directory".to_string()), + }), + message: None, + }], + id: "chatcmpl-123".to_string(), + usage: None, + }, + ResponseEvent { + choices: vec![copilot::copilot_chat::ResponseChoice { + index: Some(0), + finish_reason: Some("tool_calls".to_string()), + delta: Some(copilot::copilot_chat::ResponseDelta { + content: None, + role: None, + tool_calls: vec![], + reasoning_opaque: None, + reasoning_text: None, + }), + message: None, + }], + id: "chatcmpl-123".to_string(), + usage: None, + }, + ]; + + let mapped = futures::executor::block_on(async { + map_to_language_model_completion_events( + Box::pin(futures::stream::iter(events.into_iter().map(Ok))), + true, + ) + .collect::>() + .await + }); + + let mut has_reasoning_details = false; + let mut has_tool_use = false; + let mut reasoning_opaque_value: Option = None; + let mut reasoning_text_value: Option = None; + + for event_result in mapped { + match event_result { + Ok(LanguageModelCompletionEvent::ReasoningDetails(details)) => { + has_reasoning_details = true; + reasoning_opaque_value = details + .get("reasoning_opaque") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + reasoning_text_value = details + .get("reasoning_text") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => { + has_tool_use = true; + assert_eq!(tool_use.id.to_string(), "call_abc123"); + assert_eq!(tool_use.name.as_ref(), "list_directory"); + } + _ => {} + } + } + + assert!( + has_reasoning_details, + "Should emit ReasoningDetails event for Gemini 3 reasoning" + ); + assert!(has_tool_use, "Should emit ToolUse event"); + assert_eq!( + reasoning_opaque_value, + Some("encrypted_reasoning_token_xyz".to_string()), + "Should capture reasoning_opaque" + ); + assert_eq!( + reasoning_text_value, + Some("Let me check the directory".to_string()), + "Should capture reasoning_text" + ); + } } struct ConfigurationView { copilot_status: Option,