fabro/lib/apps/fabro-cli/tests/it/workflow/acp.rs
2026-09-01 16:14:34 -04:00

325 lines
10 KiB
Rust

#![expect(
clippy::disallowed_methods,
reason = "integration test initializes an isolated git repository with the system git binary"
)]
use fabro_acp::test_support::fake_acp_agent_script;
use fabro_config::Storage;
use fabro_test::test_context;
use fabro_types::EventBody;
use fabro_vault::{SecretType, Vault};
use super::{find_run_dir, has_event, read_conclusion, run_events, run_id_for, run_state};
use crate::cmd::support::output_stdout;
#[test]
fn acp_backend_workflow() {
let mut context = test_context!();
context.write_home(
".fabro/settings.toml",
"[server.auth]\nmethods = [\"dev-token\"]\n",
);
context.isolated_server();
let fake_agent = write_fake_acp_agent(&context);
let acp_config = fake_acp_config_attr(&fake_agent);
let workflow = context.temp_dir.join("acp_backend.fabro");
context.write_temp(
"acp_backend.fabro",
format!(
r#"digraph ACP {{
graph [goal="Exercise ACP backend"]
start [shape=Mdiamond]
work [type="agent", backend="acp", prompt="write hello.txt", acp.config={acp_config}]
exit [shape=Msquare]
start -> work
work -> exit
}}"#
),
);
init_git_repo(&context.temp_dir);
context
.run_cmd()
.args(["--auto-approve", "--environment", "local"])
.arg(&workflow)
.assert()
.success();
let run_dir = find_run_dir(&context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));
let events = run_events(&run_dir);
assert!(has_event(&run_dir, "agent.acp.started"));
assert!(has_event(&run_dir, "agent.acp.completed"));
let completed = events
.iter()
.find_map(|event| match &event.event.body {
EventBody::StageCompleted(props) if event.event.node_id.as_deref() == Some("work") => {
Some(props)
}
_ => None,
})
.expect("work stage should complete");
assert_eq!(completed.response.as_deref(), Some("hello from acp"));
assert!(
completed
.files_touched
.iter()
.any(|file| file == "hello.txt"),
"files_touched should include hello.txt: {:?}",
completed.files_touched
);
let state = serde_json::to_value(run_state(&run_dir)).expect("run state should serialize");
let stages = state["stages"]
.as_object()
.expect("run state should contain stages");
assert!(
stages.values().any(|stage| {
stage["provider_used"]["mode"] == "acp"
&& stage["provider_used"]["provider"] == "acp"
&& stage["provider_used"]["model"] == "fake"
&& stage["provider_used"].get("reasoning_effort").is_none()
&& stage["provider_used"].get("speed").is_none()
}),
"run projection should include ACP model usage metadata: {stages:?}"
);
}
#[test]
fn acp_backend_does_not_inject_registered_provider_credentials() {
let mut context = test_context!();
context.write_home(
".fabro/settings.toml",
"[server.auth]\nmethods = [\"dev-token\"]\n",
);
context.isolated_server();
seed_anthropic_vault(&context.storage_dir);
let fake_agent = write_fake_acp_agent(&context);
let env_record = context.temp_dir.join("acp-env.json");
let acp_config = fake_acp_config_attr_recording_env(&fake_agent, &env_record);
let workflow = context.temp_dir.join("acp_provider_env.fabro");
context.write_temp(
"acp_provider_env.fabro",
format!(
r#"digraph ACP {{
graph [goal="Exercise ACP backend with stored provider credentials"]
start [shape=Mdiamond]
work [type="agent", backend="acp", prompt="write hello.txt", acp.config={acp_config}]
exit [shape=Msquare]
start -> work
work -> exit
}}"#
),
);
init_git_repo(&context.temp_dir);
context
.run_cmd()
.env_remove("ANTHROPIC_API_KEY")
.env_remove("OPENAI_API_KEY")
.env_remove("GEMINI_API_KEY")
.args(["--auto-approve", "--environment", "local"])
.arg(&workflow)
.assert()
.success();
let run_dir = find_run_dir(&context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));
let recorded_env: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(&env_record)
.expect("fake ACP agent should record its environment"),
)
.expect("fake ACP environment record should be valid JSON");
assert_eq!(recorded_env, serde_json::json!({}));
}
#[test]
fn acp_artifacts_are_listed_when_touched_file_mtime_precedes_attempt_start() {
let mut context = test_context!();
context.write_home(
".fabro/settings.toml",
"[server.auth]\nmethods = [\"dev-token\"]\n",
);
context.isolated_server();
let fake_agent = write_fake_acp_agent(&context);
let acp_config = fake_acp_config_attr_with_env(&fake_agent, vec![
serde_json::json!({
"name": "ACP_WRITE_PATH",
"value": "verification-artifacts/report.md",
}),
serde_json::json!({
"name": "ACP_WRITE_CONTENT",
"value": "verified\n",
}),
serde_json::json!({
"name": "ACP_WRITE_MTIME_EPOCH",
"value": "946684800",
}),
]);
context.write_temp(
"acp_artifact_collection.fabro",
format!(
r#"digraph ACPArtifacts {{
graph [goal="Capture verification artifacts"]
start [shape=Mdiamond]
verify [type="agent", backend="acp", prompt="write verification artifact", acp.config={acp_config}]
exit [shape=Msquare]
start -> verify
verify -> exit
}}"#
),
);
context.write_temp(
"workflow.toml",
r#"_version = 1
[workflow]
graph = "acp_artifact_collection.fabro"
[run]
goal = "Capture verification artifacts"
[run.checkpoint]
exclude_globs = ["verification-artifacts/**"]
[run.artifacts]
include = ["verification-artifacts/**"]
"#,
);
init_git_repo(&context.temp_dir);
context
.run_cmd()
.args(["--auto-approve", "--environment", "local"])
.arg(context.temp_dir.join("workflow.toml"))
.assert()
.success();
let run_dir = find_run_dir(&context);
let run_id = run_id_for(&run_dir);
let events = run_events(&run_dir);
let completed = events
.iter()
.find_map(|event| match &event.event.body {
EventBody::StageCompleted(props)
if event.event.node_id.as_deref() == Some("verify") =>
{
Some(props)
}
_ => None,
})
.expect("verify stage should complete");
assert!(
completed
.files_touched
.iter()
.any(|file| file == "verification-artifacts/report.md"),
"files_touched should include the artifact path: {:?}",
completed.files_touched
);
let output = context
.command()
.args(["--json", "artifact", "list", &run_id])
.output()
.expect("artifact list should execute");
assert!(
output.status.success(),
"artifact list failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let artifacts: Vec<serde_json::Value> =
serde_json::from_str(&output_stdout(&output)).expect("artifact list JSON should parse");
assert!(
artifacts.iter().any(|artifact| {
artifact["node_slug"] == "verify"
&& artifact["relative_path"] == "verification-artifacts/report.md"
}),
"artifact list should include the touched verification artifact: {artifacts:?}"
);
let completed_run = events
.iter()
.find_map(|event| match &event.event.body {
EventBody::RunCompleted(props) => Some(props),
_ => None,
})
.expect("run.completed should be emitted");
assert!(
completed_run.artifact_count > 0,
"run.completed should report captured artifacts"
);
}
fn write_fake_acp_agent(context: &fabro_test::TestContext) -> std::path::PathBuf {
context.write_temp("fake_acp_agent.py", fake_acp_agent_script());
context.temp_dir.join("fake_acp_agent.py")
}
fn fake_acp_config_attr(script_path: &std::path::Path) -> String {
fake_acp_config_attr_with_env(script_path, Vec::new())
}
fn fake_acp_config_attr_recording_env(
script_path: &std::path::Path,
env_record: &std::path::Path,
) -> String {
fake_acp_config_attr_with_env(script_path, vec![
serde_json::json!({"name": "ACP_ENV_RECORD", "value": env_record.to_string_lossy()}),
serde_json::json!({
"name": "ACP_ENV_RECORD_KEYS",
"value": "ANTHROPIC_API_KEY,OPENAI_API_KEY,GEMINI_API_KEY",
}),
])
}
fn fake_acp_config_attr_with_env(
script_path: &std::path::Path,
extra_env: Vec<serde_json::Value>,
) -> String {
let mut env = vec![serde_json::json!({"name": "ACP_MODE", "value": "write_file"})];
env.extend(extra_env);
let config = serde_json::json!({
"type": "stdio",
"name": "fake",
"command": "python3",
"args": [script_path.to_string_lossy()],
"env": env,
})
.to_string();
format!("{config:?}")
}
fn seed_anthropic_vault(storage_dir: &std::path::Path) {
let mut vault =
Vault::load(Storage::new(storage_dir).secrets_path()).expect("test vault should load");
vault
.set(
"ANTHROPIC_API_KEY",
"vault-anthropic-key",
SecretType::Token,
None,
)
.expect("Anthropic credential should store in test vault");
}
fn init_git_repo(dir: &std::path::Path) {
let output = std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(dir)
.output()
.expect("git init should run");
assert!(
output.status.success(),
"git init failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}