fix(tests): isolate agent tests with per-test temp directories

All 26 agent tests shared std::env::temp_dir() as workspace, causing
them to contend on the same SQLite database file when running in
parallel. This caused flaky "database is locked" failures in CI
(e.g. clear_history_resets_conversation).

Fix: each test helper now creates its own tempfile::TempDir, returning
it alongside the Agent so it stays alive for the test duration.

Verified: 36/36 pass across 5 consecutive runs with zero flakiness.
This commit is contained in:
cyrus@tinyhumans.ai
2026-03-31 17:28:09 +05:30
parent 483b628e7e
commit a3fbdc68b1
+55 -41
View File
@@ -249,12 +249,17 @@ impl Tool for CountingTool {
}
}
fn make_memory() -> Arc<dyn Memory> {
/// Create an isolated memory instance with its own temp directory.
/// The returned `TempDir` must be held alive for the duration of the test
/// to prevent the directory (and its SQLite database) from being deleted.
fn make_memory() -> (Arc<dyn Memory>, tempfile::TempDir) {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = MemoryConfig {
backend: "none".into(),
..MemoryConfig::default()
};
Arc::from(memory::create_memory(&cfg, &std::env::temp_dir(), None).unwrap())
let mem = Arc::from(memory::create_memory(&cfg, tmp.path(), None).unwrap());
(mem, tmp)
}
fn make_sqlite_memory() -> (Arc<dyn Memory>, tempfile::TempDir) {
@@ -267,19 +272,23 @@ fn make_sqlite_memory() -> (Arc<dyn Memory>, tempfile::TempDir) {
(mem, tmp)
}
/// Build an agent with an isolated temp workspace.
/// Returns `(Agent, TempDir)` — hold `_tmp` in the test to keep the dir alive.
fn build_agent_with(
provider: Box<dyn Provider>,
tools: Vec<Box<dyn Tool>>,
dispatcher: Box<dyn ToolDispatcher>,
) -> Agent {
Agent::builder()
) -> (Agent, tempfile::TempDir) {
let (mem, tmp) = make_memory();
let agent = Agent::builder()
.provider(provider)
.tools(tools)
.memory(make_memory())
.memory(mem)
.tool_dispatcher(dispatcher)
.workspace_dir(std::env::temp_dir())
.workspace_dir(tmp.path().to_path_buf())
.build()
.unwrap()
.unwrap();
(agent, tmp)
}
fn build_agent_with_memory(
@@ -287,32 +296,36 @@ fn build_agent_with_memory(
tools: Vec<Box<dyn Tool>>,
mem: Arc<dyn Memory>,
auto_save: bool,
) -> Agent {
Agent::builder()
) -> (Agent, tempfile::TempDir) {
let tmp = tempfile::TempDir::new().unwrap();
let agent = Agent::builder()
.provider(provider)
.tools(tools)
.memory(mem)
.tool_dispatcher(Box::new(NativeToolDispatcher))
.workspace_dir(std::env::temp_dir())
.workspace_dir(tmp.path().to_path_buf())
.auto_save(auto_save)
.build()
.unwrap()
.unwrap();
(agent, tmp)
}
fn build_agent_with_config(
provider: Box<dyn Provider>,
tools: Vec<Box<dyn Tool>>,
config: AgentConfig,
) -> Agent {
Agent::builder()
) -> (Agent, tempfile::TempDir) {
let (mem, tmp) = make_memory();
let agent = Agent::builder()
.provider(provider)
.tools(tools)
.memory(make_memory())
.memory(mem)
.tool_dispatcher(Box::new(NativeToolDispatcher))
.workspace_dir(std::env::temp_dir())
.workspace_dir(tmp.path().to_path_buf())
.config(config)
.build()
.unwrap()
.unwrap();
(agent, tmp)
}
/// Helper: create a ChatResponse with tool calls (native format).
@@ -348,7 +361,7 @@ fn xml_tool_response(name: &str, args: &str) -> ChatResponse {
#[tokio::test]
async fn turn_returns_text_when_no_tools_called() {
let provider = Box::new(ScriptedProvider::new(vec![text_response("Hello world")]));
let mut agent = build_agent_with(
let (mut agent, _tmp) = build_agent_with(
provider,
vec![Box::new(EchoTool)],
Box::new(NativeToolDispatcher),
@@ -376,7 +389,7 @@ async fn turn_executes_single_tool_then_returns() {
text_response("I ran the tool"),
]));
let mut agent = build_agent_with(
let (mut agent, _tmp) = build_agent_with(
provider,
vec![Box::new(EchoTool)],
Box::new(NativeToolDispatcher),
@@ -416,7 +429,7 @@ async fn turn_handles_multi_step_tool_chain() {
text_response("Done after 3 calls"),
]));
let mut agent = build_agent_with(
let (mut agent, _tmp) = build_agent_with(
provider,
vec![Box::new(counting_tool)],
Box::new(NativeToolDispatcher),
@@ -454,7 +467,7 @@ async fn turn_bails_out_at_max_iterations() {
..AgentConfig::default()
};
let mut agent = build_agent_with_config(provider, vec![Box::new(EchoTool)], config);
let (mut agent, _tmp) = build_agent_with_config(provider, vec![Box::new(EchoTool)], config);
let result = agent.turn("infinite loop").await;
assert!(result.is_err());
@@ -480,7 +493,7 @@ async fn turn_handles_unknown_tool_gracefully() {
text_response("I couldn't find that tool"),
]));
let mut agent = build_agent_with(
let (mut agent, _tmp) = build_agent_with(
provider,
vec![Box::new(EchoTool)],
Box::new(NativeToolDispatcher),
@@ -520,7 +533,7 @@ async fn turn_recovers_from_tool_failure() {
text_response("Tool failed but I recovered"),
]));
let mut agent = build_agent_with(
let (mut agent, _tmp) = build_agent_with(
provider,
vec![Box::new(FailingTool)],
Box::new(NativeToolDispatcher),
@@ -544,7 +557,7 @@ async fn turn_recovers_from_tool_error() {
text_response("I recovered from the error"),
]));
let mut agent = build_agent_with(
let (mut agent, _tmp) = build_agent_with(
provider,
vec![Box::new(PanickingTool)],
Box::new(NativeToolDispatcher),
@@ -563,7 +576,7 @@ async fn turn_recovers_from_tool_error() {
#[tokio::test]
async fn turn_propagates_provider_error() {
let mut agent = build_agent_with(
let (mut agent, _tmp) = build_agent_with(
Box::new(FailingProvider),
vec![],
Box::new(NativeToolDispatcher),
@@ -591,7 +604,7 @@ async fn history_trims_after_max_messages() {
..AgentConfig::default()
};
let mut agent = build_agent_with_config(provider, vec![], config);
let (mut agent, _tmp) = build_agent_with_config(provider, vec![], config);
for i in 0..max_history + 5 {
let _ = agent.turn(&format!("msg {i}")).await.unwrap();
@@ -622,7 +635,7 @@ async fn auto_save_stores_messages_in_memory() {
"I remember everything",
)]));
let mut agent = build_agent_with_memory(
let (mut agent, _tmp2) = build_agent_with_memory(
provider,
vec![],
mem.clone(),
@@ -644,7 +657,7 @@ async fn auto_save_disabled_does_not_store() {
let (mem, _tmp) = make_sqlite_memory();
let provider = Box::new(ScriptedProvider::new(vec![text_response("hello")]));
let mut agent = build_agent_with_memory(
let (mut agent, _tmp2) = build_agent_with_memory(
provider,
vec![],
mem.clone(),
@@ -668,7 +681,7 @@ async fn xml_dispatcher_parses_and_loops() {
text_response("XML tool completed"),
]));
let mut agent = build_agent_with(
let (mut agent, _tmp) = build_agent_with(
provider,
vec![Box::new(EchoTool)],
Box::new(XmlToolDispatcher),
@@ -684,7 +697,7 @@ async fn xml_dispatcher_parses_and_loops() {
#[tokio::test]
async fn native_dispatcher_sends_tool_specs() {
let provider = Box::new(ScriptedProvider::new(vec![text_response("ok")]));
let mut agent = build_agent_with(
let (mut agent, _tmp) = build_agent_with(
provider,
vec![Box::new(EchoTool)],
Box::new(NativeToolDispatcher),
@@ -714,7 +727,7 @@ async fn turn_handles_empty_text_response() {
tool_calls: vec![],
}]));
let mut agent = build_agent_with(provider, vec![], Box::new(NativeToolDispatcher));
let (mut agent, _tmp) = build_agent_with(provider, vec![], Box::new(NativeToolDispatcher));
let response = agent.turn("hi").await.unwrap();
assert!(response.is_empty());
@@ -727,7 +740,7 @@ async fn turn_handles_none_text_response() {
tool_calls: vec![],
}]));
let mut agent = build_agent_with(provider, vec![], Box::new(NativeToolDispatcher));
let (mut agent, _tmp) = build_agent_with(provider, vec![], Box::new(NativeToolDispatcher));
// Should not panic — falls back to empty string
let response = agent.turn("hi").await.unwrap();
@@ -752,7 +765,7 @@ async fn turn_preserves_text_alongside_tool_calls() {
text_response("Here are the results"),
]));
let mut agent = build_agent_with(
let (mut agent, _tmp) = build_agent_with(
provider,
vec![Box::new(EchoTool)],
Box::new(NativeToolDispatcher),
@@ -801,7 +814,7 @@ async fn turn_handles_multiple_tools_in_one_response() {
text_response("All 3 done"),
]));
let mut agent = build_agent_with(
let (mut agent, _tmp) = build_agent_with(
provider,
vec![Box::new(counting_tool)],
Box::new(NativeToolDispatcher),
@@ -826,7 +839,7 @@ async fn turn_handles_multiple_tools_in_one_response() {
#[tokio::test]
async fn system_prompt_injected_on_first_turn() {
let provider = Box::new(ScriptedProvider::new(vec![text_response("ok")]));
let mut agent = build_agent_with(
let (mut agent, _tmp) = build_agent_with(
provider,
vec![Box::new(EchoTool)],
Box::new(NativeToolDispatcher),
@@ -850,7 +863,7 @@ async fn system_prompt_not_duplicated_on_second_turn() {
text_response("first"),
text_response("second"),
]));
let mut agent = build_agent_with(
let (mut agent, _tmp) = build_agent_with(
provider,
vec![Box::new(EchoTool)],
Box::new(NativeToolDispatcher),
@@ -882,7 +895,7 @@ async fn history_contains_all_expected_entries_after_tool_loop() {
text_response("final answer"),
]));
let mut agent = build_agent_with(
let (mut agent, _tmp) = build_agent_with(
provider,
vec![Box::new(EchoTool)],
Box::new(NativeToolDispatcher),
@@ -921,11 +934,12 @@ async fn history_contains_all_expected_entries_after_tool_loop() {
#[tokio::test]
async fn builder_fails_without_provider() {
let (mem, _tmp) = make_memory();
let result = Agent::builder()
.tools(vec![])
.memory(make_memory())
.memory(mem)
.tool_dispatcher(Box::new(NativeToolDispatcher))
.workspace_dir(std::path::PathBuf::from("/tmp"))
.workspace_dir(_tmp.path().to_path_buf())
.build();
assert!(result.is_err(), "Building without provider should fail");
@@ -943,7 +957,7 @@ async fn multi_turn_maintains_growing_history() {
text_response("response 3"),
]));
let mut agent = build_agent_with(provider, vec![], Box::new(NativeToolDispatcher));
let (mut agent, _tmp) = build_agent_with(provider, vec![], Box::new(NativeToolDispatcher));
let r1 = agent.turn("msg 1").await.unwrap();
let len_after_1 = agent.history().len();
@@ -1261,7 +1275,7 @@ async fn clear_history_resets_conversation() {
text_response("second"),
]));
let mut agent = build_agent_with(provider, vec![], Box::new(NativeToolDispatcher));
let (mut agent, _tmp) = build_agent_with(provider, vec![], Box::new(NativeToolDispatcher));
let _ = agent.turn("hi").await.unwrap();
assert!(!agent.history().is_empty());
@@ -1284,7 +1298,7 @@ async fn clear_history_resets_conversation() {
#[tokio::test]
async fn run_single_delegates_to_turn() {
let provider = Box::new(ScriptedProvider::new(vec![text_response("via run_single")]));
let mut agent = build_agent_with(provider, vec![], Box::new(NativeToolDispatcher));
let (mut agent, _tmp) = build_agent_with(provider, vec![], Box::new(NativeToolDispatcher));
let response = agent.run_single("test").await.unwrap();
assert!(