use super::*;
use crate::agent_events::{AgentEvent, AgentEventSink, ToolCallStatus};
use std::sync::Mutex as StdMutex;
struct CapturingSink {
events: Arc<StdMutex<Vec<AgentEvent>>>,
}
impl AgentEventSink for CapturingSink {
fn handle_event(&self, event: &AgentEvent) {
self.events
.lock()
.expect("event capture mutex poisoned")
.push(event.clone());
}
}
fn read_file_tool_decl() -> VmValue {
let mut path_param = std::collections::BTreeMap::new();
path_param.insert("type".to_string(), VmValue::String(Rc::from("string")));
let mut params = std::collections::BTreeMap::new();
params.insert("path".to_string(), VmValue::Dict(Rc::new(path_param)));
let mut tool = std::collections::BTreeMap::new();
tool.insert("name".to_string(), VmValue::String(Rc::from("read_file")));
tool.insert(
"description".to_string(),
VmValue::String(Rc::from("Read a file.")),
);
tool.insert("parameters".to_string(), VmValue::Dict(Rc::new(params)));
tool.insert("executor".to_string(), VmValue::String(Rc::from("harn")));
let mut envelope = std::collections::BTreeMap::new();
envelope.insert(
"tools".to_string(),
VmValue::List(Rc::new(vec![VmValue::Dict(Rc::new(tool))])),
);
VmValue::Dict(Rc::new(envelope))
}
#[tokio::test(flavor = "current_thread")]
async fn tool_call_update_durations_populated_on_terminal_absent_on_in_progress() {
reset_llm_mock_state();
let dir =
std::env::temp_dir().join(format!("harn-tool-duration-test-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&dir).expect("create temp dir");
let file_path = dir.join("hello.txt");
std::fs::write(&file_path, "hello world\n").expect("write temp file");
let file_path_str = file_path.to_string_lossy().into_owned();
let response_text = format!(
"<tool_call>\nread_file({{ path: \"{}\" }})\n</tool_call>",
file_path_str.replace('\\', "\\\\").replace('"', "\\\"")
);
crate::llm::mock::push_llm_mock(crate::llm::mock::LlmMock {
text: response_text,
tool_calls: Vec::new(),
match_pattern: None,
consume_on_match: true,
input_tokens: None,
output_tokens: None,
cache_read_tokens: None,
cache_write_tokens: None,
thinking: None,
thinking_summary: None,
stop_reason: None,
model: "mock".to_string(),
provider: None,
blocks: None,
error: None,
});
let captured: Arc<StdMutex<Vec<AgentEvent>>> = Arc::new(StdMutex::new(Vec::new()));
let sink = Arc::new(CapturingSink {
events: captured.clone(),
});
let mut opts = base_opts(vec![serde_json::json!({
"role": "user",
"content": "read the staged file",
})]);
opts.tools = Some(read_file_tool_decl());
let mut config = base_agent_config();
config.session_id = format!("tool-duration-test-{}", uuid::Uuid::now_v7());
config.persistent = true;
config.max_iterations = 1;
config.event_sink = Some(sink);
let _ = run_agent_loop_internal(&mut opts, config)
.await
.expect("agent loop runs");
let events = captured.lock().expect("captured events mutex poisoned");
let updates: Vec<_> = events
.iter()
.filter_map(|e| match e {
AgentEvent::ToolCallUpdate {
tool_name,
status,
duration_ms,
execution_duration_ms,
..
} if tool_name == "read_file" => Some((*status, *duration_ms, *execution_duration_ms)),
_ => None,
})
.collect();
assert!(
!updates.is_empty(),
"expected at least one read_file ToolCallUpdate, got events: {:#?}",
events
);
let in_progress_count = updates
.iter()
.filter(|(s, _, _)| matches!(s, ToolCallStatus::InProgress))
.count();
let completed_updates: Vec<_> = updates
.iter()
.filter(|(s, _, _)| matches!(s, ToolCallStatus::Completed))
.collect();
assert!(
in_progress_count >= 1,
"expected an InProgress update before Completed; got: {updates:?}"
);
assert_eq!(
completed_updates.len(),
1,
"expected exactly one Completed terminal update; got: {updates:?}"
);
for (status, duration_ms, exec_ms) in &updates {
match status {
ToolCallStatus::Pending | ToolCallStatus::InProgress => {
assert!(
duration_ms.is_none(),
"intermediate {status:?} update must not carry duration_ms; got {duration_ms:?}"
);
assert!(
exec_ms.is_none(),
"intermediate {status:?} update must not carry execution_duration_ms; got {exec_ms:?}"
);
}
ToolCallStatus::Completed | ToolCallStatus::Failed => {
assert!(
duration_ms.is_some(),
"terminal {status:?} update must carry duration_ms"
);
assert!(
exec_ms.is_some(),
"terminal {status:?} update must carry execution_duration_ms"
);
let total = duration_ms.unwrap();
let exec = exec_ms.unwrap();
assert!(
total + 1 >= exec,
"duration_ms ({total}) should be >= execution_duration_ms ({exec})"
);
}
}
}
let _ = std::fs::remove_dir_all(&dir);
reset_llm_mock_state();
}