Skip to content

Commit 304a00f

Browse files
committed
fix(google): handle thoughtSignature vagaries during streaming
Gemini 3.0-flash-preview may attach `thoughtSignature` to ordinary response-text chunks during streaming, causing both the CLI and UI to silently hide them as Thinking and truncate the response. Always emit signed text as regular text during streaming since we cannot look ahead to know whether function calls will follow. The signature is still tracked and propagated to tool-call metadata. The only impact is the user may briefly see reasoning text before tool calls, which is harmless. Change-Id: Ie6fac28e437867acbf883341ada2996ff10c7f78 Signed-off-by: rabi <ramishra@redhat.com>
1 parent 860c7d7 commit 304a00f

File tree

1 file changed

+55
-109
lines changed

1 file changed

+55
-109
lines changed

crates/goose/src/providers/formats/google.rs

Lines changed: 55 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -227,38 +227,6 @@ enum SignedTextHandling {
227227
SignedTextAsRegularText,
228228
}
229229

230-
pub fn process_response_part(
231-
part: &Value,
232-
last_signature: &mut Option<String>,
233-
) -> Option<MessageContent> {
234-
let has_signature = part.get(THOUGHT_SIGNATURE_KEY).is_some();
235-
let handling = if has_signature {
236-
SignedTextHandling::SignedTextAsThinking
237-
} else {
238-
SignedTextHandling::SignedTextAsRegularText
239-
};
240-
process_response_part_impl(part, last_signature, handling)
241-
}
242-
243-
/// Gemini 2.x includes thoughtSignature on first chunk as metadata, not actual thinking.
244-
fn process_response_part_for_model(
245-
part: &Value,
246-
last_signature: &mut Option<String>,
247-
model_version: Option<&str>,
248-
) -> Option<MessageContent> {
249-
let is_gemini_2 = model_version
250-
.map(|m| m.starts_with("gemini-2"))
251-
.unwrap_or(false);
252-
253-
let has_signature = part.get(THOUGHT_SIGNATURE_KEY).is_some();
254-
let handling = if has_signature && !is_gemini_2 {
255-
SignedTextHandling::SignedTextAsThinking
256-
} else {
257-
SignedTextHandling::SignedTextAsRegularText
258-
};
259-
process_response_part_impl(part, last_signature, handling)
260-
}
261-
262230
fn process_response_part_non_streaming(
263231
part: &Value,
264232
last_signature: &mut Option<String>,
@@ -488,8 +456,6 @@ where
488456
}
489457
}
490458

491-
let model_version = chunk.get("modelVersion").and_then(|v| v.as_str());
492-
493459
let parts = chunk
494460
.get("candidates")
495461
.and_then(|v| v.as_array())
@@ -500,7 +466,9 @@ where
500466

