mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
Unit tests (run_progress.rs, start.rs): - Bug 1: handle_json_line reads pre-rename field names (name/stage/branch/message) but real JSONL uses post-rename names (node_label/node_id/node_id/error) - Bug 5: start_run doesn't write Starting status before spawning engine - Bug 6: handle_json_line missing DevcontainerLifecycleStarted dispatch Integration tests (cli.rs): - Bug 2: _run_engine reads spec.workflow_path instead of cached graph.fabro - Bug 3: attach loop doesn't delete interview_request.json after handling - Bug 4: attach hardcodes verbose=false, ignoring spec.verbose Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
776 lines
24 KiB
Rust
776 lines
24 KiB
Rust
use assert_cmd::Command;
|
|
use predicates::prelude::*;
|
|
|
|
#[allow(deprecated)]
|
|
fn arc() -> Command {
|
|
Command::cargo_bin("fabro").unwrap()
|
|
}
|
|
|
|
// == LLM: prompt ==============================================================
|
|
|
|
#[test]
|
|
fn prompt_reads_from_stdin() {
|
|
let result = arc()
|
|
.args(["llm", "prompt", "--no-stream", "-m", "test-model"])
|
|
.write_stdin("hello from stdin")
|
|
.assert()
|
|
.failure();
|
|
|
|
// Should NOT complain about missing prompt
|
|
result.stderr(predicate::str::contains("no prompt provided").not());
|
|
}
|
|
|
|
#[test]
|
|
fn prompt_concatenates_stdin_and_arg() {
|
|
let result = arc()
|
|
.args([
|
|
"llm",
|
|
"prompt",
|
|
"--no-stream",
|
|
"-m",
|
|
"test-model",
|
|
"summarize this",
|
|
])
|
|
.write_stdin("some input text")
|
|
.assert()
|
|
.failure();
|
|
|
|
result.stderr(predicate::str::contains("no prompt provided").not());
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires API key"]
|
|
fn prompt_no_stream_generates_response() {
|
|
arc()
|
|
.args([
|
|
"llm",
|
|
"prompt",
|
|
"--no-stream",
|
|
"-m",
|
|
"claude-sonnet-4-5",
|
|
"Say just the word 'hello'",
|
|
])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::is_empty().not());
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires API key"]
|
|
fn prompt_stream_generates_response() {
|
|
arc()
|
|
.args([
|
|
"llm",
|
|
"prompt",
|
|
"-m",
|
|
"claude-sonnet-4-5",
|
|
"Say just the word 'hello'",
|
|
])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::is_empty().not());
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires API key"]
|
|
fn prompt_usage_shows_tokens() {
|
|
arc()
|
|
.args([
|
|
"llm",
|
|
"prompt",
|
|
"--no-stream",
|
|
"-u",
|
|
"-m",
|
|
"claude-sonnet-4-5",
|
|
"Say just the word 'hello'",
|
|
])
|
|
.assert()
|
|
.success()
|
|
.stderr(predicate::str::contains("Tokens:"));
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires API key"]
|
|
fn prompt_schema_no_stream_generates_json() {
|
|
let assert = arc()
|
|
.args([
|
|
"llm", "prompt", "--no-stream", "-m", "claude-sonnet-4-5",
|
|
"--schema", r#"{"type":"object","properties":{"greeting":{"type":"string"}},"required":["greeting"]}"#,
|
|
"Return a JSON object with a greeting field set to hello",
|
|
])
|
|
.assert()
|
|
.success();
|
|
|
|
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
|
|
let parsed: serde_json::Value =
|
|
serde_json::from_str(stdout.trim()).expect("stdout should be valid JSON");
|
|
assert!(
|
|
parsed.get("greeting").is_some(),
|
|
"expected 'greeting' key in output"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires API key"]
|
|
fn prompt_schema_stream_generates_json() {
|
|
let assert = arc()
|
|
.args([
|
|
"llm", "prompt", "-m", "claude-sonnet-4-5",
|
|
"--schema", r#"{"type":"object","properties":{"greeting":{"type":"string"}},"required":["greeting"]}"#,
|
|
"Return a JSON object with a greeting field set to hello",
|
|
])
|
|
.assert()
|
|
.success();
|
|
|
|
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
|
|
let parsed: serde_json::Value =
|
|
serde_json::from_str(stdout.trim()).expect("stdout should be valid JSON");
|
|
assert!(
|
|
parsed.get("greeting").is_some(),
|
|
"expected 'greeting' key in output"
|
|
);
|
|
}
|
|
|
|
// == LLM: chat ================================================================
|
|
|
|
#[test]
|
|
#[ignore = "requires API key"]
|
|
fn chat_multi_turn_with_system_prompt() {
|
|
let assert = arc()
|
|
.args([
|
|
"llm",
|
|
"chat",
|
|
"-m",
|
|
"claude-haiku-4-5",
|
|
"-s",
|
|
"You are a pilot. End every response with 'Roger that.'",
|
|
])
|
|
.write_stdin("What is your profession?\nWhat did I just ask you?\n")
|
|
.assert()
|
|
.success();
|
|
|
|
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
|
|
let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap();
|
|
|
|
// Verify model info printed to stderr
|
|
assert!(
|
|
stderr.contains("Using model:"),
|
|
"stderr should show model info"
|
|
);
|
|
|
|
// Verify the system prompt influenced the output
|
|
assert!(
|
|
stdout.to_lowercase().contains("roger that"),
|
|
"response should follow pilot system prompt, got: {stdout}"
|
|
);
|
|
|
|
// Verify multi-turn: the second response should reference the first question
|
|
assert!(
|
|
stdout.to_lowercase().contains("profession")
|
|
|| stdout.to_lowercase().contains("asked")
|
|
|| stdout.to_lowercase().contains("pilot"),
|
|
"second response should show multi-turn context, got: {stdout}"
|
|
);
|
|
}
|
|
|
|
// == Exec =====================================================================
|
|
|
|
#[test]
|
|
fn exec_missing_api_key_exits_with_error() {
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
arc()
|
|
.args(["exec", "test prompt"])
|
|
.env_clear()
|
|
.env("HOME", tmp.path().to_str().unwrap())
|
|
.current_dir(tmp.path())
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("API key not set"));
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires API key"]
|
|
fn exec_creates_file() {
|
|
dotenvy::dotenv().ok();
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
arc()
|
|
.args([
|
|
"exec",
|
|
"--auto-approve",
|
|
"--permissions",
|
|
"full",
|
|
"--provider",
|
|
"anthropic",
|
|
"--model",
|
|
"claude-haiku-4-5",
|
|
"Create a file called hello.txt containing exactly 'Hello'",
|
|
])
|
|
.current_dir(tmp.path())
|
|
.timeout(std::time::Duration::from_secs(120))
|
|
.assert()
|
|
.success();
|
|
let path = tmp.path().join("hello.txt");
|
|
assert!(path.exists(), "hello.txt should have been created");
|
|
let content = std::fs::read_to_string(&path).expect("read hello.txt");
|
|
assert!(
|
|
content.contains("Hello"),
|
|
"Expected 'Hello' in hello.txt, got: {content}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires API key"]
|
|
fn exec_shell_command() {
|
|
dotenvy::dotenv().ok();
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
arc()
|
|
.args([
|
|
"exec",
|
|
"--auto-approve",
|
|
"--permissions",
|
|
"full",
|
|
"--provider",
|
|
"anthropic",
|
|
"--model",
|
|
"claude-haiku-4-5",
|
|
"Run the shell command `echo arc_test_marker_42` and tell me what it printed",
|
|
])
|
|
.current_dir(tmp.path())
|
|
.timeout(std::time::Duration::from_secs(120))
|
|
.assert()
|
|
.success();
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires API key"]
|
|
fn exec_read_only_blocks_write() {
|
|
dotenvy::dotenv().ok();
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
arc()
|
|
.args([
|
|
"exec",
|
|
"--auto-approve",
|
|
"--permissions",
|
|
"read-only",
|
|
"--provider",
|
|
"anthropic",
|
|
"--model",
|
|
"claude-haiku-4-5",
|
|
"Create a file called forbidden.txt containing 'should not exist'",
|
|
])
|
|
.current_dir(tmp.path())
|
|
.timeout(std::time::Duration::from_secs(120))
|
|
.assert()
|
|
.success();
|
|
assert!(
|
|
!tmp.path().join("forbidden.txt").exists(),
|
|
"forbidden.txt should NOT exist under read-only permissions"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires API key"]
|
|
fn exec_json_output_format() {
|
|
dotenvy::dotenv().ok();
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
let output = arc()
|
|
.args([
|
|
"exec",
|
|
"--auto-approve",
|
|
"--permissions",
|
|
"full",
|
|
"--output-format",
|
|
"json",
|
|
"--provider",
|
|
"anthropic",
|
|
"--model",
|
|
"claude-haiku-4-5",
|
|
"Create a file called test.txt containing 'test'",
|
|
])
|
|
.current_dir(tmp.path())
|
|
.timeout(std::time::Duration::from_secs(120))
|
|
.assert()
|
|
.success()
|
|
.get_output()
|
|
.stdout
|
|
.clone();
|
|
let stdout = String::from_utf8(output).expect("valid utf8");
|
|
assert!(!stdout.trim().is_empty(), "json output should not be empty");
|
|
// Every non-empty line should be valid JSON
|
|
let lines: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect();
|
|
assert!(!lines.is_empty(), "should have at least one NDJSON line");
|
|
let first: serde_json::Value =
|
|
serde_json::from_str(lines[0]).expect("first line should be valid JSON");
|
|
assert!(
|
|
first.get("event").is_some() || first.get("type").is_some(),
|
|
"NDJSON line should have an event or type field, got: {first}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires API key"]
|
|
fn exec_read_and_edit() {
|
|
dotenvy::dotenv().ok();
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(tmp.path().join("data.txt"), "old content").expect("write data.txt");
|
|
arc()
|
|
.args([
|
|
"exec",
|
|
"--auto-approve",
|
|
"--permissions",
|
|
"full",
|
|
"--provider",
|
|
"anthropic",
|
|
"--model",
|
|
"claude-haiku-4-5",
|
|
"Read data.txt then replace its entire content with 'new content'",
|
|
])
|
|
.current_dir(tmp.path())
|
|
.timeout(std::time::Duration::from_secs(120))
|
|
.assert()
|
|
.success();
|
|
let content = std::fs::read_to_string(tmp.path().join("data.txt")).expect("read data.txt");
|
|
assert!(
|
|
content.contains("new content"),
|
|
"Expected 'new content' in data.txt, got: {content}"
|
|
);
|
|
}
|
|
|
|
// == Arc: serve =========================================================
|
|
|
|
#[test]
|
|
#[cfg(feature = "server")]
|
|
fn serve_help() {
|
|
let output = arc().args(["serve", "--help"]).output().expect("runs");
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8(output.stdout).unwrap();
|
|
insta::assert_snapshot!(stdout);
|
|
}
|
|
|
|
// == Doctor ===================================================================
|
|
|
|
#[test]
|
|
fn doctor_no_color_when_no_color_set() {
|
|
arc()
|
|
.args(["doctor", "--dry-run"])
|
|
.env_clear()
|
|
.env("NO_COLOR", "1")
|
|
.assert()
|
|
.stdout(predicate::str::contains("\x1b[").not());
|
|
}
|
|
|
|
// == JSONL logging ============================================================
|
|
|
|
#[test]
|
|
fn dry_run_writes_jsonl_and_live_json() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let run_dir = tmp.path().join("run");
|
|
|
|
arc()
|
|
.args([
|
|
"run",
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
"--run-dir",
|
|
run_dir.to_str().unwrap(),
|
|
"../../../test/simple.fabro",
|
|
])
|
|
.assert()
|
|
.success();
|
|
|
|
// progress.jsonl must exist and contain valid JSON lines
|
|
let jsonl_path = run_dir.join("progress.jsonl");
|
|
assert!(jsonl_path.exists(), "progress.jsonl should exist");
|
|
let jsonl_content = std::fs::read_to_string(&jsonl_path).unwrap();
|
|
let lines: Vec<&str> = jsonl_content.lines().collect();
|
|
assert!(
|
|
!lines.is_empty(),
|
|
"progress.jsonl should have at least one line"
|
|
);
|
|
|
|
// Every line must be valid JSON with ts, run_id, and event keys
|
|
let first_line: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
|
|
assert!(first_line.get("ts").is_some(), "line should have ts");
|
|
assert!(
|
|
first_line.get("run_id").is_some(),
|
|
"line should have run_id"
|
|
);
|
|
assert!(first_line.get("event").is_some(), "line should have event");
|
|
|
|
// Events should contain WorkflowRunStarted (may not be first due to exec env events)
|
|
let has_run_started = lines.iter().any(|line| {
|
|
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
|
|
parsed["event"].as_str() == Some("WorkflowRunStarted")
|
|
});
|
|
assert!(has_run_started, "events should contain WorkflowRunStarted");
|
|
|
|
// run_id should be non-empty after WorkflowRunStarted
|
|
let last_line: serde_json::Value = serde_json::from_str(lines[lines.len() - 1]).unwrap();
|
|
let run_id = last_line["run_id"].as_str().unwrap();
|
|
assert!(!run_id.is_empty(), "run_id should be non-empty");
|
|
|
|
// live.json must exist and contain valid JSON matching the last JSONL line
|
|
let live_path = run_dir.join("live.json");
|
|
assert!(live_path.exists(), "live.json should exist");
|
|
let live_content: serde_json::Value =
|
|
serde_json::from_str(&std::fs::read_to_string(&live_path).unwrap()).unwrap();
|
|
assert!(live_content.get("ts").is_some());
|
|
assert!(live_content.get("run_id").is_some());
|
|
assert!(live_content.get("event").is_some());
|
|
}
|
|
|
|
// == --run-id passthrough =====================================================
|
|
|
|
#[test]
|
|
fn run_id_passthrough_uses_provided_ulid() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let run_dir = tmp.path().join("run");
|
|
let my_ulid = "01JTEST1234567890ABCDE";
|
|
|
|
arc()
|
|
.args([
|
|
"run",
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
"--run-id",
|
|
my_ulid,
|
|
"--run-dir",
|
|
run_dir.to_str().unwrap(),
|
|
"../../../test/simple.fabro",
|
|
])
|
|
.assert()
|
|
.success()
|
|
.stderr(predicate::str::contains(my_ulid));
|
|
}
|
|
|
|
// == --detach flag =============================================================
|
|
|
|
#[test]
|
|
fn detach_flag_appears_in_help() {
|
|
arc()
|
|
.args(["run", "--help"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("--detach"));
|
|
}
|
|
|
|
#[test]
|
|
fn detach_prints_ulid_and_exits() {
|
|
let output = arc()
|
|
.args([
|
|
"run",
|
|
"--detach",
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
"../../../test/simple.fabro",
|
|
])
|
|
.assert()
|
|
.success()
|
|
.get_output()
|
|
.stdout
|
|
.clone();
|
|
|
|
let stdout = String::from_utf8(output).unwrap();
|
|
let ulid = stdout.trim();
|
|
// ULID is 26 uppercase alphanumeric chars
|
|
assert_eq!(ulid.len(), 26, "expected 26-char ULID, got: {ulid:?}");
|
|
assert!(
|
|
ulid.chars().all(|c| c.is_ascii_alphanumeric()),
|
|
"expected alphanumeric ULID, got: {ulid:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn detach_creates_run_dir_with_detach_log() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let run_dir = tmp.path().join("detached-run");
|
|
|
|
let output = arc()
|
|
.args([
|
|
"run",
|
|
"--detach",
|
|
"--dry-run",
|
|
"--auto-approve",
|
|
"--run-dir",
|
|
run_dir.to_str().unwrap(),
|
|
"../../../test/simple.fabro",
|
|
])
|
|
.assert()
|
|
.success()
|
|
.get_output()
|
|
.stdout
|
|
.clone();
|
|
|
|
let ulid = String::from_utf8(output).unwrap();
|
|
let ulid = ulid.trim();
|
|
assert!(!ulid.is_empty(), "should print a ULID");
|
|
|
|
// Run dir should have been created with detach.log
|
|
assert!(run_dir.exists(), "run dir should exist");
|
|
assert!(
|
|
run_dir.join("detach.log").exists(),
|
|
"detach.log should exist in run dir"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn detach_conflicts_with_resume() {
|
|
arc()
|
|
.args([
|
|
"run",
|
|
"--detach",
|
|
"--resume",
|
|
"/tmp/fake-checkpoint.json",
|
|
"../../../test/simple.fabro",
|
|
])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("cannot be used with"));
|
|
}
|
|
|
|
// == Bug regression: create/start/attach lifecycle ============================
|
|
|
|
/// Helper: create a minimal run directory that `resolve_run` can find.
|
|
/// Sets up manifest.json, status.json, spec.json, and progress.jsonl.
|
|
fn setup_run_dir(
|
|
home: &std::path::Path,
|
|
run_id: &str,
|
|
spec_overrides: serde_json::Value,
|
|
progress_lines: &[&str],
|
|
) -> std::path::PathBuf {
|
|
let run_dir = home.join(".fabro").join("runs").join(run_id);
|
|
std::fs::create_dir_all(&run_dir).unwrap();
|
|
|
|
// manifest.json for resolve_run
|
|
let manifest = serde_json::json!({
|
|
"run_id": run_id,
|
|
"workflow_name": "test",
|
|
"goal": "",
|
|
"start_time": "2026-01-01T00:00:00Z",
|
|
"node_count": 1,
|
|
"edge_count": 0
|
|
});
|
|
std::fs::write(
|
|
run_dir.join("manifest.json"),
|
|
serde_json::to_string(&manifest).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
// Merge spec defaults with overrides
|
|
let mut spec = serde_json::json!({
|
|
"run_id": run_id,
|
|
"workflow_path": "/tmp/test.fabro",
|
|
"dot_source": "digraph { start -> exit }",
|
|
"working_directory": "/tmp",
|
|
"goal": null,
|
|
"model": "test-model",
|
|
"provider": null,
|
|
"sandbox_provider": "local",
|
|
"labels": {},
|
|
"verbose": false,
|
|
"no_retro": true,
|
|
"ssh": false,
|
|
"preserve_sandbox": false,
|
|
"dry_run": true,
|
|
"auto_approve": true,
|
|
"resume": null,
|
|
"run_branch": null
|
|
});
|
|
if let (Some(base), Some(overrides)) = (spec.as_object_mut(), spec_overrides.as_object()) {
|
|
for (k, v) in overrides {
|
|
base.insert(k.clone(), v.clone());
|
|
}
|
|
}
|
|
std::fs::write(
|
|
run_dir.join("spec.json"),
|
|
serde_json::to_string(&spec).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
// progress.jsonl
|
|
std::fs::write(run_dir.join("progress.jsonl"), progress_lines.join("\n")).unwrap();
|
|
|
|
run_dir
|
|
}
|
|
|
|
// Bug 2: _run_engine should use cached graph.fabro, not spec.workflow_path.
|
|
// When the original workflow file is deleted between create and start,
|
|
// the engine should read the snapshot saved at create time.
|
|
#[test]
|
|
fn bug2_run_engine_uses_cached_graph_not_original_path() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let run_dir = dir.path().join("run");
|
|
std::fs::create_dir_all(&run_dir).unwrap();
|
|
|
|
let dot = "\
|
|
digraph G {
|
|
start [shape=Mdiamond, label=\"Start\"]
|
|
exit [shape=Msquare, label=\"Exit\"]
|
|
start -> exit
|
|
}";
|
|
|
|
// spec.json: workflow_path points to a file that no longer exists
|
|
let spec = serde_json::json!({
|
|
"run_id": "test-bug2",
|
|
"workflow_path": "/nonexistent/deleted-workflow.fabro",
|
|
"dot_source": dot,
|
|
"working_directory": run_dir.to_str().unwrap(),
|
|
"goal": null,
|
|
"model": "test-model",
|
|
"provider": null,
|
|
"sandbox_provider": "local",
|
|
"labels": {},
|
|
"verbose": false,
|
|
"no_retro": true,
|
|
"ssh": false,
|
|
"preserve_sandbox": false,
|
|
"dry_run": true,
|
|
"auto_approve": true,
|
|
"resume": null,
|
|
"run_branch": null
|
|
});
|
|
std::fs::write(
|
|
run_dir.join("spec.json"),
|
|
serde_json::to_string(&spec).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
// The cached graph snapshot saved by `fabro create`
|
|
std::fs::write(run_dir.join("graph.fabro"), dot).unwrap();
|
|
|
|
// _run_engine should use graph.fabro and never reference the deleted file.
|
|
// Bug: it reads spec.workflow_path → fails with file-not-found.
|
|
let output = arc()
|
|
.args(["_run_engine", "--run-dir", run_dir.to_str().unwrap()])
|
|
.env("NO_COLOR", "1")
|
|
.timeout(std::time::Duration::from_secs(15))
|
|
.output()
|
|
.expect("process should start");
|
|
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
assert!(
|
|
!stderr.contains("deleted-workflow.fabro"),
|
|
"bug2: engine should use cached graph.fabro, not the original \
|
|
(deleted) workflow path.\nstderr: {stderr}"
|
|
);
|
|
}
|
|
|
|
// Bug 3: attach loop must delete interview_request.json after handling it
|
|
// to prevent re-prompting the user on the next poll iteration.
|
|
#[test]
|
|
fn bug3_attach_cleans_up_interview_request_after_handling() {
|
|
let home = tempfile::tempdir().unwrap();
|
|
|
|
let run_dir = setup_run_dir(
|
|
home.path(),
|
|
"bug3-test",
|
|
serde_json::json!({}),
|
|
&[
|
|
r#"{"ts":"2026-01-01T00:00:01Z","run_id":"bug3","event":"StageStarted","node_id":"gate","name":"Gate","index":0,"attempt":1,"max_attempts":1}"#,
|
|
],
|
|
);
|
|
|
|
// Status: running
|
|
std::fs::write(
|
|
run_dir.join("status.json"),
|
|
serde_json::json!({"status": "running", "updated_at": "2026-01-01T00:00:00Z"}).to_string(),
|
|
)
|
|
.unwrap();
|
|
|
|
// interview_request.json — a question the engine wrote
|
|
let question = serde_json::json!({
|
|
"text": "Approve?",
|
|
"question_type": "YesNo",
|
|
"options": [],
|
|
"allow_freeform": false,
|
|
"default": {"value": "Yes", "selected_option": null, "selected_options": [], "text": null},
|
|
"timeout_seconds": 1.0,
|
|
"stage": "gate",
|
|
"metadata": {}
|
|
});
|
|
std::fs::write(
|
|
run_dir.join("interview_request.json"),
|
|
serde_json::to_string(&question).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
// Dead engine so attach exits after one iteration
|
|
std::fs::write(run_dir.join("run.pid"), "99999999").unwrap();
|
|
|
|
// Pipe "y\n" so ConsoleInterviewer doesn't block on stdin
|
|
let _ = arc()
|
|
.env("HOME", home.path())
|
|
.env("NO_COLOR", "1")
|
|
.args(["attach", "bug3-test"])
|
|
.write_stdin("y\n")
|
|
.timeout(std::time::Duration::from_secs(5))
|
|
.output();
|
|
|
|
// Bug: interview_request.json is never deleted by the attach loop.
|
|
// After the fix it should be removed immediately after handling.
|
|
assert!(
|
|
!run_dir.join("interview_request.json").exists(),
|
|
"bug3: interview_request.json should be deleted after being handled by attach"
|
|
);
|
|
}
|
|
|
|
// Bug 4: attach should respect the verbose flag from spec.json.
|
|
// Currently ProgressUI is created with verbose=false regardless of spec.
|
|
#[test]
|
|
fn bug4_attach_respects_verbose_from_spec() {
|
|
let home = tempfile::tempdir().unwrap();
|
|
|
|
// Use pre-rename field names so handle_json_line can parse them
|
|
// (isolates this test from bug 1). With 2 turns and 1 tool call,
|
|
// verbose mode should display "(2 turns, 1 tools, …)" in the output.
|
|
let run_dir = setup_run_dir(
|
|
home.path(),
|
|
"bug4-test",
|
|
serde_json::json!({"verbose": true}),
|
|
&[
|
|
r#"{"ts":"2026-01-01T12:00:00Z","run_id":"bug4","event":"StageStarted","node_id":"code","name":"Code","index":0,"attempt":1,"max_attempts":1}"#,
|
|
r#"{"ts":"2026-01-01T12:00:01Z","run_id":"bug4","event":"Agent.AssistantMessage","stage":"code","model":"claude-sonnet"}"#,
|
|
r#"{"ts":"2026-01-01T12:00:02Z","run_id":"bug4","event":"Agent.AssistantMessage","stage":"code","model":"claude-sonnet"}"#,
|
|
r#"{"ts":"2026-01-01T12:00:03Z","run_id":"bug4","event":"Agent.ToolCallStarted","stage":"code","tool_name":"read_file","tool_call_id":"tc1","arguments":{}}"#,
|
|
r#"{"ts":"2026-01-01T12:00:04Z","run_id":"bug4","event":"Agent.ToolCallCompleted","stage":"code","tool_name":"read_file","tool_call_id":"tc1","is_error":false}"#,
|
|
r#"{"ts":"2026-01-01T12:00:10Z","run_id":"bug4","event":"StageCompleted","node_id":"code","name":"Code","index":0,"duration_ms":10000,"status":"success","usage":{"input_tokens":1000,"output_tokens":500}}"#,
|
|
],
|
|
);
|
|
|
|
// Succeeded status + conclusion so attach exits after reading events
|
|
std::fs::write(
|
|
run_dir.join("status.json"),
|
|
serde_json::json!({"status": "succeeded", "updated_at": "2026-01-01T12:00:10Z"})
|
|
.to_string(),
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
run_dir.join("conclusion.json"),
|
|
serde_json::json!({
|
|
"timestamp": "2026-01-01T12:00:10Z",
|
|
"status": "success",
|
|
"duration_ms": 10000,
|
|
"stages": [],
|
|
"total_retries": 0
|
|
})
|
|
.to_string(),
|
|
)
|
|
.unwrap();
|
|
|
|
let output = arc()
|
|
.env("HOME", home.path())
|
|
.env("NO_COLOR", "1")
|
|
.args(["attach", "bug4-test"])
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.output()
|
|
.expect("process should start");
|
|
|
|
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
|
|
// Bug: verbose is hardcoded false, so stats are suppressed.
|
|
// Fix: load spec.verbose and pass it to ProgressUI.
|
|
assert!(
|
|
stderr.contains("turns") && stderr.contains("tools"),
|
|
"bug4: attach should show verbose stats when spec.verbose=true.\nstderr: {stderr}"
|
|
);
|
|
}
|