From 26260e1c3d4c1784dde78ef530ce1f95f761ed4d Mon Sep 17 00:00:00 2001 From: rabi Date: Thu, 8 Jan 2026 19:46:44 +0530 Subject: [PATCH] fix(google): treat signed text as regular content in streaming Change streaming to use SignedTextAsRegularText instead of SkipSignedText. Signatures are tracked via last_signature and attached to function calls through effective_signature = signature.or(last_signature). Gemini 2.5 models include thoughtSignature on the first streaming chunk regardless of whether function calls follow. The old SkipSignedText behavior discarded text parts with signatures, causing content loss. Signature placement by model: - Gemini 2.5: first part (with or without function calls) - Gemini 3 with function calls: first function call part - Gemini 3 without function calls: last part if thought generated Signed-off-by: rabi --- crates/goose/src/providers/formats/google.rs | 43 ++++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/goose/src/providers/formats/google.rs b/crates/goose/src/providers/formats/google.rs index 278459288ac2..0ebd134c66e5 100644 --- a/crates/goose/src/providers/formats/google.rs +++ b/crates/goose/src/providers/formats/google.rs @@ -309,7 +309,6 @@ pub fn process_map(map: &Map, parent_key: Option<&str>) -> Value #[derive(Clone, Copy)] enum SignedTextHandling { - SkipSignedText, SignedTextAsThinking, SignedTextAsRegularText, } @@ -318,8 +317,12 @@ pub fn process_response_part( part: &Value, last_signature: &mut Option, ) -> Option { - // For streaming: skip text with signatures (matches Anthropic/OpenAI behavior) - process_response_part_impl(part, last_signature, SignedTextHandling::SkipSignedText) + // Gemini 2.5 models include thoughtSignature on the first streaming chunk + process_response_part_impl( + part, + last_signature, + SignedTextHandling::SignedTextAsRegularText, + ) } fn process_response_part_non_streaming( @@ -353,7 +356,6 @@ fn process_response_part_impl( return None; } match (signature, signed_text_handling) { - (Some(_), SignedTextHandling::SkipSignedText) => None, (Some(sig), SignedTextHandling::SignedTextAsThinking) => { Some(MessageContent::thinking(text.to_string(), sig.to_string())) } @@ -1343,6 +1345,39 @@ mod tests { assert_eq!(tool_calls, vec!["test_tool"]); } + #[tokio::test] + async fn test_streaming_with_thought_signature() { + use futures::StreamExt; + + let signed_stream = concat!( + r#"data: {"candidates": [{"content": {"role": "model", "#, + r#""parts": [{"text": "Begin", "thoughtSignature": "sig123"}]}}]}"#, + "\n", + r#"data: {"candidates": [{"content": {"role": "model", "#, + r#""parts": [{"text": " middle"}]}}]}"#, + "\n", + r#"data: {"candidates": [{"content": {"role": "model", "#, + r#""parts": [{"text": " end"}]}}]}"# + ); + let lines: Vec> = + signed_stream.lines().map(|l| Ok(l.to_string())).collect(); + let stream = Box::pin(futures::stream::iter(lines)); + let mut message_stream = std::pin::pin!(response_to_streaming_message(stream)); + + let mut text_parts = Vec::new(); + + while let Some(result) = message_stream.next().await { + let (message, _usage) = result.unwrap(); + if let Some(msg) = message { + if let Some(MessageContent::Text(text)) = msg.content.first() { + text_parts.push(text.text.clone()); + } + } + } + + assert_eq!(text_parts, vec!["Begin", " middle", " end"]); + } + #[tokio::test] async fn test_streaming_error_response() { use futures::StreamExt;