use std::path::Path;
use std::time::Duration;
use super::helpers::*;
use super::launch::*;
use super::monitor::*;
use super::plan::*;
use super::prompt::*;
use super::types::*;
#[test]
fn test_slugify_basic() {
assert_eq!(slugify("add batch retry logic"), "add-batch-retry-logic");
}
#[test]
fn test_slugify_special_chars() {
assert_eq!(
slugify("Fix: authentication (timeout) on slow connections!"),
"fix-authentication-timeout-on-slow-connections"
);
}
#[test]
fn test_slugify_truncation() {
let long_desc = "add a very long feature description that definitely exceeds the sixty character limit for branch slugs";
let slug = slugify(long_desc);
assert!(slug.len() <= 60, "slug too long: {} chars", slug.len());
assert!(!slug.ends_with('-'));
}
#[test]
fn test_slugify_leading_trailing_hyphens() {
assert_eq!(slugify(" hello world "), "hello-world");
}
#[test]
fn test_parse_duration_hours() {
assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200));
}
#[test]
fn test_parse_duration_minutes() {
assert_eq!(parse_duration("30m").unwrap(), Duration::from_secs(1800));
}
#[test]
fn test_parse_duration_seconds() {
assert_eq!(parse_duration("90s").unwrap(), Duration::from_secs(90));
}
#[test]
fn test_parse_duration_bare_number() {
assert_eq!(parse_duration("120").unwrap(), Duration::from_secs(120));
}
#[test]
fn test_parse_duration_zero() {
assert!(parse_duration("0h").is_err());
}
#[test]
fn test_parse_duration_empty() {
assert!(parse_duration("").is_err());
}
#[test]
fn test_parse_duration_invalid() {
assert!(parse_duration("abc").is_err());
}
#[test]
fn test_parse_container_mode() {
assert_eq!(parse_container_mode("none").unwrap(), ContainerMode::None);
assert_eq!(parse_container_mode("local").unwrap(), ContainerMode::None);
assert_eq!(
parse_container_mode("docker").unwrap(),
ContainerMode::Docker
);
assert_eq!(
parse_container_mode("podman").unwrap(),
ContainerMode::Podman
);
assert_eq!(
parse_container_mode("Docker").unwrap(),
ContainerMode::Docker
);
assert!(parse_container_mode("kubernetes").is_err());
}
#[test]
fn test_parse_verify_level() {
assert_eq!(parse_verify_level("local").unwrap(), VerifyLevel::Local);
assert_eq!(parse_verify_level("ci").unwrap(), VerifyLevel::Ci);
assert_eq!(
parse_verify_level("thorough").unwrap(),
VerifyLevel::Thorough
);
assert_eq!(parse_verify_level("CI").unwrap(), VerifyLevel::Ci);
assert!(parse_verify_level("extreme").is_err());
}
#[test]
fn test_tmux_session_name() {
assert_eq!(
tmux_session_name("XZ3j-81jF-add-batch-retry-logic"),
"XZ3j-81jF-add-batch-retry-logic"
);
}
#[test]
fn test_tmux_session_name_sanitization() {
assert_eq!(
tmux_session_name("XZ3j-81jF-fix.auth:bug"),
"XZ3j-81jF-fix-auth-bug"
);
}
#[test]
fn test_tmux_session_name_truncation() {
let long = "a".repeat(70);
let name = tmux_session_name(&long);
assert!(name.len() <= 64);
}
#[test]
fn test_build_prompt_contains_essentials() {
let conventions = ProjectConventions {
test_command: Some("cargo test".to_string()),
lint_commands: vec!["cargo clippy -- -D warnings".to_string()],
allowed_tools: vec!["Bash(cargo *)".to_string()],
};
let opts = KickoffOpts {
description: "add retry logic",
issue: None,
container: ContainerMode::None,
verify: VerifyLevel::Local,
model: "opus",
image: "",
timeout: Duration::from_secs(3600),
dry_run: false,
branch: None,
quiet: false,
design_doc: None,
doc_path: None,
skip_permissions: false,
permission_mode: None,
};
let prompt = build_prompt(&opts, 42, "feature/add-retry-logic", &conventions);
assert!(prompt.contains("add retry logic"));
assert!(prompt.contains("#42"));
assert!(prompt.contains("feature/add-retry-logic"));
assert!(prompt.contains("cargo test"));
assert!(prompt.contains("KICKOFF"));
assert!(prompt.contains("crosslink session"));
}
#[test]
fn test_build_prompt_ci_verification() {
let conventions = ProjectConventions {
test_command: None,
lint_commands: vec![],
allowed_tools: vec![],
};
let opts = KickoffOpts {
description: "test ci",
issue: None,
container: ContainerMode::None,
verify: VerifyLevel::Ci,
model: "opus",
image: "",
timeout: Duration::from_secs(3600),
dry_run: false,
branch: None,
quiet: false,
design_doc: None,
doc_path: None,
skip_permissions: false,
permission_mode: None,
};
let prompt = build_prompt(&opts, 1, "feature/test-ci", &conventions);
assert!(prompt.contains("CI Verification"));
assert!(prompt.contains("gh pr create"));
assert!(!prompt.contains("Adversarial"));
}
#[test]
fn test_build_prompt_thorough_verification() {
let conventions = ProjectConventions {
test_command: None,
lint_commands: vec![],
allowed_tools: vec![],
};
let opts = KickoffOpts {
description: "test thorough",
issue: None,
container: ContainerMode::None,
verify: VerifyLevel::Thorough,
model: "opus",
image: "",
timeout: Duration::from_secs(3600),
dry_run: false,
branch: None,
quiet: false,
design_doc: None,
doc_path: None,
skip_permissions: false,
permission_mode: None,
};
let prompt = build_prompt(&opts, 1, "feature/test-thorough", &conventions);
assert!(prompt.contains("CI Verification"));
assert!(prompt.contains("Adversarial Self-Review"));
}
#[test]
fn test_build_allowed_tools_base() {
let conventions = ProjectConventions {
test_command: None,
lint_commands: vec![],
allowed_tools: vec![],
};
let tools = build_allowed_tools(&conventions, &VerifyLevel::Local);
assert!(tools.contains("Read"));
assert!(tools.contains("Bash(crosslink *)"));
assert!(!tools.contains("Bash(gh *)"));
}
#[test]
fn test_build_allowed_tools_ci() {
let conventions = ProjectConventions {
test_command: None,
lint_commands: vec![],
allowed_tools: vec!["Bash(cargo *)".to_string()],
};
let tools = build_allowed_tools(&conventions, &VerifyLevel::Ci);
assert!(tools.contains("Bash(gh *)"));
assert!(tools.contains("Bash(cargo *)"));
}
#[test]
fn test_detect_conventions_rust() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
let conv = detect_conventions(dir.path());
assert_eq!(conv.test_command.as_deref(), Some("cargo test"));
assert!(conv.allowed_tools.contains(&"Bash(cargo *)".to_string()));
}
#[test]
fn test_detect_conventions_node() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
let conv = detect_conventions(dir.path());
assert_eq!(conv.test_command.as_deref(), Some("npm test"));
assert!(conv.allowed_tools.contains(&"Bash(npm *)".to_string()));
}
#[test]
fn test_detect_conventions_rust_in_subdir() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("santana-core")).unwrap();
std::fs::write(dir.path().join("santana-core/Cargo.toml"), "[package]").unwrap();
let conv = detect_conventions(dir.path());
assert!(
conv.allowed_tools.contains(&"Bash(cargo *)".to_string()),
"expected Bash(cargo *) when Cargo.toml is one level deep, got {:?}",
conv.allowed_tools
);
}
#[test]
fn test_detect_conventions_rust_two_levels_deep_not_detected() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("crates/foo")).unwrap();
std::fs::write(dir.path().join("crates/foo/Cargo.toml"), "[package]").unwrap();
let conv = detect_conventions(dir.path());
assert!(
!conv.allowed_tools.contains(&"Bash(cargo *)".to_string()),
"two-levels-deep Cargo.toml should not trigger detection; got {:?}",
conv.allowed_tools
);
}
#[test]
fn test_detect_conventions_python_in_subdir_with_pytest() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("python-svc")).unwrap();
std::fs::write(dir.path().join("python-svc/pyproject.toml"), "[project]").unwrap();
let conv = detect_conventions(dir.path());
assert!(conv.allowed_tools.contains(&"Bash(uv *)".to_string()));
assert!(
conv.allowed_tools.contains(&"Bash(pytest *)".to_string()),
"GH#584 explicitly mentioned pytest as a missing tool"
);
}
#[test]
fn test_detect_conventions_skips_node_modules() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
std::fs::create_dir_all(dir.path().join("node_modules/weird-pkg")).unwrap();
std::fs::write(
dir.path().join("node_modules/weird-pkg/Cargo.toml"),
"[package]",
)
.unwrap();
let conv = detect_conventions(dir.path());
assert!(
!conv.allowed_tools.contains(&"Bash(cargo *)".to_string()),
"Cargo.toml inside node_modules/ must not enable cargo tools"
);
}
#[test]
fn test_detect_conventions_skips_hidden_dirs() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".cache/leaky")).unwrap();
std::fs::write(dir.path().join(".cache/leaky/Cargo.toml"), "[package]").unwrap();
let conv = detect_conventions(dir.path());
assert!(
!conv.allowed_tools.contains(&"Bash(cargo *)".to_string()),
"manifests under hidden dirs must not enable tooling"
);
}
#[test]
fn test_read_kickoff_allowed_tools_returns_empty_when_missing() {
let dir = tempfile::tempdir().unwrap();
assert!(read_kickoff_allowed_tools(dir.path()).is_empty());
}
#[test]
fn test_read_kickoff_allowed_tools_returns_configured_array() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("hook-config.json"),
r#"{
"kickoff": {
"allowed_tools": ["Bash(cargo *)", "Bash(make deploy *)"]
}
}"#,
)
.unwrap();
let tools = read_kickoff_allowed_tools(dir.path());
assert_eq!(
tools,
vec![
"Bash(cargo *)".to_string(),
"Bash(make deploy *)".to_string()
]
);
}
#[test]
fn test_read_kickoff_allowed_tools_returns_empty_when_key_absent() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("hook-config.json"),
r#"{"tracking_mode": "strict"}"#,
)
.unwrap();
assert!(read_kickoff_allowed_tools(dir.path()).is_empty());
}
#[test]
fn test_read_kickoff_allowed_tools_tolerates_malformed_json() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("hook-config.json"), "not valid json").unwrap();
assert!(read_kickoff_allowed_tools(dir.path()).is_empty());
}
#[test]
fn test_rand_suffix_range() {
let s = rand_suffix();
assert!(s < 10000);
}
#[test]
fn test_slugify_all_special_chars() {
assert_eq!(slugify("!!!@@@###"), "");
}
#[test]
fn test_slugify_single_word() {
assert_eq!(slugify("refactor"), "refactor");
}
#[test]
fn test_slugify_unicode() {
assert_eq!(slugify("add café support"), "add-café-support");
}
#[test]
fn test_slugify_consecutive_separators() {
assert_eq!(slugify("fix -- the -- bug"), "fix-the-bug");
}
#[test]
fn test_slugify_numbers() {
assert_eq!(slugify("add v2 api endpoint"), "add-v2-api-endpoint");
}
#[test]
fn test_slugify_empty() {
assert_eq!(slugify(""), "");
}
#[test]
fn test_slugify_truncation_cuts_at_word_boundary() {
let desc = "implement-the-very-important-feature-that-does-something-really-great";
let slug = slugify(desc);
assert!(slug.len() <= 60);
assert!(!slug.ends_with('-'));
}
#[test]
fn test_verify_level_name() {
assert_eq!(verify_level_name(&VerifyLevel::Local), "local");
assert_eq!(verify_level_name(&VerifyLevel::Ci), "ci");
assert_eq!(verify_level_name(&VerifyLevel::Thorough), "thorough");
}
#[test]
fn test_build_test_lint_instructions_with_commands() {
let conv = ProjectConventions {
test_command: Some("cargo test".to_string()),
lint_commands: vec![
"cargo clippy -- -D warnings".to_string(),
"cargo fmt --check".to_string(),
],
allowed_tools: vec![],
};
let section = build_test_lint_instructions(&conv, 42);
assert!(section.contains("`cargo test`"));
assert!(section.contains("`cargo clippy -- -D warnings`"));
assert!(section.contains("`cargo fmt --check`"));
assert!(section.contains("crosslink comment 42"));
}
#[test]
fn test_build_test_lint_instructions_without_commands() {
let conv = ProjectConventions {
test_command: None,
lint_commands: vec![],
allowed_tools: vec![],
};
let section = build_test_lint_instructions(&conv, 7);
assert!(section.contains("Run the project's test suite"));
assert!(section.contains("Run lint and format checks"));
assert!(section.contains("crosslink comment 7"));
}
#[test]
fn test_build_ci_verification_section_content() {
let section = build_ci_verification_section();
assert!(section.contains("CI Verification"));
assert!(section.contains("gh pr create"));
assert!(section.contains("gh run list"));
assert!(section.contains("CI_FAILED"));
assert!(section.contains("Maximum 5 CI fix-and-retry"));
}
#[test]
fn test_build_adversarial_review_section_content() {
let section = build_adversarial_review_section();
assert!(section.contains("Adversarial Self-Review"));
assert!(section.contains("git diff main...HEAD"));
assert!(section.contains("unwrap()"));
}
#[test]
fn test_build_final_steps_section_content() {
let section = build_final_steps_section();
assert!(section.contains("Self-review checklist"));
assert!(section.contains("crosslink session end"));
assert!(section.contains(".kickoff-status"));
assert!(section.contains("DONE"));
}
#[test]
fn test_missing_exclude_patterns_empty_file() {
let patterns = missing_exclude_patterns("");
assert_eq!(
patterns,
vec![
"KICKOFF.md",
".kickoff-status",
".kickoff-slug",
".kickoff-metadata.json",
".kickoff-doc.json",
"PLAN_KICKOFF.md",
".kickoff-plan.json",
".kickoff-criteria.json",
".kickoff-report.json",
]
);
}
#[test]
fn test_missing_exclude_patterns_one_present() {
let patterns = missing_exclude_patterns("KICKOFF.md\nsome-other-file\n");
assert!(patterns.contains(&".kickoff-status"));
assert!(patterns.contains(&".kickoff-slug"));
assert!(patterns.contains(&"PLAN_KICKOFF.md"));
assert!(patterns.contains(&".kickoff-plan.json"));
assert!(patterns.contains(&".kickoff-criteria.json"));
assert!(patterns.contains(&".kickoff-report.json"));
assert!(!patterns.contains(&"KICKOFF.md"));
}
#[test]
fn test_missing_exclude_patterns_all_present() {
let patterns = missing_exclude_patterns(
"KICKOFF.md\n.kickoff-status\n.kickoff-slug\n.kickoff-metadata.json\n.kickoff-doc.json\nPLAN_KICKOFF.md\n.kickoff-plan.json\n.kickoff-criteria.json\n.kickoff-report.json\n",
);
assert!(patterns.is_empty());
}
#[test]
fn test_missing_exclude_patterns_with_whitespace() {
let patterns = missing_exclude_patterns(
" KICKOFF.md \n .kickoff-status \n .kickoff-slug \n .kickoff-metadata.json \n .kickoff-doc.json \n PLAN_KICKOFF.md \n .kickoff-plan.json \n .kickoff-criteria.json \n .kickoff-report.json \n",
);
assert!(patterns.is_empty());
}
#[test]
fn test_verify_protected_doc_not_protected_without_breadcrumb() {
let tmp = tempfile::tempdir().unwrap();
assert!(matches!(
verify_protected_doc(tmp.path()),
DocIntegrity::NotProtected
));
}
#[test]
fn test_verify_protected_doc_match_on_unchanged_doc() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".design")).unwrap();
let doc_rel = ".design/foo.md";
let body = "# Foo design\n\nContents.\n";
std::fs::write(tmp.path().join(doc_rel), body).unwrap();
let breadcrumb = KickoffDocBreadcrumb {
rel_path: doc_rel.to_string(),
doc_hash: super::pipeline::compute_doc_hash(body),
};
std::fs::write(
tmp.path().join(".kickoff-doc.json"),
serde_json::to_string(&breadcrumb).unwrap(),
)
.unwrap();
match verify_protected_doc(tmp.path()) {
DocIntegrity::Match { rel_path } => assert_eq!(rel_path, doc_rel),
other => panic!("expected Match, got {other:?}"),
}
}
#[test]
fn test_verify_protected_doc_mismatch_on_edited_doc() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".design")).unwrap();
let doc_rel = ".design/foo.md";
let original = "# Foo design\n\nOriginal contents.\n";
let modified = "# Foo design\n\nAgent rewrote this section.\n";
let breadcrumb = KickoffDocBreadcrumb {
rel_path: doc_rel.to_string(),
doc_hash: super::pipeline::compute_doc_hash(original),
};
std::fs::write(
tmp.path().join(".kickoff-doc.json"),
serde_json::to_string(&breadcrumb).unwrap(),
)
.unwrap();
std::fs::write(tmp.path().join(doc_rel), modified).unwrap();
match verify_protected_doc(tmp.path()) {
DocIntegrity::Mismatch {
rel_path,
expected,
actual,
} => {
assert_eq!(rel_path, doc_rel);
assert_eq!(expected, super::pipeline::compute_doc_hash(original));
assert_eq!(actual, super::pipeline::compute_doc_hash(modified));
}
other => panic!("expected Mismatch, got {other:?}"),
}
}
#[test]
fn test_verify_protected_doc_missing_when_doc_deleted() {
let tmp = tempfile::tempdir().unwrap();
let doc_rel = ".design/foo.md";
let breadcrumb = KickoffDocBreadcrumb {
rel_path: doc_rel.to_string(),
doc_hash: super::pipeline::compute_doc_hash("placeholder"),
};
std::fs::write(
tmp.path().join(".kickoff-doc.json"),
serde_json::to_string(&breadcrumb).unwrap(),
)
.unwrap();
match verify_protected_doc(tmp.path()) {
DocIntegrity::Missing { rel_path, .. } => assert_eq!(rel_path, doc_rel),
other => panic!("expected Missing, got {other:?}"),
}
}
#[test]
fn test_verify_protected_doc_missing_on_malformed_breadcrumb() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(".kickoff-doc.json"), "not json").unwrap();
assert!(matches!(
verify_protected_doc(tmp.path()),
DocIntegrity::Missing { .. }
));
}
#[test]
fn test_build_allowed_tools_thorough() {
let conventions = ProjectConventions {
test_command: None,
lint_commands: vec![],
allowed_tools: vec![],
};
let tools = build_allowed_tools(&conventions, &VerifyLevel::Thorough);
assert!(tools.contains("Bash(gh *)"));
assert!(tools.contains("Bash(sleep *)"));
}
#[test]
fn test_build_allowed_tools_includes_project_tools() {
let conventions = ProjectConventions {
test_command: None,
lint_commands: vec![],
allowed_tools: vec!["Bash(cargo *)".to_string(), "Bash(npm *)".to_string()],
};
let tools = build_allowed_tools(&conventions, &VerifyLevel::Local);
assert!(tools.contains("Bash(cargo *)"));
assert!(tools.contains("Bash(npm *)"));
assert!(!tools.contains("Bash(gh *)"));
}
#[test]
fn test_detect_conventions_python() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("pyproject.toml"), "[project]").unwrap();
let conv = detect_conventions(dir.path());
assert_eq!(conv.test_command.as_deref(), Some("uv run pytest"));
assert!(conv.lint_commands.contains(&"ruff check .".to_string()));
assert!(conv.allowed_tools.contains(&"Bash(python3 *)".to_string()));
}
#[test]
fn test_detect_conventions_go() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("go.mod"), "module example").unwrap();
let conv = detect_conventions(dir.path());
assert_eq!(conv.test_command.as_deref(), Some("go test ./..."));
assert!(conv.lint_commands.contains(&"go vet ./...".to_string()));
assert!(conv.allowed_tools.contains(&"Bash(go *)".to_string()));
}
#[test]
fn test_detect_conventions_just() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("justfile"), "build:").unwrap();
let conv = detect_conventions(dir.path());
assert!(conv.allowed_tools.contains(&"Bash(just *)".to_string()));
}
#[test]
fn test_detect_conventions_make() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Makefile"), "build:").unwrap();
let conv = detect_conventions(dir.path());
assert!(conv.allowed_tools.contains(&"Bash(make *)".to_string()));
}
#[test]
fn test_detect_conventions_elixir() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("mix.exs"),
r#"defmodule MyApp.MixProject do
use Mix.Project
defp deps do
[{:phoenix, "~> 1.7"}, {:credo, "~> 1.7", only: [:dev, :test]}, {:sobelow, "~> 0.13", only: :dev}]
end
end"#,
)
.unwrap();
let conv = detect_conventions(dir.path());
assert_eq!(conv.test_command.as_deref(), Some("mix test"));
assert!(conv
.lint_commands
.contains(&"mix format --check-formatted".to_string()));
assert!(conv
.lint_commands
.contains(&"mix credo --strict".to_string()));
assert!(conv
.lint_commands
.contains(&"mix sobelow --config".to_string()));
assert!(conv.allowed_tools.contains(&"Bash(mix test *)".to_string()));
assert!(conv
.allowed_tools
.contains(&"Bash(mix credo *)".to_string()));
assert!(conv
.allowed_tools
.contains(&"Bash(mix sobelow *)".to_string()));
assert!(conv
.allowed_tools
.contains(&"Bash(mix phx.routes *)".to_string()));
}
#[test]
fn test_detect_conventions_elixir_minimal() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("mix.exs"),
"defmodule MyApp.MixProject do\n use Mix.Project\nend",
)
.unwrap();
let conv = detect_conventions(dir.path());
assert_eq!(conv.test_command.as_deref(), Some("mix test"));
assert!(conv
.lint_commands
.contains(&"mix format --check-formatted".to_string()));
assert!(!conv
.lint_commands
.contains(&"mix credo --strict".to_string()));
assert!(!conv
.allowed_tools
.contains(&"mcp__tidewave__get_logs".to_string()));
}
#[test]
fn test_detect_conventions_elixir_with_tidewave() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("mix.exs"),
r#"defmodule MyApp.MixProject do
defp deps do
[{:tidewave, "~> 0.1", only: :dev}]
end
end"#,
)
.unwrap();
let conv = detect_conventions(dir.path());
assert!(conv
.allowed_tools
.contains(&"mcp__tidewave__get_logs".to_string()));
assert!(conv
.allowed_tools
.contains(&"mcp__tidewave__get_docs".to_string()));
assert!(conv
.allowed_tools
.contains(&"mcp__tidewave__project_eval".to_string()));
}
#[test]
fn test_detect_conventions_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let conv = detect_conventions(dir.path());
assert!(conv.test_command.is_none());
assert!(conv.lint_commands.is_empty());
assert!(conv.allowed_tools.is_empty());
}
#[test]
fn test_detect_conventions_multi_language() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
std::fs::write(dir.path().join("package.json"), "{}").unwrap();
let conv = detect_conventions(dir.path());
assert_eq!(conv.test_command.as_deref(), Some("cargo test"));
assert!(conv.allowed_tools.contains(&"Bash(cargo *)".to_string()));
assert!(conv.allowed_tools.contains(&"Bash(npm *)".to_string()));
}
#[test]
fn test_detect_conventions_requirements_txt() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap();
let conv = detect_conventions(dir.path());
assert_eq!(conv.test_command.as_deref(), Some("uv run pytest"));
assert!(conv.allowed_tools.contains(&"Bash(uv *)".to_string()));
}
#[test]
fn test_detect_conventions_crosslink_subdir_cargo() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("crosslink")).unwrap();
std::fs::write(dir.path().join("crosslink/Cargo.toml"), "[package]").unwrap();
let conv = detect_conventions(dir.path());
assert_eq!(conv.test_command.as_deref(), Some("cargo test"));
}
#[test]
fn test_parse_duration_whitespace() {
assert_eq!(
parse_duration(" 30m ").unwrap(),
Duration::from_secs(1800)
);
}
#[test]
fn test_parse_duration_large_value() {
assert_eq!(parse_duration("24h").unwrap(), Duration::from_secs(86400));
}
#[test]
fn test_tmux_session_name_empty() {
assert_eq!(tmux_session_name(""), "");
}
#[test]
fn test_build_prompt_local_has_no_ci_or_adversarial() {
let conventions = ProjectConventions {
test_command: None,
lint_commands: vec![],
allowed_tools: vec![],
};
let opts = KickoffOpts {
description: "test local",
issue: None,
container: ContainerMode::None,
verify: VerifyLevel::Local,
model: "opus",
image: "",
timeout: Duration::from_secs(3600),
dry_run: false,
branch: None,
quiet: false,
design_doc: None,
doc_path: None,
skip_permissions: false,
permission_mode: None,
};
let prompt = build_prompt(&opts, 1, "feature/test-local", &conventions);
assert!(!prompt.contains("CI Verification"));
assert!(!prompt.contains("Adversarial Self-Review"));
assert!(prompt.contains("Final Steps"));
}
#[test]
fn test_build_prompt_contains_blocked_actions() {
let conventions = ProjectConventions {
test_command: None,
lint_commands: vec![],
allowed_tools: vec![],
};
let opts = KickoffOpts {
description: "test blocked actions",
issue: None,
container: ContainerMode::None,
verify: VerifyLevel::Local,
model: "opus",
image: "",
timeout: Duration::from_secs(3600),
dry_run: false,
branch: None,
quiet: false,
design_doc: None,
doc_path: None,
skip_permissions: false,
permission_mode: None,
};
let prompt = build_prompt(&opts, 1, "feature/test", &conventions);
assert!(prompt.contains("Blocked Actions"));
assert!(prompt.contains("git push"));
assert!(prompt.contains("git merge"));
assert!(prompt.contains("git reset"));
}
#[test]
fn test_build_prompt_embeds_issue_id_in_instructions() {
let conventions = ProjectConventions {
test_command: Some("cargo test".to_string()),
lint_commands: vec!["cargo clippy".to_string()],
allowed_tools: vec![],
};
let opts = KickoffOpts {
description: "test issue refs",
issue: None,
container: ContainerMode::None,
verify: VerifyLevel::Local,
model: "opus",
image: "",
timeout: Duration::from_secs(3600),
dry_run: false,
branch: None,
quiet: false,
design_doc: None,
doc_path: None,
skip_permissions: false,
permission_mode: None,
};
let prompt = build_prompt(&opts, 999, "feature/test-refs", &conventions);
assert!(prompt.contains("#999"));
assert!(prompt.contains("crosslink session work 999"));
assert!(prompt.contains("crosslink comment 999"));
}
#[test]
fn test_build_prompt_empty_conventions_uses_generic_instructions() {
let conventions = ProjectConventions {
test_command: None,
lint_commands: vec![],
allowed_tools: vec![],
};
let opts = KickoffOpts {
description: "test generic",
issue: None,
container: ContainerMode::None,
verify: VerifyLevel::Local,
model: "opus",
image: "",
timeout: Duration::from_secs(3600),
dry_run: false,
branch: None,
quiet: false,
design_doc: None,
doc_path: None,
skip_permissions: false,
permission_mode: None,
};
let prompt = build_prompt(&opts, 1, "feature/test-generic", &conventions);
assert!(prompt.contains("Run the project's test suite"));
assert!(prompt.contains("Run lint and format checks"));
assert!(!prompt.contains("`cargo test`"));
}
#[test]
fn test_build_prompt_with_design_doc() {
let doc = super::super::design_doc::DesignDoc {
title: "Batch Retry".to_string(),
summary: "Add retry logic.".to_string(),
requirements: vec!["REQ-1: Retry 3 times".to_string()],
requirement_groups: Vec::new(),
acceptance_criteria: vec!["AC-1: Tests pass".to_string()],
architecture: "Middleware pattern".to_string(),
open_questions: Vec::new(),
out_of_scope: vec!["Not doing X".to_string()],
unknown_sections: Vec::new(),
};
let conventions = ProjectConventions {
test_command: None,
lint_commands: vec![],
allowed_tools: vec![],
};
let opts = KickoffOpts {
description: "batch retry",
issue: None,
container: ContainerMode::None,
verify: VerifyLevel::Local,
model: "opus",
image: "",
timeout: Duration::from_secs(3600),
dry_run: false,
branch: None,
quiet: false,
design_doc: Some(&doc),
doc_path: None,
skip_permissions: false,
permission_mode: None,
};
let prompt = build_prompt(&opts, 1, "feature/batch-retry", &conventions);
assert!(prompt.contains("## Design Specification"));
assert!(prompt.contains("Add retry logic."));
assert!(prompt.contains("REQ-1: Retry 3 times"));
assert!(prompt.contains("AC-1: Tests pass"));
assert!(prompt.contains("Middleware pattern"));
assert!(prompt.contains("Not doing X"));
assert!(!prompt.contains("Escalation Required"));
}
#[test]
fn test_build_plan_prompt_contains_essentials() {
let doc = super::super::design_doc::DesignDoc {
title: "Batch Retry".to_string(),
summary: "Add retry logic.".to_string(),
requirements: vec!["REQ-1: Retry 3 times".to_string()],
requirement_groups: Vec::new(),
acceptance_criteria: vec!["AC-1: Tests pass".to_string()],
architecture: "Middleware".to_string(),
open_questions: Vec::new(),
out_of_scope: Vec::new(),
unknown_sections: Vec::new(),
};
let prompt = build_plan_prompt(&doc, Some(42), None);
assert!(prompt.contains("KICKOFF PLAN"));
assert!(prompt.contains("Batch Retry"));
assert!(prompt.contains("#42"));
assert!(prompt.contains("Design Specification"));
assert!(prompt.contains("REQ-1: Retry 3 times"));
assert!(prompt.contains(".kickoff-plan.json"));
assert!(prompt.contains("read-only"));
assert!(prompt.contains("gaps"));
assert!(prompt.contains("assumptions"));
assert!(prompt.contains("estimated_subtasks"));
assert!(prompt.contains("conflicts"));
}
#[test]
fn test_build_plan_prompt_with_open_questions() {
let doc = super::super::design_doc::DesignDoc {
title: "Auth".to_string(),
summary: String::new(),
requirements: Vec::new(),
requirement_groups: Vec::new(),
acceptance_criteria: Vec::new(),
architecture: String::new(),
open_questions: vec!["Q1: OAuth or JWT?".to_string()],
out_of_scope: Vec::new(),
unknown_sections: Vec::new(),
};
let prompt = build_plan_prompt(&doc, None, None);
assert!(prompt.contains("Escalation Required"));
assert!(prompt.contains("Q1: OAuth or JWT?"));
assert!(!prompt.contains("Issue"));
}
#[test]
fn test_build_plan_prompt_without_issue() {
let doc = super::super::design_doc::DesignDoc {
title: "Test".to_string(),
summary: "S".to_string(),
requirements: Vec::new(),
requirement_groups: Vec::new(),
acceptance_criteria: Vec::new(),
architecture: String::new(),
open_questions: Vec::new(),
out_of_scope: Vec::new(),
unknown_sections: Vec::new(),
};
let prompt = build_plan_prompt(&doc, None, None);
assert!(prompt.contains("KICKOFF PLAN"));
assert!(!prompt.contains("**Issue**"));
}
#[test]
fn test_build_allowed_tools_plan_is_read_only() {
let tools = build_allowed_tools_plan();
assert!(tools.contains("Read"));
assert!(tools.contains("Glob"));
assert!(tools.contains("Grep"));
assert!(!tools.contains("Write"));
assert!(!tools.contains("Edit"));
}
#[test]
fn test_build_allowed_tools_plan_no_destructive_bash() {
let tools = build_allowed_tools_plan();
assert!(!tools.contains("Bash(mkdir"));
assert!(!tools.contains("Bash(touch"));
assert!(!tools.contains("Bash(echo"));
assert!(tools.contains("Bash(git status"));
assert!(tools.contains("Bash(ls"));
}
#[test]
fn test_missing_exclude_patterns_includes_plan_files() {
let patterns = missing_exclude_patterns("");
assert!(patterns.contains(&"PLAN_KICKOFF.md"));
assert!(patterns.contains(&".kickoff-plan.json"));
}
#[test]
fn test_build_prompt_with_design_doc_open_questions() {
let doc = super::super::design_doc::DesignDoc {
title: "Auth Feature".to_string(),
summary: "Add auth.".to_string(),
requirements: vec!["REQ-1: Login".to_string()],
requirement_groups: Vec::new(),
acceptance_criteria: vec!["AC-1: Can log in".to_string()],
architecture: String::new(),
open_questions: vec![
"Q1: OAuth or JWT?".to_string(),
"Q2: Session duration?".to_string(),
],
out_of_scope: Vec::new(),
unknown_sections: Vec::new(),
};
let conventions = ProjectConventions {
test_command: None,
lint_commands: vec![],
allowed_tools: vec![],
};
let opts = KickoffOpts {
description: "auth feature",
issue: None,
container: ContainerMode::None,
verify: VerifyLevel::Local,
model: "opus",
image: "",
timeout: Duration::from_secs(3600),
dry_run: false,
branch: None,
quiet: false,
design_doc: Some(&doc),
doc_path: None,
skip_permissions: false,
permission_mode: None,
};
let prompt = build_prompt(&opts, 1, "feature/auth", &conventions);
assert!(prompt.contains("## Design Specification"));
assert!(prompt.contains("Escalation Required"));
assert!(prompt.contains("Q1: OAuth or JWT?"));
assert!(prompt.contains("Q2: Session duration?"));
assert!(prompt.contains("crosslink comment"));
}
#[test]
fn test_parse_criterion_id_with_prefix() {
let (id, text) = parse_criterion_id("AC-1: Tests pass");
assert_eq!(id, "AC-1");
assert_eq!(text, "Tests pass");
}
#[test]
fn test_parse_criterion_id_without_prefix() {
let (id, text) = parse_criterion_id("Tests pass");
assert_eq!(id, "");
assert_eq!(text, "Tests pass");
}
#[test]
fn test_parse_criterion_id_multidigit() {
let (id, text) = parse_criterion_id("AC-12: Complex thing");
assert_eq!(id, "AC-12");
assert_eq!(text, "Complex thing");
}
#[test]
fn test_parse_criterion_id_lowercase() {
let (id, text) = parse_criterion_id("ac-3: Lower case");
assert_eq!(id, "AC-3");
assert_eq!(text, "Lower case");
}
#[test]
fn test_extract_criteria_all_explicit() {
let doc = super::super::design_doc::DesignDoc {
title: String::new(),
summary: String::new(),
requirements: vec![],
requirement_groups: Vec::new(),
acceptance_criteria: vec!["AC-1: First".to_string(), "AC-2: Second".to_string()],
architecture: String::new(),
open_questions: vec![],
out_of_scope: vec![],
unknown_sections: vec![],
};
let result = extract_criteria(&doc, "test.md");
assert_eq!(result.criteria.len(), 2);
assert_eq!(result.criteria[0].id, "AC-1");
assert_eq!(result.criteria[0].text, "First");
assert_eq!(result.criteria[1].id, "AC-2");
assert_eq!(result.criteria[1].text, "Second");
assert_eq!(result.source_doc, "test.md");
}
#[test]
fn test_extract_criteria_all_auto() {
let doc = super::super::design_doc::DesignDoc {
title: String::new(),
summary: String::new(),
requirements: vec![],
requirement_groups: Vec::new(),
acceptance_criteria: vec!["First item".to_string(), "Second item".to_string()],
architecture: String::new(),
open_questions: vec![],
out_of_scope: vec![],
unknown_sections: vec![],
};
let result = extract_criteria(&doc, "test.md");
assert_eq!(result.criteria[0].id, "AC-1");
assert_eq!(result.criteria[0].text, "First item");
assert_eq!(result.criteria[1].id, "AC-2");
assert_eq!(result.criteria[1].text, "Second item");
assert_eq!(result.criteria[0].criterion_type, "functional");
}
#[test]
fn test_extract_criteria_mixed_ids_skip_collisions() {
let doc = super::super::design_doc::DesignDoc {
title: String::new(),
summary: String::new(),
requirements: vec![],
requirement_groups: Vec::new(),
acceptance_criteria: vec![
"AC-1: Explicit first".to_string(),
"Auto assigned".to_string(),
"AC-3: Explicit third".to_string(),
"Another auto".to_string(),
],
architecture: String::new(),
open_questions: vec![],
out_of_scope: vec![],
unknown_sections: vec![],
};
let result = extract_criteria(&doc, "design.md");
assert_eq!(result.criteria[0].id, "AC-1");
assert_eq!(result.criteria[1].id, "AC-2"); assert_eq!(result.criteria[2].id, "AC-3");
assert_eq!(result.criteria[3].id, "AC-4"); }
#[test]
fn test_build_reporting_section_has_full_schema() {
let section = build_reporting_section();
assert!(section.contains("Spec Validation"));
assert!(section.contains(".kickoff-criteria.json"));
assert!(section.contains(".kickoff-report.json"));
assert!(section.contains("pass"));
assert!(section.contains("fail"));
assert!(section.contains("partial"));
assert!(section.contains("evidence"));
assert!(section.contains("schema_version"));
assert!(section.contains("agent_id"));
assert!(section.contains("phases"));
assert!(section.contains("commits"));
assert!(section.contains("files_changed"));
assert!(section.contains("duration_s"));
}
#[test]
fn test_build_reporting_section_has_validation_instructions() {
let section = build_reporting_section();
assert!(section.contains("not_applicable"));
assert!(section.contains("needs_clarification"));
assert!(section.contains("Be strict"));
assert!(section.contains("concrete evidence"));
}
#[test]
fn test_build_prompt_with_criteria_includes_validation() {
let conventions = ProjectConventions {
test_command: None,
lint_commands: vec![],
allowed_tools: vec![],
};
let doc = super::super::design_doc::DesignDoc {
title: "Test".to_string(),
summary: "Summary".to_string(),
requirements: vec![],
requirement_groups: Vec::new(),
acceptance_criteria: vec!["Users can log in".to_string()],
architecture: String::new(),
open_questions: vec![],
out_of_scope: vec![],
unknown_sections: vec![],
};
let opts = KickoffOpts {
description: "test feature",
issue: None,
container: ContainerMode::None,
verify: VerifyLevel::Local,
model: "opus",
image: "",
timeout: Duration::from_secs(3600),
dry_run: false,
branch: None,
quiet: false,
design_doc: Some(&doc),
doc_path: None,
skip_permissions: false,
permission_mode: None,
};
let prompt = build_prompt(&opts, 1, "feature/test", &conventions);
assert!(prompt.contains("Spec Validation"));
assert!(prompt.contains(".kickoff-criteria.json"));
assert!(prompt.contains("schema_version"));
assert!(prompt.contains("phases"));
}
#[test]
fn test_build_prompt_without_criteria_no_validation() {
let conventions = ProjectConventions {
test_command: None,
lint_commands: vec![],
allowed_tools: vec![],
};
let doc = super::super::design_doc::DesignDoc {
title: "Test".to_string(),
summary: "Summary".to_string(),
requirements: vec![],
requirement_groups: Vec::new(),
acceptance_criteria: vec![],
architecture: String::new(),
open_questions: vec![],
out_of_scope: vec![],
unknown_sections: vec![],
};
let opts = KickoffOpts {
description: "test feature",
issue: None,
container: ContainerMode::None,
verify: VerifyLevel::Local,
model: "opus",
image: "",
timeout: Duration::from_secs(3600),
dry_run: false,
branch: None,
quiet: false,
design_doc: Some(&doc),
doc_path: None,
skip_permissions: false,
permission_mode: None,
};
let prompt = build_prompt(&opts, 1, "feature/test", &conventions);
assert!(!prompt.contains("Spec Validation"));
}
#[test]
fn test_build_prompt_validation_ordering() {
let conventions = ProjectConventions {
test_command: Some("cargo test".to_string()),
lint_commands: vec![],
allowed_tools: vec![],
};
let doc = super::super::design_doc::DesignDoc {
title: "Test".to_string(),
summary: "Summary".to_string(),
requirements: vec![],
requirement_groups: Vec::new(),
acceptance_criteria: vec!["Criterion one".to_string()],
architecture: String::new(),
open_questions: vec![],
out_of_scope: vec![],
unknown_sections: vec![],
};
let opts = KickoffOpts {
description: "test feature",
issue: None,
container: ContainerMode::None,
verify: VerifyLevel::Local,
model: "opus",
image: "",
timeout: Duration::from_secs(3600),
dry_run: false,
branch: None,
quiet: false,
design_doc: Some(&doc),
doc_path: None,
skip_permissions: false,
permission_mode: None,
};
let prompt = build_prompt(&opts, 1, "feature/test", &conventions);
let test_pos = prompt.find("Run tests").expect("should have test section");
let validation_pos = prompt
.find("Spec Validation")
.expect("should have validation");
let final_pos = prompt.find("Final Steps").expect("should have final steps");
assert!(
test_pos < validation_pos,
"validation should come after tests"
);
assert!(
validation_pos < final_pos,
"validation should come before final steps"
);
}
fn sample_report() -> KickoffReport {
KickoffReport {
validated_at: "2026-03-03T12:00:00Z".to_string(),
criteria: vec![
CriterionVerdict {
id: "AC-1".to_string(),
verdict: "pass".to_string(),
evidence: "test_login passes".to_string(),
},
CriterionVerdict {
id: "AC-2".to_string(),
verdict: "partial".to_string(),
evidence: "HTTP only, not WebSocket".to_string(),
},
CriterionVerdict {
id: "AC-3".to_string(),
verdict: "fail".to_string(),
evidence: "not implemented".to_string(),
},
],
summary: ReportSummary {
total: 3,
pass: 1,
fail: 1,
partial: 1,
not_applicable: 0,
needs_clarification: 0,
},
schema_version: None,
agent_id: None,
issue_id: None,
status: None,
started_at: None,
completed_at: None,
phases: None,
unresolved_questions: None,
commits: None,
files_changed: None,
}
}
#[test]
fn test_format_report_table_symbols() {
let report = sample_report();
let output = format_report_table(&report);
assert!(output.contains("\u{2713} AC-1"));
assert!(output.contains("~ AC-2"));
assert!(output.contains("\u{2717} AC-3"));
}
#[test]
fn test_format_report_table_summary_line() {
let report = sample_report();
let output = format_report_table(&report);
assert!(output.contains("3 criteria: 1 pass, 1 partial, 1 fail"));
}
#[test]
fn test_format_report_markdown_has_table_header() {
let report = sample_report();
let output = format_report_markdown(&report);
assert!(output.contains("| ID | Verdict | Evidence |"));
assert!(output.contains("|---|---|---|"));
assert!(output.contains("| AC-1 |"));
}
#[test]
fn test_kickoff_report_deserialization() {
let report = sample_report();
let json = serde_json::to_string(&report).unwrap();
let parsed: KickoffReport = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, report);
}
#[test]
fn test_exclude_patterns_includes_report_files() {
let patterns = missing_exclude_patterns("");
assert!(patterns.contains(&".kickoff-criteria.json"));
assert!(patterns.contains(&".kickoff-report.json"));
}
#[test]
fn test_kickoff_report_backward_compat() {
let old_json = r#"{
"validated_at": "2026-03-03T12:00:00Z",
"criteria": [
{ "id": "AC-1", "verdict": "pass", "evidence": "test passes" }
],
"summary": {
"total": 1, "pass": 1, "fail": 0,
"partial": 0, "not_applicable": 0, "needs_clarification": 0
}
}"#;
let report: KickoffReport = serde_json::from_str(old_json).unwrap();
assert_eq!(report.criteria.len(), 1);
assert!(report.schema_version.is_none());
assert!(report.agent_id.is_none());
assert!(report.phases.is_none());
assert!(report.commits.is_none());
assert!(report.files_changed.is_none());
}
#[test]
fn test_kickoff_report_full_roundtrip() {
let report = KickoffReport {
validated_at: "2026-03-03T14:00:00Z".to_string(),
criteria: vec![CriterionVerdict {
id: "AC-1".to_string(),
verdict: "pass".to_string(),
evidence: "all tests green".to_string(),
}],
summary: ReportSummary {
total: 1,
pass: 1,
fail: 0,
partial: 0,
not_applicable: 0,
needs_clarification: 0,
},
schema_version: Some(1),
agent_id: Some("driver--batch-retry".to_string()),
issue_id: Some(42),
status: Some("completed".to_string()),
started_at: Some("2026-03-03T12:00:00Z".to_string()),
completed_at: Some("2026-03-03T14:00:00Z".to_string()),
phases: Some(PhaseTimings {
exploration: Some(PhaseTiming {
duration_s: 120,
files_read: Some(34),
..Default::default()
}),
testing: Some(PhaseTiming {
duration_s: 90,
tests_run: Some(146),
tests_passed: Some(146),
tests_failed: Some(0),
..Default::default()
}),
..Default::default()
}),
unresolved_questions: Some(vec!["Max backoff?".to_string()]),
commits: Some(vec!["abc1234".to_string(), "def5678".to_string()]),
files_changed: Some(vec!["src/retry.rs".to_string()]),
};
let json = serde_json::to_string_pretty(&report).unwrap();
let parsed: KickoffReport = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, report);
}
#[test]
fn test_phase_timing_partial_fields() {
let json = r#"{ "duration_s": 60 }"#;
let timing: PhaseTiming = serde_json::from_str(json).unwrap();
assert_eq!(timing.duration_s, 60);
assert!(timing.files_read.is_none());
assert!(timing.tests_run.is_none());
}
#[test]
fn test_validate_kickoff_report_warnings() {
let report = sample_report();
let warnings = validate_kickoff_report(&report);
assert!(warnings.iter().any(|w| w.contains("schema_version")));
assert!(warnings.iter().any(|w| w.contains("agent_id")));
}
#[test]
fn test_format_duration() {
assert_eq!(format_duration(30), "30s");
assert_eq!(format_duration(60), "1m");
assert_eq!(format_duration(90), "1m 30s");
assert_eq!(format_duration(3600), "1h");
assert_eq!(format_duration(5400), "1h 30m");
assert_eq!(format_duration(7200), "2h");
}
#[test]
fn test_format_report_table_with_phases() {
let mut report = sample_report();
report.agent_id = Some("driver--batch-retry".to_string());
report.issue_id = Some(42);
report.status = Some("completed".to_string());
report.phases = Some(PhaseTimings {
exploration: Some(PhaseTiming {
duration_s: 120,
files_read: Some(34),
..Default::default()
}),
testing: Some(PhaseTiming {
duration_s: 90,
tests_run: Some(146),
tests_passed: Some(146),
tests_failed: Some(0),
..Default::default()
}),
..Default::default()
});
let output = format_report_table(&report);
assert!(output.contains("driver--batch-retry"));
assert!(output.contains("Issue: #42"));
assert!(output.contains("Phase Timing:"));
assert!(output.contains("exploration"));
assert!(output.contains("34 files read"));
assert!(output.contains("146/146 passed"));
}
#[test]
fn test_format_report_table_without_phases() {
let report = sample_report();
let output = format_report_table(&report);
assert!(!output.contains("Phase Timing:"));
assert!(output.contains("Acceptance Criteria:"));
}
#[test]
fn test_format_report_markdown_with_metadata() {
let mut report = sample_report();
report.agent_id = Some("driver--test".to_string());
report.issue_id = Some(10);
report.status = Some("completed".to_string());
let output = format_report_markdown(&report);
assert!(output.contains("**Agent**: driver--test"));
assert!(output.contains("**Issue**: #10"));
assert!(output.contains("**Status**: completed"));
assert!(output.contains("| ID | Verdict | Evidence |"));
}
#[test]
fn test_format_report_all_table() {
let r1 = KickoffReport {
validated_at: "2026-03-03T12:00:00Z".to_string(),
criteria: vec![CriterionVerdict {
id: "AC-1".to_string(),
verdict: "pass".to_string(),
evidence: "ok".to_string(),
}],
summary: ReportSummary {
total: 1,
pass: 1,
fail: 0,
partial: 0,
not_applicable: 0,
needs_clarification: 0,
},
schema_version: Some(1),
agent_id: Some("driver--alpha".to_string()),
issue_id: Some(1),
status: Some("completed".to_string()),
started_at: None,
completed_at: None,
phases: Some(PhaseTimings {
testing: Some(PhaseTiming {
duration_s: 60,
tests_run: Some(50),
tests_passed: Some(50),
..Default::default()
}),
..Default::default()
}),
unresolved_questions: None,
commits: None,
files_changed: None,
};
let r2 = KickoffReport {
status: Some("failed".to_string()),
..r1.clone()
};
let reports = vec![("alpha", r1), ("beta", r2)];
let output = format_report_all_table(&reports);
assert!(output.contains("2 agents"));
assert!(output.contains("alpha"));
assert!(output.contains("beta"));
assert!(output.contains("1 completed, 1 failed"));
}
#[test]
fn test_preflight_check_passes_when_commands_available() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("hook-config.json"), "{}").unwrap();
let result = preflight_check(&ContainerMode::Docker, &VerifyLevel::Local, dir.path());
let _ = result;
}
#[test]
fn test_preflight_check_missing_command_includes_hint() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("hook-config.json"), "{}").unwrap();
let result = preflight_check(&ContainerMode::Podman, &VerifyLevel::Thorough, dir.path());
if let Err(e) = result {
let msg = e.to_string();
if msg.contains("podman") {
assert!(msg.contains("Pre-flight check failed"));
assert!(msg.contains("podman"));
}
if msg.contains("GitHub CLI") {
assert!(msg.contains("gh"));
}
}
}
#[test]
fn test_build_agent_command_without_sandbox() {
let cmd = build_agent_command(
"timeout",
3600,
"opus",
"Read,Write",
"KICKOFF.md",
None,
Path::new("/tmp/worktree"),
false,
None,
None,
);
assert_eq!(
cmd,
"timeout 3600s env -u CLAUDECODE claude --model 'opus' --allowedTools 'Read,Write' -- \"$(cat 'KICKOFF.md')\""
);
}
#[test]
fn test_build_agent_command_with_sandbox() {
let cmd = build_agent_command(
"timeout",
3600,
"opus",
"Read,Write",
"KICKOFF.md",
Some("bwrap --bind {{worktree}} /workspace --"),
Path::new("/tmp/my-worktree"),
false,
None,
None,
);
assert!(cmd.starts_with("timeout 3600s bwrap --bind '/tmp/my-worktree' /workspace --"));
assert!(cmd.contains("env -u CLAUDECODE claude"));
}
#[test]
fn test_build_agent_command_with_skip_permissions() {
let cmd = build_agent_command(
"timeout",
3600,
"opus",
"Read,Write",
"KICKOFF.md",
None,
Path::new("/tmp/worktree"),
true,
None,
None,
);
assert!(
cmd.contains("--dangerously-skip-permissions"),
"Should include skip permissions flag"
);
assert!(cmd.contains("claude --dangerously-skip-permissions --model 'opus'"));
}
#[test]
fn test_build_agent_command_with_permission_mode_auto() {
let cmd = build_agent_command(
"timeout",
3600,
"opus",
"Read,Write",
"KICKOFF.md",
None,
Path::new("/tmp/worktree"),
false,
None,
Some("auto"),
);
assert!(
cmd.contains("--permission-mode 'auto'"),
"permission_mode=auto should emit --permission-mode 'auto', got: {cmd}"
);
assert!(
!cmd.contains("--dangerously-skip-permissions"),
"permission_mode must not coexist with --dangerously-skip-permissions"
);
}
#[test]
fn test_build_agent_command_permission_mode_wins_over_skip_permissions() {
let cmd = build_agent_command(
"timeout",
3600,
"opus",
"Read,Write",
"KICKOFF.md",
None,
Path::new("/tmp/worktree"),
true,
None,
Some("acceptEdits"),
);
assert!(
cmd.contains("--permission-mode 'acceptEdits'"),
"permission_mode should win over skip_permissions, got: {cmd}"
);
assert!(
!cmd.contains("--dangerously-skip-permissions"),
"skip_permissions must be suppressed when permission_mode is set, got: {cmd}"
);
}
#[test]
fn test_build_agent_command_empty_permission_mode_treated_as_none() {
let cmd = build_agent_command(
"timeout",
3600,
"opus",
"Read,Write",
"KICKOFF.md",
None,
Path::new("/tmp/worktree"),
true,
None,
Some(""),
);
assert!(
!cmd.contains("--permission-mode"),
"empty permission_mode must not emit the flag, got: {cmd}"
);
assert!(
cmd.contains("--dangerously-skip-permissions"),
"with skip_permissions=true and empty permission_mode, the legacy flag wins, got: {cmd}"
);
}
#[test]
fn test_build_agent_command_plan_kickoff() {
let cmd = build_agent_command(
"gtimeout",
1800,
"sonnet",
"Read,Glob",
"PLAN_KICKOFF.md",
None,
Path::new("/tmp/worktree"),
false,
None,
None,
);
assert!(cmd.starts_with("gtimeout 1800s"));
assert!(cmd.contains("$(cat 'PLAN_KICKOFF.md')"));
}
#[test]
fn test_build_agent_command_propagates_claude_config_dir() {
let cmd = build_agent_command(
"timeout",
3600,
"opus",
"Read,Write",
"KICKOFF.md",
None,
Path::new("/tmp/worktree"),
false,
Some("/Users/me/.claude-work"),
None,
);
assert_eq!(
cmd,
"timeout 3600s env -u CLAUDECODE CLAUDE_CONFIG_DIR='/Users/me/.claude-work' claude --model 'opus' --allowedTools 'Read,Write' -- \"$(cat 'KICKOFF.md')\""
);
}
#[test]
fn test_build_agent_command_omits_empty_claude_config_dir() {
let cmd = build_agent_command(
"timeout",
3600,
"opus",
"Read,Write",
"KICKOFF.md",
None,
Path::new("/tmp/worktree"),
false,
Some(""),
None,
);
assert!(!cmd.contains("CLAUDE_CONFIG_DIR="));
assert!(cmd.starts_with("timeout 3600s env -u CLAUDECODE claude"));
}
#[test]
fn test_build_agent_command_escapes_claude_config_dir_with_quotes() {
let cmd = build_agent_command(
"timeout",
3600,
"opus",
"Read,Write",
"KICKOFF.md",
None,
Path::new("/tmp/worktree"),
false,
Some("/weird/it's-a-path"),
None,
);
assert!(cmd.contains("CLAUDE_CONFIG_DIR='/weird/it'\\''s-a-path'"));
}
#[test]
fn test_build_agent_command_with_sandbox_includes_claude_config_dir() {
let cmd = build_agent_command(
"timeout",
3600,
"opus",
"Read,Write",
"KICKOFF.md",
Some("bwrap --bind {{worktree}} /workspace --"),
Path::new("/tmp/my-worktree"),
false,
Some("/Users/me/.claude-work"),
None,
);
assert!(cmd.contains(
"bwrap --bind '/tmp/my-worktree' /workspace -- env -u CLAUDECODE CLAUDE_CONFIG_DIR='/Users/me/.claude-work' claude"
));
}
#[cfg(unix)]
fn write_claude_stub(dir: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
let stub = dir.join("claude");
std::fs::write(
&stub,
"#!/bin/sh\nprintf 'CCD=%s\\n' \"$CLAUDE_CONFIG_DIR\"\nexit 0\n",
)
.unwrap();
std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
}
#[cfg(unix)]
fn resolve_test_timeout_cmd() -> Option<&'static str> {
["timeout", "gtimeout"].into_iter().find(|cmd| {
std::process::Command::new(cmd)
.arg("--version")
.output()
.is_ok_and(|o| o.status.success())
})
}
#[cfg(unix)]
fn run_built_command_in_bash(
cmd: &str,
cwd: &std::path::Path,
extra_path: &std::path::Path,
) -> std::process::Output {
let path = format!(
"{}:{}",
extra_path.display(),
std::env::var("PATH").unwrap_or_default()
);
std::process::Command::new("bash")
.arg("-c")
.arg(cmd)
.current_dir(cwd)
.env("PATH", &path)
.output()
.expect("failed to spawn bash")
}
#[test]
#[cfg(unix)]
fn test_build_agent_command_env_var_actually_reaches_claude() {
let Some(timeout_cmd) = resolve_test_timeout_cmd() else {
eprintln!("skipping: neither `timeout` nor `gtimeout` available on test host");
return;
};
let tmp = tempfile::tempdir().unwrap();
write_claude_stub(tmp.path());
std::fs::write(tmp.path().join("KICKOFF.md"), "noop").unwrap();
let cmd = build_agent_command(
timeout_cmd,
3600,
"opus",
"Read,Write",
"KICKOFF.md",
None,
tmp.path(),
false,
Some("/expected/value"),
None,
);
let output = run_built_command_in_bash(&cmd, tmp.path(), tmp.path());
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"command failed:\n status: {:?}\n stdout: {stdout}\n stderr: {stderr}\n cmd: {cmd}",
output.status
);
assert!(
stdout.contains("CCD=/expected/value"),
"CLAUDE_CONFIG_DIR did not reach claude:\n stdout: {stdout}\n cmd: {cmd}"
);
}
#[test]
#[cfg(unix)]
fn test_build_agent_command_env_var_reaches_claude_through_sandbox() {
let Some(timeout_cmd) = resolve_test_timeout_cmd() else {
eprintln!("skipping: neither `timeout` nor `gtimeout` available on test host");
return;
};
let tmp = tempfile::tempdir().unwrap();
write_claude_stub(tmp.path());
std::fs::write(tmp.path().join("KICKOFF.md"), "noop").unwrap();
use std::os::unix::fs::PermissionsExt;
let sandbox = tmp.path().join("noop-sandbox");
std::fs::write(&sandbox, "#!/bin/sh\nexec \"$@\"\n").unwrap();
std::fs::set_permissions(&sandbox, std::fs::Permissions::from_mode(0o755)).unwrap();
let cmd = build_agent_command(
timeout_cmd,
3600,
"opus",
"Read,Write",
"KICKOFF.md",
Some(&sandbox.to_string_lossy()),
tmp.path(),
false,
Some("/sandbox-passthrough/value"),
None,
);
let output = run_built_command_in_bash(&cmd, tmp.path(), tmp.path());
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"command failed:\n status: {:?}\n stdout: {stdout}\n stderr: {stderr}\n cmd: {cmd}",
output.status
);
assert!(
stdout.contains("CCD=/sandbox-passthrough/value"),
"CLAUDE_CONFIG_DIR did not reach claude through sandbox:\n stdout: {stdout}\n cmd: {cmd}"
);
}
#[test]
#[cfg(unix)]
fn test_build_agent_command_omitted_env_var_does_not_break_launch() {
let Some(timeout_cmd) = resolve_test_timeout_cmd() else {
eprintln!("skipping: neither `timeout` nor `gtimeout` available on test host");
return;
};
let tmp = tempfile::tempdir().unwrap();
write_claude_stub(tmp.path());
std::fs::write(tmp.path().join("KICKOFF.md"), "noop").unwrap();
let cmd = build_agent_command(
timeout_cmd,
3600,
"opus",
"Read,Write",
"KICKOFF.md",
None,
tmp.path(),
false,
None,
None,
);
let output = run_built_command_in_bash(&cmd, tmp.path(), tmp.path());
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"command failed:\n status: {:?}\n stdout: {stdout}\n stderr: {stderr}\n cmd: {cmd}",
output.status
);
assert!(
stdout.contains("CCD="),
"expected stub to print CCD= line:\n stdout: {stdout}"
);
}
#[test]
fn test_read_sandbox_command_not_configured() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("hook-config.json"), "{}").unwrap();
assert!(read_sandbox_command(dir.path()).is_none());
}
#[test]
fn test_read_sandbox_command_configured() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("hook-config.json"),
r#"{"sandbox": {"command": "bwrap --bind {{worktree}} /workspace --"}}"#,
)
.unwrap();
let cmd = read_sandbox_command(dir.path());
assert_eq!(
cmd.as_deref(),
Some("bwrap --bind {{worktree}} /workspace --")
);
}
#[test]
fn test_read_sandbox_command_empty_string_ignored() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("hook-config.json"),
r#"{"sandbox": {"command": ""}}"#,
)
.unwrap();
assert!(read_sandbox_command(dir.path()).is_none());
}
#[test]
fn test_preflight_check_validates_sandbox_binary() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("hook-config.json"),
r#"{"sandbox": {"command": "crosslink_nonexistent_sandbox_xyz --isolate --"}}"#,
)
.unwrap();
let result = preflight_check(&ContainerMode::None, &VerifyLevel::Local, dir.path());
if let Err(e) = result {
let msg = e.to_string();
assert!(msg.contains("crosslink_nonexistent_sandbox_xyz"));
assert!(msg.contains("sandbox.command"));
}
}
#[test]
fn test_command_available_nonexistent() {
assert!(!command_available("crosslink_nonexistent_binary_xyz"));
}
#[test]
fn test_command_available_real() {
assert!(command_available("which"));
}
#[test]
fn test_detect_platform_returns_valid_variant() {
let platform = detect_platform();
match platform {
Platform::MacOS | Platform::Windows | Platform::Linux(_) => {}
}
}
#[test]
fn test_install_hint_timeout_macos() {
let hint = install_hint("timeout", &Platform::MacOS);
assert!(hint.contains("brew install coreutils"));
assert!(hint.contains("gtimeout"));
}
#[test]
fn test_install_hint_timeout_debian() {
let hint = install_hint("timeout", &Platform::Linux(LinuxDistro::Debian));
assert!(hint.contains("sudo apt install coreutils"));
}
#[test]
fn test_install_hint_timeout_fedora() {
let hint = install_hint("timeout", &Platform::Linux(LinuxDistro::Fedora));
assert!(hint.contains("sudo dnf install coreutils"));
}
#[test]
fn test_install_hint_timeout_arch() {
let hint = install_hint("timeout", &Platform::Linux(LinuxDistro::Arch));
assert!(hint.contains("sudo pacman -S coreutils"));
}
#[test]
fn test_install_hint_tmux_macos() {
let hint = install_hint("tmux", &Platform::MacOS);
assert!(hint.contains("brew install tmux"));
assert!(hint.contains("--container docker"));
}
#[test]
fn test_install_hint_tmux_debian() {
let hint = install_hint("tmux", &Platform::Linux(LinuxDistro::Debian));
assert!(hint.contains("sudo apt install tmux"));
}
#[test]
fn test_install_hint_tmux_windows() {
let hint = install_hint("tmux", &Platform::Windows);
assert!(hint.contains("not available on Windows"));
assert!(hint.contains("--container docker"));
}
#[test]
fn test_install_hint_claude_macos() {
let hint = install_hint("claude", &Platform::MacOS);
assert!(hint.contains("brew install claude-code"));
assert!(hint.contains("npm install"));
}
#[test]
fn test_install_hint_claude_linux() {
let hint = install_hint("claude", &Platform::Linux(LinuxDistro::Other));
assert!(hint.contains("npm install -g @anthropic-ai/claude-code"));
}
#[test]
fn test_install_hint_gh_macos() {
let hint = install_hint("gh", &Platform::MacOS);
assert!(hint.contains("brew install gh"));
}
#[test]
fn test_install_hint_gh_debian() {
let hint = install_hint("gh", &Platform::Linux(LinuxDistro::Debian));
assert!(hint.contains("sudo apt"));
assert!(hint.contains("githubcli"));
}
#[test]
fn test_install_hint_gh_windows() {
let hint = install_hint("gh", &Platform::Windows);
assert!(hint.contains("winget install"));
}
#[test]
fn test_install_hint_docker_macos() {
let hint = install_hint("docker", &Platform::MacOS);
assert!(hint.contains("brew install --cask docker"));
assert!(hint.contains("--container none"));
}
#[test]
fn test_install_hint_docker_debian() {
let hint = install_hint("docker", &Platform::Linux(LinuxDistro::Debian));
assert!(hint.contains("get.docker.com"));
assert!(hint.contains("usermod"));
}
#[test]
fn test_install_hint_podman_macos() {
let hint = install_hint("podman", &Platform::MacOS);
assert!(hint.contains("brew install podman"));
}
#[test]
fn test_install_hint_podman_fedora() {
let hint = install_hint("podman", &Platform::Linux(LinuxDistro::Fedora));
assert!(hint.contains("sudo dnf install podman"));
}
#[test]
fn test_install_hint_podman_windows() {
let hint = install_hint("podman", &Platform::Windows);
assert!(hint.contains("winget install RedHat.Podman"));
}
#[test]
fn test_install_hint_unknown_command() {
let hint = install_hint("unknown_tool", &Platform::MacOS);
assert!(hint.contains("unknown_tool"));
assert!(hint.contains("package manager"));
}
#[test]
fn test_kickoff_report_phase3_backward_compat() {
let phase3_json = include_str!("../../../test-fixtures/phase3-report.json");
let report: KickoffReport =
serde_json::from_str(phase3_json).expect("Phase 3 JSON must deserialize");
assert_eq!(report.validated_at, "2026-03-01T12:00:00Z");
assert_eq!(report.criteria.len(), 2);
assert_eq!(report.criteria[0].id, "AC-1");
assert_eq!(report.criteria[0].verdict, "pass");
assert_eq!(report.criteria[1].verdict, "fail");
assert_eq!(report.summary.total, 2);
assert_eq!(report.summary.pass, 1);
assert_eq!(report.summary.fail, 1);
assert!(report.schema_version.is_none());
assert!(report.agent_id.is_none());
assert!(report.issue_id.is_none());
assert!(report.status.is_none());
assert!(report.started_at.is_none());
assert!(report.completed_at.is_none());
assert!(report.phases.is_none());
assert!(report.unresolved_questions.is_none());
assert!(report.commits.is_none());
assert!(report.files_changed.is_none());
let serialized = serde_json::to_string(&report).expect("serialize");
let roundtrip: KickoffReport =
serde_json::from_str(&serialized).expect("round-trip deserialize");
assert_eq!(report, roundtrip);
}
#[test]
fn test_build_prompt_contains_report_json_schema() {
let doc = super::super::design_doc::DesignDoc {
title: "Test Feature".to_string(),
summary: String::new(),
requirements: vec![],
requirement_groups: Vec::new(),
acceptance_criteria: vec!["AC-1: Widget renders".to_string()],
architecture: String::new(),
open_questions: vec![],
out_of_scope: vec![],
unknown_sections: vec![],
};
let conventions = ProjectConventions {
test_command: None,
lint_commands: vec![],
allowed_tools: vec![],
};
let opts = KickoffOpts {
description: "test feature",
issue: None,
container: ContainerMode::None,
verify: VerifyLevel::Local,
model: "opus",
image: "",
timeout: Duration::from_secs(3600),
dry_run: false,
branch: None,
quiet: false,
design_doc: Some(&doc),
doc_path: Some("test.md"),
skip_permissions: false,
permission_mode: None,
};
let prompt = build_prompt(&opts, 1, "feature/test", &conventions);
assert!(prompt.contains("schema_version"));
assert!(prompt.contains("agent_id"));
assert!(prompt.contains("issue_id"));
assert!(prompt.contains("validated_at"));
assert!(prompt.contains("criteria"));
assert!(prompt.contains("summary"));
assert!(prompt.contains(".kickoff-report.json"));
}
#[test]
fn test_build_prompt_contains_validation_section() {
let doc = super::super::design_doc::DesignDoc {
title: "Validated Feature".to_string(),
summary: String::new(),
requirements: vec![],
requirement_groups: Vec::new(),
acceptance_criteria: vec!["AC-1: Must work".to_string()],
architecture: String::new(),
open_questions: vec![],
out_of_scope: vec![],
unknown_sections: vec![],
};
let conventions = ProjectConventions {
test_command: None,
lint_commands: vec![],
allowed_tools: vec![],
};
let opts = KickoffOpts {
description: "validated feature",
issue: None,
container: ContainerMode::None,
verify: VerifyLevel::Local,
model: "opus",
image: "",
timeout: Duration::from_secs(3600),
dry_run: false,
branch: None,
quiet: false,
design_doc: Some(&doc),
doc_path: Some("test.md"),
skip_permissions: false,
permission_mode: None,
};
let prompt = build_prompt(&opts, 1, "feature/validated", &conventions);
assert!(prompt.contains("Spec Validation & Reporting"));
assert!(prompt.contains("Criteria Validation"));
assert!(prompt.contains(".kickoff-criteria.json"));
assert!(prompt.contains("pass"));
assert!(prompt.contains("fail"));
assert!(prompt.contains("partial"));
assert!(prompt.contains("not_applicable"));
assert!(prompt.contains("needs_clarification"));
}
#[test]
fn test_plan_tools_are_read_only() {
let tools = build_allowed_tools_plan();
assert!(
!tools.contains("Write"),
"plan tools must not include Write"
);
assert!(!tools.contains("Edit"), "plan tools must not include Edit");
assert!(
!tools.contains("Bash(git commit"),
"plan tools must not allow git commit"
);
assert!(
!tools.contains("Bash(git push"),
"plan tools must not allow git push"
);
assert!(tools.contains("Read"));
assert!(tools.contains("Glob"));
assert!(tools.contains("Grep"));
assert!(tools.contains("Bash(git log"));
assert!(tools.contains("Bash(git diff"));
}
#[test]
fn test_watchdog_config_defaults() {
let cfg = WatchdogConfig::default();
assert!(cfg.enabled);
assert_eq!(cfg.staleness_secs, 300);
assert_eq!(cfg.max_nudges, 5);
assert_eq!(cfg.check_interval_secs, 120);
assert_eq!(cfg.grace_period_secs, 300);
}
#[test]
fn test_read_watchdog_config_missing_file() {
let dir = tempfile::tempdir().unwrap();
let cfg = read_watchdog_config(dir.path());
assert!(cfg.enabled);
assert_eq!(cfg.staleness_secs, 300);
}
#[test]
fn test_read_watchdog_config_no_watchdog_key() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("hook-config.json"), "{}").unwrap();
let cfg = read_watchdog_config(dir.path());
assert!(cfg.enabled);
}
#[test]
fn test_read_watchdog_config_custom_values() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("hook-config.json"),
r#"{"watchdog": {"enabled": false, "staleness_secs": 600, "max_nudges": 10}}"#,
)
.unwrap();
let cfg = read_watchdog_config(dir.path());
assert!(!cfg.enabled);
assert_eq!(cfg.staleness_secs, 600);
assert_eq!(cfg.max_nudges, 10);
assert_eq!(cfg.check_interval_secs, 120); }
#[test]
fn test_build_watchdog_script_contains_key_elements() {
let cfg = WatchdogConfig {
enabled: true,
staleness_secs: 300,
max_nudges: 3,
check_interval_secs: 60,
grace_period_secs: 120,
};
let script = build_watchdog_script("feat-my-agent", Path::new("/tmp/wt"), &cfg);
assert!(script.contains("sleep 120")); assert!(script.contains("sleep 60")); assert!(script.contains(".kickoff-status"));
assert!(script.contains("feat-my-agent"));
assert!(script.contains("last-heartbeat"));
assert!(script.contains("continue working"));
assert!(script.contains("NUDGES"));
assert!(script.contains("-gt 300")); assert!(script.contains("-ge 3")); }
use super::pipeline::{self, PipelineState, RunProbe, RunRecord};
fn pipeline_with_runs(stage: &str, runs: Vec<RunRecord>) -> PipelineState {
PipelineState {
schema_version: 1,
design_doc: ".design/foo.md".to_string(),
doc_hash: "sha256:deadbeef".to_string(),
stage: stage.to_string(),
plans: Vec::new(),
runs,
}
}
fn running_row(agent_id: &str, worktree: &str, started_at: &str) -> RunRecord {
RunRecord {
agent_id: agent_id.to_string(),
worktree: worktree.to_string(),
issue_id: Some(1),
started_at: started_at.to_string(),
completed_at: None,
status: "running".to_string(),
}
}
#[test]
fn test_mark_running_writes_real_identity_no_pending() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".design")).unwrap();
let doc = tmp.path().join(".design/foo.md");
std::fs::write(&doc, "# Foo\n").unwrap();
let wt = tmp.path().join(".worktrees/repo--abcd--foo");
let state =
pipeline::mark_running(&doc, "repo--abcd--foo", &wt.to_string_lossy(), Some(7)).unwrap();
let row = state.runs.last().unwrap();
assert_eq!(row.agent_id, "repo--abcd--foo");
assert_ne!(row.agent_id, "pending");
assert_eq!(row.worktree, wt.to_string_lossy());
assert_ne!(row.worktree, "pending");
assert_eq!(row.status, "running");
assert_eq!(row.issue_id, Some(7));
assert_eq!(state.stage, "running");
}
#[test]
fn test_reconcile_done_sentinel_marks_completed_with_timestamp() {
let mut state = pipeline_with_runs(
"running",
vec![running_row("a1", "/wt/a1", "2026-05-12T20:22:43+00:00")],
);
let changed = pipeline::reconcile_runs(&mut state, "2026-06-12T00:00:00+00:00", |_r| {
(
RunProbe::SentinelDone,
Some("2026-05-13T10:00:00+00:00".to_string()),
)
});
assert!(changed);
let row = &state.runs[0];
assert_eq!(row.status, "completed");
assert_eq!(
row.completed_at.as_deref(),
Some("2026-05-13T10:00:00+00:00")
);
assert_eq!(state.stage, "complete");
}
#[test]
fn test_reconcile_failed_sentinel_marks_failed() {
let mut state = pipeline_with_runs(
"running",
vec![running_row("a1", "/wt/a1", "2026-05-12T20:22:43+00:00")],
);
let changed = pipeline::reconcile_runs(&mut state, "2026-06-12T00:00:00+00:00", |_r| {
(RunProbe::SentinelFailed, None)
});
assert!(changed);
assert_eq!(state.runs[0].status, "failed");
assert_eq!(
state.runs[0].completed_at.as_deref(),
Some("2026-06-12T00:00:00+00:00")
);
}
#[test]
fn test_reconcile_missing_worktree_marks_aborted() {
let mut state = pipeline_with_runs(
"running",
vec![running_row("a1", "/wt/gone", "2026-05-12T20:22:43+00:00")],
);
let changed = pipeline::reconcile_runs(&mut state, "2026-06-12T00:00:00+00:00", |_r| {
(RunProbe::Gone, None)
});
assert!(changed);
assert_eq!(state.runs[0].status, "aborted");
assert_eq!(
state.runs[0].completed_at.as_deref(),
Some("2026-06-12T00:00:00+00:00")
);
assert_eq!(state.stage, "designed");
}
#[test]
fn test_reconcile_live_agent_row_untouched() {
let mut state = pipeline_with_runs(
"running",
vec![running_row("a1", "/wt/a1", "2026-05-12T20:22:43+00:00")],
);
let changed = pipeline::reconcile_runs(&mut state, "2026-06-12T00:00:00+00:00", |_r| {
(RunProbe::LiveRunning, None)
});
assert!(!changed);
assert_eq!(state.runs[0].status, "running");
assert!(state.runs[0].completed_at.is_none());
assert_eq!(state.stage, "running");
}
#[test]
fn test_reconcile_all_rows_not_just_last() {
let mut state = pipeline_with_runs(
"running",
vec![
running_row("a1", "/wt/a1", "2026-05-12T20:22:43+00:00"),
running_row("a2", "/wt/a2", "2026-05-13T20:22:43+00:00"),
running_row("a3", "/wt/a3", "2026-05-14T20:22:43+00:00"),
],
);
let changed = pipeline::reconcile_runs(&mut state, "2026-06-12T00:00:00+00:00", |_r| {
(RunProbe::Gone, None)
});
assert!(changed);
assert!(state.runs.iter().all(|r| r.status == "aborted"));
}
#[test]
fn test_probe_pending_worktree_is_gone_when_no_live_agent() {
let row = running_row("pending", "pending", "2026-05-12T20:22:43+00:00");
let (verdict, _mtime) = pipeline::probe_run_worktree(&row, &[]);
assert_eq!(verdict, RunProbe::Gone);
}
#[test]
fn test_probe_done_sentinel_on_disk() {
let tmp = tempfile::tempdir().unwrap();
let wt = tmp.path().join("wt-done");
std::fs::create_dir_all(&wt).unwrap();
std::fs::write(wt.join(".kickoff-status"), "DONE\n").unwrap();
let row = running_row("a1", &wt.to_string_lossy(), "2026-05-12T20:22:43+00:00");
let (verdict, mtime) = pipeline::probe_run_worktree(&row, &[]);
assert_eq!(verdict, RunProbe::SentinelDone);
assert!(mtime.is_some());
}
#[test]
fn test_probe_failed_sentinel_on_disk() {
let tmp = tempfile::tempdir().unwrap();
let wt = tmp.path().join("wt-fail");
std::fs::create_dir_all(&wt).unwrap();
std::fs::write(wt.join(".kickoff-status"), "CI_FAILED\n").unwrap();
let row = running_row("a1", &wt.to_string_lossy(), "2026-05-12T20:22:43+00:00");
let (verdict, _mtime) = pipeline::probe_run_worktree(&row, &[]);
assert_eq!(verdict, RunProbe::SentinelFailed);
}
#[test]
fn test_probe_live_worktree_running_status() {
let tmp = tempfile::tempdir().unwrap();
let wt = tmp.path().join("wt-run");
std::fs::create_dir_all(&wt).unwrap();
std::fs::write(wt.join(".kickoff-status"), "RUNNING\n").unwrap();
let row = running_row("a1", &wt.to_string_lossy(), "2026-05-12T20:22:43+00:00");
let (verdict, _) = pipeline::probe_run_worktree(&row, &["a1".to_string()]);
assert_eq!(verdict, RunProbe::LiveRunning);
let (verdict, _) = pipeline::probe_run_worktree(&row, &[]);
assert_eq!(verdict, RunProbe::Indeterminate);
}
const GH614_LEGACY_PIPELINE_JSON: &str = r#"{
"schema_version": 1,
"design_doc": ".design/forecast-decode.md",
"doc_hash": "sha256:abc",
"stage": "running",
"plans": [],
"runs": [
{
"agent_id": "pending",
"worktree": "pending",
"issue_id": 1,
"started_at": "2026-05-12T20:22:43.929777+00:00",
"status": "running"
},
{
"agent_id": "pending",
"worktree": "pending",
"issue_id": 1,
"started_at": "2026-05-14T09:10:00.000000+00:00",
"status": "running"
}
]
}"#;
#[test]
fn test_legacy_pending_file_reconciles_to_aborted_and_persists() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".design")).unwrap();
let doc = tmp.path().join(".design/forecast-decode.md");
std::fs::write(&doc, "# Forecast decode\n").unwrap();
let pipeline_file = tmp.path().join(".design/forecast-decode.pipeline.json");
std::fs::write(&pipeline_file, GH614_LEGACY_PIPELINE_JSON).unwrap();
let mut state = pipeline::read_pipeline_state(&doc).expect("legacy file must parse");
assert_eq!(state.runs.len(), 2);
assert!(state.runs.iter().all(|r| r.status == "running"));
let changed = pipeline::reconcile_runs_for_display(&doc, &mut state, &[]);
assert!(changed);
assert!(state.runs.iter().all(|r| r.status == "aborted"));
assert!(state.runs.iter().all(|r| r.completed_at.is_some()));
assert!(state.runs.iter().all(|r| r.agent_id == "pending"));
assert_ne!(state.stage, "running");
let display = pipeline::stage_display(&state, &doc);
assert!(!display.contains("running"));
let reread = pipeline::read_pipeline_state(&doc).unwrap();
assert!(reread.runs.iter().all(|r| r.status == "aborted"));
}
#[test]
fn test_reconcile_is_idempotent() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".design")).unwrap();
let doc = tmp.path().join(".design/forecast-decode.md");
std::fs::write(&doc, "# Forecast decode\n").unwrap();
let pipeline_file = tmp.path().join(".design/forecast-decode.pipeline.json");
std::fs::write(&pipeline_file, GH614_LEGACY_PIPELINE_JSON).unwrap();
let mut state = pipeline::read_pipeline_state(&doc).unwrap();
assert!(pipeline::reconcile_runs_for_display(&doc, &mut state, &[]));
let before = std::fs::read_to_string(&pipeline_file).unwrap();
let changed_again = pipeline::reconcile_runs_for_display(&doc, &mut state, &[]);
assert!(!changed_again);
let after = std::fs::read_to_string(&pipeline_file).unwrap();
assert_eq!(before, after);
}
#[test]
fn test_stage_transition_aborted_with_plan_falls_back_to_planned() {
let mut state = pipeline_with_runs(
"running",
vec![running_row("a1", "/wt/gone", "2026-05-12T20:22:43+00:00")],
);
state.plans.push(super::pipeline::PlanRecord {
agent_id: "p1".to_string(),
worktree: "/wt/p1".to_string(),
started_at: "2026-05-10T00:00:00+00:00".to_string(),
completed_at: Some("2026-05-10T01:00:00+00:00".to_string()),
status: "done".to_string(),
blocking_gaps: 0,
advisory_gaps: 0,
plan_file: Some(".design/foo.plan.json".to_string()),
});
let changed = pipeline::reconcile_runs(&mut state, "2026-06-12T00:00:00+00:00", |_r| {
(RunProbe::Gone, None)
});
assert!(changed);
assert_eq!(state.runs[0].status, "aborted");
assert_eq!(state.stage, "planned");
}
#[test]
fn test_mark_run_finished_matches_by_worktree() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".design")).unwrap();
let doc = tmp.path().join(".design/foo.md");
std::fs::write(&doc, "# Foo\n").unwrap();
let pipeline_file = tmp.path().join(".design/foo.pipeline.json");
let mut state = pipeline_with_runs(
"running",
vec![
running_row("a1", "/wt/a1", "2026-05-12T20:22:43+00:00"),
running_row("a2", "/wt/a2", "2026-05-13T20:22:43+00:00"),
],
);
std::fs::write(
&pipeline_file,
serde_json::to_string_pretty(&state).unwrap(),
)
.unwrap();
let updated = pipeline::mark_run_finished(&doc, &mut state, "/wt/a2", None, "completed");
assert!(updated);
assert_eq!(state.runs[0].status, "running"); assert_eq!(state.runs[1].status, "completed");
assert!(state.runs[1].completed_at.is_some());
assert_eq!(state.stage, "running");
}
#[test]
fn test_mark_run_finished_legacy_fallback_by_started_at() {
let doc = Path::new("/nonexistent/.design/foo.md");
let mut state = pipeline_with_runs(
"running",
vec![
running_row("pending", "pending", "2026-05-12T20:22:43+00:00"),
running_row("pending", "pending", "2026-05-14T09:10:00+00:00"),
],
);
let updated = pipeline::mark_run_finished(
doc,
&mut state,
"pending",
Some("2026-05-14T09:11:00+00:00"),
"completed",
);
assert!(updated);
assert_eq!(state.runs[0].status, "running");
assert_eq!(state.runs[1].status, "completed");
}
#[test]
fn test_reconcile_completion_by_worktree_scans_design_dir() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".design")).unwrap();
let doc = tmp.path().join(".design/foo.md");
std::fs::write(&doc, "# Foo\n").unwrap();
let wt = tmp.path().join(".worktrees/repo--abcd--foo");
let state = pipeline_with_runs(
"running",
vec![running_row(
"repo--abcd--foo",
&wt.to_string_lossy(),
"2026-05-12T20:22:43+00:00",
)],
);
std::fs::write(
tmp.path().join(".design/foo.pipeline.json"),
serde_json::to_string_pretty(&state).unwrap(),
)
.unwrap();
let hit =
pipeline::reconcile_completion_by_worktree(tmp.path(), &wt.to_string_lossy(), "completed");
assert!(hit);
let reread = pipeline::read_pipeline_state(&doc).unwrap();
assert_eq!(reread.runs[0].status, "completed");
assert_eq!(reread.stage, "complete");
}