Fix child startup readiness and ordered provider tools

Wait for shell bootstrap before dispatching child prompts, serialize provider preprocessing, and make permission callbacks idempotent. Restrict task lists to concrete multistep plans and stop inferring completion from tool activity.

Update regression fixtures and resolve existing lint and test-layout failures. Verified formatting, both presubmit Clippy commands, and 354 targeted nextest tests.
This commit is contained in:
2026-09-09 13:55:34 -05:00
parent 4b703c019f
commit 1845b9e20e
41 changed files with 1097 additions and 895 deletions
@@ -386,7 +386,6 @@ impl CodebaseIndex {
repository: ModelHandle<Repository>,
store_client: Arc<dyn StoreClient>,
embedding_config: EmbeddingConfig,
max_files_repo_limit: usize,
embedding_generation_batch_size: usize,
ctx: &mut ModelContext<Self>,
) -> Self {
@@ -398,7 +397,7 @@ impl CodebaseIndex {
ctx,
);
if let Err(err) = index.build_and_sync_from_repository_root(max_files_repo_limit, ctx) {
if let Err(err) = index.build_and_sync_from_repository_root(ctx) {
safe_error!(
safe: ("Failed to build index: {err:?}"),
full: ("Failed to build index at root {}: {err:?}", index.repo_path.display())
@@ -803,7 +802,6 @@ impl CodebaseIndex {
#[cfg(feature = "local_fs")]
fn build_and_sync_from_repository_root(
&mut self,
max_files_repo_limit: usize,
ctx: &mut ModelContext<'_, Self>,
) -> Result<(), Error> {
let repo_path = self.repo_path.clone();
@@ -1127,13 +1125,9 @@ impl CodebaseIndex {
/// Performs a full reparse of the merkle tree, followed by a full server sync. This force evicts the
/// existing merkle tree state.
#[cfg(feature = "local_fs")]
pub(super) fn full_sync_index(
&mut self,
max_files_repo_limit: usize,
ctx: &mut ModelContext<Self>,
) -> Result<(), Error> {
pub(super) fn full_sync_index(&mut self, ctx: &mut ModelContext<Self>) -> Result<(), Error> {
self.update_tree_sync_state(TreeSourceSyncState::unsynced(), ctx);
self.build_and_sync_from_repository_root(max_files_repo_limit, ctx)
self.build_and_sync_from_repository_root(ctx)
}
/// Attempt to perform a full SERVER sync on the current index. We only proceed with the sync if there is
@@ -1328,7 +1322,6 @@ impl CodebaseIndex {
#[cfg(not(feature = "local_fs"))]
pub fn build_and_sync_from_repository_root(
&mut self,
_max_num_files_limit: usize,
_ctx: &mut ModelContext<'_, Self>,
) -> Result<(), Error> {
Err(Error::UnsupportedPlatform)
@@ -1705,7 +1698,6 @@ impl CodebaseIndex {
store_client: Arc<dyn StoreClient>,
embedding_config: EmbeddingConfig,
snapshot_bytes: Vec<u8>,
max_files_repo_limit: usize,
embedding_generation_batch_size: usize,
ctx: &mut ModelContext<Self>,
) -> Result<Self, Error> {
@@ -1716,7 +1708,7 @@ impl CodebaseIndex {
embedding_generation_batch_size,
ctx,
);
index.rebuild_and_sync_from_snapshot(snapshot_bytes, max_files_repo_limit, ctx);
index.rebuild_and_sync_from_snapshot(snapshot_bytes, ctx);
Ok(index)
}
@@ -1724,7 +1716,6 @@ impl CodebaseIndex {
fn rebuild_and_sync_from_snapshot(
&mut self,
snapshot_bytes: Vec<u8>,
max_files_repo_limit: usize,
ctx: &mut ModelContext<'_, Self>,
) {
let repo_metadata = RepoMetadata {
@@ -1756,7 +1747,6 @@ impl CodebaseIndex {
let (changed_files, gitignores) = Self::diff_filesystem_with_tree(
repo_path.clone(),
&tree,
max_files_repo_limit,
)
.map_err(SnapshotLoadError::DiffFailed)?;
@@ -1922,7 +1912,6 @@ impl CodebaseIndex {
fn diff_filesystem_with_tree(
repo_path: PathBuf,
tree: &MerkleTree,
max_files_repo_limit: usize,
) -> Result<(ChangedFiles, Vec<Gitignore>), Error> {
let mut gitignores = Self::construct_initial_ignores(&repo_path);
@@ -723,7 +723,7 @@ impl CodebaseIndexManager {
))
) {
index.update(_ctx, |code_index, ctx| {
let _ = code_index.full_sync_index(self.max_files_repo_limit, ctx);
let _ = code_index.full_sync_index(ctx);
});
}
}
@@ -940,7 +940,6 @@ impl CodebaseIndexManager {
let index = Self::build_and_sync_codebase_index_internal(
self.store_client.clone(),
handle,
self.max_files_repo_limit,
self.embedding_generation_batch_size,
#[cfg(feature = "local_fs")]
snapshot_storage,
@@ -967,7 +966,6 @@ impl CodebaseIndexManager {
fn build_and_sync_codebase_index_internal(
store_client: Arc<dyn StoreClient>,
repository: ModelHandle<Repository>,
max_files_repo_limit: usize,
embedding_generation_batch_size: usize,
#[cfg(feature = "local_fs")] snapshot_storage: Option<SnapshotStorage>,
ctx: &mut ModelContext<Self>,
@@ -991,7 +989,6 @@ impl CodebaseIndexManager {
store_client.clone(),
snapshot_storage.path(),
repository.clone(),
max_files_repo_limit,
embedding_generation_batch_size,
ctx,
) {
@@ -1020,7 +1017,6 @@ impl CodebaseIndexManager {
repository,
store_client,
EmbeddingConfig::default(),
max_files_repo_limit,
embedding_generation_batch_size,
ctx,
)
@@ -1185,7 +1181,7 @@ impl CodebaseIndexManager {
};
codebase_index.update(ctx, |index, ctx| {
let _ = index.full_sync_index(self.max_files_repo_limit, ctx);
let _ = index.full_sync_index(ctx);
})
}
@@ -157,7 +157,6 @@ pub(super) fn read_snapshot(
store_client: Arc<dyn StoreClient>,
snapshot_dir: &Path,
repository: ModelHandle<Repository>,
max_files_repo_limit: usize,
embedding_generation_batch_size: usize,
ctx: &mut ModelContext<CodebaseIndex>,
) -> anyhow::Result<CodebaseIndex> {
@@ -171,7 +170,6 @@ pub(super) fn read_snapshot(
store_client.clone(),
EmbeddingConfig::default(),
snapshot_bytes,
max_files_repo_limit,
embedding_generation_batch_size,
ctx,
);
@@ -674,6 +674,7 @@ async fn acquire_repository_lock(storage_root: &Path, root_path: &Path) -> Resul
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.with_context(|| format!("Failed to open local index lock {}", lock_path.display()))?;
loop {
@@ -1044,6 +1044,9 @@ impl ProviderRun {
call.state = PendingToolCallState::PermissionPending { request };
Ok(())
}
PendingToolCallState::PermissionPending { request: pending } if pending == &request => {
Ok(())
}
PendingToolCallState::Resolved { .. } => {
Err(ProviderRunProtocolError::DuplicateToolUpdate {
call_id: call.call.id.clone(),
@@ -1068,6 +1071,12 @@ impl ProviderRun {
let call = self.pending_tool_call_mut(work_id, call_id)?;
let pending_request_id = match &call.state {
PendingToolCallState::PermissionPending { request } => request.id.clone(),
PendingToolCallState::Approved {
request_id: approved_request_id,
decision: approved_decision,
} if approved_request_id == request_id && approved_decision == &decision => {
return Ok(());
}
PendingToolCallState::Resolved { .. } => {
return Err(ProviderRunProtocolError::DuplicateToolUpdate {
call_id: call.call.id.clone(),
@@ -528,6 +528,61 @@ fn exact_batch_submission_rejects_missing_duplicate_and_unknown_results_without_
assert_eq!(run, before);
}
#[test]
fn repeated_permission_notifications_preserve_pending_sibling_tools() {
let mut run = run();
let batch = accept_tool_turn(
&mut run,
tool_turn(
vec![
tool_call("first", "run_shell_command"),
tool_call("second", "run_shell_command"),
],
&["run_shell_command"],
),
);
let request = PermissionRequest {
id: "permission-first".to_string(),
call_id: "first".to_string(),
kind: PermissionKind::Execute,
reason: Some("run a command".to_string()),
};
run.request_tool_permission(&batch.work_id, request.clone())
.unwrap();
run.request_tool_permission(&batch.work_id, request.clone())
.unwrap();
let mut conflicting = request;
conflicting.id = "different-permission".to_string();
assert!(
run.request_tool_permission(&batch.work_id, conflicting)
.is_err()
);
for _ in 0..2 {
run.resolve_tool_permission(
&batch.work_id,
"first",
"permission-first",
PermissionDecision::AllowOnce,
)
.unwrap();
}
assert!(
run.resolve_tool_permission(
&batch.work_id,
"first",
"permission-first",
PermissionDecision::AlwaysAllow,
)
.is_err()
);
run.start_tool(&batch.work_id, "first").unwrap();
let ProviderRunState::AwaitingTools { batch } = run.state() else {
panic!("permission notifications must leave the batch pending");
};
assert_eq!(batch.calls[0].state, PendingToolCallState::Executing);
assert_eq!(batch.calls[1].state, PendingToolCallState::Proposed);
}
#[test]
fn permission_denial_becomes_one_correlated_result() {
let mut run = run();
+2 -119
View File
@@ -144,122 +144,5 @@ impl AgentRuntime for ChatGPTSubscriptionRuntime {
}
#[cfg(test)]
mod tests {
use rig_core::client::CompletionClient;
use rig_core::completion::{AssistantContent, CompletionModel, Message};
use rig_core::message::{ToolResultContent, UserContent};
use rig_core::providers::chatgpt::ChatGPTAuth;
use rig_core::test_utils::RecordingHttpClient;
use super::*;
const COMPLETED_RESPONSE: &str = r#"data: {"type":"response.output_text.delta","delta":"ok"}
data: {"type":"response.completed","response":{"id":"resp_test","object":"response","created_at":1,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-5.3-codex","usage":{"input_tokens":1,"input_tokens_details":{"cached_tokens":0},"output_tokens":1,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":2},"output":[{"type":"message","id":"msg_test","status":"completed","role":"assistant","content":[{"type":"output_text","annotations":[],"text":"ok"}]}],"tools":[]}}
data: [DONE]"#;
#[test]
fn runtime_descriptor_identifies_chatgpt_subscription() {
let runtime = ChatGPTSubscriptionRuntime::new(ChatGPTSubscriptionRuntimeConfig {
model: "gpt-5.3-codex".to_string(),
reasoning_effort: Some("high".to_string()),
max_output_tokens: None,
auth_file: None,
});
assert_eq!(
runtime.descriptor().id,
"rig-chatgpt-subscription:gpt-5.3-codex"
);
}
#[test]
fn reasoning_effort_is_encoded_for_the_responses_request() {
let request = TurnRequest::new(
"gpt-5.4".to_string(),
vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("hello".to_string()),
}],
);
let request = build_completion_request(
request,
None,
true,
false,
reasoning_additional_params(Some("xhigh")),
)
.expect("request should convert");
assert_eq!(
request.additional_params,
reasoning_additional_params(Some("xhigh"))
);
assert_eq!(
reasoning_additional_params(Some("ultra")),
Some(serde_json::json!({
"reasoning": { "effort": "max" }
}))
);
}
#[tokio::test]
async fn chatgpt_follow_up_request_preserves_responses_call_ids() {
let http_client = RecordingHttpClient::new(COMPLETED_RESPONSE);
let client = chatgpt::Client::builder()
.api_key(ChatGPTAuth::AccessToken {
access_token: "test-token".to_string(),
account_id: None,
})
.http_client(http_client.clone())
.build()
.expect("client should build");
let model = client.completion_model("gpt-5.3-codex");
let assistant_tool_call = AssistantContent::tool_call_with_call_id(
"fc_native_1",
"call_native_1".to_string(),
"read_files",
serde_json::json!({"files": ["Cargo.toml"]}),
);
let tool_result = UserContent::tool_result_with_call_id(
"fc_native_1",
"call_native_1".to_string(),
rig_core::OneOrMany::one(ToolResultContent::text("contents")),
);
let chat_history = rig_core::OneOrMany::many(vec![
Message::Assistant {
id: None,
content: rig_core::OneOrMany::one(assistant_tool_call),
},
Message::User {
content: rig_core::OneOrMany::one(tool_result),
},
Message::user("Continue."),
])
.expect("history should contain messages");
model
.completion(rig_core::completion::CompletionRequest {
model: Some("gpt-5.3-codex".to_string()),
preamble: None,
chat_history,
documents: Vec::new(),
tools: Vec::new(),
temperature: None,
max_tokens: None,
tool_choice: None,
additional_params: None,
output_schema: None,
record_telemetry_content: false,
})
.await
.expect("request should reach the mocked provider");
let requests = http_client.requests();
assert_eq!(requests.len(), 1);
let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap();
let input = body["input"].as_array().expect("input should be an array");
assert_eq!(input[0]["call_id"], "call_native_1");
assert_eq!(input[1]["call_id"], "call_native_1");
}
}
#[path = "chatgpt_tests.rs"]
mod tests;
@@ -0,0 +1,117 @@
use rig_core::client::CompletionClient;
use rig_core::completion::{AssistantContent, CompletionModel, Message};
use rig_core::message::{ToolResultContent, UserContent};
use rig_core::providers::chatgpt::ChatGPTAuth;
use rig_core::test_utils::RecordingHttpClient;
use super::*;
const COMPLETED_RESPONSE: &str = r#"data: {"type":"response.output_text.delta","delta":"ok"}
data: {"type":"response.completed","response":{"id":"resp_test","object":"response","created_at":1,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-5.3-codex","usage":{"input_tokens":1,"input_tokens_details":{"cached_tokens":0},"output_tokens":1,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":2},"output":[{"type":"message","id":"msg_test","status":"completed","role":"assistant","content":[{"type":"output_text","annotations":[],"text":"ok"}]}],"tools":[]}}
data: [DONE]"#;
#[test]
fn runtime_descriptor_identifies_chatgpt_subscription() {
let runtime = ChatGPTSubscriptionRuntime::new(ChatGPTSubscriptionRuntimeConfig {
model: "gpt-5.3-codex".to_string(),
reasoning_effort: Some("high".to_string()),
max_output_tokens: None,
auth_file: None,
});
assert_eq!(
runtime.descriptor().id,
"rig-chatgpt-subscription:gpt-5.3-codex"
);
}
#[test]
fn reasoning_effort_is_encoded_for_the_responses_request() {
let request = TurnRequest::new(
"gpt-5.4".to_string(),
vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("hello".to_string()),
}],
);
let request = build_completion_request(
request,
None,
true,
false,
reasoning_additional_params(Some("xhigh")),
)
.expect("request should convert");
assert_eq!(
request.additional_params,
reasoning_additional_params(Some("xhigh"))
);
assert_eq!(
reasoning_additional_params(Some("ultra")),
Some(serde_json::json!({
"reasoning": { "effort": "max" }
}))
);
}
#[tokio::test]
async fn chatgpt_follow_up_request_preserves_responses_call_ids() {
let http_client = RecordingHttpClient::new(COMPLETED_RESPONSE);
let client = chatgpt::Client::builder()
.api_key(ChatGPTAuth::AccessToken {
access_token: "test-token".to_string(),
account_id: None,
})
.http_client(http_client.clone())
.build()
.expect("client should build");
let model = client.completion_model("gpt-5.3-codex");
let assistant_tool_call = AssistantContent::tool_call_with_call_id(
"fc_native_1",
"call_native_1".to_string(),
"read_files",
serde_json::json!({"files": ["Cargo.toml"]}),
);
let tool_result = UserContent::tool_result_with_call_id(
"fc_native_1",
"call_native_1".to_string(),
rig_core::OneOrMany::one(ToolResultContent::text("contents")),
);
let chat_history = rig_core::OneOrMany::many(vec![
Message::Assistant {
id: None,
content: rig_core::OneOrMany::one(assistant_tool_call),
},
Message::User {
content: rig_core::OneOrMany::one(tool_result),
},
Message::user("Continue."),
])
.expect("history should contain messages");
model
.completion(rig_core::completion::CompletionRequest {
model: Some("gpt-5.3-codex".to_string()),
preamble: None,
chat_history,
documents: Vec::new(),
tools: Vec::new(),
temperature: None,
max_tokens: None,
tool_choice: None,
additional_params: None,
output_schema: None,
record_telemetry_content: false,
})
.await
.expect("request should reach the mocked provider");
let requests = http_client.requests();
assert_eq!(requests.len(), 1);
let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap();
let input = body["input"].as_array().expect("input should be an array");
assert_eq!(input[0]["call_id"], "call_native_1");
assert_eq!(input[1]["call_id"], "call_native_1");
}
+2 -11
View File
@@ -243,14 +243,5 @@ fn native_descriptor(provider: &str, model: &str) -> RuntimeDescriptor {
}
#[cfg(test)]
mod tests {
use super::native_descriptor;
#[test]
fn native_descriptors_are_provider_specific() {
let descriptor = native_descriptor("anthropic", "claude-sonnet");
assert_eq!(descriptor.id, "rig-anthropic:claude-sonnet");
assert_eq!(descriptor.display_name, "Rig / anthropic / claude-sonnet");
}
}
#[path = "native_tests.rs"]
mod tests;
@@ -0,0 +1,9 @@
use super::native_descriptor;
#[test]
fn native_descriptors_are_provider_specific() {
let descriptor = native_descriptor("anthropic", "claude-sonnet");
assert_eq!(descriptor.id, "rig-anthropic:claude-sonnet");
assert_eq!(descriptor.display_name, "Rig / anthropic / claude-sonnet");
}
+2 -116
View File
@@ -519,122 +519,8 @@ fn text_indicates_authentication_failure(text: &str) -> bool {
}
#[cfg(test)]
mod tests {
use galaxy_agent_core::{AgentErrorKind, StopReason};
use rig_core::completion::CompletionError;
use super::{
completion_error_stop_reason, domain_tool_call, is_keepalive_event, map_completion_error,
};
#[test]
fn domain_tool_call_prefers_responses_call_id() {
let tool_call = rig_core::message::ToolCall::new(
"fc_item_123".to_string(),
rig_core::message::ToolFunction {
name: "read_files".to_string(),
arguments: serde_json::json!({"files": ["Cargo.toml"]}),
},
)
.with_call_id("call_123".to_string());
let call = domain_tool_call(tool_call);
assert_eq!(call.id, "call_123");
assert_eq!(call.name, "read_files");
}
#[test]
fn domain_tool_call_falls_back_to_wire_id_without_responses_call_id() {
let tool_call = rig_core::message::ToolCall::new(
"fc_item_123".to_string(),
rig_core::message::ToolFunction {
name: "read_files".to_string(),
arguments: serde_json::json!({"files": ["Cargo.toml"]}),
},
);
let call = domain_tool_call(tool_call);
assert_eq!(call.id, "fc_item_123");
assert_eq!(call.name, "read_files");
}
#[test]
fn provider_context_window_error_maps_to_semantic_stop_reason_and_kind() {
let status = rig_core::http_client::Response::builder()
.status(400)
.body(())
.unwrap()
.status();
let error = CompletionError::from_http_response(
status,
r#"{"error":{"message":"Your input exceeds the context window of this model. Please adjust your input and try again.","code":"400"}}"#,
);
assert_eq!(
completion_error_stop_reason(&error),
Some(StopReason::ContextWindowExceeded)
);
let mapped = map_completion_error(error);
assert_eq!(mapped.kind, AgentErrorKind::ContextWindowExceeded);
assert!(!mapped.recoverable);
}
#[test]
fn provider_context_length_string_maps_to_semantic_stop_reason() {
let error = CompletionError::ProviderError(
"context_length_exceeded: maximum context length is 128000 tokens".to_string(),
);
assert_eq!(
completion_error_stop_reason(&error),
Some(StopReason::ContextWindowExceeded)
);
}
#[test]
fn provider_keepalive_event_is_a_transport_heartbeat() {
assert!(is_keepalive_event(&serde_json::json!({
"type": "keepalive",
"sequence_number": 3,
})));
assert!(!is_keepalive_event(&serde_json::json!({
"type": "unsupported",
})));
}
#[test]
fn provider_server_error_is_recoverable() {
let status = rig_core::http_client::Response::builder()
.status(503)
.body(())
.unwrap()
.status();
let error = CompletionError::from_http_response(
status,
r#"{"error":{"message":"Service temporarily unavailable"}}"#,
);
let mapped = map_completion_error(error);
assert_eq!(mapped.kind, AgentErrorKind::Provider);
assert!(mapped.recoverable);
}
#[test]
fn flattened_bedrock_credential_errors_are_authentication_failures() {
for message in [
r#"{"__type":"ExpiredTokenException","message":"The security token included in the request is expired"}"#,
"UnrecognizedClientException: The security token included in the request is invalid",
"AccessDeniedException: not authorized to perform bedrock:ConverseStream",
] {
let mapped = map_completion_error(CompletionError::ProviderError(message.to_string()));
assert_eq!(mapped.kind, AgentErrorKind::Authentication);
assert!(!mapped.recoverable);
assert!(mapped.user_message.is_some());
}
}
}
#[path = "stream_unit_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "stream_tests.rs"]
@@ -0,0 +1,114 @@
use galaxy_agent_core::{AgentErrorKind, StopReason};
use rig_core::completion::CompletionError;
use super::{
completion_error_stop_reason, domain_tool_call, is_keepalive_event, map_completion_error,
};
#[test]
fn domain_tool_call_prefers_responses_call_id() {
let tool_call = rig_core::message::ToolCall::new(
"fc_item_123".to_string(),
rig_core::message::ToolFunction {
name: "read_files".to_string(),
arguments: serde_json::json!({"files": ["Cargo.toml"]}),
},
)
.with_call_id("call_123".to_string());
let call = domain_tool_call(tool_call);
assert_eq!(call.id, "call_123");
assert_eq!(call.name, "read_files");
}
#[test]
fn domain_tool_call_falls_back_to_wire_id_without_responses_call_id() {
let tool_call = rig_core::message::ToolCall::new(
"fc_item_123".to_string(),
rig_core::message::ToolFunction {
name: "read_files".to_string(),
arguments: serde_json::json!({"files": ["Cargo.toml"]}),
},
);
let call = domain_tool_call(tool_call);
assert_eq!(call.id, "fc_item_123");
assert_eq!(call.name, "read_files");
}
#[test]
fn provider_context_window_error_maps_to_semantic_stop_reason_and_kind() {
let status = rig_core::http_client::Response::builder()
.status(400)
.body(())
.unwrap()
.status();
let error = CompletionError::from_http_response(
status,
r#"{"error":{"message":"Your input exceeds the context window of this model. Please adjust your input and try again.","code":"400"}}"#,
);
assert_eq!(
completion_error_stop_reason(&error),
Some(StopReason::ContextWindowExceeded)
);
let mapped = map_completion_error(error);
assert_eq!(mapped.kind, AgentErrorKind::ContextWindowExceeded);
assert!(!mapped.recoverable);
}
#[test]
fn provider_context_length_string_maps_to_semantic_stop_reason() {
let error = CompletionError::ProviderError(
"context_length_exceeded: maximum context length is 128000 tokens".to_string(),
);
assert_eq!(
completion_error_stop_reason(&error),
Some(StopReason::ContextWindowExceeded)
);
}
#[test]
fn provider_keepalive_event_is_a_transport_heartbeat() {
assert!(is_keepalive_event(&serde_json::json!({
"type": "keepalive",
"sequence_number": 3,
})));
assert!(!is_keepalive_event(&serde_json::json!({
"type": "unsupported",
})));
}
#[test]
fn provider_server_error_is_recoverable() {
let status = rig_core::http_client::Response::builder()
.status(503)
.body(())
.unwrap()
.status();
let error = CompletionError::from_http_response(
status,
r#"{"error":{"message":"Service temporarily unavailable"}}"#,
);
let mapped = map_completion_error(error);
assert_eq!(mapped.kind, AgentErrorKind::Provider);
assert!(mapped.recoverable);
}
#[test]
fn flattened_bedrock_credential_errors_are_authentication_failures() {
for message in [
r#"{"__type":"ExpiredTokenException","message":"The security token included in the request is expired"}"#,
"UnrecognizedClientException: The security token included in the request is invalid",
"AccessDeniedException: not authorized to perform bedrock:ConverseStream",
] {
let mapped = map_completion_error(CompletionError::ProviderError(message.to_string()));
assert_eq!(mapped.kind, AgentErrorKind::Authentication);
assert!(!mapped.recoverable);
assert!(mapped.user_message.is_some());
}
}