Preserve thinking block signatures for Anthropic multi-turn conversations

The Anthropic API requires a `signature` field on thinking blocks when
they are sent back in conversation history. Previously, thinking blocks
were stored as plain text in Turn::Assistant.reasoning, losing the
signature. Now Thinking/RedactedThinking content parts are preserved
in provider_parts (which retains signatures), and the lossy
reconstruction path is skipped when provider_parts already contains
thinking blocks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-02-23 14:37:12 -05:00
parent 6fee3d82f1
commit 9342b5be76
2 changed files with 58 additions and 11 deletions

View file

@ -28,17 +28,25 @@ impl History {
..
} => {
let mut parts: Vec<ContentPart> = 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();

View file

@ -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();