feat(gdk): preserve thinking and redacted-thinking blocks across turns (#11636)

This commit is contained in:
Alex Hancock
2026-08-31 15:24:39 +00:00
committed by GitHub
parent 5dc3fa847e
commit 7d97fe1ead
2 changed files with 377 additions and 3 deletions
+353 -3
View File
@@ -204,6 +204,10 @@ pub enum MessageContent {
id: String,
name: String,
arguments_json: String,
#[uniffi(default = None)]
provider_metadata_json: Option<String>,
#[uniffi(default = None)]
tool_error_json: Option<String>,
},
ToolResult {
id: String,
@@ -247,11 +251,24 @@ impl MessageContent {
id,
name,
arguments_json,
provider_metadata_json,
tool_error_json,
} => {
let arguments = parse_json_object(arguments_json)?;
Ok(GooseMessageContent::tool_request(
let metadata = provider_metadata_json
.as_deref()
.map(parse_json_object)
.transpose()?;
let tool_call = match tool_error_json {
Some(error_json) => Err(serde_json::from_str(error_json)?),
None => {
let arguments = parse_json_object(arguments_json)?;
Ok(CallToolRequestParams::new(name.clone()).with_arguments(arguments))
}
};
Ok(GooseMessageContent::tool_request_with_metadata(
id.clone(),
Ok(CallToolRequestParams::new(name.clone()).with_arguments(arguments)),
tool_call,
metadata.as_ref(),
))
}
MessageContent::ToolResult {
@@ -283,6 +300,59 @@ impl MessageContent {
}
}
}
/// Maps provider output back onto the binding surface so callers can replay
/// an assistant turn without reparsing `message_json`. Thinking signatures
/// and redacted payloads are carried through verbatim: providers reject
/// replayed thinking blocks whose signature was dropped or altered.
fn from_goose_content(content: &GooseMessageContent) -> Option<Self> {
match content {
GooseMessageContent::Text(text) => Some(MessageContent::Text {
text: text.text.clone(),
}),
GooseMessageContent::Image(image) => Some(MessageContent::Image {
mime_type: image.mime_type.clone(),
data: base64::engine::general_purpose::STANDARD
.decode(&image.data)
.ok()?,
}),
GooseMessageContent::ToolRequest(request) => {
let provider_metadata_json = request
.metadata
.as_ref()
.and_then(|metadata| serde_json::to_string(metadata).ok());
match &request.tool_call {
Ok(tool_call) => Some(MessageContent::ToolRequest {
id: request.id.clone(),
name: tool_call.name.to_string(),
arguments_json: serde_json::to_string(
&tool_call.arguments.clone().unwrap_or_default(),
)
.ok()?,
provider_metadata_json,
tool_error_json: None,
}),
Err(error) => Some(MessageContent::ToolRequest {
id: request.id.clone(),
name: String::new(),
arguments_json: "{}".to_string(),
provider_metadata_json,
tool_error_json: Some(serde_json::to_string(error).ok()?),
}),
}
}
GooseMessageContent::Thinking(thinking) => Some(MessageContent::Thinking {
thinking: thinking.thinking.clone(),
signature: thinking.signature.clone(),
}),
GooseMessageContent::RedactedThinking(redacted) => {
Some(MessageContent::RedactedThinking {
data: redacted.data.clone(),
})
}
_ => None,
}
}
}
fn chrono_now() -> i64 {
@@ -455,6 +525,8 @@ pub enum StreamChunk {
id: String,
name: String,
arguments_json: String,
#[uniffi(default = None)]
provider_metadata_json: Option<String>,
},
ThinkingChunk {
thinking: String,
@@ -537,6 +609,9 @@ impl From<GooseError> for GooseStreamError {
#[derive(Debug, Clone, uniffi::Record)]
pub struct ProviderCompletion {
pub message_json: String,
/// The assistant turn as binding types, ready to append to history and
/// replay on the next request without reparsing `message_json`.
pub content: Vec<MessageContent>,
pub usage: Option<Usage>,
}
@@ -675,6 +750,11 @@ impl ProviderHandle {
Ok(ProviderCompletion {
message_json: serde_json::to_string(&message)?,
content: message
.content
.iter()
.filter_map(MessageContent::from_goose_content)
.collect(),
usage: Some(Usage::from_provider_usage(&usage)?),
})
}
@@ -1103,6 +1183,10 @@ fn message_to_chunks(message: Message) -> Vec<StreamChunk> {
name: tool_call.name.to_string(),
arguments_json: serde_json::to_string(&tool_call.arguments.unwrap_or_default())
.unwrap_or_else(|_| "{}".to_string()),
provider_metadata_json: request
.metadata
.as_ref()
.and_then(|metadata| serde_json::to_string(metadata).ok()),
}),
Err(error) => Some(StreamChunk::ErrorChunk {
error: GooseStreamError {
@@ -1275,4 +1359,270 @@ mod tests {
assert_eq!(absent.cache_read_input_tokens, None);
assert_eq!(absent.cache_creation_input_tokens, None);
}
#[test]
fn thinking_content_round_trips_with_signature() {
let original = MessageContent::Thinking {
thinking: "step one, then step two".to_string(),
signature: "ErUBCkYIBRgCIkAe0pAQ==".to_string(),
};
let goose = original.to_goose_content().unwrap();
let GooseMessageContent::Thinking(thinking) = &goose else {
panic!("expected thinking content");
};
assert_eq!(thinking.thinking, "step one, then step two");
assert_eq!(thinking.signature, "ErUBCkYIBRgCIkAe0pAQ==");
let round_tripped = MessageContent::from_goose_content(&goose).unwrap();
let MessageContent::Thinking {
thinking,
signature,
} = round_tripped
else {
panic!("expected thinking content");
};
assert_eq!(thinking, "step one, then step two");
assert_eq!(signature, "ErUBCkYIBRgCIkAe0pAQ==");
}
#[test]
fn redacted_thinking_content_round_trips_opaque_data() {
let original = MessageContent::RedactedThinking {
data: "EroBCkYIBRgCKkBb0pAQopaque".to_string(),
};
let goose = original.to_goose_content().unwrap();
let GooseMessageContent::RedactedThinking(redacted) = &goose else {
panic!("expected redacted thinking content");
};
assert_eq!(redacted.data, "EroBCkYIBRgCKkBb0pAQopaque");
let round_tripped = MessageContent::from_goose_content(&goose).unwrap();
let MessageContent::RedactedThinking { data } = round_tripped else {
panic!("expected redacted thinking content");
};
assert_eq!(data, "EroBCkYIBRgCKkBb0pAQopaque");
}
#[test]
fn tool_request_round_trips_provider_metadata() {
let original = MessageContent::ToolRequest {
id: "call_456".to_string(),
name: "test_tool".to_string(),
arguments_json: "{}".to_string(),
provider_metadata_json: Some(
r#"{"extra_content":{"google":{"thought_signature":"nested_sig_xyz789"}}}"#
.to_string(),
),
tool_error_json: None,
};
let goose = original.to_goose_content().unwrap();
let GooseMessageContent::ToolRequest(request) = &goose else {
panic!("expected tool request");
};
assert_eq!(
request.metadata.as_ref().unwrap()["extra_content"]["google"]["thought_signature"],
"nested_sig_xyz789"
);
let round_tripped = MessageContent::from_goose_content(&goose).unwrap();
let MessageContent::ToolRequest {
provider_metadata_json,
..
} = &round_tripped
else {
panic!("expected tool request");
};
assert_eq!(
serde_json::from_str::<Value>(provider_metadata_json.as_ref().unwrap()).unwrap(),
serde_json::json!({"extra_content":{"google":{"thought_signature":"nested_sig_xyz789"}}})
);
let messages = convert_messages(vec![ProviderMessage {
role: MessageRole::Assistant,
content: vec![round_tripped],
}])
.unwrap();
let spec = goose_providers::formats::openai::format_messages(
&messages,
&goose_providers::images::ImageFormat::OpenAi,
);
assert_eq!(
spec[0]["tool_calls"][0]["extra_content"]["google"]["thought_signature"],
"nested_sig_xyz789"
);
}
#[test]
fn completion_content_preserves_malformed_tool_requests() {
let error = rmcp::model::ErrorData {
code: rmcp::model::ErrorCode::INVALID_REQUEST,
message: std::borrow::Cow::from(
"The provided function name was empty; a tool call must name a tool".to_string(),
),
data: None,
};
let message = Message::assistant()
.with_tool_request("call_bad_1", Err(error))
.with_text("done");
let content: Vec<MessageContent> = message
.content
.iter()
.filter_map(MessageContent::from_goose_content)
.collect();
assert_eq!(
content.len(),
2,
"the failed tool request must not be dropped"
);
let MessageContent::ToolRequest {
id,
tool_error_json,
..
} = &content[0]
else {
panic!("expected tool request");
};
assert_eq!(id, "call_bad_1");
assert!(tool_error_json.is_some());
let replayed = convert_messages(vec![ProviderMessage {
role: MessageRole::Assistant,
content,
}])
.unwrap();
let GooseMessageContent::ToolRequest(request) = &replayed[0].content[0] else {
panic!("expected tool request");
};
let replayed_error = request
.tool_call
.as_ref()
.expect_err("replayed request must stay a failed tool call");
assert_eq!(replayed_error.code, rmcp::model::ErrorCode::INVALID_REQUEST);
assert!(replayed_error.message.contains("must name a tool"));
}
#[test]
fn streaming_tool_chunks_carry_provider_metadata() {
let mut metadata = goose_providers::conversation::message::ProviderMetadata::new();
metadata.insert(
"extra_content".to_string(),
serde_json::json!({"google": {"thought_signature": "stream_sig_abc123"}}),
);
let message = Message::assistant().with_tool_request_with_metadata(
"call_stream_1",
Ok(CallToolRequestParams::new("test_tool")),
Some(&metadata),
None,
);
let chunks = message_to_chunks(message);
let StreamChunk::ToolChunk {
provider_metadata_json,
..
} = chunks
.iter()
.find(|chunk| matches!(chunk, StreamChunk::ToolChunk { .. }))
.expect("expected a tool chunk")
else {
unreachable!()
};
assert_eq!(
serde_json::from_str::<Value>(provider_metadata_json.as_ref().unwrap()).unwrap(),
serde_json::json!({"extra_content":{"google":{"thought_signature":"stream_sig_abc123"}}})
);
}
#[test]
fn thinking_blocks_survive_multi_turn_replay() {
let assistant_turn = ProviderMessage {
role: MessageRole::Assistant,
content: vec![
MessageContent::Thinking {
thinking: "the user wants the capital".to_string(),
signature: "sig-abc123".to_string(),
},
MessageContent::RedactedThinking {
data: "opaque-payload".to_string(),
},
MessageContent::Text {
text: "Paris".to_string(),
},
],
};
let history = vec![
ProviderMessage {
role: MessageRole::User,
content: vec![MessageContent::Text {
text: "what is the capital of France?".to_string(),
}],
},
assistant_turn,
ProviderMessage {
role: MessageRole::User,
content: vec![MessageContent::Text {
text: "and of Spain?".to_string(),
}],
},
];
let messages = convert_messages(history).unwrap();
assert!(matches!(
messages[1].content[0],
GooseMessageContent::Thinking(_)
));
assert!(matches!(
messages[1].content[1],
GooseMessageContent::RedactedThinking(_)
));
let spec = goose_providers::formats::anthropic::format_messages(&messages);
let assistant = &spec[1]["content"];
assert_eq!(assistant[0]["type"], "thinking");
assert_eq!(assistant[0]["thinking"], "the user wants the capital");
assert_eq!(assistant[0]["signature"], "sig-abc123");
assert_eq!(assistant[1]["type"], "redacted_thinking");
assert_eq!(assistant[1]["data"], "opaque-payload");
assert!(assistant[1].get("thinking").is_none());
assert_eq!(assistant[2]["type"], "text");
assert_eq!(assistant[2]["text"], "Paris");
}
#[test]
fn completion_content_preserves_thinking_for_the_next_turn() {
let message = Message::assistant()
.with_thinking("reasoning to replay", "sig-xyz")
.with_redacted_thinking("opaque-payload")
.with_text("Madrid");
let content: Vec<MessageContent> = message
.content
.iter()
.filter_map(MessageContent::from_goose_content)
.collect();
assert!(matches!(
&content[0],
MessageContent::Thinking { thinking, signature }
if thinking == "reasoning to replay" && signature == "sig-xyz"
));
assert!(matches!(
&content[1],
MessageContent::RedactedThinking { data } if data == "opaque-payload"
));
let replayed = convert_messages(vec![ProviderMessage {
role: MessageRole::Assistant,
content,
}])
.unwrap();
assert_eq!(replayed[0].content, message.content);
}
}
+24
View File
@@ -410,6 +410,18 @@
"type": "String",
"default": null,
"docs": ""
},
{
"name": "provider_metadata_json",
"type": "Option<String>",
"default": null,
"docs": ""
},
{
"name": "tool_error_json",
"type": "Option<String>",
"default": null,
"docs": ""
}
]
},
@@ -669,6 +681,12 @@
"type": "String",
"default": null,
"docs": ""
},
{
"name": "provider_metadata_json",
"type": "Option<String>",
"default": null,
"docs": ""
}
]
},
@@ -800,6 +818,12 @@
"default": null,
"docs": ""
},
{
"name": "content",
"type": "Vec<MessageContent>",
"default": null,
"docs": "The assistant turn as binding types, ready to append to history and replay on the next request without reparsing `message_json`."
},
{
"name": "usage",
"type": "Option<Usage>",