use super::*;
fn test_automation() -> crate::automation_v2::types::AutomationV2Spec {
crate::automation_v2::types::AutomationV2Spec {
knowledge: tandem_orchestrator::KnowledgeBinding::default(),
automation_id: "automation-test".to_string(),
name: "test".to_string(),
description: None,
status: crate::automation_v2::types::AutomationV2Status::Active,
schedule: crate::automation_v2::types::AutomationV2Schedule {
schedule_type: crate::automation_v2::types::AutomationV2ScheduleType::Manual,
cron_expression: None,
interval_seconds: None,
timezone: "UTC".to_string(),
misfire_policy: crate::RoutineMisfirePolicy::Skip,
},
agents: Vec::new(),
flow: crate::automation_v2::types::AutomationFlowSpec {
nodes: vec![crate::automation_v2::types::AutomationFlowNode {
knowledge: tandem_orchestrator::KnowledgeBinding::default(),
node_id: "research-brief".to_string(),
agent_id: "research".to_string(),
objective: "Research".to_string(),
depends_on: Vec::new(),
input_refs: Vec::new(),
output_contract: None,
retry_policy: None,
timeout_ms: None,
max_tool_calls: None,
stage_kind: None,
gate: None,
metadata: None,
}],
},
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::new(),
created_at_ms: 0,
updated_at_ms: 0,
creator_id: "tests".to_string(),
workspace_root: None,
metadata: None,
next_fire_at_ms: None,
last_fired_at_ms: None,
scope_policy: None,
watch_conditions: Vec::new(),
handoff_config: None,
}
}
fn test_run_with_output(output: Value) -> crate::automation_v2::types::AutomationV2RunRecord {
crate::automation_v2::types::AutomationV2RunRecord {
run_id: "run-test".to_string(),
automation_id: "automation-test".to_string(),
tenant_context: tandem_types::TenantContext::local_implicit(),
trigger_type: "manual".to_string(),
status: crate::automation_v2::types::AutomationRunStatus::Running,
created_at_ms: 0,
updated_at_ms: 0,
started_at_ms: Some(0),
finished_at_ms: None,
active_session_ids: Vec::new(),
latest_session_id: None,
active_instance_ids: Vec::new(),
checkpoint: crate::automation_v2::types::AutomationRunCheckpoint {
completed_nodes: Vec::new(),
pending_nodes: Vec::new(),
node_outputs: std::collections::HashMap::from([("research-brief".to_string(), output)]),
node_attempts: std::collections::HashMap::new(),
blocked_nodes: Vec::new(),
awaiting_gate: None,
gate_history: Vec::new(),
lifecycle_history: Vec::new(),
last_failure: None,
},
runtime_context: None,
automation_snapshot: None,
pause_reason: None,
resume_reason: None,
detail: None,
stop_kind: None,
stop_reason: None,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
estimated_cost_usd: 0.0,
scheduler: None,
trigger_reason: None,
consumed_handoff_id: None,
learning_summary: None,
}
}
#[test]
fn promote_materialized_output_completes_missing_output_repairs() {
let node = crate::automation_v2::types::AutomationFlowNode {
knowledge: tandem_orchestrator::KnowledgeBinding::default(),
node_id: "research-brief".to_string(),
agent_id: "agent-a".to_string(),
objective: "Research".to_string(),
depends_on: Vec::new(),
input_refs: Vec::new(),
output_contract: Some(crate::automation_v2::types::AutomationFlowOutputContract {
kind: "structured_json".to_string(),
validator: Some(crate::AutomationOutputValidatorKind::StructuredJson),
enforcement: None,
schema: None,
summary_guidance: None,
}),
retry_policy: None,
timeout_ms: None,
max_tool_calls: None,
stage_kind: None,
gate: None,
metadata: Some(json!({
"builder": {
"output_path": ".tandem/runs/run-test/artifacts/research-brief.json"
}
})),
};
let mut output = json!({
"status": "needs_repair",
"blocked_reason": "required output `.tandem/runs/run-test/artifacts/research-brief.json` was not created in the current attempt",
"failure_kind": "artifact_rejected",
"validator_summary": {
"outcome": "needs_repair",
"reason": "required output `.tandem/runs/run-test/artifacts/research-brief.json` was not created in the current attempt",
"unmet_requirements": ["current_attempt_output_missing"]
},
"artifact_validation": {
"rejected_artifact_reason": "required output `.tandem/runs/run-test/artifacts/research-brief.json` was not created in the current attempt",
"unmet_requirements": ["current_attempt_output_missing"],
"validation_basis": {
"current_attempt_output_materialized": false,
"verified_output_materialized": false
}
},
"attempt_evidence": {
"artifact": {
"status": "missing",
"path": ".tandem/runs/run-test/artifacts/research-brief.json"
}
}
});
promote_materialized_output(
&mut output,
&node,
".tandem/runs/run-test/artifacts/research-brief.json",
"{\"status\":\"completed\"}",
None,
);
assert_eq!(node_output_status(&output), "completed");
assert_eq!(
output
.pointer("/artifact_validation/accepted_candidate_source")
.and_then(Value::as_str),
Some("verified_output")
);
assert_eq!(
output
.pointer("/artifact_validation/validation_basis/current_attempt_output_materialized")
.and_then(Value::as_bool),
Some(true)
);
assert_eq!(
output
.pointer("/attempt_evidence/artifact/status")
.and_then(Value::as_str),
Some("written")
);
}
#[test]
fn promote_materialized_output_marks_session_salvage_recovery_source() {
let node = &test_automation().flow.nodes[0];
let mut output = json!({
"status": "completed",
"artifact_validation": {
"validation_basis": {}
},
"attempt_evidence": {
"artifact": {
"status": "missing"
}
}
});
promote_materialized_output(
&mut output,
node,
".tandem/runs/run-test/artifacts/research-brief.json",
"{\"status\":\"completed\"}",
Some("session_text_salvage"),
);
assert_eq!(
output
.pointer("/artifact_validation/artifact_recovered_from_session")
.and_then(Value::as_bool),
Some(true)
);
assert_eq!(
output
.pointer("/attempt_evidence/artifact/recovery_source")
.and_then(Value::as_str),
Some("session_text_salvage")
);
assert_eq!(
output
.pointer("/artifact_validation/accepted_candidate_source")
.and_then(Value::as_str),
Some("session_write_recovery")
);
}
#[test]
fn derive_terminal_run_state_marks_blocked_outputs_as_blocked() {
let automation = test_automation();
let run = test_run_with_output(json!({
"status": "blocked",
"failure_kind": "research_citations_missing",
}));
assert_eq!(
derive_terminal_run_state(&automation, &run, false),
DerivedTerminalRunState::Blocked {
blocked_nodes: vec!["research-brief".to_string()],
detail: "automation run blocked by upstream node outcome".to_string(),
}
);
}
#[test]
fn derive_terminal_run_state_marks_verify_failed_outputs_as_failed() {
let automation = test_automation();
let run = test_run_with_output(json!({
"status": "verify_failed",
"failure_kind": "verification_failed",
}));
assert_eq!(
derive_terminal_run_state(&automation, &run, false),
DerivedTerminalRunState::Failed {
failed_nodes: vec!["research-brief".to_string()],
blocked_nodes: Vec::new(),
detail: "automation run failed from node outcomes: research-brief".to_string(),
}
);
}
#[test]
fn derive_terminal_run_state_does_not_block_repairable_research_outputs() {
let automation = test_automation();
let run = test_run_with_output(json!({
"status": "needs_repair",
"failure_kind": "research_missing_reads",
"artifact_validation": {
"repair_exhausted": false
}
}));
assert_eq!(
derive_terminal_run_state(&automation, &run, false),
DerivedTerminalRunState::Completed
);
}
#[test]
fn derive_terminal_run_state_fails_pending_repairable_nodes_at_attempt_cap() {
let automation = test_automation();
let mut run = test_run_with_output(json!({
"status": "needs_repair",
"artifact_validation": {
"repair_exhausted": false
}
}));
run.checkpoint.pending_nodes = vec!["research-brief".to_string()];
run.checkpoint
.node_attempts
.insert("research-brief".to_string(), 3);
assert_eq!(
derive_terminal_run_state(&automation, &run, false),
DerivedTerminalRunState::Failed {
failed_nodes: vec!["research-brief".to_string()],
blocked_nodes: Vec::new(),
detail: "automation run failed from node outcomes: research-brief".to_string(),
}
);
}
#[test]
fn derive_terminal_run_state_fails_pending_nodes_that_exhausted_attempts() {
let automation = test_automation();
let mut run = test_run_with_output(json!({
"status": "completed",
}));
run.checkpoint.node_outputs.clear();
run.checkpoint.pending_nodes = vec!["research-brief".to_string()];
run.checkpoint
.node_attempts
.insert("research-brief".to_string(), 3);
assert_eq!(
derive_terminal_run_state(&automation, &run, true),
DerivedTerminalRunState::Failed {
failed_nodes: vec!["research-brief".to_string()],
blocked_nodes: Vec::new(),
detail: "automation run failed from node outcomes: research-brief".to_string(),
}
);
}
#[test]
fn repairable_workspace_file_failure_requeues_even_when_run_artifact_passed() {
let mut run = test_run_with_output(json!({
"status": "needs_repair",
"failure_kind": "artifact_rejected",
"validator_summary": {
"outcome": "passed",
"unmet_requirements": []
},
"artifact_validation": {
"validation_outcome": "needs_repair",
"unmet_requirements": ["required_workspace_files_missing"],
"required_next_tool_actions": ["Write `tandem-review.md` before updating the run artifact."],
"repair_exhausted": false
}
}));
run.checkpoint.pending_nodes.clear();
assert!(crate::app::state::automation_node_has_passing_artifact(
"research-brief",
&run.checkpoint
));
reconcile_pending_nodes_after_node_output(
&mut run.checkpoint,
"research-brief",
true,
false,
&std::collections::HashSet::new(),
);
assert_eq!(
run.checkpoint.pending_nodes,
vec!["research-brief".to_string()]
);
}
#[test]
fn terminal_workspace_file_repair_failure_does_not_requeue() {
let mut run = test_run_with_output(json!({
"status": "needs_repair",
"validator_summary": {
"outcome": "passed",
"unmet_requirements": []
},
"artifact_validation": {
"unmet_requirements": ["required_workspace_files_missing"],
"repair_exhausted": true
}
}));
run.checkpoint.pending_nodes = vec!["research-brief".to_string()];
reconcile_pending_nodes_after_node_output(
&mut run.checkpoint,
"research-brief",
true,
true,
&std::collections::HashSet::new(),
);
assert!(run.checkpoint.pending_nodes.is_empty());
}
#[test]
fn needs_repair_output_with_passing_validator_is_not_settled_completed() {
let run = test_run_with_output(json!({
"status": "needs_repair",
"blocked_reason": "Required workspace files were not written in the current attempt.",
"validator_summary": {
"outcome": "passed",
"unmet_requirements": []
},
"artifact_validation": {
"validation_outcome": "passed",
"unmet_requirements": [],
"repair_exhausted": false
}
}));
assert!(crate::app::state::automation_node_has_passing_artifact(
"research-brief",
&run.checkpoint
));
assert!(
!run_node_is_settled_completed(&run, "research-brief"),
"a stale needs_repair output must not suppress later repair attempts"
);
}
#[test]
fn workflow_failure_evidence_extracts_missing_workspace_files_and_actions() {
let output = json!({
"artifact_validation": {
"must_write_file_statuses": [{
"path": "tandem-review.md",
"materialized_by_current_attempt": false,
"touched_by_current_attempt": false
}],
"required_next_tool_actions": [
"Write `tandem-review.md` before updating the run artifact."
]
}
});
assert_eq!(
output_missing_workspace_paths(Some(&output)),
vec!["tandem-review.md".to_string()]
);
assert_eq!(
output_required_next_tool_actions(Some(&output)),
vec!["Write `tandem-review.md` before updating the run artifact.".to_string()]
);
}
#[test]
fn transient_execution_error_output_requests_retry_without_handoff_requirements() {
let node = &test_automation().flow.nodes[0];
let output = build_node_execution_error_output(
node,
"provider stream connect timeout after 90000 ms",
false,
);
assert_eq!(node_output_status(&output), "needs_repair");
assert_eq!(node_output_failure_kind(&output), "execution_failed");
assert_eq!(
output.get("blocker_category").and_then(Value::as_str),
Some("provider_connect_timeout")
);
assert_eq!(
output.get("blocked_reason").and_then(Value::as_str),
Some("provider stream connect timeout after 90000 ms")
);
assert!(output
.pointer("/validator_summary/unmet_requirements")
.and_then(Value::as_array)
.is_some_and(|items| items.is_empty()));
assert!(output
.pointer("/artifact_validation/required_next_tool_actions")
.and_then(Value::as_array)
.is_some_and(|items| items.iter().any(|value| value.as_str().is_some_and(
|text| text.contains("Do not classify this attempt as a missing handoff")
))));
}
#[test]
fn generic_provider_error_is_classified_and_normalized() {
let node = &test_automation().flow.nodes[0];
let output = build_node_execution_error_output(node, "Provider returned error", false);
assert_eq!(node_output_status(&output), "needs_repair");
assert_eq!(
output.get("blocker_category").and_then(Value::as_str),
Some("provider_server_error")
);
assert_eq!(
output.get("blocked_reason").and_then(Value::as_str),
Some("provider returned error before any node response was recorded")
);
}
#[test]
fn transient_provider_retry_backoff_escalates_between_attempts() {
assert_eq!(
transient_provider_retry_backoff_ms("Provider returned error", 1),
Some(2_000)
);
assert_eq!(
transient_provider_retry_backoff_ms("Provider returned error", 2),
Some(5_000)
);
assert_eq!(
transient_provider_retry_backoff_ms("provider stream connect timeout after 90000 ms", 3),
Some(8_000)
);
assert_eq!(
transient_provider_retry_backoff_ms("authentication failed", 1),
None
);
}
#[test]
fn tool_resolution_execution_error_output_uses_dedicated_blocker_category() {
let node = &test_automation().flow.nodes[0];
let output = build_node_execution_error_output_with_category(
node,
"required automation capabilities were not offered after MCP/tool sync: email_delivery",
false,
"tool_resolution_failed",
);
assert_eq!(node_output_status(&output), "needs_repair");
assert_eq!(
output.get("blocker_category").and_then(Value::as_str),
Some("tool_resolution_failed")
);
assert!(output
.pointer("/artifact_validation/required_next_tool_actions")
.and_then(Value::as_array)
.is_some_and(|items| items.iter().any(|value| value
.as_str()
.is_some_and(|text| text.contains("collapsed tool set")))));
}
#[test]
fn terminal_execution_error_output_marks_node_failed() {
let node = &test_automation().flow.nodes[0];
let output = build_node_execution_error_output(
node,
"provider stream connect timeout after 90000 ms",
true,
);
assert_eq!(node_output_status(&output), "failed");
assert_eq!(node_output_failure_kind(&output), "run_failed");
assert!(output
.pointer("/artifact_validation/required_next_tool_actions")
.and_then(Value::as_array)
.is_some_and(|items| items.is_empty()));
}