use crate::brain::agent::service::tool_loop::refresh_cli_flags;
use crate::brain::provider::{
FallbackProvider, LLMRequest, LLMResponse, Provider, ProviderError, ProviderStream,
};
use async_trait::async_trait;
use std::sync::Arc;
struct OwnershipMock {
name: String,
handles_tools: bool,
manages_context: bool,
fails: bool,
declared_wait: Option<String>,
}
impl OwnershipMock {
fn cli(name: &str, fails: bool) -> Self {
Self {
name: name.to_string(),
handles_tools: true,
manages_context: true,
fails,
declared_wait: None,
}
}
fn api(name: &str, fails: bool) -> Self {
Self {
name: name.to_string(),
handles_tools: false,
manages_context: false,
fails,
declared_wait: None,
}
}
fn with_wait(mut self, msg: &str) -> Self {
self.declared_wait = Some(msg.to_string());
self
}
fn throttle(&self) -> ProviderError {
match &self.declared_wait {
Some(msg) => ProviderError::RateLimitExceeded(msg.clone()),
None => {
ProviderError::RateLimitExceeded(format!("{} throttled, retry shortly", self.name))
}
}
}
}
#[async_trait]
impl Provider for OwnershipMock {
async fn complete(
&self,
request: LLMRequest,
) -> crate::brain::provider::error::Result<LLMResponse> {
if self.fails {
return Err(self.throttle());
}
Ok(LLMResponse {
id: format!("{}-response", self.name),
model: request.model,
content: vec![],
stop_reason: None,
usage: crate::brain::provider::TokenUsage {
input_tokens: 0,
output_tokens: 0,
..Default::default()
},
streaming_active_secs: None,
tool_text_leak: false,
})
}
async fn stream(
&self,
_request: LLMRequest,
) -> crate::brain::provider::error::Result<ProviderStream> {
if self.fails {
return Err(self.throttle());
}
Ok(Box::pin(futures::stream::empty()))
}
fn name(&self) -> &str {
&self.name
}
fn default_model(&self) -> &str {
"mock-default"
}
fn supported_models(&self) -> Vec<String> {
vec!["mock-default".to_string()]
}
fn context_window(&self, _model: &str) -> Option<u32> {
Some(4096)
}
fn calculate_cost(&self, _model: &str, _input: u32, _output: u32) -> f64 {
0.0
}
fn cli_handles_tools(&self) -> bool {
self.handles_tools
}
fn cli_manages_context(&self) -> bool {
self.manages_context
}
}
fn request_for(model: &str) -> LLMRequest {
LLMRequest {
model: model.into(),
messages: vec![],
system: None,
system_suffix: None,
max_tokens: None,
temperature: None,
tools: None,
stream: false,
metadata: None,
working_directory: None,
session_id: None,
}
}
#[test]
fn chain_on_primary_reports_the_primary() {
let chain = FallbackProvider::new(
Arc::new(OwnershipMock::api("zhipu", false)),
vec![Arc::new(OwnershipMock::cli("claude-cli", false))],
);
assert!(!chain.cli_handles_tools());
assert!(!chain.cli_manages_context());
}
#[test]
fn swapping_onto_a_cli_hands_tool_execution_to_the_cli() {
let chain = FallbackProvider::new(
Arc::new(OwnershipMock::api("zhipu", false)),
vec![Arc::new(OwnershipMock::cli("claude-cli", false))],
);
assert!(!chain.cli_handles_tools());
assert!(chain.force_next_fallback("throttled", "mock-default"));
assert!(
chain.cli_handles_tools(),
"after rotating onto an agentic CLI the chain must report that the \
CLI executes its own tools, or every call runs twice"
);
assert!(chain.cli_manages_context());
}
#[test]
fn swapping_off_a_cli_hands_tool_execution_back_to_opencrabs() {
let chain = FallbackProvider::new(
Arc::new(OwnershipMock::cli("claude-cli", false)),
vec![Arc::new(OwnershipMock::api("zhipu", false))],
);
assert!(chain.cli_handles_tools());
assert!(chain.force_next_fallback("cli unavailable", "mock-default"));
assert!(
!chain.cli_handles_tools(),
"after rotating onto an API provider OpenCrabs owns tool execution"
);
assert!(!chain.cli_manages_context());
}
#[tokio::test]
async fn a_real_failover_moves_ownership_not_just_a_forced_swap() {
let chain = FallbackProvider::new(
Arc::new(OwnershipMock::api("zhipu", true)),
vec![Arc::new(OwnershipMock::cli("claude-cli", false))],
);
assert!(!chain.cli_handles_tools());
let resp = chain
.complete(request_for("mock-default"))
.await
.expect("the CLI fallback should serve the call");
assert_eq!(resp.id, "claude-cli-response");
assert!(
chain.cli_handles_tools(),
"the entry that served the call owns its tool execution"
);
}
#[tokio::test]
async fn a_declared_wait_failover_to_a_cli_hands_ownership_without_sticking() {
let chain = FallbackProvider::new(
Arc::new(OwnershipMock::api("zhipu", true).with_wait("retry after 157000 s")),
vec![Arc::new(OwnershipMock::cli("claude-cli", false))],
);
assert!(!chain.cli_handles_tools());
let resp = chain
.complete(request_for("mock-default"))
.await
.expect("the CLI fallback should serve the call");
assert_eq!(resp.id, "claude-cli-response");
assert!(
chain.cli_handles_tools(),
"the entry that answered owns its tool execution"
);
assert!(chain.cli_manages_context());
assert_eq!(
chain.active_subprovider_name(),
None,
"a declared-wait failover must not make the CLI sticky"
);
}
#[test]
fn refresh_cli_flags_adopts_the_post_swap_owner() {
let chain: Arc<dyn Provider> = Arc::new(FallbackProvider::new(
Arc::new(OwnershipMock::api("zhipu", false)),
vec![Arc::new(OwnershipMock::cli("claude-cli", false))],
));
let mut is_cli_provider = chain.cli_handles_tools();
let mut cli_owns_context = chain.cli_manages_context();
assert!(!is_cli_provider);
assert!(!cli_owns_context);
chain.force_next_fallback("throttled", "mock-default");
refresh_cli_flags(&chain, &mut is_cli_provider, &mut cli_owns_context);
assert!(is_cli_provider);
assert!(cli_owns_context);
}
#[test]
fn refresh_cli_flags_is_a_noop_without_a_swap() {
let chain: Arc<dyn Provider> = Arc::new(FallbackProvider::new(
Arc::new(OwnershipMock::api("zhipu", false)),
vec![Arc::new(OwnershipMock::api("openrouter", false))],
));
let mut is_cli_provider = false;
let mut cli_owns_context = false;
refresh_cli_flags(&chain, &mut is_cli_provider, &mut cli_owns_context);
assert!(!is_cli_provider);
assert!(!cli_owns_context);
}