mod common;
use agent_block_testkit::server::MockLlm;
use agent_block_testkit::shapes::{anthropic, openai};
use predicates::prelude::*;
use serde_json::json;
use std::sync::atomic::Ordering;
use tempfile::tempdir;
fn two_paths(paths: &[String]) -> (String, String) {
match paths.len() {
0 => ("file_a.lua".to_string(), "file_b.lua".to_string()),
1 => (paths[0].clone(), "file_b.lua".to_string()),
_ => (paths[0].clone(), paths[1].clone()),
}
}
fn fs_edit_line1(id: &str, path: &str, expect: &str, replace: &str) -> serde_json::Value {
anthropic::tool_use(
id,
"fs_edit",
json!({
"path": path,
"edits": [{
"start_line": 1,
"end_line": 1,
"expect": expect,
"replace": replace
}]
}),
)
}
fn fs_edit_both(path_a: &str, path_b: &str) -> Vec<serde_json::Value> {
vec![
fs_edit_line1(
"toolu_multi_a",
path_a,
"print(\"a-old\")",
"print(\"a-new\")",
),
fs_edit_line1(
"toolu_multi_b",
path_b,
"print(\"b-old\")",
"print(\"b-new\")",
),
]
}
#[tokio::test]
async fn compile_loop_diff_anthropic_mock_iterates_until_pass() {
let (base_url, call_count, ct) =
common::compile_loop_diff_anthropic_mock::spawn_compile_loop_diff_anthropic_mock_server()
.await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let url_clone = base_url.clone();
tokio::task::spawn_blocking(move || {
let tmp = tempdir().expect("tempdir");
let target_file = tmp.path().join("target.lua");
common::agent_block_cmd()
.args([
"-s",
&common::fixture("compile_loop_diff_anthropic_mock.lua"),
])
.env("ANTHROPIC_BASE_URL_TEST", &url_clone)
.env(
"COMPILE_LOOP_TARGET",
target_file.to_str().expect("utf8 path"),
)
.env("AGENT_BLOCK_HOME", tmp.path())
.env("RUST_LOG", "off")
.assert()
.success()
.stdout(predicate::str::contains("COMPILE_LOOP_DIFF_MOCK_PASS"));
})
.await
.expect("subprocess assertion task should not panic");
assert_eq!(
call_count.load(Ordering::SeqCst),
2,
"two iterations, one call each: the edit that is refused, then the one that lands"
);
ct.cancel();
}
#[tokio::test]
async fn compile_loop_openai_mock_iterates_until_pass() {
let (base_url, call_count, ct) =
common::compile_loop_openai_mock::spawn_compile_loop_openai_mock_server().await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let url_clone = base_url.clone();
tokio::task::spawn_blocking(move || {
let tmp = tempdir().expect("tempdir");
let target_file = tmp.path().join("target.lua");
common::agent_block_cmd()
.args(["-s", &common::fixture("compile_loop_openai_mock.lua")])
.env("OPENAI_BASE_URL_TEST", &url_clone)
.env(
"COMPILE_LOOP_TARGET",
target_file.to_str().expect("utf8 path"),
)
.env("AGENT_BLOCK_HOME", tmp.path())
.env("RUST_LOG", "off")
.assert()
.success()
.stdout(predicate::str::contains("COMPILE_LOOP_MOCK_PASS"));
})
.await
.expect("subprocess assertion task should not panic");
assert_eq!(
call_count.load(Ordering::SeqCst),
2,
"expected exactly 2 HTTP calls to the mock (turn 1: broken, turn 2: fixed)"
);
ct.cancel();
}
#[tokio::test]
async fn compile_loop_anthropic_mock_iterates_until_pass() {
let (base_url, call_count, ct) =
common::compile_loop_anthropic_mock::spawn_compile_loop_anthropic_mock_server().await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let url_clone = base_url.clone();
tokio::task::spawn_blocking(move || {
let tmp = tempdir().expect("tempdir");
let target_file = tmp.path().join("target.lua");
common::agent_block_cmd()
.args(["-s", &common::fixture("compile_loop_anthropic_mock.lua")])
.env("ANTHROPIC_BASE_URL_TEST", &url_clone)
.env(
"COMPILE_LOOP_TARGET",
target_file.to_str().expect("utf8 path"),
)
.env("AGENT_BLOCK_HOME", tmp.path())
.env("RUST_LOG", "off")
.assert()
.success()
.stdout(predicate::str::contains("COMPILE_LOOP_MOCK_PASS"));
})
.await
.expect("subprocess assertion task should not panic");
assert_eq!(
call_count.load(Ordering::SeqCst),
2,
"expected exactly 2 HTTP calls to the anthropic mock"
);
ct.cancel();
}
#[tokio::test]
async fn compile_loop_diff_multi_anthropic_mock_iterates_until_pass() {
let handle = MockLlm::anthropic(|req| {
let (path_a, path_b) = two_paths(&req.paths);
if req.call_index == 0 {
anthropic::tool_use_response(fs_edit_both(&path_a, &path_b))
} else {
anthropic::text_response("DONE")
}
})
.spawn()
.await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let url_clone = handle.base_url.clone();
tokio::task::spawn_blocking(move || {
let tmp = tempdir().expect("tempdir");
let file_a = tmp.path().join("file_a.lua");
let file_b = tmp.path().join("file_b.lua");
common::agent_block_cmd()
.args([
"-s",
&common::fixture("compile_loop_diff_multi_anthropic_mock.lua"),
])
.env("ANTHROPIC_BASE_URL_TEST", &url_clone)
.env(
"COMPILE_LOOP_TARGET_FILES",
format!(
"{}:{}",
file_a.to_str().expect("utf8 path"),
file_b.to_str().expect("utf8 path")
),
)
.env("AGENT_BLOCK_HOME", tmp.path())
.env("RUST_LOG", "off")
.assert()
.success()
.stdout(predicate::str::contains(
"COMPILE_LOOP_DIFF_MULTI_MOCK_PASS",
));
})
.await
.expect("subprocess assertion task should not panic");
assert_eq!(
handle.state.call_count(),
1,
"one iteration: both files are patched in one turn and the verify passes"
);
handle.ct.cancel();
}
#[tokio::test]
async fn compile_loop_fs_edit_tool_converges() {
let handle = MockLlm::anthropic(|req| {
let (path_a, path_b) = two_paths(&req.paths);
if req.call_index == 0 {
anthropic::tool_use_response(vec![
anthropic::tool_use(
"toolu_asr_1",
"fs_edit",
json!({"path": path_a, "edits": [{
"start_line": 1, "end_line": 1,
"expect": "print(\"a-old\")", "replace": "print(\"a-new\")"
}]}),
),
anthropic::tool_use(
"toolu_asr_2",
"fs_edit",
json!({"path": path_b, "edits": [{
"start_line": 1, "end_line": 1,
"expect": "print(\"b-old\")", "replace": "print(\"b-new\")"
}]}),
),
])
} else {
anthropic::text_response("DONE")
}
})
.spawn()
.await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let url_clone = handle.base_url.clone();
tokio::task::spawn_blocking(move || {
let tmp = tempdir().expect("tempdir");
let file_a = tmp.path().join("file_a.lua");
let file_b = tmp.path().join("file_b.lua");
common::agent_block_cmd()
.args([
"-s",
&common::fixture("compile_loop_asr_anthropic_mock.lua"),
])
.env("ANTHROPIC_BASE_URL_TEST", &url_clone)
.env(
"COMPILE_LOOP_TARGET_FILES",
format!(
"{}:{}",
file_a.to_str().expect("utf8 path"),
file_b.to_str().expect("utf8 path")
),
)
.env("AGENT_BLOCK_HOME", tmp.path())
.env("RUST_LOG", "off")
.assert()
.success()
.stdout(predicate::str::contains("COMPILE_LOOP_ASR_MOCK_PASS"));
})
.await
.expect("subprocess assertion task should not panic");
assert_eq!(
handle.state.call_count(),
1,
"expected exactly 1 HTTP call (both edits in one turn, then the verify)"
);
assert!(
handle.state.declared_count_of("fs_edit") >= 1,
"tool_mode=auto must declare the fs_edit tool in the request"
);
handle.ct.cancel();
}
#[tokio::test]
async fn compile_loop_broken_openai_tool_calls_shape_converges() {
let handle = MockLlm::openai(|req| {
let (path_a, path_b) = two_paths(&req.paths);
let (expect_a, expect_b) = if req.call_index == 0 {
("print(\"WRONG-a\")", "print(\"WRONG-b\")")
} else {
("print(\"a-old\")", "print(\"b-old\")")
};
openai::tool_calls_response(vec![
openai::tool_call_object_args_no_id(
"fs_edit",
json!({"path": path_a, "edits": [{
"start_line": 1, "end_line": 1,
"expect": expect_a, "replace": "print(\"a-new\")"
}]}),
),
openai::tool_call_object_args_no_id(
"fs_edit",
json!({"path": path_b, "edits": [{
"start_line": 1, "end_line": 1,
"expect": expect_b, "replace": "print(\"b-new\")"
}]}),
),
])
})
.spawn()
.await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let url_clone = handle.base_url.clone();
tokio::task::spawn_blocking(move || {
let tmp = tempdir().expect("tempdir");
let file_a = tmp.path().join("file_a.lua");
let file_b = tmp.path().join("file_b.lua");
common::agent_block_cmd()
.args([
"-s",
&common::fixture("compile_loop_broken_openai_mock.lua"),
])
.env("OPENAI_BASE_URL_TEST", &url_clone)
.env(
"COMPILE_LOOP_TARGET_FILES",
format!(
"{}:{}",
file_a.to_str().expect("utf8 path"),
file_b.to_str().expect("utf8 path")
),
)
.env("AGENT_BLOCK_HOME", tmp.path())
.env("RUST_LOG", "off")
.assert()
.success()
.stdout(predicate::str::contains(
"COMPILE_LOOP_BROKEN_OPENAI_MOCK_PASS",
));
})
.await
.expect("subprocess assertion task should not panic");
assert_eq!(
handle.state.call_count(),
2,
"expected exactly 2 HTTP calls (rejected broken-shape edits, then the ones that land)"
);
let ids = handle.state.tool_result_ids();
assert!(
ids.len() >= 2,
"expected >=2 role=tool messages carrying tool results, got {ids:?}"
);
assert!(
ids.iter()
.all(|id| id.len() == 9 && id.chars().all(|c| c.is_ascii_alphanumeric())),
"every role=tool message must carry a synthesized 9-char alphanumeric id, got {ids:?}"
);
assert_ne!(
ids[0], ids[1],
"two tool calls in one turn must get distinct synthesized ids, got {ids:?}"
);
handle.ct.cancel();
}
#[tokio::test]
async fn compile_loop_diff_multi_anthropic_mock_two_iter_converges() {
let handle = MockLlm::anthropic(|req| {
let (path_a, path_b) = two_paths(&req.paths);
if req.call_index == 0 {
anthropic::tool_use_response(vec![fs_edit_line1(
"toolu_multi_wrong",
&path_a,
"print(\"WRONG\")",
"print(\"a-new\")",
)])
} else {
anthropic::tool_use_response(fs_edit_both(&path_a, &path_b))
}
})
.spawn()
.await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let url_clone = handle.base_url.clone();
tokio::task::spawn_blocking(move || {
let tmp = tempdir().expect("tempdir");
let file_a = tmp.path().join("file_a.lua");
let file_b = tmp.path().join("file_b.lua");
common::agent_block_cmd()
.args([
"-s",
&common::fixture("compile_loop_diff_multi_anthropic_mock_two_iter.lua"),
])
.env("ANTHROPIC_BASE_URL_TEST", &url_clone)
.env(
"COMPILE_LOOP_TARGET_FILES",
format!(
"{}:{}",
file_a.to_str().expect("utf8 path"),
file_b.to_str().expect("utf8 path")
),
)
.env("AGENT_BLOCK_HOME", tmp.path())
.env("RUST_LOG", "off")
.assert()
.success()
.stdout(predicate::str::contains(
"COMPILE_LOOP_DIFF_MULTI_MOCK_TWO_ITER_PASS",
));
})
.await
.expect("subprocess assertion task should not panic");
assert_eq!(
handle.state.call_count(),
2,
"two iterations, one call each: the rejected edit, then the two that apply"
);
handle.ct.cancel();
}
#[tokio::test]
async fn compile_loop_reads_a_large_file_by_range() {
let (addr, state) = common::compile_loop_range_mock::spawn_range_mock().await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let base_url = format!("http://{addr}");
tokio::task::spawn_blocking(move || {
let tmp = tempdir().expect("tempdir");
let target_file = tmp.path().join("range_target.lua");
common::agent_block_cmd()
.args(["-s", &common::fixture("compile_loop_range_mock.lua")])
.env("ANTHROPIC_BASE_URL_TEST", &base_url)
.env(
"COMPILE_LOOP_RANGE_TARGET",
target_file.to_str().expect("utf8 path"),
)
.env("AGENT_BLOCK_HOME", tmp.path())
.env("RUST_LOG", "off")
.assert()
.success()
.stdout(predicate::str::contains("READ_FILE_RANGE_VERBATIM_PASS"));
})
.await
.expect("subprocess assertion task should not panic");
let results = state.tool_result_texts();
assert!(
results.len() >= 2,
"expected the read and the range results, got {results:?}"
);
assert!(
results[0].contains("too large: 600 lines") && results[0].contains("read_file_range"),
"fs_read on an oversized file must answer its length and point at the range read, got {:?}",
results[0]
);
let expected_range = (10..=20)
.map(|n| format!("{n}\t-- verbatim-line-{n:02}"))
.collect::<Vec<_>>()
.join("\n");
assert_eq!(
results[1], expected_range,
"read_file_range must hand back lines 10-20 verbatim and line-numbered"
);
assert_eq!(
state.call_count.load(Ordering::SeqCst),
3,
"expected exactly 3 calls (read, range, edit) — one per iteration"
);
}
#[tokio::test]
async fn compile_loop_openai_mock_three_turn_converges() {
let (base_url, call_count, ct) = common::compile_loop_openai_mock_three_turn::spawn_compile_loop_openai_mock_three_turn_server().await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let url_clone = base_url.clone();
tokio::task::spawn_blocking(move || {
let tmp = tempdir().expect("tempdir");
let target_file = tmp.path().join("target.lua");
common::agent_block_cmd()
.args([
"-s",
&common::fixture("compile_loop_openai_mock_three_turn.lua"),
])
.env("OPENAI_BASE_URL_TEST", &url_clone)
.env(
"COMPILE_LOOP_TARGET",
target_file.to_str().expect("utf8 path"),
)
.env("AGENT_BLOCK_HOME", tmp.path())
.env("RUST_LOG", "off")
.assert()
.success()
.stdout(predicate::str::contains("COMPILE_LOOP_MOCK_PASS"));
})
.await
.expect("subprocess assertion task (run 1) should not panic");
assert_eq!(
call_count.load(Ordering::SeqCst),
3,
"run 1: expected exactly 3 HTTP calls to the 3-turn mock (broken1, broken2, fixed)"
);
call_count.store(0, Ordering::SeqCst);
let url_clone2 = base_url.clone();
tokio::task::spawn_blocking(move || {
let tmp = tempdir().expect("tempdir");
let target_file = tmp.path().join("target.lua");
common::agent_block_cmd()
.args([
"-s",
&common::fixture("compile_loop_openai_mock_three_turn.lua"),
])
.env("OPENAI_BASE_URL_TEST", &url_clone2)
.env(
"COMPILE_LOOP_TARGET",
target_file.to_str().expect("utf8 path"),
)
.env("AGENT_BLOCK_HOME", tmp.path())
.env("RUST_LOG", "off")
.assert()
.success()
.stdout(predicate::str::contains("COMPILE_LOOP_MOCK_PASS"));
})
.await
.expect("subprocess assertion task (run 2) should not panic");
assert_eq!(
call_count.load(Ordering::SeqCst),
3,
"run 2: expected exactly 3 HTTP calls to the 3-turn mock (broken1, broken2, fixed)"
);
ct.cancel();
}
#[tokio::test]
async fn compile_loop_extra_tools_are_declared_and_dispatched() {
let handle = MockLlm::anthropic(|req| {
let declared_hint = req
.body
.to_string()
.contains("Return the replacement the spec is asking for");
if !declared_hint {
return anthropic::text_response("get_hint was not declared");
}
if req.call_index == 0 {
anthropic::tool_use_response(vec![anthropic::tool_use(
"toolu_hint",
"get_hint",
json!({}),
)])
} else {
let path = req.paths.first().cloned().unwrap_or_default();
anthropic::tool_use_response(vec![anthropic::tool_use(
"toolu_edit",
"fs_edit",
json!({"path": path, "edits": [{
"start_line": 1, "end_line": 1,
"expect": "print(\"hello\")", "replace": "print(\"world\")"
}]}),
)])
}
})
.spawn()
.await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let url = handle.base_url.clone();
tokio::task::spawn_blocking(move || {
let tmp = tempdir().expect("tempdir");
let target = tmp.path().join("target.lua");
common::agent_block_cmd()
.args(["-s", &common::fixture("compile_loop_extra_tools_mock.lua")])
.env("ANTHROPIC_BASE_URL_TEST", &url)
.env("COMPILE_LOOP_TARGET", target.to_str().expect("utf8 path"))
.env("AGENT_BLOCK_HOME", tmp.path())
.env("RUST_LOG", "off")
.assert()
.success()
.stdout(predicate::str::contains("[XT] ok=true"))
.stdout(predicate::str::contains("[XT] hint_calls=1"));
})
.await
.expect("subprocess assertion task should not panic");
handle.ct.cancel();
}
#[tokio::test]
async fn compile_loop_read_only_withholds_the_edit_tool() {
let handle = MockLlm::anthropic(|_req| anthropic::text_response("I would edit line 1."))
.spawn()
.await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let url = handle.base_url.clone();
tokio::task::spawn_blocking(move || {
let tmp = tempdir().expect("tempdir");
let target = tmp.path().join("target.lua");
common::agent_block_cmd()
.args(["-s", &common::fixture("compile_loop_read_only_mock.lua")])
.env("ANTHROPIC_BASE_URL_TEST", &url)
.env("COMPILE_LOOP_TARGET", target.to_str().expect("utf8 path"))
.env("AGENT_BLOCK_HOME", tmp.path())
.env("RUST_LOG", "off")
.assert()
.success()
.stdout(predicate::str::contains("[RO] ok=false"))
.stdout(predicate::str::contains("[RO] failure_reason=max_iters"));
})
.await
.expect("subprocess assertion task should not panic");
assert_eq!(
handle.state.declared_count_of("fs_edit"),
0,
"read_only must not declare the edit tool: {:?}",
handle.state.declared_tool_names()
);
assert!(
handle.state.declared_count_of("fs_read") >= 1,
"read_only still declares the reads: {:?}",
handle.state.declared_tool_names()
);
handle.ct.cancel();
}
#[tokio::test]
async fn compile_loop_stops_when_the_verify_repeats_itself() {
let handle = MockLlm::anthropic(|req| {
let path = req.paths.first().cloned().unwrap_or_default();
let line = req.call_index + 1;
anthropic::tool_use_response(vec![anthropic::tool_use(
&format!("toolu_stag_{line}"),
"fs_edit",
json!({"path": path, "edits": [{
"start_line": line, "end_line": line,
"expect": format!("line-{line}"), "replace": format!("edited-{line}")
}]}),
)])
})
.spawn()
.await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let url = handle.base_url.clone();
tokio::task::spawn_blocking(move || {
let tmp = tempdir().expect("tempdir");
let target = tmp.path().join("target.lua");
common::agent_block_cmd()
.args(["-s", &common::fixture("compile_loop_stagnation_mock.lua")])
.env("ANTHROPIC_BASE_URL_TEST", &url)
.env("COMPILE_LOOP_TARGET", target.to_str().expect("utf8 path"))
.env("AGENT_BLOCK_HOME", tmp.path())
.env("RUST_LOG", "off")
.assert()
.success()
.stdout(predicate::str::contains("[STAG] ok=false"))
.stdout(predicate::str::contains("[STAG] failure_reason=stagnation"))
.stdout(predicate::str::contains("[STAG] iters=3"));
})
.await
.expect("subprocess assertion task should not panic");
assert_eq!(
handle.state.call_count(),
3,
"the run gives up on the third identical verify, well inside its budget"
);
handle.ct.cancel();
}