#[cfg(test)]
mod tests {
use async_trait::async_trait;
use oxi_agent::{
AgentEvent, AgentLoop, AgentLoopConfig, ProviderResolver, SharedState,
agent_loop::config::{ApprovalConfig, ApprovalDecision},
tools::{AgentTool, AgentToolResult, ToolRegistry, ToolTier},
};
use oxi_ai::{
AssistantMessage, ContentBlock, Model, Provider, ProviderEvent, StopReason, StreamResult,
TextContent, ToolCall,
};
use std::future::Future;
use std::pin::Pin;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
struct MockResolver;
impl ProviderResolver for MockResolver {
fn resolve_provider(&self, _name: &str) -> Option<Arc<dyn Provider>> {
None
}
fn resolve_model(&self, _model_id: &str) -> Option<Model> {
Some(Model::new(
"mock/model",
"Mock Model",
oxi_ai::Api::AnthropicMessages,
"mock",
"https://mock.example.com",
))
}
}
struct MockProvider {
call_count: Arc<AtomicUsize>,
tool_name: String,
}
impl MockProvider {
fn new(tool_name: &str) -> Self {
Self {
call_count: Arc::new(AtomicUsize::new(0)),
tool_name: tool_name.to_string(),
}
}
}
impl Provider for MockProvider {
fn stream<'a>(
&'a self,
_model: &'a Model,
_context: &'a oxi_ai::Context,
_options: Option<oxi_ai::StreamOptions>,
) -> Pin<Box<dyn Future<Output = StreamResult> + Send + 'a>> {
let call_count = Arc::clone(&self.call_count);
let tool_name = self.tool_name.clone();
Box::pin(async move {
let count = call_count.fetch_add(1, Ordering::Relaxed);
if count == 0 {
let tc = ToolCall::new(
"call_1",
tool_name,
serde_json::json!({"command": "echo hello"}),
);
let mut assistant =
AssistantMessage::new(oxi_ai::Api::AnthropicMessages, "mock", "mock-model");
assistant.content = vec![ContentBlock::ToolCall(tc)];
assistant.stop_reason = StopReason::ToolUse;
let events = vec![
ProviderEvent::Start {
partial: Arc::new(assistant.clone()),
},
ProviderEvent::Done {
reason: StopReason::ToolUse,
message: assistant,
},
];
Ok(Box::pin(futures::stream::iter(events))
as Pin<
Box<dyn futures::Stream<Item = ProviderEvent> + Send>,
>)
} else {
let mut assistant =
AssistantMessage::new(oxi_ai::Api::AnthropicMessages, "mock", "mock-model");
assistant.content = vec![ContentBlock::Text(TextContent::new("Done."))];
assistant.stop_reason = StopReason::Stop;
let events = vec![
ProviderEvent::Start {
partial: Arc::new(assistant.clone()),
},
ProviderEvent::Done {
reason: StopReason::Stop,
message: assistant,
},
];
Ok(Box::pin(futures::stream::iter(events))
as Pin<
Box<dyn futures::Stream<Item = ProviderEvent> + Send>,
>)
}
})
}
}
struct ExecTool;
#[async_trait]
impl AgentTool for ExecTool {
fn name(&self) -> &str {
"bash"
}
fn label(&self) -> &str {
"Bash"
}
fn description(&self) -> &str {
"Execute shell commands"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"command": {"type": "string"}
},
"required": ["command"]
})
}
fn tool_tier(&self) -> ToolTier {
ToolTier::Exec
}
async fn execute(
&self,
_tool_call_id: &str,
_params: serde_json::Value,
_signal: Option<tokio::sync::oneshot::Receiver<()>>,
_ctx: &oxi_agent::tools::ToolContext,
) -> Result<AgentToolResult, String> {
Ok(AgentToolResult::success("executed"))
}
}
fn make_loop(config: AgentLoopConfig) -> AgentLoop {
let tool_registry = ToolRegistry::new();
tool_registry.register(ExecTool);
let provider = Arc::new(MockProvider::new("bash"));
let state = SharedState::new();
AgentLoop::new_with_resolver(
provider,
config,
Arc::new(tool_registry),
state,
Arc::new(MockResolver),
)
}
#[tokio::test]
async fn test_approval_required_event_emitted_for_exec_tool() {
let approval_hook: oxi_agent::agent_loop::config::ApprovalHook =
Arc::new(|tool_name: &str, _args: &serde_json::Value| {
let name = tool_name.to_string();
Box::pin(async move {
Ok(ApprovalDecision::RequireApproval(format!(
"Need approval for {}",
name
)))
})
});
let config = AgentLoopConfig {
model_id: "mock/model".into(),
approval_config: ApprovalConfig {
require_approval_for: vec![ToolTier::Exec],
hook: Some(approval_hook),
},
..Default::default()
};
let loop_instance = make_loop(config);
let events = Arc::new(std::sync::Mutex::new(Vec::new()));
let events_clone = Arc::clone(&events);
let result = loop_instance
.run("Run a command".into(), move |event| {
events_clone.lock().unwrap().push(event);
})
.await;
assert!(
result.is_ok(),
"Agent loop should complete: {:?}",
result.err()
);
let captured = events.lock().unwrap();
let approval_events: Vec<_> = captured
.iter()
.filter(|e| matches!(e, AgentEvent::ApprovalRequired { .. }))
.collect();
assert!(
!approval_events.is_empty(),
"Expected at least one ApprovalRequired event"
);
if let AgentEvent::ApprovalRequired {
tool_name, reason, ..
} = &approval_events[0]
{
assert_eq!(tool_name, "bash");
assert!(reason.contains("Need approval for bash"));
}
}
#[tokio::test]
async fn test_approval_denied_blocks_tool() {
let approval_hook: oxi_agent::agent_loop::config::ApprovalHook =
Arc::new(|_: &str, _: &serde_json::Value| {
Box::pin(async move { Ok(ApprovalDecision::Deny("Not allowed".into())) })
});
let config = AgentLoopConfig {
model_id: "mock/model".into(),
approval_config: ApprovalConfig {
require_approval_for: vec![ToolTier::Exec],
hook: Some(approval_hook),
},
..Default::default()
};
let loop_instance = make_loop(config);
let events = Arc::new(std::sync::Mutex::new(Vec::new()));
let events_clone = Arc::clone(&events);
let result = loop_instance
.run("Run a command".into(), move |event| {
events_clone.lock().unwrap().push(event);
})
.await;
assert!(
result.is_ok(),
"Agent loop should complete: {:?}",
result.err()
);
let captured = events.lock().unwrap();
let denied_results: Vec<_> = captured
.iter()
.filter_map(|e| {
if let AgentEvent::ToolExecutionEnd {
is_error, result, ..
} = e
{
Some((*is_error, result.content.clone()))
} else {
None
}
})
.collect();
assert!(!denied_results.is_empty(), "Expected ToolExecutionEnd");
let (is_error, content) = &denied_results[0];
assert!(*is_error, "Denied tool should have is_error=true");
assert!(
content.contains("Not allowed"),
"Should mention denial reason: {}",
content
);
}
#[tokio::test]
async fn test_approval_allows_tool_execution() {
let approval_hook: oxi_agent::agent_loop::config::ApprovalHook =
Arc::new(|_: &str, _: &serde_json::Value| {
Box::pin(async move { Ok(ApprovalDecision::Allow) })
});
let config = AgentLoopConfig {
model_id: "mock/model".into(),
approval_config: ApprovalConfig {
require_approval_for: vec![ToolTier::Exec],
hook: Some(approval_hook),
},
..Default::default()
};
let loop_instance = make_loop(config);
let events = Arc::new(std::sync::Mutex::new(Vec::new()));
let events_clone = Arc::clone(&events);
let result = loop_instance
.run("Run a command".into(), move |event| {
events_clone.lock().unwrap().push(event);
})
.await;
assert!(
result.is_ok(),
"Agent loop should complete: {:?}",
result.err()
);
let captured = events.lock().unwrap();
let results: Vec<_> = captured
.iter()
.filter_map(|e| {
if let AgentEvent::ToolExecutionEnd {
is_error, result, ..
} = e
{
Some((*is_error, result.content.clone()))
} else {
None
}
})
.collect();
assert!(!results.is_empty(), "Expected ToolExecutionEnd");
let (is_error, content) = &results[0];
assert!(
!*is_error,
"Allowed tool should have is_error=false, content: {}",
content
);
}
#[tokio::test]
async fn test_approval_disabled_by_default() {
let config = AgentLoopConfig {
model_id: "mock/model".into(),
..Default::default()
};
let loop_instance = make_loop(config);
let events = Arc::new(std::sync::Mutex::new(Vec::new()));
let events_clone = Arc::clone(&events);
let result = loop_instance
.run("Run a command".into(), move |event| {
events_clone.lock().unwrap().push(event);
})
.await;
assert!(
result.is_ok(),
"Agent loop should complete: {:?}",
result.err()
);
let captured = events.lock().unwrap();
let approval_events: Vec<_> = captured
.iter()
.filter(|e| matches!(e, AgentEvent::ApprovalRequired { .. }))
.collect();
assert!(
approval_events.is_empty(),
"Expected no ApprovalRequired when disabled"
);
}
}