use super::fixtures::*;
use crate::prompt::run;
use brazen::FinishReason;
use serde_json::{Value, json};
fn tool_use_stream(id: &str, name: &str, cmd_key: &str, cmd_val: &str) -> Vec<u8> {
stream_of(
FinishReason::ToolUse,
&[Block::ToolUse {
id,
name,
input: json!({ cmd_key: cmd_val }),
}],
)
}
pub(super) fn final_stream() -> Vec<u8> {
stream_of(FinishReason::Stop, &[Block::Text("done")])
}
fn last_line_type(bytes: &[u8]) -> String {
let lines = parse_jsonl(bytes);
lines.last().unwrap()["type"].as_str().unwrap().to_string()
}
fn finish_reason(bytes: &[u8]) -> String {
parse_jsonl(bytes)
.into_iter()
.find(|e| e["type"] == "finish")
.unwrap()["reason"]
.as_str()
.unwrap()
.to_string()
}
#[test]
fn loop_runs_two_steps_when_first_completion_is_tool_use() {
let repo = scaffold_repo(VALID_PER_REPO_PROVIDERS_YAML, Some("body"));
let harness = scaffold_harness_root();
let r1 = tool_use_stream("toolu_01", "bash", "cmd", "ls");
let r2 = final_stream();
let adapter = StubAdapter::scripted([
StubAdapter::reply_ok(&version_line()),
StubAdapter::reply_ok(&r1),
StubAdapter::reply_ok(&r2),
]);
let git = StubGit::ok();
let (clock, id) = (FixedClock::default(), FixedIdGen);
let (sleeper, tool_executor) = (
StubSleeper::default(),
StubToolExecutor::with_reply("bash", "files: a b"),
);
let branch = run(
repo.path(),
"list files",
None,
None,
None,
crate::prompt::PinnedDocs::none(),
None,
&valid_deps(
&adapter,
&sleeper,
&git,
&clock,
&id,
&tool_executor,
harness.path(),
),
)
.unwrap();
assert_eq!(branch, "ct-1-deadbeef");
let worktree = worktree_path(repo.path());
let tool_calls = tool_executor.invocations.borrow().clone();
assert_eq!(tool_calls.len(), 1);
let (step_dir, tid, name, input) = &tool_calls[0];
assert_eq!(step_dir, &repo.path().join("steps/ct-1-deadbeef/001"));
assert_eq!(
(tid.as_str(), name.as_str(), &input["cmd"]),
("toolu_01", "bash", &json!("ls"))
);
assert!(!worktree.join("steps").exists());
let step1_dir = repo.path().join("steps/ct-1-deadbeef/001");
let step2_dir = repo.path().join("steps/ct-1-deadbeef/002");
let req1: Value =
serde_json::from_slice(&std::fs::read(step1_dir.join("request.json")).unwrap()).unwrap();
assert_eq!(req1["messages"].as_array().unwrap().len(), 1);
assert_eq!(
req1["messages"][0]["content"][0]["text"],
"---\nfrom: user\ndeposited_at: iso-1\n---\nlist files"
);
let req2: Value =
serde_json::from_slice(&std::fs::read(step2_dir.join("request.json")).unwrap()).unwrap();
let msgs = req2["messages"].as_array().unwrap();
assert_eq!(msgs.len(), 3);
assert_eq!(msgs[1]["role"], "assistant");
assert_eq!(msgs[1]["content"][0]["id"], "toolu_01");
assert_eq!(msgs[2]["role"], "tool");
assert_eq!(msgs[2]["content"][0]["tool_use_id"], "toolu_01");
assert_eq!(msgs[2]["content"][0]["content"][0]["text"], "files: a b");
assert!(worktree.join("goal.md").exists());
let resp2 = std::fs::read(step2_dir.join("response.json")).unwrap();
assert_eq!(last_line_type(&resp2), "end");
assert_eq!(finish_reason(&resp2), "stop");
let runs = git.runs.borrow();
assert_eq!(runs.len(), 32);
assert_eq!(runs[16].1, vec!["add", "name"]);
assert_eq!(runs[17].1, vec!["add", "goal.md", "soul.md"]);
assert!(runs[18].1[2].contains("step 001: dispatch"));
assert_eq!(runs[19].1, vec!["status", "--porcelain", "--", "messages"]);
assert_eq!(runs[20].1, vec!["add", "messages/001-user.md"]);
assert!(runs[21].1[2].contains("transcript 001: user"));
assert_eq!(runs[22].1, vec!["rev-parse", "HEAD"]);
assert_eq!(runs[23].1, vec!["add", "messages/002-claude-sonnet-5.json"]);
assert!(runs[24].1[2].contains("transcript 002: claude-sonnet-5"));
assert_eq!(runs[25].1[..2], ["cat-file", "blob"]);
assert_eq!(runs[26].1, vec!["add", "-A"]);
assert!(runs[27].1[2].contains("transcript 003: tool"));
assert_eq!(runs[28].1, vec!["status", "--porcelain", "--", "messages"]);
assert_eq!(runs[29].1, vec!["rev-parse", "HEAD"]);
assert_eq!(runs[30].1, vec!["add", "messages/004-claude-sonnet-5.json"]);
assert!(runs[31].1[2].contains("transcript 004: claude-sonnet-5"));
let tool_entry = worktree.join("messages/003-tool.json");
let blocks: Value = serde_json::from_slice(&std::fs::read(&tool_entry).unwrap()).unwrap();
assert_eq!(blocks[0]["type"], "tool_result");
assert_eq!(blocks[0]["tool_use_id"], "toolu_01");
assert_eq!(blocks[0]["content"][0]["text"], "files: a b");
}
#[test]
fn loop_runs_three_steps_when_two_completions_in_a_row_are_tool_use() {
let repo = scaffold_repo(VALID_PER_REPO_PROVIDERS_YAML, Some("body"));
let harness = scaffold_harness_root();
let r1 = tool_use_stream("toolu_01", "bash", "cmd", "ls");
let r2 = tool_use_stream("toolu_02", "bash", "cmd", "pwd");
let r3 = final_stream();
let adapter = StubAdapter::scripted([
StubAdapter::reply_ok(&version_line()),
StubAdapter::reply_ok(&r1),
StubAdapter::reply_ok(&r2),
StubAdapter::reply_ok(&r3),
]);
let git = StubGit::ok();
let (clock, id) = (FixedClock::default(), FixedIdGen);
let (sleeper, tool_executor) = (StubSleeper::default(), StubToolExecutor::ok());
run(
repo.path(),
"go",
None,
None,
None,
crate::prompt::PinnedDocs::none(),
None,
&valid_deps(
&adapter,
&sleeper,
&git,
&clock,
&id,
&tool_executor,
harness.path(),
),
)
.unwrap();
let step3_resp = repo.path().join("steps/ct-1-deadbeef/003/response.json");
assert!(step3_resp.exists());
assert!(!repo.path().join("steps/ct-1-deadbeef/004").exists());
let invocations = tool_executor.invocations.borrow().clone();
assert_eq!(invocations.len(), 2);
assert!(invocations[0].0.ends_with("steps/ct-1-deadbeef/001"));
assert!(invocations[1].0.ends_with("steps/ct-1-deadbeef/002"));
assert_eq!(invocations[0].1, "toolu_01");
assert_eq!(invocations[1].1, "toolu_02");
}
mod emission_order;