use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use futures::StreamExt;
use serde_json::json;
use tkach::message::{Content, Message, StopReason, Usage};
use tkach::provider::Response;
use tkach::providers::Mock;
use tkach::{Agent, AgentError, CancellationToken, StreamEvent};
fn test_dir() -> std::path::PathBuf {
std::env::current_dir().unwrap()
}
fn prompt(text: &str) -> Vec<Message> {
vec![Message::user_text(text)]
}
#[tokio::test]
async fn single_turn_text_response() {
let agent = Agent::builder()
.provider(Mock::with_text("Hello, world!"))
.model("test")
.working_dir(test_dir())
.build();
let result = agent
.run(prompt("Hi"), CancellationToken::new())
.await
.unwrap();
assert_eq!(result.text, "Hello, world!");
assert_eq!(result.new_messages.len(), 1);
assert_eq!(result.stop_reason, StopReason::EndTurn);
}
#[tokio::test]
async fn tool_call_then_text_response() {
let call = Arc::new(AtomicUsize::new(0));
let call_clone = call.clone();
let mock = Mock::new(move |_req| {
let n = call_clone.fetch_add(1, Ordering::SeqCst);
match n {
0 => Ok(Response {
content: vec![Content::ToolUse {
id: "t1".into(),
name: "bash".into(),
input: json!({"command": "echo hello"}),
}],
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
}),
_ => Ok(Response {
content: vec![Content::text("The command output: hello")],
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
}),
}
});
let agent = Agent::builder()
.provider(mock)
.model("test")
.tools(tkach::tools::defaults())
.working_dir(test_dir())
.build();
let result = agent
.run(prompt("run echo hello"), CancellationToken::new())
.await
.unwrap();
assert_eq!(result.text, "The command output: hello");
assert_eq!(result.new_messages.len(), 3);
}
#[tokio::test]
async fn multiple_tool_calls_single_response() {
let call = Arc::new(AtomicUsize::new(0));
let call_clone = call.clone();
let mock = Mock::new(move |_req| {
let n = call_clone.fetch_add(1, Ordering::SeqCst);
match n {
0 => Ok(Response {
content: vec![
Content::ToolUse {
id: "t1".into(),
name: "bash".into(),
input: json!({"command": "echo first"}),
},
Content::ToolUse {
id: "t2".into(),
name: "bash".into(),
input: json!({"command": "echo second"}),
},
],
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
}),
_ => Ok(Response {
content: vec![Content::text("Both commands ran successfully")],
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
}),
}
});
let agent = Agent::builder()
.provider(mock)
.model("test")
.tools(tkach::tools::defaults())
.working_dir(test_dir())
.build();
let result = agent
.run(prompt("run two commands"), CancellationToken::new())
.await
.unwrap();
assert_eq!(result.text, "Both commands ran successfully");
let tool_results_msg = &result.new_messages[1];
assert_eq!(tool_results_msg.content.len(), 2);
}
#[tokio::test]
async fn max_turns_exceeded() {
let mock = Mock::new(|_req| {
Ok(Response {
content: vec![Content::ToolUse {
id: "t1".into(),
name: "bash".into(),
input: json!({"command": "echo loop"}),
}],
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
})
});
let agent = Agent::builder()
.provider(mock)
.model("test")
.tools(tkach::tools::defaults())
.max_turns(3)
.working_dir(test_dir())
.build();
let err = agent
.run(prompt("loop forever"), CancellationToken::new())
.await
.unwrap_err();
let AgentError::MaxTurnsReached { turns, partial } = &err else {
panic!("expected MaxTurnsReached, got {err:?}");
};
assert_eq!(*turns, 3);
assert_eq!(partial.new_messages.len(), 6);
assert_eq!(partial.stop_reason, StopReason::ToolUse);
}
#[tokio::test]
async fn cancel_before_run_returns_cancelled_immediately() {
let mock = Mock::with_text("should never get here");
let agent = Agent::builder()
.provider(mock)
.model("test")
.working_dir(test_dir())
.build();
let cancel = CancellationToken::new();
cancel.cancel();
let err = agent.run(prompt("hi"), cancel).await.unwrap_err();
let AgentError::Cancelled { partial } = &err else {
panic!("expected Cancelled, got {err:?}");
};
assert_eq!(partial.new_messages.len(), 0);
assert_eq!(partial.stop_reason, StopReason::Cancelled);
}
#[tokio::test]
async fn tool_not_found_returns_error_result() {
let call = Arc::new(AtomicUsize::new(0));
let call_clone = call.clone();
let mock = Mock::new(move |req| {
let n = call_clone.fetch_add(1, Ordering::SeqCst);
match n {
0 => Ok(Response {
content: vec![Content::ToolUse {
id: "t1".into(),
name: "nonexistent_tool".into(),
input: json!({}),
}],
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
}),
_ => {
let last_msg = req.messages.last().unwrap();
let has_error = last_msg.content.iter().any(|c| match c {
Content::ToolResult { is_error, .. } => *is_error,
_ => false,
});
assert!(has_error, "LLM should receive tool error");
Ok(Response {
content: vec![Content::text("Tool not found, sorry.")],
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
})
}
}
});
let agent = Agent::builder()
.provider(mock)
.model("test")
.working_dir(test_dir())
.build();
let result = agent
.run(prompt("use a fake tool"), CancellationToken::new())
.await
.unwrap();
assert_eq!(result.text, "Tool not found, sorry.");
}
#[tokio::test]
async fn usage_accumulates_across_turns() {
let call = Arc::new(AtomicUsize::new(0));
let call_clone = call.clone();
let mock = Mock::new(move |_req| {
let n = call_clone.fetch_add(1, Ordering::SeqCst);
match n {
0 => Ok(Response {
content: vec![Content::ToolUse {
id: "t1".into(),
name: "bash".into(),
input: json!({"command": "echo hi"}),
}],
stop_reason: StopReason::ToolUse,
usage: Usage {
input_tokens: 100,
output_tokens: 50,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
}),
_ => Ok(Response {
content: vec![Content::text("done")],
stop_reason: StopReason::EndTurn,
usage: Usage {
input_tokens: 200,
output_tokens: 30,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
}),
}
});
let agent = Agent::builder()
.provider(mock)
.model("test")
.tools(tkach::tools::defaults())
.working_dir(test_dir())
.build();
let result = agent
.run(prompt("test"), CancellationToken::new())
.await
.unwrap();
assert_eq!(result.usage.input_tokens, 300);
assert_eq!(result.usage.output_tokens, 80);
}
#[tokio::test]
async fn read_tool_reads_actual_file() {
let call = Arc::new(AtomicUsize::new(0));
let call_clone = call.clone();
let mock = Mock::new(move |req| {
let n = call_clone.fetch_add(1, Ordering::SeqCst);
match n {
0 => Ok(Response {
content: vec![Content::ToolUse {
id: "t1".into(),
name: "read".into(),
input: json!({"file_path": "Cargo.toml"}),
}],
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
}),
_ => {
let last_msg = req.messages.last().unwrap();
let content = match &last_msg.content[0] {
Content::ToolResult { content, .. } => content.clone(),
_ => panic!("expected tool result"),
};
assert!(content.contains("tkach"), "should contain package name");
Ok(Response {
content: vec![Content::text("I read the Cargo.toml")],
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
})
}
}
});
let agent = Agent::builder()
.provider(mock)
.model("test")
.tools(tkach::tools::defaults())
.working_dir(test_dir())
.build();
let result = agent
.run(prompt("read cargo toml"), CancellationToken::new())
.await
.unwrap();
assert_eq!(result.text, "I read the Cargo.toml");
}
#[tokio::test]
async fn glob_tool_finds_files() {
let call = Arc::new(AtomicUsize::new(0));
let call_clone = call.clone();
let mock = Mock::new(move |req| {
let n = call_clone.fetch_add(1, Ordering::SeqCst);
match n {
0 => Ok(Response {
content: vec![Content::ToolUse {
id: "t1".into(),
name: "glob".into(),
input: json!({"pattern": "src/**/*.rs"}),
}],
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
}),
_ => {
let last_msg = req.messages.last().unwrap();
let content = match &last_msg.content[0] {
Content::ToolResult { content, .. } => content.clone(),
_ => panic!("expected tool result"),
};
assert!(content.contains("lib.rs"), "should find lib.rs");
Ok(Response {
content: vec![Content::text("Found Rust files")],
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
})
}
}
});
let agent = Agent::builder()
.provider(mock)
.model("test")
.tools(tkach::tools::defaults())
.working_dir(test_dir())
.build();
let result = agent
.run(prompt("find rust files"), CancellationToken::new())
.await
.unwrap();
assert_eq!(result.text, "Found Rust files");
}
#[tokio::test]
async fn multi_turn_tool_chain() {
let tmp_dir = std::env::temp_dir().join("tkach_test");
let _ = std::fs::create_dir_all(&tmp_dir);
let test_file = tmp_dir.join("test.txt");
std::fs::write(&test_file, "hello world").unwrap();
let call = Arc::new(AtomicUsize::new(0));
let call_clone = call.clone();
let file_path = test_file.display().to_string();
let file_path_clone = file_path.clone();
let mock = Mock::new(move |_req| {
let n = call_clone.fetch_add(1, Ordering::SeqCst);
match n {
0 => Ok(Response {
content: vec![Content::ToolUse {
id: "t1".into(),
name: "read".into(),
input: json!({"file_path": file_path_clone}),
}],
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
}),
1 => Ok(Response {
content: vec![Content::ToolUse {
id: "t2".into(),
name: "edit".into(),
input: json!({
"file_path": file_path_clone,
"old_string": "hello world",
"new_string": "hello agent"
}),
}],
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
}),
_ => Ok(Response {
content: vec![Content::text("File updated successfully")],
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
}),
}
});
let agent = Agent::builder()
.provider(mock)
.model("test")
.tools(tkach::tools::defaults())
.working_dir(&tmp_dir)
.build();
let result = agent
.run(prompt("update the file"), CancellationToken::new())
.await
.unwrap();
assert_eq!(result.text, "File updated successfully");
let content = std::fs::read_to_string(&test_file).unwrap();
assert_eq!(content, "hello agent");
let _ = std::fs::remove_dir_all(&tmp_dir);
}
#[tokio::test]
async fn no_tools_text_only() {
let agent = Agent::builder()
.provider(Mock::with_text("I have no tools but I can chat!"))
.model("test")
.working_dir(test_dir())
.build();
let result = agent
.run(prompt("hello"), CancellationToken::new())
.await
.unwrap();
assert_eq!(result.text, "I have no tools but I can chat!");
}
#[tokio::test]
async fn end_turn_stops_even_with_tool_use_content() {
let mock = Mock::new(|_req| {
Ok(Response {
content: vec![
Content::text("Let me think..."),
Content::ToolUse {
id: "t1".into(),
name: "bash".into(),
input: json!({"command": "echo orphan"}),
},
],
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
})
});
let agent = Agent::builder()
.provider(mock)
.model("test")
.tools(tkach::tools::defaults())
.working_dir(test_dir())
.build();
let result = agent
.run(prompt("test"), CancellationToken::new())
.await
.unwrap();
assert_eq!(result.text, "Let me think...");
assert_eq!(result.new_messages.len(), 1);
}
#[tokio::test]
async fn cancel_during_bash_tool_returns_cancelled() {
let call = Arc::new(AtomicUsize::new(0));
let call_clone = call.clone();
let mock = Mock::new(move |_req| {
let n = call_clone.fetch_add(1, Ordering::SeqCst);
match n {
0 => Ok(Response {
content: vec![Content::ToolUse {
id: "t1".into(),
name: "bash".into(),
input: json!({"command": "sleep 10", "timeout_ms": 30000}),
}],
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
}),
_ => panic!("loop should not reach a second turn — cancel fired mid-batch"),
}
});
let agent = Agent::builder()
.provider(mock)
.model("test")
.tools(tkach::tools::defaults())
.working_dir(test_dir())
.build();
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
cancel_clone.cancel();
});
let start = std::time::Instant::now();
let err = agent.run(prompt("sleep"), cancel).await.unwrap_err();
let elapsed = start.elapsed();
let AgentError::Cancelled { partial } = &err else {
panic!("expected Cancelled, got {err:?}");
};
assert_eq!(partial.stop_reason, StopReason::Cancelled);
assert!(
elapsed < std::time::Duration::from_secs(5),
"expected prompt cancel, took {elapsed:?}"
);
}
#[tokio::test]
async fn provider_error_returns_partial() {
let call = Arc::new(AtomicUsize::new(0));
let call_clone = call.clone();
let mock = Mock::new(move |_req| {
let n = call_clone.fetch_add(1, Ordering::SeqCst);
match n {
0 => Ok(Response {
content: vec![Content::ToolUse {
id: "t1".into(),
name: "bash".into(),
input: json!({"command": "echo hi"}),
}],
stop_reason: StopReason::ToolUse,
usage: Usage {
input_tokens: 10,
output_tokens: 5,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
}),
_ => Err(tkach::ProviderError::Overloaded {
retry_after_ms: Some(2_000),
}),
}
});
let agent = Agent::builder()
.provider(mock)
.model("test")
.tools(tkach::tools::defaults())
.working_dir(test_dir())
.build();
let err = agent
.run(prompt("test"), CancellationToken::new())
.await
.unwrap_err();
let AgentError::Provider { source, partial } = &err else {
panic!("expected Provider error, got {err:?}");
};
assert!(source.is_retryable());
assert_eq!(partial.new_messages.len(), 2);
assert_eq!(partial.usage.input_tokens, 10);
}
#[tokio::test]
async fn stream_text_response_emits_delta_then_collects_result() {
let agent = Agent::builder()
.provider(Mock::with_text("Hello, world!"))
.model("test")
.working_dir(test_dir())
.build();
let mut stream = agent.stream(prompt("hi"), CancellationToken::new());
let mut events: Vec<StreamEvent> = Vec::new();
while let Some(ev) = stream.next().await {
events.push(ev.unwrap());
}
let result = stream.into_result().await.unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(&events[0], StreamEvent::ContentDelta(t) if t == "Hello, world!"));
assert_eq!(result.new_messages.len(), 1);
assert_eq!(result.text, "Hello, world!");
assert_eq!(result.stop_reason, StopReason::EndTurn);
}
#[tokio::test]
async fn stream_tool_call_then_text_response_executes_tool_inline() {
let call = Arc::new(AtomicUsize::new(0));
let call_clone = call.clone();
let mock = Mock::new(move |_req| {
let n = call_clone.fetch_add(1, Ordering::SeqCst);
match n {
0 => Ok(Response {
content: vec![Content::ToolUse {
id: "t1".into(),
name: "bash".into(),
input: json!({"command": "echo hi"}),
}],
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
}),
_ => Ok(Response {
content: vec![Content::text("done")],
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
}),
}
});
let agent = Agent::builder()
.provider(mock)
.model("test")
.tools(tkach::tools::defaults())
.working_dir(test_dir())
.build();
let mut stream = agent.stream(prompt("run echo"), CancellationToken::new());
let mut tool_use_seen = false;
let mut final_text = String::new();
while let Some(ev) = stream.next().await {
match ev.unwrap() {
StreamEvent::ToolUse { name, .. } => {
assert_eq!(name, "bash");
tool_use_seen = true;
}
StreamEvent::ContentDelta(t) => final_text.push_str(&t),
_ => {}
}
}
let result = stream.into_result().await.unwrap();
assert!(tool_use_seen, "consumer should see ToolUse event");
assert_eq!(final_text, "done");
assert_eq!(result.new_messages.len(), 3);
assert_eq!(result.text, "done");
}
#[tokio::test]
async fn stream_collect_result_skips_event_drain() {
let agent = Agent::builder()
.provider(Mock::with_text("ignored content"))
.model("test")
.working_dir(test_dir())
.build();
let stream = agent.stream(prompt("hi"), CancellationToken::new());
let result = stream.collect_result().await.unwrap();
assert_eq!(result.text, "ignored content");
assert_eq!(result.new_messages.len(), 1);
}
#[tokio::test]
async fn stream_cancel_before_start_returns_cancelled_via_into_result() {
let mock = Mock::with_text("never");
let agent = Agent::builder()
.provider(mock)
.model("test")
.working_dir(test_dir())
.build();
let cancel = CancellationToken::new();
cancel.cancel();
let stream = agent.stream(prompt("hi"), cancel);
let err = stream.collect_result().await.unwrap_err();
let AgentError::Cancelled { partial } = &err else {
panic!("expected Cancelled, got {err:?}");
};
assert_eq!(partial.stop_reason, StopReason::Cancelled);
assert!(partial.new_messages.is_empty());
}
#[tokio::test]
async fn stream_emits_tool_call_pending_before_executing_tool() {
use tkach::ToolClass;
let call = Arc::new(AtomicUsize::new(0));
let call_clone = call.clone();
let mock = Mock::new(move |_req| {
let n = call_clone.fetch_add(1, Ordering::SeqCst);
match n {
0 => Ok(Response {
content: vec![Content::ToolUse {
id: "t1".into(),
name: "bash".into(),
input: json!({"command": "echo hi"}),
}],
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
}),
_ => Ok(Response {
content: vec![Content::text("done")],
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
}),
}
});
let agent = Agent::builder()
.provider(mock)
.model("test")
.tools(tkach::tools::defaults())
.working_dir(test_dir())
.build();
let mut stream = agent.stream(prompt("run echo"), CancellationToken::new());
let mut sequence: Vec<&'static str> = Vec::new();
let mut pending_class: Option<ToolClass> = None;
while let Some(ev) = stream.next().await {
match ev.unwrap() {
StreamEvent::ToolUse { .. } => sequence.push("ToolUse"),
StreamEvent::ToolCallPending { name, class, .. } => {
assert_eq!(name, "bash");
pending_class = Some(class);
sequence.push("ToolCallPending");
}
StreamEvent::ContentDelta(_) => sequence.push("ContentDelta"),
_ => {}
}
}
let result = stream.into_result().await.unwrap();
let tu_pos = sequence
.iter()
.position(|x| *x == "ToolUse")
.expect("ToolUse");
let pending_pos = sequence
.iter()
.position(|x| *x == "ToolCallPending")
.expect("ToolCallPending");
assert!(
tu_pos < pending_pos,
"ToolUse must come before ToolCallPending; got: {sequence:?}"
);
assert_eq!(
pending_class,
Some(ToolClass::Mutating),
"bash is Mutating; class should be threaded through"
);
assert_eq!(result.text, "done");
}
#[tokio::test]
async fn stream_with_deny_handler_emits_pending_but_skips_execution() {
use async_trait::async_trait;
use serde_json::Value;
use tkach::{ApprovalDecision, ApprovalHandler, ToolClass};
struct DenyAll;
#[async_trait]
impl ApprovalHandler for DenyAll {
async fn approve(&self, _: &str, _: &Value, _: ToolClass) -> ApprovalDecision {
ApprovalDecision::Deny("nope".into())
}
}
let call = Arc::new(AtomicUsize::new(0));
let call_clone = call.clone();
let mock = Mock::new(move |_req| {
let n = call_clone.fetch_add(1, Ordering::SeqCst);
match n {
0 => Ok(Response {
content: vec![Content::ToolUse {
id: "t1".into(),
name: "bash".into(),
input: json!({"command": "echo hi"}),
}],
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
}),
_ => Ok(Response {
content: vec![Content::text("acknowledged the denial")],
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
}),
}
});
let agent = Agent::builder()
.provider(mock)
.model("test")
.tools(tkach::tools::defaults())
.approval(DenyAll)
.working_dir(test_dir())
.build();
let mut stream = agent.stream(prompt("run echo hi"), CancellationToken::new());
let mut got_pending = false;
while let Some(ev) = stream.next().await {
if let StreamEvent::ToolCallPending { .. } = ev.unwrap() {
got_pending = true;
}
}
let result = stream.into_result().await.unwrap();
assert!(
got_pending,
"ToolCallPending must still fire even when handler will deny"
);
let saw_denial = result.new_messages.iter().any(|m| {
m.content.iter().any(|c| match c {
Content::ToolResult {
content, is_error, ..
} => *is_error && content.contains("nope"),
_ => false,
})
});
assert!(
saw_denial,
"expected denial tool_result containing 'nope' in history"
);
}