use super::*;
use crate::automation_v2::types::{
AutomationHandoffConfig, AutomationV2Schedule, AutomationV2ScheduleType, AutomationV2Status,
HandoffArtifact, WatchCondition,
};
fn make_automation(id: &str, workspace_root: &str) -> crate::AutomationV2Spec {
crate::AutomationV2Spec {
automation_id: id.to_string(),
name: id.to_string(),
description: None,
status: AutomationV2Status::Active,
schedule: AutomationV2Schedule {
schedule_type: AutomationV2ScheduleType::Manual,
cron_expression: None,
interval_seconds: None,
timezone: "UTC".to_string(),
misfire_policy: crate::RoutineMisfirePolicy::Skip,
},
knowledge: tandem_orchestrator::KnowledgeBinding::default(),
agents: vec![],
flow: crate::automation_v2::types::AutomationFlowSpec { nodes: vec![] },
execution: crate::automation_v2::types::AutomationExecutionPolicy {
max_parallel_agents: None,
max_total_runtime_ms: None,
max_total_tool_calls: None,
max_total_tokens: None,
max_total_cost_usd: None,
},
output_targets: vec![],
created_at_ms: 1,
updated_at_ms: 1,
creator_id: "test".to_string(),
workspace_root: Some(workspace_root.to_string()),
metadata: None,
next_fire_at_ms: None,
last_fired_at_ms: None,
scope_policy: None,
watch_conditions: vec![],
handoff_config: None,
}
}
fn make_handoff(handoff_id: &str, source: &str, target: &str, atype: &str) -> HandoffArtifact {
HandoffArtifact {
handoff_id: handoff_id.to_string(),
source_automation_id: source.to_string(),
source_run_id: "run-src-1".to_string(),
source_node_id: "node-src-1".to_string(),
target_automation_id: target.to_string(),
artifact_type: atype.to_string(),
created_at_ms: 1_000,
content_path: None,
content_digest: None,
metadata: Some(serde_json::json!({ "detail": "test" })),
consumed_by_run_id: None,
consumed_by_automation_id: None,
consumed_at_ms: None,
}
}
fn tmp_workspace(label: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("tandem-handoff-{label}-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).expect("create workspace");
dir
}
#[tokio::test]
async fn handoff_auto_approve_deposits_to_approved_dir() {
let ws = tmp_workspace("deposit-approve");
let state = test_state_with_path(tmp_resource_file("hf-deposit-approve"));
let cfg = AutomationHandoffConfig {
auto_approve: true,
..AutomationHandoffConfig::default()
};
let handoff = make_handoff("hf-aa-001", "scout", "job-search", "lead");
state
.deposit_automation_v2_handoff(ws.to_str().unwrap(), &handoff, &cfg)
.await
.expect("deposit");
assert!(ws.join("shared/handoffs/approved/hf-aa-001.json").exists());
assert!(!ws.join("shared/handoffs/inbox/hf-aa-001.json").exists());
let _ = std::fs::remove_dir_all(&ws);
}
#[tokio::test]
async fn handoff_manual_approve_deposits_to_inbox() {
let ws = tmp_workspace("deposit-inbox");
let state = test_state_with_path(tmp_resource_file("hf-deposit-inbox"));
let cfg = AutomationHandoffConfig {
auto_approve: false,
..AutomationHandoffConfig::default()
};
let handoff = make_handoff("hf-ma-001", "scout", "job-search", "lead");
state
.deposit_automation_v2_handoff(ws.to_str().unwrap(), &handoff, &cfg)
.await
.expect("deposit");
assert!(ws.join("shared/handoffs/inbox/hf-ma-001.json").exists());
assert!(!ws.join("shared/handoffs/approved/hf-ma-001.json").exists());
let _ = std::fs::remove_dir_all(&ws);
}
#[tokio::test]
async fn handoff_consume_stamps_metadata_and_moves_to_archived() {
let ws = tmp_workspace("consume");
let state = test_state_with_path(tmp_resource_file("hf-consume"));
let cfg = AutomationHandoffConfig::default(); let handoff = make_handoff("hf-con-001", "scout", "job-search", "lead");
state
.deposit_automation_v2_handoff(ws.to_str().unwrap(), &handoff, &cfg)
.await
.expect("deposit");
let consumed = state
.consume_automation_v2_handoff(
ws.to_str().unwrap(),
&handoff,
&cfg,
"run-consumer-1",
"job-search",
)
.await
.expect("consume")
.expect("should be Some");
assert!(!ws.join("shared/handoffs/approved/hf-con-001.json").exists());
assert!(ws.join("shared/handoffs/archived/hf-con-001.json").exists());
assert_eq!(
consumed.consumed_by_run_id.as_deref(),
Some("run-consumer-1")
);
assert_eq!(
consumed.consumed_by_automation_id.as_deref(),
Some("job-search")
);
assert!(consumed.consumed_at_ms.is_some());
let _ = std::fs::remove_dir_all(&ws);
}
#[tokio::test]
async fn handoff_consume_is_idempotent_on_second_call() {
let ws = tmp_workspace("idempotent");
let state = test_state_with_path(tmp_resource_file("hf-idempotent"));
let cfg = AutomationHandoffConfig::default();
let handoff = make_handoff("hf-idem-001", "scout", "job-search", "lead");
state
.deposit_automation_v2_handoff(ws.to_str().unwrap(), &handoff, &cfg)
.await
.expect("deposit");
state
.consume_automation_v2_handoff(ws.to_str().unwrap(), &handoff, &cfg, "run-1", "job-search")
.await
.expect("first consume");
let second = state
.consume_automation_v2_handoff(ws.to_str().unwrap(), &handoff, &cfg, "run-2", "job-search")
.await
.expect("second consume");
assert!(
second.is_none(),
"second consume should return None (race-safe)"
);
let _ = std::fs::remove_dir_all(&ws);
}
#[tokio::test]
async fn handoff_deposit_writes_valid_json_with_correct_fields() {
let ws = tmp_workspace("roundtrip");
let state = test_state_with_path(tmp_resource_file("hf-roundtrip"));
let cfg = AutomationHandoffConfig::default();
let handoff = make_handoff("hf-rt-001", "opportunity-scout", "job-search", "lead");
state
.deposit_automation_v2_handoff(ws.to_str().unwrap(), &handoff, &cfg)
.await
.expect("deposit");
let path = ws.join("shared/handoffs/approved/hf-rt-001.json");
let content = std::fs::read_to_string(&path).expect("read file");
let parsed: HandoffArtifact = serde_json::from_str(&content).expect("valid JSON");
assert_eq!(parsed.handoff_id, "hf-rt-001");
assert_eq!(parsed.source_automation_id, "opportunity-scout");
assert_eq!(parsed.target_automation_id, "job-search");
assert_eq!(parsed.artifact_type, "lead");
assert!(parsed.consumed_by_run_id.is_none());
let _ = std::fs::remove_dir_all(&ws);
}
#[tokio::test]
async fn watch_evaluation_triggers_consumer_when_handoff_available() {
let state = ready_test_state().await;
let eval_root = state.workspace_index.snapshot().await.root;
let eval_root = std::path::PathBuf::from(&eval_root);
let mut job_search = make_automation("job-search-wt", &eval_root.to_string_lossy());
job_search.watch_conditions = vec![WatchCondition::HandoffAvailable {
source_automation_id: Some("opportunity-scout-wt".to_string()),
artifact_type: Some("lead".to_string()),
}];
state
.put_automation_v2(job_search)
.await
.expect("put job-search");
let cfg = AutomationHandoffConfig::default();
let handoff = make_handoff("hf-wt-001", "opportunity-scout-wt", "job-search-wt", "lead");
let approved_dir = eval_root.join(&cfg.approved_dir);
std::fs::create_dir_all(&approved_dir).expect("approved dir");
std::fs::write(
approved_dir.join("hf-wt-001.json"),
serde_json::to_string_pretty(&handoff).expect("serialize"),
)
.expect("write handoff");
let triggered = state.evaluate_automation_v2_watches().await;
let found = triggered
.iter()
.find(|(automation_id, _, _)| automation_id == "job-search-wt");
assert!(
found.is_some(),
"job-search-wt should be triggered; got: {triggered:?}"
);
let (_, reason, handoff_artifact) = found.unwrap();
assert!(
reason.contains("opportunity-scout-wt"),
"trigger_reason should mention source: {reason}"
);
assert!(
reason.contains("hf-wt-001"),
"trigger_reason should mention handoff id: {reason}"
);
assert!(
handoff_artifact.is_some(),
"handoff artifact should be returned"
);
assert_eq!(handoff_artifact.as_ref().unwrap().handoff_id, "hf-wt-001");
let _ = std::fs::remove_file(approved_dir.join("hf-wt-001.json"));
}
#[tokio::test]
async fn watch_evaluation_does_not_trigger_when_no_handoff_present() {
let state = ready_test_state().await;
let eval_root = state.workspace_index.snapshot().await.root;
let mut job_search = make_automation("job-search-empty", &eval_root);
job_search.watch_conditions = vec![WatchCondition::HandoffAvailable {
source_automation_id: Some("opportunity-scout-empty".to_string()),
artifact_type: None,
}];
state.put_automation_v2(job_search).await.expect("put");
let triggered = state.evaluate_automation_v2_watches().await;
let found = triggered.iter().any(|(id, _, _)| id == "job-search-empty");
assert!(!found, "should not trigger when no handoff in approved/");
}
#[tokio::test]
async fn watch_evaluation_filters_by_source_automation_id() {
let state = ready_test_state().await;
let eval_root = state.workspace_index.snapshot().await.root;
let eval_root = std::path::PathBuf::from(&eval_root);
let mut consumer = make_automation("consumer-src-filter", &eval_root.to_string_lossy());
consumer.watch_conditions = vec![WatchCondition::HandoffAvailable {
source_automation_id: Some("correct-source".to_string()),
artifact_type: None,
}];
state.put_automation_v2(consumer).await.expect("put");
let cfg = AutomationHandoffConfig::default();
let wrong_handoff = make_handoff(
"hf-src-filter-001",
"wrong-source",
"consumer-src-filter",
"lead",
);
let approved_dir = eval_root.join(&cfg.approved_dir);
std::fs::create_dir_all(&approved_dir).expect("approved dir");
std::fs::write(
approved_dir.join("hf-src-filter-001.json"),
serde_json::to_string_pretty(&wrong_handoff).expect("serialize"),
)
.expect("write");
let triggered = state.evaluate_automation_v2_watches().await;
let found = triggered
.iter()
.any(|(id, _, _)| id == "consumer-src-filter");
assert!(
!found,
"should not trigger: source filter should exclude wrong-source"
);
let _ = std::fs::remove_file(approved_dir.join("hf-src-filter-001.json"));
}
#[test]
fn scope_policy_integration_guards_cross_agent_path_access() {
use crate::automation_v2::types::AutomationScopePolicy;
let policy = AutomationScopePolicy {
readable_paths: vec!["shared/".to_string(), "job-search/".to_string()],
writable_paths: vec!["job-search/reports/".to_string()],
denied_paths: vec![],
watch_paths: vec![],
};
assert!(policy
.check_read("shared/handoffs/approved/hf.json")
.is_ok());
assert!(policy.check_read("job-search/leads.json").is_ok());
assert!(policy.check_write("job-search/reports/week1.md").is_ok());
assert!(policy.check_read("opportunity-scout/raw.json").is_err());
assert!(policy
.check_write("shared/handoffs/approved/hijack.json")
.is_err());
}