use std::sync::{Arc, Mutex};
use opi_agent::event::AgentEvent;
use opi_agent::message::AgentMessage;
use opi_agent::tool::{Tool, ToolError, ToolResult};
use opi_ai::message::{InputContent, Message};
use opi_ai::test_support::{self, MockProvider};
use opi_coding_agent::config::OpiConfig;
use opi_coding_agent::harness::CodingHarness;
use serde_json::json;
use std::pin::Pin;
use tokio_util::sync::CancellationToken;
struct RecordTool {
name: String,
call_log: Arc<Mutex<Vec<serde_json::Value>>>,
}
impl RecordTool {
fn new(name: &str, call_log: Arc<Mutex<Vec<serde_json::Value>>>) -> Self {
Self {
name: name.to_owned(),
call_log,
}
}
}
impl Tool for RecordTool {
fn definition(&self) -> opi_ai::message::ToolDef {
opi_ai::message::ToolDef {
name: self.name.clone(),
description: format!("Record tool: {}", self.name),
input_schema: json!({
"type": "object",
"properties": {
"arg": { "type": "string" }
},
"required": ["arg"]
}),
}
}
fn execute(
&self,
_call_id: &str,
arguments: serde_json::Value,
_signal: CancellationToken,
_on_update: Option<opi_agent::tool::UpdateCallback>,
) -> Pin<Box<dyn std::future::Future<Output = Result<ToolResult, ToolError>> + Send>> {
let log = self.call_log.clone();
log.lock().unwrap().push(arguments.clone());
let text = arguments
.get("arg")
.and_then(|v| v.as_str())
.unwrap_or("mock-result")
.to_owned();
Box::pin(async move {
Ok(ToolResult {
content: vec![opi_ai::message::OutputContent::Text {
text: format!("tool-result: {text}"),
}],
details: None,
is_error: false,
terminate: false,
})
})
}
}
fn event_name(event: &AgentEvent) -> &'static str {
use AgentEvent::*;
match event {
AgentStart => "AgentStart",
AgentEnd { .. } => "AgentEnd",
TurnStart => "TurnStart",
TurnEnd { .. } => "TurnEnd",
MessageStart { .. } => "MessageStart",
MessageUpdate { .. } => "MessageUpdate",
MessageEnd { .. } => "MessageEnd",
ToolExecutionStart { .. } => "ToolExecutionStart",
ToolExecutionEnd { is_error, .. } => {
if *is_error {
"ToolExecutionEnd(error)"
} else {
"ToolExecutionEnd(ok)"
}
}
_ => "Other",
}
}
#[tokio::test]
async fn harness_text_prompt_with_mock() {
let response = test_support::text_response("Hello from harness!");
let provider = MockProvider::new("mock", vec![response]);
let mut harness = CodingHarness::new(
Box::new(provider),
"mock-model".into(),
OpiConfig::default(),
std::env::current_dir().unwrap(),
);
let events: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let ev = events.clone();
harness.subscribe(Box::new(move |event| {
ev.lock().unwrap().push(event_name(event).to_owned());
}));
let result = harness.prompt("Hi there").await.unwrap();
assert!(
result.len() >= 2,
"expected >= 2 messages, got {}",
result.len()
);
if let AgentMessage::Llm(Message::User(user)) = &result[0] {
let text = &user.content[0];
assert!(
matches!(text, InputContent::Text { text } if text == "Hi there"),
"first message should be the user prompt"
);
} else {
panic!("first message should be a User message");
}
let has_assistant = result
.iter()
.any(|m| matches!(m, AgentMessage::Llm(Message::Assistant(_))));
assert!(has_assistant, "should have at least one Assistant message");
let ev_lock = events.lock().unwrap();
assert!(
ev_lock.contains(&"AgentStart".to_owned()),
"missing AgentStart"
);
assert!(ev_lock.contains(&"AgentEnd".to_owned()), "missing AgentEnd");
}
#[tokio::test]
async fn harness_tool_call_with_mock() {
let tool_call_log: Arc<Mutex<Vec<serde_json::Value>>> = Arc::new(Mutex::new(Vec::new()));
let first = test_support::tool_call_response("tc-1", "record_tool", r#"{"arg":"hello"}"#);
let second = test_support::text_response("Tool executed!");
let provider = MockProvider::new("mock", vec![first, second]);
let mut harness = CodingHarness::new(
Box::new(provider),
"mock-model".into(),
OpiConfig::default(),
std::env::current_dir().unwrap(),
);
let record_tool = RecordTool::new("record_tool", tool_call_log.clone());
harness.add_tool(Box::new(record_tool));
let result = harness.prompt("Use the record tool").await.unwrap();
let log = tool_call_log.lock().unwrap();
assert_eq!(log.len(), 1, "tool should have been called exactly once");
assert_eq!(log[0]["arg"], "hello");
assert!(
result.len() >= 4,
"expected >= 4 messages, got {}",
result.len()
);
}
#[tokio::test]
async fn harness_system_prompt_includes_tools() {
let response = test_support::text_response("ok");
let provider = MockProvider::new("mock", vec![response]);
let harness = CodingHarness::new(
Box::new(provider),
"mock-model".into(),
OpiConfig::default(),
std::env::current_dir().unwrap(),
);
let system_prompt = harness.system_prompt();
assert!(
system_prompt.contains("Available tools:"),
"system prompt should include tool section header"
);
assert!(
system_prompt.contains("read"),
"system prompt should mention read tool"
);
assert!(
system_prompt.contains("bash"),
"system prompt should mention bash tool"
);
}
#[tokio::test]
async fn harness_multi_turn_with_mock() {
let first = test_support::text_response("First response");
let second = test_support::text_response("Second response");
let provider = MockProvider::new("mock", vec![first, second]);
let mut harness = CodingHarness::new(
Box::new(provider),
"mock-model".into(),
OpiConfig::default(),
std::env::current_dir().unwrap(),
);
let result1 = harness.prompt("Hello").await.unwrap();
assert!(result1.len() >= 2, "first turn should have >= 2 messages");
let result2 = harness.continue_("Tell me more").await.unwrap();
assert!(
result2.len() >= 4,
"expected >= 4 messages after two turns, got {}",
result2.len()
);
}
#[tokio::test]
async fn harness_respects_max_iterations_config() {
let response = test_support::text_response("ok");
let provider = MockProvider::new("mock", vec![response]);
let mut config = OpiConfig::default();
config.defaults.max_iterations = 3;
let harness = CodingHarness::new(
Box::new(provider),
"mock-model".into(),
config,
std::env::current_dir().unwrap(),
);
drop(harness);
}