From f399ada2a3edb7f336e9ddff99056d184478d576 Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Wed, 28 Oct 2026 18:26:02 +0000 Subject: [PATCH] Filter unsupported reasoning from Bedrock request history --- AGENTS.md | 1 + app/src/ai/provider/client.rs | 2 + app/src/ai/provider/convert.rs | 3 + app/src/ai/provider/convert_tests.rs | 208 +++++++++++++++++++ app/src/ai/runtime/rig.rs | 28 ++- crates/galaxy_agent_rig/src/bedrock.rs | 86 +++++++- crates/galaxy_agent_rig/src/bedrock_tests.rs | 150 ++++++++++++- 7 files changed, 462 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5b67c8e7..b20ec9db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -223,6 +223,7 @@ Key invariants: - `recall_tool_history` excludes earlier calls to itself; archived tool results remain searchable by query or exact `tool_use_id` - Before progressive summarization drains messages, `ConversationMessage::archive_tool_results()` moves tool-use/result pairs into `tool_result_archive` - Bedrock prompt caching uses three cache points: system prompt, second-to-last history message, and tool configuration +- Bedrock requests project reasoning history for the selected foundation model: omit unsigned response-only reasoning and signed blocks from incompatible models while preserving Claude's complete signed blocks for tool continuations. Keep durable/UI history intact, remove messages emptied by projection, and use the discovered foundation model when invocation targets an opaque inference-profile ARN. - `ensure_tool_results_paired()` enforces Bedrock's invariant that every `tool_use` has a matching `tool_result` - Progressive summaries are prepended to provider requests as a user/assistant pair; direct-provider runs compact their live transcript at a checkpointed `ReadyToCallModel` boundary before another model call, while background summarization remains independent for session-owned runtimes - Direct-provider progressive compaction runs in a separate request alongside foreground work. Start early against the smaller usable history budget of the current and terminal-use profiles, including each profile's prompt/tools/output reservation. Apply a result only at a safe model boundary after validating its exact snapshotted prefix, preserving all newer history and recomputing live context accounting. Wait for pending compaction only after context exhaustion; cancellation and terminal cleanup must remain immediate. Cleanly completed conversations may accept a late summary while idle; stale, cancelled, or superseded generations must never replace a newer run's history. diff --git a/app/src/ai/provider/client.rs b/app/src/ai/provider/client.rs index 82fe4266..77a0c0ce 100644 --- a/app/src/ai/provider/client.rs +++ b/app/src/ai/provider/client.rs @@ -211,6 +211,7 @@ impl BedrockClient { ); let converted = build_converse_request( + model_id, messages.clone(), system_prompt.clone(), compact_summary, @@ -321,6 +322,7 @@ impl BedrockClient { let caching_config = CachingConfig::from_external_config(&external_config); let converted = build_converse_request( + model_id, messages, system_prompt, None, diff --git a/app/src/ai/provider/convert.rs b/app/src/ai/provider/convert.rs index b57e3af3..c39d3165 100644 --- a/app/src/ai/provider/convert.rs +++ b/app/src/ai/provider/convert.rs @@ -8,6 +8,7 @@ use aws_sdk_bedrockruntime::types::{ ToolUseBlock, }; use aws_smithy_types::{Blob, Document}; +use galaxy_agent_rig::prepare_bedrock_history; use serde_json::Value as JsonValue; use super::external_config::ExternalBedrockConfig; @@ -65,6 +66,7 @@ pub struct ConvertedRequest { #[allow(clippy::too_many_arguments)] pub fn build_converse_request( + model_id: &str, messages: Vec, system_prompt: Option, compact_summary: Option, @@ -75,6 +77,7 @@ pub fn build_converse_request( stop_sequences: Option>, caching_config: CachingConfig, ) -> ConvertedRequest { + let messages = prepare_bedrock_history(model_id, messages); let bedrock_messages = convert_messages(messages, &caching_config); let system = convert_system_prompt(system_prompt, compact_summary, &caching_config); let inference_config = build_inference_config(max_tokens, temperature, top_p, stop_sequences); diff --git a/app/src/ai/provider/convert_tests.rs b/app/src/ai/provider/convert_tests.rs index b44b8cb7..322c3e04 100644 --- a/app/src/ai/provider/convert_tests.rs +++ b/app/src/ai/provider/convert_tests.rs @@ -23,6 +23,7 @@ fn test_text_message_converts_to_single_block() { }]; let result = build_converse_request( + "anthropic.claude-sonnet-4-6", messages, None, None, @@ -54,6 +55,7 @@ fn test_multimodal_user_message_converts_image_to_bedrock_block() { }]; let result = build_converse_request( + "anthropic.claude-sonnet-4-6", messages, None, None, @@ -100,6 +102,7 @@ fn test_tool_use_produces_valid_json_input() { }]; let result = build_converse_request( + "anthropic.claude-sonnet-4-6", messages, None, None, @@ -134,6 +137,7 @@ fn test_tool_result_with_matching_id() { }]; let result = build_converse_request( + "anthropic.claude-sonnet-4-6", messages, None, None, @@ -166,6 +170,7 @@ fn test_tool_result_error_status() { }]; let result = build_converse_request( + "anthropic.claude-sonnet-4-6", messages, None, None, @@ -202,6 +207,7 @@ fn test_consecutive_same_role_messages_coalesced() { ]; let result = build_converse_request( + "anthropic.claude-sonnet-4-6", messages, None, None, @@ -237,6 +243,7 @@ fn test_alternating_roles_not_coalesced() { ]; let result = build_converse_request( + "anthropic.claude-sonnet-4-6", messages, None, None, @@ -262,6 +269,7 @@ fn test_system_prompt_separated_from_messages() { }]; let result = build_converse_request( + "anthropic.claude-sonnet-4-6", messages, Some("You are a helpful assistant.".to_string()), None, @@ -284,6 +292,7 @@ fn test_system_prompt_separated_from_messages() { #[test] fn test_empty_system_prompt_produces_empty_vec() { let result = build_converse_request( + "anthropic.claude-sonnet-4-6", vec![], Some("".to_string()), None, @@ -297,6 +306,7 @@ fn test_empty_system_prompt_produces_empty_vec() { assert!(result.system.is_empty()); let result2 = build_converse_request( + "anthropic.claude-sonnet-4-6", vec![], None, None, @@ -313,6 +323,7 @@ fn test_empty_system_prompt_produces_empty_vec() { #[test] fn test_empty_tools_produce_none_config() { let result = build_converse_request( + "anthropic.claude-sonnet-4-6", vec![], None, None, @@ -341,6 +352,7 @@ fn test_tool_definitions_produce_tool_config() { }]; let result = build_converse_request( + "anthropic.claude-sonnet-4-6", vec![], None, None, @@ -363,6 +375,7 @@ fn test_tool_definitions_produce_tool_config() { #[test] fn test_inference_config_max_tokens_only() { let result = build_converse_request( + "anthropic.claude-sonnet-4-6", vec![], None, None, @@ -382,6 +395,7 @@ fn test_inference_config_max_tokens_only() { #[test] fn test_inference_config_all_params() { let result = build_converse_request( + "anthropic.claude-sonnet-4-6", vec![], None, None, @@ -413,6 +427,7 @@ fn test_multipart_content_produces_multiple_blocks() { }]; let result = build_converse_request( + "anthropic.claude-sonnet-4-6", messages, None, None, @@ -461,6 +476,7 @@ fn test_tool_result_after_tool_use_coalesced_into_user_message() { ]; let result = build_converse_request( + "anthropic.claude-sonnet-4-6", messages, None, None, @@ -511,6 +527,7 @@ fn test_caching_disabled_no_cache_points() { }]; let result = build_converse_request( + "anthropic.claude-sonnet-4-6", messages, Some("System prompt".to_string()), None, @@ -570,6 +587,7 @@ fn test_caching_enabled_has_cache_points() { }]; let result = build_converse_request( + "anthropic.claude-sonnet-4-6", messages, Some("System prompt".to_string()), None, @@ -608,3 +626,193 @@ fn test_caching_enabled_has_cache_points() { assert!(has_tool_cache, "Tools should have cache point"); } } + +fn reasoning_test_request(model_id: &str, messages: Vec) -> ConvertedRequest { + build_converse_request( + model_id, + messages, + None, + None, + vec![], + 4096, + None, + None, + None, + CachingConfig { + enabled: false, + extended_ttl_requested: false, + }, + ) +} + +#[test] +fn unsupported_reasoning_history_preserves_visible_content_images_and_tool_pairs() { + for model_id in [ + "us.deepseek.r1-v1:0", + "amazon.nova-2-lite-v1:0", + "openai.gpt-oss-120b-1:0", + "qwen.qwen3-32b-v1:0", + "anthropic.claude-3-5-sonnet-20241022-v2:0", + ] { + let result = reasoning_test_request( + model_id, + vec![ + ConversationMessage { + role: MessageRole::User, + content: MessageContent::MultiPart(vec![ + ContentPart::Text("Inspect this image".to_owned()), + ContentPart::Image { + data: vec![1, 2, 3, 4], + mime_type: "image/png".to_owned(), + }, + ]), + }, + ConversationMessage { + role: MessageRole::Assistant, + content: MessageContent::MultiPart(vec![ + ContentPart::Reasoning { + text: "Display-only reasoning".to_owned(), + signature: None, + }, + ContentPart::Reasoning { + text: "Reasoning from an earlier Claude turn".to_owned(), + signature: Some("original-claude-signature".to_owned()), + }, + ContentPart::Text("I will inspect the manifest.".to_owned()), + ContentPart::ToolUse { + tool_use_id: "manifest-call".to_owned(), + name: "read_files".to_owned(), + input: json!({"files": ["Cargo.toml"]}), + }, + ]), + }, + ConversationMessage { + role: MessageRole::User, + content: MessageContent::ToolResult { + tool_use_id: "manifest-call".to_owned(), + content: "[package]".to_owned(), + is_error: false, + }, + }, + ConversationMessage { + role: MessageRole::Assistant, + content: MessageContent::MultiPart(vec![ContentPart::Reasoning { + text: "No visible assistant content".to_owned(), + signature: None, + }]), + }, + ConversationMessage { + role: MessageRole::User, + content: MessageContent::Text("Continue".to_owned()), + }, + ], + ); + assert_eq!(result.messages.len(), 3, "model: {model_id}"); + assert!(result.messages.iter().all(|message| { + !message.content().is_empty() + && message + .content() + .iter() + .all(|part| !matches!(part, ContentBlock::ReasoningContent(_))) + })); + let user_content = result.messages[0].content(); + assert!( + matches!(&user_content[0], ContentBlock::Text(text) if text == "Inspect this image") + ); + let ContentBlock::Image(image) = &user_content[1] else { + panic!("the image must survive reasoning filtering"); + }; + assert_eq!( + image.source().unwrap().as_bytes().unwrap().as_ref(), + &[1, 2, 3, 4] + ); + let assistant_content = result.messages[1].content(); + assert_eq!(assistant_content.len(), 2); + assert!( + matches!(&assistant_content[0], ContentBlock::Text(text) if text == "I will inspect the manifest.") + ); + let ContentBlock::ToolUse(tool) = &assistant_content[1] else { + panic!("the tool call must survive reasoning filtering"); + }; + assert_eq!(tool.tool_use_id(), "manifest-call"); + assert_eq!(tool.name(), "read_files"); + let final_user_content = result.messages[2].content(); + let ContentBlock::ToolResult(tool_result) = &final_user_content[0] else { + panic!("the paired tool result must survive reasoning filtering"); + }; + assert_eq!(tool_result.tool_use_id(), "manifest-call"); + assert!(matches!(&final_user_content[1], ContentBlock::Text(text) if text == "Continue")); + } +} + +#[test] +fn signed_claude_reasoning_keeps_exact_text_signatures_and_tool_order() { + use aws_sdk_bedrockruntime::types::ReasoningContentBlock; + + let result = reasoning_test_request( + "global.anthropic.claude-sonnet-4-6[1m]", + vec![ConversationMessage { + role: MessageRole::Assistant, + content: MessageContent::MultiPart(vec![ + ContentPart::Reasoning { + text: "Exact signed thinking\nincluding whitespace.\n".to_owned(), + signature: Some("first-signature".to_owned()), + }, + ContentPart::Reasoning { + text: String::new(), + signature: Some("signature-only-block".to_owned()), + }, + ContentPart::ToolUse { + tool_use_id: "signed-call".to_owned(), + name: "read_files".to_owned(), + input: json!({"files": ["Cargo.toml"]}), + }, + ]), + }], + ); + assert_eq!(result.messages.len(), 1); + let parts = result.messages[0].content(); + assert_eq!(parts.len(), 3); + for (index, text, signature) in [ + ( + 0, + "Exact signed thinking\nincluding whitespace.\n", + "first-signature", + ), + (1, "", "signature-only-block"), + ] { + let ContentBlock::ReasoningContent(ReasoningContentBlock::ReasoningText(reasoning)) = + &parts[index] + else { + panic!("signed Claude reasoning must retain its native block"); + }; + assert_eq!(reasoning.text(), text); + assert_eq!(reasoning.signature(), Some(signature)); + } + assert!( + matches!(&parts[2], ContentBlock::ToolUse(tool) if tool.tool_use_id() == "signed-call") + ); +} + +#[test] +fn unsigned_reasoning_is_not_replayed_even_to_claude() { + for signature in [None, Some(String::new()), Some(" ".to_owned())] { + let result = reasoning_test_request( + "anthropic.claude-sonnet-4-6", + vec![ConversationMessage { + role: MessageRole::Assistant, + content: MessageContent::MultiPart(vec![ + ContentPart::Reasoning { + text: "Unsigned reasoning cannot be replayed as Claude thinking".to_owned(), + signature, + }, + ContentPart::Text("Visible answer".to_owned()), + ]), + }], + ); + assert_eq!(result.messages.len(), 1); + assert!( + matches!(result.messages[0].content(), [ContentBlock::Text(text)] if text == "Visible answer") + ); + } +} diff --git a/app/src/ai/runtime/rig.rs b/app/src/ai/runtime/rig.rs index 48b4eaba..855b2823 100644 --- a/app/src/ai/runtime/rig.rs +++ b/app/src/ai/runtime/rig.rs @@ -495,18 +495,24 @@ pub(crate) async fn provider_runtime_for_request( let cross_region_inference = config.invocation_model_id.is_none() && config.cross_region_inference; let caching_config = CachingConfig::from_external_config(&ExternalBedrockConfig::load()).for_model(&model); - let invocation_model = config.invocation_model_id.clone().unwrap_or(model); + let invocation_model = config + .invocation_model_id + .clone() + .unwrap_or_else(|| model.clone()); let client = BedrockClient::from_config(config).await?; - Arc::new(BedrockRuntime::from_aws_client( - client.runtime_client(), - BedrockRigConfig { - model: invocation_model, - region: client.region().to_string(), - cross_region_inference, - prompt_caching: caching_config.enabled, - max_output_tokens, - }, - )?) + Arc::new( + BedrockRuntime::from_aws_client( + client.runtime_client(), + BedrockRigConfig { + model: invocation_model, + region: client.region().to_string(), + cross_region_inference, + prompt_caching: caching_config.enabled, + max_output_tokens, + }, + )? + .with_reasoning_model(model), + ) } crate::ai::provider::ProviderConfig::None => { anyhow::bail!( diff --git a/crates/galaxy_agent_rig/src/bedrock.rs b/crates/galaxy_agent_rig/src/bedrock.rs index 96504fba..16658838 100644 --- a/crates/galaxy_agent_rig/src/bedrock.rs +++ b/crates/galaxy_agent_rig/src/bedrock.rs @@ -1,8 +1,9 @@ use async_trait::async_trait; use aws_sdk_bedrockruntime::Client as AwsBedrockClient; use galaxy_agent_core::{ - AgentError, AgentErrorKind, AgentEventStream, AgentRuntime, RuntimeCapabilities, - RuntimeDescriptor, RuntimeKind, TurnControl, TurnRequest, + AgentError, AgentErrorKind, AgentEventStream, AgentRuntime, ContentPart, ConversationMessage, + MessageContent, MessageRole, RuntimeCapabilities, RuntimeDescriptor, RuntimeKind, TurnControl, + TurnRequest, }; use rig_bedrock::client::Client as RigBedrockClient; use rig_bedrock::completion::CompletionModel; @@ -33,6 +34,7 @@ pub struct BedrockRuntime { client: RigBedrockClient, config: BedrockRigConfig, resolved_model: String, + reasoning_model: String, descriptor: RuntimeDescriptor, } @@ -53,6 +55,7 @@ impl BedrockRuntime { Ok(Self { client: RigBedrockClient::from(client), config, + reasoning_model: resolved_model.clone(), resolved_model, descriptor, }) @@ -62,6 +65,13 @@ impl BedrockRuntime { &self.resolved_model } + /// Use the discovered foundation model's capabilities when invocation targets an + /// opaque application inference profile rather than a foundation-model ID. + pub fn with_reasoning_model(mut self, model: impl Into) -> Self { + self.reasoning_model = model.into(); + self + } + pub fn completion_model(&self) -> CompletionModel { let model = self.client.completion_model(&self.resolved_model); if self.config.prompt_caching { @@ -84,8 +94,11 @@ impl AgentRuntime for BedrockRuntime { control: TurnControl, ) -> Result { let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens); - let mut completion_request = - build_bedrock_completion_request(request, self.config.max_output_tokens)?; + let mut completion_request = build_bedrock_completion_request_for_model( + request, + self.config.max_output_tokens, + &self.reasoning_model, + )?; // Context markers and inference-profile expansion are Galaxy model // configuration, not identifiers that Rig should send unchanged. completion_request.model = Some(self.resolved_model.clone()); @@ -103,9 +116,74 @@ pub fn build_bedrock_completion_request( request: TurnRequest, configured_max_output_tokens: Option, ) -> Result { + let model = request.model.as_str().to_owned(); + build_bedrock_completion_request_for_model(request, configured_max_output_tokens, &model) +} + +fn build_bedrock_completion_request_for_model( + mut request: TurnRequest, + configured_max_output_tokens: Option, + model: &str, +) -> Result { + request.messages = prepare_bedrock_history(model, request.messages); build_completion_request(request, configured_max_output_tokens, true, true, None) } +/// Project durable history into Bedrock's input format without changing the saved +/// transcript. Reasoning output is not universally accepted as reasoning input: +/// DeepSeek, Nova and other providers return unsigned, display-only reasoning. +/// Claude tool continuations need their complete signed blocks unchanged. +pub fn prepare_bedrock_history( + model_id: &str, + messages: Vec, +) -> Vec { + let supports_signed_reasoning = accepts_signed_reasoning(model_id); + messages + .into_iter() + .filter_map(|mut message| { + if let MessageContent::MultiPart(parts) = &mut message.content { + parts.retain(|part| match part { + ContentPart::Reasoning { signature, .. } => { + message.role == MessageRole::Assistant + && supports_signed_reasoning + && signature + .as_ref() + .is_some_and(|signature| !signature.trim().is_empty()) + } + ContentPart::Text(_) + | ContentPart::Image { .. } + | ContentPart::ToolUse { .. } + | ContentPart::ToolResult { .. } => true, + }); + if parts.is_empty() { + return None; + } + } + Some(message) + }) + .collect() +} + +fn accepts_signed_reasoning(model_id: &str) -> bool { + let model_id = model_id.trim(); + // An opaque application profile may point to Claude. Preserve its signed + // blocks when no foundation-model hint is available; unsigned output is + // still omitted. Production discovery supplies that hint to the runtime. + if model_id.starts_with("arn:") && model_id.contains(":application-inference-profile/") { + return true; + } + let model = strip_context_marker(model_id.rsplit('/').next().unwrap_or(model_id)); + let model = INFERENCE_PROFILE_PREFIXES + .iter() + .find_map(|prefix| model.strip_prefix(prefix)) + .unwrap_or(model); + model.starts_with("anthropic.claude-") + && !model.starts_with("anthropic.claude-instant-") + && !model.starts_with("anthropic.claude-v1") + && !model.starts_with("anthropic.claude-v2") + && (!model.starts_with("anthropic.claude-3-") || model.starts_with("anthropic.claude-3-7-")) +} + pub fn resolve_bedrock_model_id( configured_model: &str, region: &str, diff --git a/crates/galaxy_agent_rig/src/bedrock_tests.rs b/crates/galaxy_agent_rig/src/bedrock_tests.rs index 6982a487..215d43e1 100644 --- a/crates/galaxy_agent_rig/src/bedrock_tests.rs +++ b/crates/galaxy_agent_rig/src/bedrock_tests.rs @@ -177,7 +177,7 @@ async fn cancellation_before_bedrock_stream_start_never_contacts_aws() { #[test] fn bedrock_request_preserves_system_image_reasoning_tool_and_token_semantics() { let mut request = TurnRequest::new( - "anthropic.claude-test", + "anthropic.claude-sonnet-4-6", vec![ ConversationMessage { role: MessageRole::User, @@ -278,3 +278,151 @@ fn bedrock_request_preserves_system_image_reasoning_tool_and_token_semantics() { Some(ToolResultContent::Text(text)) if text.text == "[ERROR] permission denied" )); } + +#[test] +fn bedrock_follow_ups_omit_unsigned_reasoning_without_losing_tool_calls_or_results() { + for model in [ + "deepseek.r1-v1:0", + "us.amazon.nova-2-lite-v1:0", + "openai.gpt-oss-120b-1:0", + "qwen.qwen3-32b-v1:0", + "anthropic.claude-sonnet-4-6", + ] { + let request = TurnRequest::new( + model, + vec![ + ConversationMessage { + role: MessageRole::User, + content: MessageContent::Text("Inspect the project".to_owned()), + }, + ConversationMessage { + role: MessageRole::Assistant, + content: MessageContent::MultiPart(vec![ + ContentPart::Reasoning { + text: "display-only reasoning".to_owned(), + signature: None, + }, + ContentPart::Text("Reading the manifest".to_owned()), + ContentPart::ToolUse { + tool_use_id: "manifest-call".to_owned(), + name: "read_files".to_owned(), + input: serde_json::json!({"files": ["Cargo.toml"]}), + }, + ]), + }, + ConversationMessage { + role: MessageRole::User, + content: MessageContent::ToolResult { + tool_use_id: "manifest-call".to_owned(), + content: "manifest contents".to_owned(), + is_error: false, + }, + }, + ], + ); + let converted = build_bedrock_completion_request(request, None).unwrap(); + let messages = converted.chat_history.iter().collect::>(); + let [ + _, + Message::Assistant { content, .. }, + Message::User { content: result }, + ] = messages.as_slice() + else { + panic!("expected the tool exchange for {model}"); + }; + assert!(matches!(content.iter().collect::>().as_slice(), + [AssistantContent::Text(text), AssistantContent::ToolCall(call)] + if text.text == "Reading the manifest" && call.id == "manifest-call" + )); + assert!( + matches!(result.iter().next(), Some(UserContent::ToolResult(result)) + if result.id == "manifest-call") + ); + } +} + +#[test] +fn bedrock_model_switch_drops_foreign_signed_reasoning_and_empty_messages() { + for model in [ + "amazon.nova-pro-v1:0", + "us.deepseek.r1-v1:0", + "openai.gpt-oss-120b-1:0", + "anthropic.claude-3-5-sonnet-20241022-v2:0", + "anthropic.claude-3-haiku-20240307-v1:0", + "anthropic.claude-instant-v1", + "anthropic.claude-v1", + "anthropic.claude-v2:1", + ] { + let messages = vec![ + ConversationMessage { + role: MessageRole::User, + content: MessageContent::Text("Question".to_owned()), + }, + ConversationMessage { + role: MessageRole::Assistant, + content: MessageContent::MultiPart(vec![ContentPart::Reasoning { + text: "Earlier thinking".to_owned(), + signature: Some("original-signature".to_owned()), + }]), + }, + ConversationMessage { + role: MessageRole::Assistant, + content: MessageContent::MultiPart(vec![ + ContentPart::Reasoning { + text: "Earlier thinking".to_owned(), + signature: Some("original-signature".to_owned()), + }, + ContentPart::Text("Visible answer".to_owned()), + ]), + }, + ]; + // The runtime's selected model takes precedence over a historical request model. + let request = TurnRequest::new("anthropic.claude-sonnet-4-6", messages); + let converted = build_bedrock_completion_request_for_model(request, None, model).unwrap(); + assert_eq!(converted.chat_history.len(), 2); + assert!( + matches!(converted.chat_history.iter().nth(1), Some(Message::Assistant { content, .. }) + if matches!(content.iter().collect::>().as_slice(), [AssistantContent::Text(text)] if text.text == "Visible answer")) + ); + } +} + +#[test] +fn bedrock_preserves_signed_claude_blocks_exactly_across_identifier_forms() { + let signed = ConversationMessage { + role: MessageRole::Assistant, + content: MessageContent::MultiPart(vec![ + ContentPart::Reasoning { + text: "Unmodified thinking".to_owned(), + signature: Some("signed-text".to_owned()), + }, + ContentPart::Reasoning { + text: String::new(), + signature: Some("signature-only".to_owned()), + }, + ContentPart::Text("Answer".to_owned()), + ]), + }; + for model in [ + "anthropic.claude-3-7-sonnet-20250219-v1:0", + "global.anthropic.claude-sonnet-4-6[1m]", + "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6", + "arn:aws:bedrock:us-east-1:123456789012:inference-profile/us.anthropic.claude-sonnet-4-6", + "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/opaque-profile", + ] { + assert_eq!( + prepare_bedrock_history(model, vec![signed.clone()]), + vec![signed.clone()] + ); + } + for signature in [None, Some(String::new()), Some(" ".to_owned())] { + let message = ConversationMessage { + role: MessageRole::Assistant, + content: MessageContent::MultiPart(vec![ContentPart::Reasoning { + text: "Display only".to_owned(), + signature, + }]), + }; + assert!(prepare_bedrock_history("anthropic.claude-sonnet-4-6", vec![message]).is_empty()); + } +}