Improve the formatting of tool calls, show thinking, treat Reasoning and Thinking as the same thing (sorry Kant) (#7626)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -240,7 +240,6 @@ pub fn render_message(message: &Message, debug: bool) {
|
|||||||
println!("Image: [data: {}, type: {}]", image.data, image.mime_type);
|
println!("Image: [data: {}, type: {}]", image.data, image.mime_type);
|
||||||
}
|
}
|
||||||
MessageContent::Thinking(t) => render_thinking(&t.thinking, theme),
|
MessageContent::Thinking(t) => render_thinking(&t.thinking, theme),
|
||||||
MessageContent::Reasoning(r) => render_thinking(&r.text, theme),
|
|
||||||
MessageContent::RedactedThinking(_) => {
|
MessageContent::RedactedThinking(_) => {
|
||||||
println!("\n{}", style("Thinking:").dim().italic());
|
println!("\n{}", style("Thinking:").dim().italic());
|
||||||
print_markdown("Thinking was redacted", theme);
|
print_markdown("Thinking was redacted", theme);
|
||||||
@@ -280,10 +279,7 @@ pub fn render_message_streaming(
|
|||||||
let theme = get_theme();
|
let theme = get_theme();
|
||||||
|
|
||||||
for content in &message.content {
|
for content in &message.content {
|
||||||
if !matches!(
|
if !matches!(content, MessageContent::Thinking(_)) {
|
||||||
content,
|
|
||||||
MessageContent::Thinking(_) | MessageContent::Reasoning(_)
|
|
||||||
) {
|
|
||||||
*thinking_header_shown = false;
|
*thinking_header_shown = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,9 +318,6 @@ pub fn render_message_streaming(
|
|||||||
MessageContent::Thinking(t) => {
|
MessageContent::Thinking(t) => {
|
||||||
render_thinking_streaming(&t.thinking, buffer, thinking_header_shown, theme);
|
render_thinking_streaming(&t.thinking, buffer, thinking_header_shown, theme);
|
||||||
}
|
}
|
||||||
MessageContent::Reasoning(r) => {
|
|
||||||
render_thinking_streaming(&r.text, buffer, thinking_header_shown, theme);
|
|
||||||
}
|
|
||||||
MessageContent::RedactedThinking(_) => {
|
MessageContent::RedactedThinking(_) => {
|
||||||
flush_markdown_buffer(buffer, theme);
|
flush_markdown_buffer(buffer, theme);
|
||||||
println!("\n{}", style("Thinking:").dim().italic());
|
println!("\n{}", style("Thinking:").dim().italic());
|
||||||
|
|||||||
@@ -21,9 +21,8 @@ use goose::config::declarative_providers::{
|
|||||||
};
|
};
|
||||||
use goose::conversation::message::{
|
use goose::conversation::message::{
|
||||||
ActionRequired, ActionRequiredData, FrontendToolRequest, Message, MessageContent,
|
ActionRequired, ActionRequiredData, FrontendToolRequest, Message, MessageContent,
|
||||||
MessageMetadata, ReasoningContent, RedactedThinkingContent, SystemNotificationContent,
|
MessageMetadata, RedactedThinkingContent, SystemNotificationContent, SystemNotificationType,
|
||||||
SystemNotificationType, ThinkingContent, TokenState, ToolConfirmationRequest, ToolRequest,
|
ThinkingContent, TokenState, ToolConfirmationRequest, ToolRequest, ToolResponse,
|
||||||
ToolResponse,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::routes::recipe_utils::RecipeManifest;
|
use crate::routes::recipe_utils::RecipeManifest;
|
||||||
@@ -552,7 +551,6 @@ derive_utoipa!(Icon as IconSchema);
|
|||||||
ActionRequiredData,
|
ActionRequiredData,
|
||||||
ThinkingContent,
|
ThinkingContent,
|
||||||
RedactedThinkingContent,
|
RedactedThinkingContent,
|
||||||
ReasoningContent,
|
|
||||||
FrontendToolRequest,
|
FrontendToolRequest,
|
||||||
ResourceContentsSchema,
|
ResourceContentsSchema,
|
||||||
SystemNotificationType,
|
SystemNotificationType,
|
||||||
|
|||||||
@@ -1444,7 +1444,7 @@ impl Agent {
|
|||||||
|
|
||||||
// Collect reasoning content to attach to tool request messages
|
// Collect reasoning content to attach to tool request messages
|
||||||
let reasoning_content: Vec<MessageContent> = response.content.iter()
|
let reasoning_content: Vec<MessageContent> = response.content.iter()
|
||||||
.filter(|c| matches!(c, MessageContent::Reasoning(_)))
|
.filter(|c| matches!(c, MessageContent::Thinking(_)))
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
@@ -403,7 +403,6 @@ fn format_message_for_compacting(msg: &Message) -> String {
|
|||||||
MessageContent::SystemNotification(notification) => {
|
MessageContent::SystemNotification(notification) => {
|
||||||
Some(format!("system_notification: {}", notification.msg))
|
Some(format!("system_notification: {}", notification.msg))
|
||||||
}
|
}
|
||||||
MessageContent::Reasoning(_) => None,
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
@@ -26,13 +26,31 @@ where
|
|||||||
{
|
{
|
||||||
use serde::de::Error;
|
use serde::de::Error;
|
||||||
|
|
||||||
let mut raw: Vec<serde_json::Value> = Vec::deserialize(deserializer)?;
|
let raw: Vec<serde_json::Value> = Vec::deserialize(deserializer)?;
|
||||||
|
|
||||||
// Filter out old "conversationCompacted" messages from pre-14.0
|
let mut migrated = Vec::with_capacity(raw.len());
|
||||||
raw.retain(|item| item.get("type").and_then(|v| v.as_str()) != Some("conversationCompacted"));
|
for item in raw {
|
||||||
|
match item.get("type").and_then(|v| v.as_str()) {
|
||||||
|
// Filter out old "conversationCompacted" messages from pre-14.0
|
||||||
|
Some("conversationCompacted") => {}
|
||||||
|
// Migrate old "reasoning" content to "thinking". Invalid legacy reasoning
|
||||||
|
// blocks are dropped so they don't fail deserialization.
|
||||||
|
Some("reasoning") => {
|
||||||
|
if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
|
||||||
|
migrated.push(serde_json::json!({
|
||||||
|
"type": "thinking",
|
||||||
|
"thinking": text,
|
||||||
|
"signature": ""
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => migrated.push(item),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut content: Vec<MessageContent> = serde_json::from_value(serde_json::Value::Array(raw))
|
let mut content: Vec<MessageContent> =
|
||||||
.map_err(|e| Error::custom(format!("Failed to deserialize MessageContent: {}", e)))?;
|
serde_json::from_value(serde_json::Value::Array(migrated))
|
||||||
|
.map_err(|e| Error::custom(format!("Failed to deserialize MessageContent: {}", e)))?;
|
||||||
|
|
||||||
for message_content in &mut content {
|
for message_content in &mut content {
|
||||||
if let MessageContent::Text(text_content) = message_content {
|
if let MessageContent::Text(text_content) = message_content {
|
||||||
@@ -176,11 +194,6 @@ pub struct SystemNotificationContent {
|
|||||||
pub data: Option<serde_json::Value>,
|
pub data: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
|
|
||||||
pub struct ReasoningContent {
|
|
||||||
pub text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
|
||||||
/// Content passed inside a message, which can be both simple content and tool content
|
/// Content passed inside a message, which can be both simple content and tool content
|
||||||
#[serde(tag = "type", rename_all = "camelCase")]
|
#[serde(tag = "type", rename_all = "camelCase")]
|
||||||
@@ -195,7 +208,6 @@ pub enum MessageContent {
|
|||||||
Thinking(ThinkingContent),
|
Thinking(ThinkingContent),
|
||||||
RedactedThinking(RedactedThinkingContent),
|
RedactedThinking(RedactedThinkingContent),
|
||||||
SystemNotification(SystemNotificationContent),
|
SystemNotification(SystemNotificationContent),
|
||||||
Reasoning(ReasoningContent),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for MessageContent {
|
impl fmt::Display for MessageContent {
|
||||||
@@ -237,7 +249,6 @@ impl fmt::Display for MessageContent {
|
|||||||
MessageContent::SystemNotification(r) => {
|
MessageContent::SystemNotification(r) => {
|
||||||
write!(f, "[SystemNotification: {}]", r.msg)
|
write!(f, "[SystemNotification: {}]", r.msg)
|
||||||
}
|
}
|
||||||
MessageContent::Reasoning(r) => write!(f, "[Reasoning: {}]", r.text),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -450,10 +461,6 @@ impl MessageContent {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reasoning<S: Into<String>>(text: S) -> Self {
|
|
||||||
MessageContent::Reasoning(ReasoningContent { text: text.into() })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn as_system_notification(&self) -> Option<&SystemNotificationContent> {
|
pub fn as_system_notification(&self) -> Option<&SystemNotificationContent> {
|
||||||
if let MessageContent::SystemNotification(ref notification) = self {
|
if let MessageContent::SystemNotification(ref notification) = self {
|
||||||
Some(notification)
|
Some(notification)
|
||||||
@@ -525,14 +532,6 @@ impl MessageContent {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the reasoning content if this is a ReasoningContent variant
|
|
||||||
pub fn as_reasoning(&self) -> Option<&ReasoningContent> {
|
|
||||||
match self {
|
|
||||||
MessageContent::Reasoning(reasoning) => Some(reasoning),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Content> for MessageContent {
|
impl From<Content> for MessageContent {
|
||||||
@@ -1109,6 +1108,50 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialization_migrates_reasoning_to_thinking() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"role": "assistant",
|
||||||
|
"created": 1740171566,
|
||||||
|
"content": [
|
||||||
|
{ "type": "reasoning", "text": "step by step" },
|
||||||
|
{ "type": "text", "text": "final answer" }
|
||||||
|
],
|
||||||
|
"metadata": { "agentVisible": true, "userVisible": true }
|
||||||
|
});
|
||||||
|
|
||||||
|
let message: Message = serde_json::from_value(json).unwrap();
|
||||||
|
assert_eq!(message.content.len(), 2);
|
||||||
|
|
||||||
|
let MessageContent::Thinking(thinking) = &message.content[0] else {
|
||||||
|
panic!("Expected Thinking content");
|
||||||
|
};
|
||||||
|
assert_eq!(thinking.thinking, "step by step");
|
||||||
|
assert!(thinking.signature.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialization_drops_invalid_reasoning_blocks() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"role": "assistant",
|
||||||
|
"created": 1740171566,
|
||||||
|
"content": [
|
||||||
|
{ "type": "reasoning" },
|
||||||
|
{ "type": "reasoning", "text": 42 },
|
||||||
|
{ "type": "text", "text": "still here" }
|
||||||
|
],
|
||||||
|
"metadata": { "agentVisible": true, "userVisible": true }
|
||||||
|
});
|
||||||
|
|
||||||
|
let message: Message = serde_json::from_value(json).unwrap();
|
||||||
|
assert_eq!(message.content.len(), 1);
|
||||||
|
|
||||||
|
let MessageContent::Text(text) = &message.content[0] else {
|
||||||
|
panic!("Expected Text content");
|
||||||
|
};
|
||||||
|
assert_eq!(text.text, "still here");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_from_prompt_message_text() {
|
fn test_from_prompt_message_text() {
|
||||||
let prompt_content = PromptMessageContent::Text {
|
let prompt_content = PromptMessageContent::Text {
|
||||||
|
|||||||
@@ -171,11 +171,13 @@ pub fn format_messages(messages: &[Message]) -> Vec<Value> {
|
|||||||
// Skip
|
// Skip
|
||||||
}
|
}
|
||||||
MessageContent::Thinking(thinking) => {
|
MessageContent::Thinking(thinking) => {
|
||||||
content.push(json!({
|
if !thinking.signature.is_empty() {
|
||||||
TYPE_FIELD: THINKING_TYPE,
|
content.push(json!({
|
||||||
THINKING_TYPE: thinking.thinking,
|
TYPE_FIELD: THINKING_TYPE,
|
||||||
SIGNATURE_FIELD: thinking.signature
|
THINKING_TYPE: thinking.thinking,
|
||||||
}));
|
SIGNATURE_FIELD: thinking.signature
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
MessageContent::RedactedThinking(redacted) => {
|
MessageContent::RedactedThinking(redacted) => {
|
||||||
content.push(json!({
|
content.push(json!({
|
||||||
@@ -196,10 +198,6 @@ pub fn format_messages(messages: &[Message]) -> Vec<Value> {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MessageContent::Reasoning(_reasoning) => {
|
|
||||||
// Reasoning content is for OpenAI-compatible APIs (e.g., DeepSeek)
|
|
||||||
// Anthropic doesn't use this format, so skip it
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,8 +561,11 @@ where
|
|||||||
|
|
||||||
try_stream! {
|
try_stream! {
|
||||||
let mut accumulated_text = String::new();
|
let mut accumulated_text = String::new();
|
||||||
|
let mut accumulated_thinking = String::new();
|
||||||
|
let mut accumulated_thinking_signature = String::new();
|
||||||
let mut accumulated_tool_calls: std::collections::HashMap<String, (String, String)> = std::collections::HashMap::new();
|
let mut accumulated_tool_calls: std::collections::HashMap<String, (String, String)> = std::collections::HashMap::new();
|
||||||
let mut current_tool_id: Option<String> = None;
|
let mut current_tool_id: Option<String> = None;
|
||||||
|
let mut current_block_type: Option<String> = None;
|
||||||
let mut final_usage: Option<crate::providers::base::ProviderUsage> = None;
|
let mut final_usage: Option<crate::providers::base::ProviderUsage> = None;
|
||||||
let mut message_id: Option<String> = None;
|
let mut message_id: Option<String> = None;
|
||||||
|
|
||||||
@@ -619,13 +620,33 @@ where
|
|||||||
"content_block_start" => {
|
"content_block_start" => {
|
||||||
// A new content block started
|
// A new content block started
|
||||||
if let Some(content_block) = event.data.get("content_block") {
|
if let Some(content_block) = event.data.get("content_block") {
|
||||||
if content_block.get("type") == Some(&json!("tool_use")) {
|
let block_type = content_block.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
if let Some(id) = content_block.get("id").and_then(|v| v.as_str()) {
|
current_block_type = Some(block_type.to_string());
|
||||||
current_tool_id = Some(id.to_string());
|
match block_type {
|
||||||
if let Some(name) = content_block.get("name").and_then(|v| v.as_str()) {
|
"tool_use" => {
|
||||||
accumulated_tool_calls.insert(id.to_string(), (name.to_string(), String::new()));
|
if let Some(id) = content_block.get("id").and_then(|v| v.as_str()) {
|
||||||
|
current_tool_id = Some(id.to_string());
|
||||||
|
if let Some(name) = content_block.get("name").and_then(|v| v.as_str()) {
|
||||||
|
accumulated_tool_calls.insert(id.to_string(), (name.to_string(), String::new()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
THINKING_TYPE => {
|
||||||
|
accumulated_thinking.clear();
|
||||||
|
}
|
||||||
|
REDACTED_THINKING_TYPE => {
|
||||||
|
// Yield redacted thinking immediately — there are no deltas for it
|
||||||
|
if let Some(data) = content_block.get("data").and_then(|v| v.as_str()) {
|
||||||
|
let mut message = Message::new(
|
||||||
|
Role::Assistant,
|
||||||
|
chrono::Utc::now().timestamp(),
|
||||||
|
vec![MessageContent::redacted_thinking(data)],
|
||||||
|
);
|
||||||
|
message.id = message_id.clone();
|
||||||
|
yield (Some(message), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -646,6 +667,20 @@ where
|
|||||||
message.id = message_id.clone();
|
message.id = message_id.clone();
|
||||||
yield (Some(message), None);
|
yield (Some(message), None);
|
||||||
}
|
}
|
||||||
|
} else if delta.get("type") == Some(&json!("thinking_delta")) {
|
||||||
|
// Thinking content delta — stream incrementally for real-time UI
|
||||||
|
if let Some(thinking) = delta.get("thinking").and_then(|v| v.as_str()) {
|
||||||
|
accumulated_thinking.push_str(thinking);
|
||||||
|
|
||||||
|
// Yield partial thinking (no signature yet) for live display
|
||||||
|
let mut message = Message::new(
|
||||||
|
Role::Assistant,
|
||||||
|
chrono::Utc::now().timestamp(),
|
||||||
|
vec![MessageContent::thinking(thinking, "")],
|
||||||
|
);
|
||||||
|
message.id = message_id.clone();
|
||||||
|
yield (Some(message), None);
|
||||||
|
}
|
||||||
} else if delta.get("type") == Some(&json!("input_json_delta")) {
|
} else if delta.get("type") == Some(&json!("input_json_delta")) {
|
||||||
// Tool input delta
|
// Tool input delta
|
||||||
if let Some(tool_id) = ¤t_tool_id {
|
if let Some(tool_id) = ¤t_tool_id {
|
||||||
@@ -655,12 +690,34 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if delta.get("type") == Some(&json!("signature_delta")) {
|
||||||
|
// Signature for a thinking block
|
||||||
|
if let Some(sig) = delta.get("signature").and_then(|v| v.as_str()) {
|
||||||
|
accumulated_thinking_signature.push_str(sig);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
"content_block_stop" => {
|
"content_block_stop" => {
|
||||||
// Content block finished
|
// Content block finished
|
||||||
|
if current_block_type.as_deref() == Some(THINKING_TYPE) && !accumulated_thinking.is_empty() {
|
||||||
|
// Yield the complete thinking block with signature for session storage
|
||||||
|
let mut message = Message::new(
|
||||||
|
Role::Assistant,
|
||||||
|
chrono::Utc::now().timestamp(),
|
||||||
|
vec![MessageContent::thinking(
|
||||||
|
std::mem::take(&mut accumulated_thinking),
|
||||||
|
std::mem::take(&mut accumulated_thinking_signature),
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
message.id = message_id.clone();
|
||||||
|
yield (Some(message), None);
|
||||||
|
current_block_type = None;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current_block_type = None;
|
||||||
|
|
||||||
if let Some(tool_id) = current_tool_id.take() {
|
if let Some(tool_id) = current_tool_id.take() {
|
||||||
// Tool call finished, yield complete tool call
|
// Tool call finished, yield complete tool call
|
||||||
if let Some((name, args)) = accumulated_tool_calls.remove(&tool_id) {
|
if let Some((name, args)) = accumulated_tool_calls.remove(&tool_id) {
|
||||||
@@ -863,80 +920,6 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_thinking_response() -> Result<()> {
|
|
||||||
let response = json!({
|
|
||||||
"id": "msg_456",
|
|
||||||
"type": "message",
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "thinking",
|
|
||||||
"thinking": "This is a step-by-step thought process...",
|
|
||||||
"signature": "EuYBCkQYAiJAVbJNBoH7HQiDcMwwAMhWqNyoe4G2xHRprK8ICM8gZzu16i7Se4EiEbmlKqNH1GtwcX1BMK6iLu8bxWn5wPVIFBIMnptdlVal7ZX5iNPFGgwWjX+BntcEOHky4HciMFVef7FpQeqnuiL1Xt7J4OLHZSyu4tcr809AxAbclcJ5dm1xE5gZrUO+/v60cnJM2ipQp4B8/3eHI03KSV6bZR/vMrBSYCV+aa/f5KHX2cRtLGp/Ba+3Tk/efbsg01WSduwAIbR4coVrZLnGJXNyVTFW/Be2kLy/ECZnx8cqvU3oQOg="
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "redacted_thinking",
|
|
||||||
"data": "EmwKAhgBEgy3va3pzix/LafPsn4aDFIT2Xlxh0L5L8rLVyIwxtE3rAFBa8cr3qpP"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"text": "I've analyzed the problem and here's the solution."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"model": "claude-3-7-sonnet-20250219",
|
|
||||||
"stop_reason": "end_turn",
|
|
||||||
"stop_sequence": null,
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": 10,
|
|
||||||
"output_tokens": 45,
|
|
||||||
"cache_creation_input_tokens": 0,
|
|
||||||
"cache_read_input_tokens": 0,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let message = response_to_message(&response)?;
|
|
||||||
let usage = get_usage(&response)?;
|
|
||||||
|
|
||||||
assert_eq!(message.content.len(), 3);
|
|
||||||
|
|
||||||
if let MessageContent::Thinking(thinking) = &message.content[0] {
|
|
||||||
assert_eq!(
|
|
||||||
thinking.thinking,
|
|
||||||
"This is a step-by-step thought process..."
|
|
||||||
);
|
|
||||||
assert!(thinking
|
|
||||||
.signature
|
|
||||||
.starts_with("EuYBCkQYAiJAVbJNBoH7HQiDcMwwAMhWqNyoe4G2xHRprK8ICM8g"));
|
|
||||||
} else {
|
|
||||||
panic!("Expected Thinking content at index 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let MessageContent::RedactedThinking(redacted) = &message.content[1] {
|
|
||||||
assert_eq!(
|
|
||||||
redacted.data,
|
|
||||||
"EmwKAhgBEgy3va3pzix/LafPsn4aDFIT2Xlxh0L5L8rLVyIwxtE3rAFBa8cr3qpP"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
panic!("Expected RedactedThinking content at index 1");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let MessageContent::Text(text) = &message.content[2] {
|
|
||||||
assert_eq!(
|
|
||||||
text.text,
|
|
||||||
"I've analyzed the problem and here's the solution."
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
panic!("Expected Text content at index 2");
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(usage.input_tokens, Some(10));
|
|
||||||
assert_eq!(usage.output_tokens, Some(45));
|
|
||||||
assert_eq!(usage.total_tokens, Some(55));
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_message_to_anthropic_spec() {
|
fn test_message_to_anthropic_spec() {
|
||||||
let messages = vec![
|
let messages = vec![
|
||||||
@@ -957,6 +940,21 @@ mod tests {
|
|||||||
assert_eq!(spec[2]["content"][0]["text"], "How are you?");
|
assert_eq!(spec[2]["content"][0]["text"], "How are you?");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_to_anthropic_spec_skips_unsigned_thinking() {
|
||||||
|
let messages = vec![
|
||||||
|
Message::assistant().with_content(MessageContent::thinking("internal", "")),
|
||||||
|
Message::assistant().with_text("Hi there"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let spec = format_messages(&messages);
|
||||||
|
|
||||||
|
assert_eq!(spec.len(), 1);
|
||||||
|
assert_eq!(spec[0]["role"], "assistant");
|
||||||
|
assert_eq!(spec[0]["content"][0]["type"], "text");
|
||||||
|
assert_eq!(spec[0]["content"][0]["text"], "Hi there");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_tools_to_anthropic_spec() {
|
fn test_tools_to_anthropic_spec() {
|
||||||
let tools = vec![
|
let tools = vec![
|
||||||
|
|||||||
@@ -125,11 +125,6 @@ pub fn to_bedrock_message_content(content: &MessageContent) -> Result<bedrock::C
|
|||||||
.build()?,
|
.build()?,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MessageContent::Reasoning(_reasoning) => {
|
|
||||||
// Reasoning content is for OpenAI-compatible APIs (e.g., DeepSeek)
|
|
||||||
// Bedrock doesn't use this format, so skip
|
|
||||||
bedrock::ContentBlock::Text("".to_string())
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -206,10 +206,6 @@ fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<Data
|
|||||||
MessageContent::SystemNotification(_)
|
MessageContent::SystemNotification(_)
|
||||||
| MessageContent::ToolConfirmationRequest(_)
|
| MessageContent::ToolConfirmationRequest(_)
|
||||||
| MessageContent::ActionRequired(_) => {}
|
| MessageContent::ActionRequired(_) => {}
|
||||||
MessageContent::Reasoning(_reasoning) => {
|
|
||||||
// Reasoning content is for OpenAI-compatible APIs (e.g., DeepSeek)
|
|
||||||
// Databricks doesn't use this format, so skip
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ fn process_response_part_impl(
|
|||||||
if is_thought {
|
if is_thought {
|
||||||
match signature {
|
match signature {
|
||||||
Some(sig) => Some(MessageContent::thinking(text.to_string(), sig.to_string())),
|
Some(sig) => Some(MessageContent::thinking(text.to_string(), sig.to_string())),
|
||||||
None => Some(MessageContent::reasoning(text.to_string())),
|
None => Some(MessageContent::thinking(text.to_string(), "")),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Some(MessageContent::text(text.to_string()))
|
Some(MessageContent::text(text.to_string()))
|
||||||
@@ -1050,14 +1050,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_thought_without_signature_maps_to_reasoning() {
|
fn test_thought_without_signature_maps_to_thinking() {
|
||||||
let response = google_response(vec![json!({
|
let response = google_response(vec![json!({
|
||||||
"text": "Working through options...",
|
"text": "Working through options...",
|
||||||
"thought": true
|
"thought": true
|
||||||
})]);
|
})]);
|
||||||
let native = response_to_message(response).unwrap();
|
let native = response_to_message(response).unwrap();
|
||||||
assert_eq!(native.content.len(), 1);
|
assert_eq!(native.content.len(), 1);
|
||||||
assert!(native.content[0].as_reasoning().is_some());
|
assert!(native.content[0].as_thinking().is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1188,30 +1188,28 @@ mod tests {
|
|||||||
async fn test_streaming_with_thought_signature() {
|
async fn test_streaming_with_thought_signature() {
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
|
|
||||||
async fn collect_streaming_text(raw: &str) -> (String, usize, usize) {
|
async fn collect_streaming_text(raw: &str) -> (String, usize) {
|
||||||
let lines: Vec<Result<String, anyhow::Error>> =
|
let lines: Vec<Result<String, anyhow::Error>> =
|
||||||
raw.lines().map(|l| Ok(l.to_string())).collect();
|
raw.lines().map(|l| Ok(l.to_string())).collect();
|
||||||
let stream = Box::pin(futures::stream::iter(lines));
|
let stream = Box::pin(futures::stream::iter(lines));
|
||||||
let mut msg_stream = std::pin::pin!(response_to_streaming_message(stream));
|
let mut msg_stream = std::pin::pin!(response_to_streaming_message(stream));
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
let mut thinking = 0usize;
|
let mut thinking = 0usize;
|
||||||
let mut reasoning = 0usize;
|
|
||||||
while let Some(Ok((message, _))) = msg_stream.next().await {
|
while let Some(Ok((message, _))) = msg_stream.next().await {
|
||||||
if let Some(msg) = message {
|
if let Some(msg) = message {
|
||||||
for c in &msg.content {
|
for c in &msg.content {
|
||||||
match c {
|
match c {
|
||||||
MessageContent::Text(t) => text.push_str(&t.text),
|
MessageContent::Text(t) => text.push_str(&t.text),
|
||||||
MessageContent::Thinking(_) => thinking += 1,
|
MessageContent::Thinking(_) => thinking += 1,
|
||||||
MessageContent::Reasoning(_) => reasoning += 1,
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(text, thinking, reasoning)
|
(text, thinking)
|
||||||
}
|
}
|
||||||
|
|
||||||
let (text, thinking, reasoning) = collect_streaming_text(concat!(
|
let (text, thinking) = collect_streaming_text(concat!(
|
||||||
r#"data: {"candidates": [{"content": {"role": "model", "#,
|
r#"data: {"candidates": [{"content": {"role": "model", "#,
|
||||||
r#""parts": [{"text": "Hello", "thoughtSignature": "sig1"}]}}], "#,
|
r#""parts": [{"text": "Hello", "thoughtSignature": "sig1"}]}}], "#,
|
||||||
r#""modelVersion": "gemini-3-flash-preview"}"#,
|
r#""modelVersion": "gemini-3-flash-preview"}"#,
|
||||||
@@ -1221,10 +1219,9 @@ mod tests {
|
|||||||
))
|
))
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(thinking, 0);
|
assert_eq!(thinking, 0);
|
||||||
assert_eq!(reasoning, 0);
|
|
||||||
assert_eq!(text, "Hello world");
|
assert_eq!(text, "Hello world");
|
||||||
|
|
||||||
let (text, thinking, reasoning) = collect_streaming_text(concat!(
|
let (text, thinking) = collect_streaming_text(concat!(
|
||||||
r#"data: {"candidates": [{"content": {"role": "model", "#,
|
r#"data: {"candidates": [{"content": {"role": "model", "#,
|
||||||
r#""parts": [{"text": "SECURITY.md: Project"}]}}], "#,
|
r#""parts": [{"text": "SECURITY.md: Project"}]}}], "#,
|
||||||
r#""modelVersion": "gemini-3-flash-preview"}"#,
|
r#""modelVersion": "gemini-3-flash-preview"}"#,
|
||||||
@@ -1235,10 +1232,9 @@ mod tests {
|
|||||||
))
|
))
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(thinking, 0);
|
assert_eq!(thinking, 0);
|
||||||
assert_eq!(reasoning, 0);
|
|
||||||
assert_eq!(text, "SECURITY.md: Project policies.\n\nRead it?");
|
assert_eq!(text, "SECURITY.md: Project policies.\n\nRead it?");
|
||||||
|
|
||||||
let (text, thinking, reasoning) = collect_streaming_text(concat!(
|
let (text, thinking) = collect_streaming_text(concat!(
|
||||||
r#"data: {"candidates": [{"content": {"role": "model", "#,
|
r#"data: {"candidates": [{"content": {"role": "model", "#,
|
||||||
r#""parts": [{"text": "one "}]}}], "modelVersion": "gemini-3-flash-preview"}"#,
|
r#""parts": [{"text": "one "}]}}], "modelVersion": "gemini-3-flash-preview"}"#,
|
||||||
"\n",
|
"\n",
|
||||||
@@ -1250,10 +1246,9 @@ mod tests {
|
|||||||
))
|
))
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(thinking, 0);
|
assert_eq!(thinking, 0);
|
||||||
assert_eq!(reasoning, 0);
|
|
||||||
assert_eq!(text, "one two three");
|
assert_eq!(text, "one two three");
|
||||||
|
|
||||||
let (text, thinking, reasoning) = collect_streaming_text(concat!(
|
let (text, thinking) = collect_streaming_text(concat!(
|
||||||
r#"data: {"candidates": [{"content": {"role": "model", "#,
|
r#"data: {"candidates": [{"content": {"role": "model", "#,
|
||||||
r#""parts": [{"text": "internal chain", "thought": true, "thoughtSignature": "sig4"}]}}]}"#,
|
r#""parts": [{"text": "internal chain", "thought": true, "thoughtSignature": "sig4"}]}}]}"#,
|
||||||
"\n",
|
"\n",
|
||||||
@@ -1262,7 +1257,6 @@ mod tests {
|
|||||||
))
|
))
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(thinking, 1);
|
assert_eq!(thinking, 1);
|
||||||
assert_eq!(reasoning, 0);
|
|
||||||
assert_eq!(text, "visible");
|
assert_eq!(text, "visible");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,20 +105,15 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MessageContent::Thinking(_) => {
|
MessageContent::Thinking(t) => {
|
||||||
// Thinking blocks are not directly used in OpenAI format
|
reasoning_text.push_str(&t.thinking);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
MessageContent::RedactedThinking(_) => {
|
MessageContent::RedactedThinking(_) => {
|
||||||
// Redacted thinking blocks are not directly used in OpenAI format
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
MessageContent::SystemNotification(_) => {
|
MessageContent::SystemNotification(_) => {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
MessageContent::Reasoning(r) => {
|
|
||||||
reasoning_text.push_str(&r.text);
|
|
||||||
}
|
|
||||||
MessageContent::ToolRequest(request) => match &request.tool_call {
|
MessageContent::ToolRequest(request) => match &request.tool_call {
|
||||||
Ok(tool_call) => {
|
Ok(tool_call) => {
|
||||||
let sanitized_name = sanitize_function_name(&tool_call.name);
|
let sanitized_name = sanitize_function_name(&tool_call.name);
|
||||||
@@ -346,7 +341,7 @@ pub fn response_to_message(response: &Value) -> anyhow::Result<Message> {
|
|||||||
if let Some(reasoning_content) = reasoning_value {
|
if let Some(reasoning_content) = reasoning_value {
|
||||||
if let Some(reasoning_str) = reasoning_content.as_str() {
|
if let Some(reasoning_str) = reasoning_content.as_str() {
|
||||||
if !reasoning_str.is_empty() {
|
if !reasoning_str.is_empty() {
|
||||||
content.push(MessageContent::reasoning(reasoning_str));
|
content.push(MessageContent::thinking(reasoning_str, ""));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -646,7 +641,7 @@ where
|
|||||||
|
|
||||||
let mut contents = Vec::new();
|
let mut contents = Vec::new();
|
||||||
if !accumulated_reasoning_content.is_empty() {
|
if !accumulated_reasoning_content.is_empty() {
|
||||||
contents.push(MessageContent::reasoning(&accumulated_reasoning_content));
|
contents.push(MessageContent::thinking(&accumulated_reasoning_content, ""));
|
||||||
accumulated_reasoning_content.clear();
|
accumulated_reasoning_content.clear();
|
||||||
}
|
}
|
||||||
let mut sorted_indices: Vec<_> = tool_call_data.keys().cloned().collect();
|
let mut sorted_indices: Vec<_> = tool_call_data.keys().cloned().collect();
|
||||||
@@ -706,7 +701,7 @@ where
|
|||||||
|
|
||||||
if let Some(reasoning) = &chunk.choices[0].delta.reasoning_content {
|
if let Some(reasoning) = &chunk.choices[0].delta.reasoning_content {
|
||||||
if !reasoning.is_empty() {
|
if !reasoning.is_empty() {
|
||||||
content.push(MessageContent::reasoning(reasoning));
|
content.push(MessageContent::thinking(reasoning, ""));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1837,11 +1832,11 @@ data: [DONE]"#;
|
|||||||
let message = response_to_message(&response)?;
|
let message = response_to_message(&response)?;
|
||||||
assert_eq!(message.content.len(), 2);
|
assert_eq!(message.content.len(), 2);
|
||||||
|
|
||||||
// First should be reasoning content
|
// First should be thinking content (reasoning is mapped to thinking)
|
||||||
if let MessageContent::Reasoning(reasoning) = &message.content[0] {
|
if let MessageContent::Thinking(thinking) = &message.content[0] {
|
||||||
assert_eq!(reasoning.text, "Let me think about this step by step...");
|
assert_eq!(thinking.thinking, "Let me think about this step by step...");
|
||||||
} else {
|
} else {
|
||||||
panic!("Expected Reasoning content");
|
panic!("Expected Thinking content, got {:?}", message.content[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second should be text content
|
// Second should be text content
|
||||||
@@ -1858,7 +1853,10 @@ data: [DONE]"#;
|
|||||||
fn test_format_messages_with_reasoning_content() -> anyhow::Result<()> {
|
fn test_format_messages_with_reasoning_content() -> anyhow::Result<()> {
|
||||||
// Test that reasoning_content is properly included in formatted messages
|
// Test that reasoning_content is properly included in formatted messages
|
||||||
let mut message = Message::assistant()
|
let mut message = Message::assistant()
|
||||||
.with_content(MessageContent::reasoning("Thinking through the problem..."))
|
.with_content(MessageContent::thinking(
|
||||||
|
"Thinking through the problem...",
|
||||||
|
"",
|
||||||
|
))
|
||||||
.with_text("The result is 42");
|
.with_text("The result is 42");
|
||||||
|
|
||||||
// Add a tool call to test that reasoning_content works with tool calls
|
// Add a tool call to test that reasoning_content works with tool calls
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ fn reasoning_from_summary(summary: &[SummaryText]) -> Option<MessageContent> {
|
|||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(MessageContent::reasoning(text))
|
Some(MessageContent::thinking(text, ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -881,10 +881,10 @@ mod tests {
|
|||||||
|
|
||||||
let message = responses_api_to_message(&response)?;
|
let message = responses_api_to_message(&response)?;
|
||||||
|
|
||||||
let reasoning = message.content.iter().find_map(|c| c.as_reasoning());
|
let thinking = message.content.iter().find_map(|c| c.as_thinking());
|
||||||
assert!(reasoning.is_some(), "should contain reasoning content");
|
assert!(thinking.is_some(), "should contain thinking content");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reasoning.unwrap().text,
|
thinking.unwrap().thinking,
|
||||||
"Thinking about the question...\nThe answer is straightforward."
|
"Thinking about the question...\nThe answer is straightforward."
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -938,7 +938,7 @@ mod tests {
|
|||||||
let messages = responses_api_to_streaming_message(response_stream);
|
let messages = responses_api_to_streaming_message(response_stream);
|
||||||
futures::pin_mut!(messages);
|
futures::pin_mut!(messages);
|
||||||
|
|
||||||
let mut reasoning_parts = Vec::new();
|
let mut thinking_parts = Vec::new();
|
||||||
let mut text_parts = Vec::new();
|
let mut text_parts = Vec::new();
|
||||||
|
|
||||||
while let Some(item) = messages.next().await {
|
while let Some(item) = messages.next().await {
|
||||||
@@ -946,7 +946,7 @@ mod tests {
|
|||||||
if let Some(msg) = message {
|
if let Some(msg) = message {
|
||||||
for content in msg.content {
|
for content in msg.content {
|
||||||
match &content {
|
match &content {
|
||||||
MessageContent::Reasoning(r) => reasoning_parts.push(r.text.clone()),
|
MessageContent::Thinking(t) => thinking_parts.push(t.thinking.clone()),
|
||||||
MessageContent::Text(t) => text_parts.push(t.text.clone()),
|
MessageContent::Text(t) => text_parts.push(t.text.clone()),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -955,10 +955,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!reasoning_parts.is_empty(),
|
!thinking_parts.is_empty(),
|
||||||
"should capture reasoning from stream"
|
"should capture thinking from stream"
|
||||||
);
|
);
|
||||||
assert_eq!(reasoning_parts.join(""), "Let me think step by step.");
|
assert_eq!(thinking_parts.join(""), "Let me think step by step.");
|
||||||
assert!(text_parts.concat().contains("Paris."));
|
assert!(text_parts.concat().contains("Paris."));
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -65,10 +65,6 @@ pub fn format_messages(messages: &[Message]) -> Vec<Value> {
|
|||||||
MessageContent::FrontendToolRequest(_tool_request) => {
|
MessageContent::FrontendToolRequest(_tool_request) => {
|
||||||
// Skip frontend tool requests
|
// Skip frontend tool requests
|
||||||
}
|
}
|
||||||
MessageContent::Reasoning(_reasoning) => {
|
|
||||||
// Reasoning content is for OpenAI-compatible APIs (e.g., DeepSeek)
|
|
||||||
// Snowflake doesn't use this format, so skip
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5876,27 +5876,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/ReasoningContent"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"type"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"reasoning"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Content passed inside a message, which can be both simple content and tool content",
|
"description": "Content passed inside a message, which can be both simple content and tool content",
|
||||||
@@ -6863,17 +6842,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ReasoningContent": {
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"text"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"text": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Recipe": {
|
"Recipe": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -670,8 +670,6 @@ export type MessageContent = (TextContent & {
|
|||||||
type: 'redactedThinking';
|
type: 'redactedThinking';
|
||||||
}) | (SystemNotificationContent & {
|
}) | (SystemNotificationContent & {
|
||||||
type: 'systemNotification';
|
type: 'systemNotification';
|
||||||
}) | (ReasoningContent & {
|
|
||||||
type: 'reasoning';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type MessageEvent = {
|
export type MessageEvent = {
|
||||||
@@ -1009,10 +1007,6 @@ export type ReadResourceResponse = {
|
|||||||
uri: string;
|
uri: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReasoningContent = {
|
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Recipe = {
|
export type Recipe = {
|
||||||
activities?: Array<string> | null;
|
activities?: Array<string> | null;
|
||||||
author?: Author | null;
|
author?: Author | null;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import MarkdownContent from './MarkdownContent';
|
|||||||
import ToolCallWithResponse from './ToolCallWithResponse';
|
import ToolCallWithResponse from './ToolCallWithResponse';
|
||||||
import {
|
import {
|
||||||
getTextAndImageContent,
|
getTextAndImageContent,
|
||||||
getReasoningContent,
|
getThinkingContent,
|
||||||
getToolRequests,
|
getToolRequests,
|
||||||
getToolResponses,
|
getToolResponses,
|
||||||
getToolConfirmationContent,
|
getToolConfirmationContent,
|
||||||
@@ -48,7 +48,7 @@ export default function GooseMessage({
|
|||||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
let { textContent, imagePaths } = getTextAndImageContent(message);
|
let { textContent, imagePaths } = getTextAndImageContent(message);
|
||||||
const reasoningContent = getReasoningContent(message);
|
const thinkingContent = getThinkingContent(message);
|
||||||
|
|
||||||
const splitChainOfThought = (text: string): { displayText: string; cotText: string | null } => {
|
const splitChainOfThought = (text: string): { displayText: string; cotText: string | null } => {
|
||||||
const regex = /<think>([\s\S]*?)<\/think>/i;
|
const regex = /<think>([\s\S]*?)<\/think>/i;
|
||||||
@@ -131,26 +131,16 @@ export default function GooseMessage({
|
|||||||
return (
|
return (
|
||||||
<div className="goose-message flex w-[90%] justify-start min-w-0">
|
<div className="goose-message flex w-[90%] justify-start min-w-0">
|
||||||
<div className="flex flex-col w-full min-w-0">
|
<div className="flex flex-col w-full min-w-0">
|
||||||
{reasoningContent && (
|
{thinkingContent && (
|
||||||
<details className="mb-2">
|
<div className="mb-2 text-xs text-gray-400/70 italic">
|
||||||
<summary className="cursor-pointer text-xs text-textSubtle select-none">
|
<MarkdownContent content={thinkingContent} />
|
||||||
Show reasoning
|
</div>
|
||||||
</summary>
|
|
||||||
<div className="mt-2 text-sm">
|
|
||||||
<MarkdownContent content={reasoningContent} />
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{cotText && (
|
{cotText && (
|
||||||
<details className="bg-background-secondary border border-border-primary rounded p-2 mb-2">
|
<div className="mb-2 text-sm text-gray-400 italic">
|
||||||
<summary className="cursor-pointer text-sm text-text-secondary select-none">
|
<MarkdownContent content={cotText} />
|
||||||
Show thinking
|
</div>
|
||||||
</summary>
|
|
||||||
<div className="mt-2">
|
|
||||||
<MarkdownContent content={cotText} />
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(displayText.trim() || imagePaths.length > 0) && (
|
{(displayText.trim() || imagePaths.length > 0) && (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import MarkdownContent from './MarkdownContent';
|
|
||||||
import Expand from './ui/Expand';
|
import Expand from './ui/Expand';
|
||||||
|
|
||||||
export type ToolCallArgumentValue =
|
export type ToolCallArgumentValue =
|
||||||
@@ -14,6 +13,12 @@ interface ToolCallArgumentsProps {
|
|||||||
args: Record<string, ToolCallArgumentValue>;
|
args: Record<string, ToolCallArgumentValue>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatValue(value: ToolCallArgumentValue): string {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (typeof value === 'object' && value !== null) return JSON.stringify(value, null, 2);
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function ToolCallArguments({ args }: ToolCallArgumentsProps) {
|
export function ToolCallArguments({ args }: ToolCallArgumentsProps) {
|
||||||
const [expandedKeys, setExpandedKeys] = useState<Record<string, boolean>>({});
|
const [expandedKeys, setExpandedKeys] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
@@ -22,46 +27,33 @@ export function ToolCallArguments({ args }: ToolCallArgumentsProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderValue = (key: string, value: ToolCallArgumentValue) => {
|
const renderValue = (key: string, value: ToolCallArgumentValue) => {
|
||||||
if (typeof value === 'string') {
|
const text = formatValue(value).trim();
|
||||||
const needsExpansion = value.length > 60;
|
const needsExpansion = text.length > 60 || text.includes('\n');
|
||||||
const isExpanded = expandedKeys[key];
|
const isExpanded = expandedKeys[key];
|
||||||
|
|
||||||
if (!needsExpansion) {
|
return (
|
||||||
return (
|
<div className="font-sans text-sm mb-2">
|
||||||
<div className="font-sans text-sm mb-2">
|
<div className={`flex flex-row items-stretch ${!isExpanded && needsExpansion ? 'truncate min-w-0' : ''}`}>
|
||||||
<div className="flex flex-row">
|
<button
|
||||||
<span className="text-text-secondary min-w-[140px]">{key}</span>
|
onClick={() => needsExpansion && toggleKey(key)}
|
||||||
<span className="text-text-secondary">{value}</span>
|
className={`flex text-left text-text-secondary min-w-[140px] ${needsExpansion ? 'cursor-pointer' : 'cursor-default'}`}
|
||||||
</div>
|
>
|
||||||
</div>
|
<span>{key}</span>
|
||||||
);
|
</button>
|
||||||
}
|
<div className={`w-full flex items-stretch ${!isExpanded && needsExpansion ? 'truncate min-w-0' : ''}`}>
|
||||||
|
{isExpanded ? (
|
||||||
return (
|
<pre className="font-mono text-xs text-text-secondary whitespace-pre-wrap max-w-full overflow-x-auto">
|
||||||
<div className={`font-sans text-sm mb-2 ${isExpanded ? '' : 'truncate min-w-0'}`}>
|
{text}
|
||||||
<div className={`flex flex-row items-stretch ${isExpanded ? '' : 'truncate min-w-0'}`}>
|
</pre>
|
||||||
<button
|
) : (
|
||||||
onClick={() => toggleKey(key)}
|
<button
|
||||||
className="flex text-left text-text-secondary min-w-[140px]"
|
onClick={() => needsExpansion && toggleKey(key)}
|
||||||
>
|
className={`text-left text-text-secondary font-mono text-xs ${needsExpansion ? 'truncate min-w-0 cursor-pointer' : 'cursor-default'}`}
|
||||||
<span>{key}</span>
|
>
|
||||||
</button>
|
{text.split('\n')[0]}
|
||||||
<div className={`w-full flex items-stretch ${isExpanded ? '' : 'truncate min-w-0'}`}>
|
</button>
|
||||||
{isExpanded ? (
|
)}
|
||||||
<div>
|
{needsExpansion && (
|
||||||
<MarkdownContent
|
|
||||||
content={value}
|
|
||||||
className="font-sans text-sm text-text-secondary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => toggleKey(key)}
|
|
||||||
className={`text-left text-text-secondary ${isExpanded ? '' : 'truncate min-w-0'}`}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleKey(key)}
|
onClick={() => toggleKey(key)}
|
||||||
className="flex flex-row items-stretch grow text-text-secondary pr-2"
|
className="flex flex-row items-stretch grow text-text-secondary pr-2"
|
||||||
@@ -69,27 +61,9 @@ export function ToolCallArguments({ args }: ToolCallArgumentsProps) {
|
|||||||
<div className="min-w-2 grow" />
|
<div className="min-w-2 grow" />
|
||||||
<Expand size={5} isExpanded={isExpanded} />
|
<Expand size={5} isExpanded={isExpanded} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle non-string values (arrays, objects, etc.)
|
|
||||||
const content = Array.isArray(value)
|
|
||||||
? value.map((item, index) => `${index + 1}. ${JSON.stringify(item)}`).join('\n')
|
|
||||||
: typeof value === 'object' && value !== null
|
|
||||||
? JSON.stringify(value, null, 2)
|
|
||||||
: String(value);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-2">
|
|
||||||
<div className="flex flex-row font-sans text-sm">
|
|
||||||
<span className="text-text-secondary min-w-[140px]">{key}</span>
|
|
||||||
<pre className="whitespace-pre-wrap text-text-secondary overflow-x-auto max-w-full">
|
|
||||||
{content}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -866,7 +866,7 @@ interface ToolResultViewProps {
|
|||||||
isStartExpanded: boolean;
|
isStartExpanded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolResultView({ toolCall, result, isStartExpanded }: ToolResultViewProps) {
|
function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) {
|
||||||
const hasText = (c: ContentBlock): c is ContentBlock & { text: string } =>
|
const hasText = (c: ContentBlock): c is ContentBlock & { text: string } =>
|
||||||
'text' in c && typeof (c as Record<string, unknown>).text === 'string';
|
'text' in c && typeof (c as Record<string, unknown>).text === 'string';
|
||||||
|
|
||||||
@@ -879,18 +879,6 @@ function ToolResultView({ toolCall, result, isStartExpanded }: ToolResultViewPro
|
|||||||
const hasResource = (c: ContentBlock): c is ContentBlock & { resource: unknown } =>
|
const hasResource = (c: ContentBlock): c is ContentBlock & { resource: unknown } =>
|
||||||
'resource' in c;
|
'resource' in c;
|
||||||
|
|
||||||
const wrapMarkdown = (text: string): string => {
|
|
||||||
if (
|
|
||||||
['code_execution__list_functions', 'code_execution__get_function_details'].includes(
|
|
||||||
toolCall.name
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return '```typescript\n' + text + '\n```';
|
|
||||||
} else {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolCallExpandable
|
<ToolCallExpandable
|
||||||
label={<span className="pl-4 py-1 font-sans text-sm">Output</span>}
|
label={<span className="pl-4 py-1 font-sans text-sm">Output</span>}
|
||||||
@@ -898,10 +886,9 @@ function ToolResultView({ toolCall, result, isStartExpanded }: ToolResultViewPro
|
|||||||
>
|
>
|
||||||
<div className="pl-4 pr-4 py-4">
|
<div className="pl-4 pr-4 py-4">
|
||||||
{hasText(result) && (
|
{hasText(result) && (
|
||||||
<MarkdownContent
|
<pre className="font-mono text-xs whitespace-pre-wrap max-w-full overflow-x-auto">
|
||||||
content={wrapMarkdown(result.text)}
|
{result.text.trim()}
|
||||||
className="whitespace-pre-wrap max-w-full overflow-x-auto"
|
</pre>
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{hasImage(result) && (
|
{hasImage(result) && (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import ToolCallWithResponse from '../ToolCallWithResponse';
|
|||||||
import ImagePreview from '../ImagePreview';
|
import ImagePreview from '../ImagePreview';
|
||||||
import {
|
import {
|
||||||
getTextAndImageContent,
|
getTextAndImageContent,
|
||||||
getReasoningContent,
|
getThinkingContent,
|
||||||
ToolRequestMessageContent,
|
ToolRequestMessageContent,
|
||||||
ToolResponseMessageContent,
|
ToolResponseMessageContent,
|
||||||
} from '../../types/message';
|
} from '../../types/message';
|
||||||
@@ -83,7 +83,7 @@ export const SessionMessages: React.FC<SessionMessagesProps> = ({
|
|||||||
messages
|
messages
|
||||||
.map((message, index) => {
|
.map((message, index) => {
|
||||||
const { textContent, imagePaths } = getTextAndImageContent(message);
|
const { textContent, imagePaths } = getTextAndImageContent(message);
|
||||||
const reasoningContent = getReasoningContent(message);
|
const thinkingContent = getThinkingContent(message);
|
||||||
|
|
||||||
// Get tool requests from the message
|
// Get tool requests from the message
|
||||||
const toolRequests = message.content
|
const toolRequests = message.content
|
||||||
@@ -121,16 +121,11 @@ export const SessionMessages: React.FC<SessionMessagesProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col w-full">
|
<div className="flex flex-col w-full">
|
||||||
{/* Reasoning content */}
|
{/* Thinking content */}
|
||||||
{reasoningContent && (
|
{thinkingContent && (
|
||||||
<details className="mb-2">
|
<div className="mb-2 text-sm text-gray-400 italic">
|
||||||
<summary className="cursor-pointer text-xs text-textSubtle select-none">
|
<MarkdownContent content={thinkingContent} />
|
||||||
Show reasoning
|
</div>
|
||||||
</summary>
|
|
||||||
<div className="mt-2 text-sm">
|
|
||||||
<MarkdownContent content={reasoningContent} />
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Text content */}
|
{/* Text content */}
|
||||||
|
|||||||
@@ -187,6 +187,21 @@ function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[
|
|||||||
incomingMsg.content.length === 1
|
incomingMsg.content.length === 1
|
||||||
) {
|
) {
|
||||||
lastContent.text += newContent.text;
|
lastContent.text += newContent.text;
|
||||||
|
} else if (
|
||||||
|
lastContent?.type === 'thinking' &&
|
||||||
|
newContent?.type === 'thinking' &&
|
||||||
|
incomingMsg.content.length === 1 &&
|
||||||
|
'thinking' in lastContent &&
|
||||||
|
'thinking' in newContent
|
||||||
|
) {
|
||||||
|
// For thinking blocks: if the new block has a signature, it's the complete
|
||||||
|
// block from content_block_stop — replace entirely. Otherwise append the delta.
|
||||||
|
if ('signature' in newContent && newContent.signature) {
|
||||||
|
lastContent.thinking = newContent.thinking;
|
||||||
|
lastContent.signature = newContent.signature;
|
||||||
|
} else {
|
||||||
|
lastContent.thinking += newContent.thinking;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
lastMsg.content.push(...incomingMsg.content);
|
lastMsg.content.push(...incomingMsg.content);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,16 +97,16 @@ export function getTextAndImageContent(message: Message): {
|
|||||||
return { textContent, imagePaths };
|
return { textContent, imagePaths };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReasoningContent(message: Message): string | null {
|
export function getThinkingContent(message: Message): string | null {
|
||||||
const reasoningContents = message.content
|
const thinkingContents = message.content
|
||||||
.filter((content) => content.type === 'reasoning')
|
.filter((content) => content.type === 'thinking')
|
||||||
.map((content) => {
|
.map((content) => {
|
||||||
if ('text' in content) return content.text;
|
if ('thinking' in content) return content.thinking;
|
||||||
return '';
|
return '';
|
||||||
})
|
})
|
||||||
.filter((text) => text.length > 0);
|
.filter((text) => text.length > 0);
|
||||||
|
|
||||||
return reasoningContents.length > 0 ? reasoningContents.join('') : null;
|
return thinkingContents.length > 0 ? thinkingContents.join('') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getToolRequests(message: Message): (ToolRequest & { type: 'toolRequest' })[] {
|
export function getToolRequests(message: Message): (ToolRequest & { type: 'toolRequest' })[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user