501467
if let Some(parts) = parts {
502468
for part in parts {
503-
if let Some(content) = process_response_part_for_model(part, &mut last_signature, model_version) {
469+
// Always emit text as regular text during streaming — we can't
470+
// know yet whether function calls will follow.
471+
if let Some(content) = process_response_part_impl(part, &mut last_signature, SignedTextHandling::SignedTextAsRegularText) {
504472
let message = Message::new(
505473
Role::Assistant,
506474
chrono::Utc::now().timestamp(),
@@ -1192,90 +1160,68 @@ mod tests {
11921160
async fn test_streaming_with_thought_signature() {
11931161
use futures::StreamExt;
11941162

1195-
let gemini3_stream = concat!(
1196-
r#"data: {"candidates": [{"content": {"role": "model", "#,
1197-
r#""parts": [{"text": "Begin", "thoughtSignature": "sig123"}]}}], "#,
1198-
r#""modelVersion": "gemini-3-pro"}"#,
1199-
"\n",
1200-
r#"data: {"candidates": [{"content": {"role": "model", "#,
1201-
r#""parts": [{"text": " end"}]}}], "modelVersion": "gemini-3-pro"}"#
1202-
);
1203-
let lines: Vec<Result<String, anyhow::Error>> =
1204-
gemini3_stream.lines().map(|l| Ok(l.to_string())).collect();
1205-
let stream = Box::pin(futures::stream::iter(lines));
1206-
let mut message_stream = std::pin::pin!(response_to_streaming_message(stream));
1207-
1208-
let mut text_parts = Vec::new();
1209-
let mut thinking_parts = Vec::new();
1210-
1211-
while let Some(result) = message_stream.next().await {
1212-
let (message, _usage) = result.unwrap();
1213-
if let Some(msg) = message {
1214-
match msg.content.first() {
1215-
Some(MessageContent::Text(text)) => text_parts.push(text.text.clone()),
1216-
Some(MessageContent::Thinking(t)) => thinking_parts.push(t.thinking.clone()),
1217-
_ => {}
1163+
async fn collect_streaming_text(raw: &str) -> (String, usize) {
1164+
let lines: Vec<Result<String, anyhow::Error>> =
1165+
raw.lines().map(|l| Ok(l.to_string())).collect();
1166+
let stream = Box::pin(futures::stream::iter(lines));
1167+
let mut msg_stream = std::pin::pin!(response_to_streaming_message(stream));
1168+
let mut text = String::new();
1169+
let mut thinking = 0usize;
1170+
while let Some(Ok((message, _))) = msg_stream.next().await {
1171+
if let Some(msg) = message {
1172+
for c in &msg.content {
1173+
match c {
1174+
MessageContent::Text(t) => text.push_str(&t.text),
1175+
MessageContent::Thinking(_) => thinking += 1,
1176+
_ => {}
1177+
}
1178+
}
12181179
}
12191180
}
1181+
(text, thinking)
12201182
}
12211183

1222-
assert_eq!(thinking_parts, vec!["Begin"]);
1223-
assert_eq!(text_parts, vec![" end"]);
1224-
1225-
let gemini25_stream = concat!(
1184+
// First chunk signed
1185+
let (text, thinking) = collect_streaming_text(concat!(
12261186
r#"data: {"candidates": [{"content": {"role": "model", "#,
1227-
r#""parts": [{"text": "Begin", "thoughtSignature": "sig123"}]}}], "#,
1228-
r#""modelVersion": "gemini-2.5-pro"}"#,
1187+
r#""parts": [{"text": "Hello", "thoughtSignature": "sig1"}]}}], "#,
1188+
r#""modelVersion": "gemini-3-flash-preview"}"#,
12291189
"\n",
12301190
r#"data: {"candidates": [{"content": {"role": "model", "#,
1231-
r#""parts": [{"text": " end"}]}}], "modelVersion": "gemini-2.5-pro"}"#
1232-
);
1233-
let lines: Vec<Result<String, anyhow::Error>> =
1234-
gemini25_stream.lines().map(|l| Ok(l.to_string())).collect();
1235-
let stream = Box::pin(futures::stream::iter(lines));
1236-
let mut message_stream = std::pin::pin!(response_to_streaming_message(stream));
1237-
1238-
let mut text_parts = Vec::new();
1239-
1240-
while let Some(result) = message_stream.next().await {
1241-
let (message, _usage) = result.unwrap();
1242-
if let Some(msg) = message {
1243-
if let Some(MessageContent::Text(text)) = msg.content.first() {
1244-
text_parts.push(text.text.clone());
1245-
}
1246-
}
1247-
}
1248-
1249-
assert_eq!(text_parts, vec!["Begin", " end"]);
1250-
1251-
let unknown_stream = concat!(
1191+
r#""parts": [{"text": " world"}]}}], "modelVersion": "gemini-3-flash-preview"}"#
1192+
))
1193+
.await;
1194+
assert_eq!(thinking, 0);
1195+
assert_eq!(text, "Hello world");
1196+
1197+
// Last chunk signed (the reported truncation bug)
1198+
let (text, thinking) = collect_streaming_text(concat!(
12521199
r#"data: {"candidates": [{"content": {"role": "model", "#,
1253-
r#""parts": [{"text": "Begin", "thoughtSignature": "sig123"}]}}]}"#,
1200+
r#""parts": [{"text": "SECURITY.md: Project"}]}}], "#,
1201+
r#""modelVersion": "gemini-3-flash-preview"}"#,
12541202
"\n",
12551203
r#"data: {"candidates": [{"content": {"role": "model", "#,
1256-
r#""parts": [{"text": " end"}]}}]}"#
1257-
);
1258-
let lines: Vec<Result<String, anyhow::Error>> =
1259-
unknown_stream.lines().map(|l| Ok(l.to_string())).collect();
1260-
let stream = Box::pin(futures::stream::iter(lines));
1261-
let mut message_stream = std::pin::pin!(response_to_streaming_message(stream));
1262-
1263-
let mut text_parts = Vec::new();
1264-
let mut thinking_parts = Vec::new();
1265-
1266-
while let Some(result) = message_stream.next().await {
1267-
let (message, _usage) = result.unwrap();
1268-
if let Some(msg) = message {
1269-
match msg.content.first() {
1270-
Some(MessageContent::Text(text)) => text_parts.push(text.text.clone()),
1271-
Some(MessageContent::Thinking(t)) => thinking_parts.push(t.thinking.clone()),
1272-
_ => {}
1273-
}
1274-
}
1275-
}
1276-
1277-
assert_eq!(thinking_parts, vec!["Begin"]);
1278-
assert_eq!(text_parts, vec![" end"]);
1204+
r#""parts": [{"text": " policies.\n\nRead it?", "thoughtSignature": "sig2"}]}}], "#,
1205+
r#""modelVersion": "gemini-3-flash-preview"}"#
1206+
))
1207+
.await;
1208+
assert_eq!(thinking, 0);
1209+
assert_eq!(text, "SECURITY.md: Project policies.\n\nRead it?");
1210+
1211+
// Intermediate chunk signed
1212+
let (text, thinking) = collect_streaming_text(concat!(
1213+
r#"data: {"candidates": [{"content": {"role": "model", "#,
1214+
r#""parts": [{"text": "one "}]}}], "modelVersion": "gemini-3-flash-preview"}"#,
1215+
"\n",
1216+
r#"data: {"candidates": [{"content": {"role": "model", "#,
1217+
r#""parts": [{"text": "two ", "thoughtSignature": "sig3"}]}}], "modelVersion": "gemini-3-flash-preview"}"#,
1218+
"\n",
1219+
r#"data: {"candidates": [{"content": {"role": "model", "#,
1220+
r#""parts": [{"text": "three"}]}}], "modelVersion": "gemini-3-flash-preview"}"#
1221+
))
1222+
.await;
1223+
assert_eq!(thinking, 0);
1224+
assert_eq!(text, "one two three");
12791225
}
12801226

12811227
#[tokio::test]

0 commit comments

Comments
 (0)