diff --git a/crates/agent/src/history.rs b/crates/agent/src/history.rs index 789cb7113..4b7c7a5ae 100644 --- a/crates/agent/src/history.rs +++ b/crates/agent/src/history.rs @@ -28,17 +28,25 @@ impl History { .. } => { let mut parts: Vec = Vec::new(); - // Provider-specific opaque parts (e.g. OpenAI reasoning items) - // must precede function calls for correct round-tripping. + // Provider-specific opaque parts (e.g. OpenAI reasoning items, + // Anthropic thinking blocks with signatures) must precede + // function calls for correct round-tripping. parts.extend(provider_parts.iter().cloned()); - if let Some(reasoning_text) = reasoning { - parts.push(ContentPart::Thinking( - llm::types::ThinkingData { - text: reasoning_text.clone(), - signature: None, - redacted: false, - }, - )); + // Only reconstruct thinking from plain text if provider_parts + // doesn't already contain thinking blocks (which preserve signatures). + let has_thinking_parts = provider_parts + .iter() + .any(|p| matches!(p, ContentPart::Thinking(_) | ContentPart::RedactedThinking(_))); + if !has_thinking_parts { + if let Some(reasoning_text) = reasoning { + parts.push(ContentPart::Thinking( + llm::types::ThinkingData { + text: reasoning_text.clone(), + signature: None, + redacted: false, + }, + )); + } } if !content.is_empty() { parts.push(ContentPart::text(content)); @@ -167,6 +175,38 @@ mod tests { assert_eq!(thinking_parts.len(), 1); } + #[test] + fn thinking_with_signature_preserved_via_provider_parts() { + let mut history = History::default(); + let thinking = ContentPart::Thinking(llm::types::ThinkingData { + text: "Let me think...".into(), + signature: Some("sig_abc123".into()), + redacted: false, + }); + history.push(Turn::Assistant { + content: "The answer".into(), + tool_calls: vec![], + reasoning: Some("Let me think...".into()), + provider_parts: vec![thinking], + usage: Usage::default(), + response_id: "resp_4".into(), + timestamp: SystemTime::now(), + }); + let messages = history.convert_to_messages(); + let thinking_parts: Vec<_> = messages[0] + .content + .iter() + .filter_map(|p| match p { + ContentPart::Thinking(td) => Some(td), + _ => None, + }) + .collect(); + // Should have exactly one thinking block (from provider_parts, not duplicated) + assert_eq!(thinking_parts.len(), 1); + // Signature must be preserved + assert_eq!(thinking_parts[0].signature.as_deref(), Some("sig_abc123")); + } + #[test] fn assistant_turn_preserves_provider_parts() { let mut history = History::default(); diff --git a/crates/agent/src/session.rs b/crates/agent/src/session.rs index eb87c09f0..fc92aad31 100644 --- a/crates/agent/src/session.rs +++ b/crates/agent/src/session.rs @@ -338,7 +338,14 @@ impl Session { .message .content .iter() - .filter(|p| matches!(p, llm::types::ContentPart::Other { .. })) + .filter(|p| { + matches!( + p, + llm::types::ContentPart::Other { .. } + | llm::types::ContentPart::Thinking(_) + | llm::types::ContentPart::RedactedThinking(_) + ) + }) .cloned() .collect(); let usage = response.usage.clone();