use crate::agents::AgentRole;
use crate::common::domain_types::AgentName;
use crate::reducer::event::{AgentErrorKind, PipelinePhase, TimeoutOutputKind};
use crate::reducer::io_tests::{create_test_state, reduce, PipelineEvent, PipelineState};
#[test]
fn test_agent_invocation_failed_retriable_network_does_not_wrap_model() {
let base_state = create_test_state();
let state = PipelineState {
agent_chain: base_state
.agent_chain
.with_agents(
vec!["agent1".to_string()],
vec![vec!["model1".to_string(), "model2".to_string()]],
AgentRole::Developer,
)
.advance_to_next_model(), ..base_state
};
assert_eq!(state.agent_chain.current_model_index, 1);
let new_state = reduce(
state,
PipelineEvent::agent_invocation_failed(
AgentRole::Developer,
AgentName::from("agent1"),
1,
AgentErrorKind::Network,
true,
),
);
assert_eq!(new_state.agent_chain.current_agent_index, 0);
assert_eq!(
new_state.agent_chain.current_model_index, 1,
"Network error should NOT change model (check_pending takes priority)"
);
assert!(
new_state.connectivity.check_pending,
"Network error should set check_pending for connectivity verification"
);
}
#[test]
fn test_agent_fallback_from_last_agent_wraps_and_increments_cycle() {
let base_state = create_test_state();
let state = PipelineState {
agent_chain: base_state
.agent_chain
.with_agents(
vec!["agent1".to_string(), "agent2".to_string()],
vec![vec!["model1".to_string()], vec!["model2".to_string()]],
AgentRole::Developer,
)
.switch_to_next_agent(), ..base_state
};
assert_eq!(state.agent_chain.current_agent_index, 1);
assert_eq!(state.agent_chain.retry_cycle, 0);
let new_state = reduce(
state,
PipelineEvent::agent_fallback_triggered(
AgentRole::Developer,
AgentName::from("agent2"),
AgentName::from("agent1"),
),
);
assert_eq!(new_state.agent_chain.current_agent_index, 0);
assert_eq!(new_state.agent_chain.current_model_index, 0);
assert_eq!(new_state.agent_chain.retry_cycle, 1);
}
#[test]
fn test_agent_invocation_failed_retriable_network_on_single_model_wraps() {
let base_state = create_test_state();
let state = PipelineState {
agent_chain: base_state.agent_chain.with_agents(
vec!["agent1".to_string()],
vec![vec!["model1".to_string()]], AgentRole::Developer,
),
..base_state
};
let new_state = reduce(
state,
PipelineEvent::agent_invocation_failed(
AgentRole::Developer,
AgentName::from("agent1"),
1,
AgentErrorKind::Network,
true,
),
);
assert_eq!(new_state.agent_chain.current_agent_index, 0);
assert_eq!(new_state.agent_chain.current_model_index, 0);
}
#[test]
fn test_timed_out_retries_same_agent_before_fallback() {
let base_state = create_test_state();
let state = PipelineState {
continuation: crate::reducer::state::ContinuationState::with_limits(2, 3, 2),
agent_chain: base_state.agent_chain.with_agents(
vec!["agent-a".to_string(), "agent-b".to_string()],
vec![
vec!["model-a1".to_string(), "model-a2".to_string()],
vec!["model-b1".to_string()],
],
AgentRole::Developer,
),
..base_state
};
assert_eq!(
state.agent_chain.current_agent().map(String::as_str),
Some("agent-a")
);
assert_eq!(state.agent_chain.current_model_index, 0);
let after_first_timeout = reduce(
state,
PipelineEvent::agent_timed_out(
AgentRole::Developer,
AgentName::from("agent-a"),
TimeoutOutputKind::PartialResult,
Some(".agent/logs/developer_0.log".to_string()),
None,
),
);
assert!(
!after_first_timeout.continuation.xsd_retry_pending,
"Timeout retry should not set xsd_retry_pending (XSD retry is only for invalid XML)"
);
assert_eq!(
after_first_timeout.continuation.xsd_retry_count, 0,
"Timeout retry should not increment xsd_retry_count (XSD retry is only for invalid XML)"
);
assert_eq!(
after_first_timeout
.agent_chain
.current_agent()
.map(String::as_str),
Some("agent-a"),
"Timeout should retry same agent first"
);
assert_eq!(
after_first_timeout.agent_chain.current_model_index, 0,
"Timeout retry should not advance model"
);
let after_second_timeout = reduce(
after_first_timeout,
PipelineEvent::agent_timed_out(
AgentRole::Developer,
AgentName::from("agent-a"),
TimeoutOutputKind::PartialResult,
Some(".agent/logs/developer_0.log".to_string()),
None,
),
);
assert_eq!(
after_second_timeout
.agent_chain
.current_agent()
.map(String::as_str),
Some("agent-b"),
"After retry budget exhaustion, timeout should fall back to next agent"
);
assert_eq!(
after_second_timeout.agent_chain.current_model_index, 0,
"Model index should reset to 0 when switching agents"
);
}
#[test]
fn test_internal_error_retries_same_agent_before_fallback_without_xsd_retry() {
use crate::reducer::event::AgentErrorKind;
let base_state = create_test_state();
let state = PipelineState {
continuation: crate::reducer::state::ContinuationState::with_limits(2, 3, 2),
agent_chain: base_state.agent_chain.with_agents(
vec!["agent-a".to_string(), "agent-b".to_string()],
vec![vec![], vec![]],
AgentRole::Developer,
),
..base_state
};
let after_first_failure = reduce(
state,
PipelineEvent::agent_invocation_failed(
AgentRole::Developer,
AgentName::from("agent-a"),
1,
AgentErrorKind::InternalError,
false,
),
);
assert_eq!(
after_first_failure
.agent_chain
.current_agent()
.map(String::as_str),
Some("agent-a"),
"Internal error should retry same agent first"
);
assert!(
!after_first_failure.continuation.xsd_retry_pending,
"Internal error retry should not set xsd_retry_pending (XSD retry is only for invalid XML)"
);
assert_eq!(
after_first_failure.continuation.xsd_retry_count, 0,
"Internal error retry should not increment xsd_retry_count (XSD retry is only for invalid XML)"
);
}
#[test]
fn test_timed_out_partial_output_preserves_session_id_for_context_retry() {
let base_state = create_test_state();
let state = PipelineState {
agent_chain: base_state
.agent_chain
.with_agents(
vec!["agent-a".to_string(), "agent-b".to_string()],
vec![vec![], vec![]],
AgentRole::Developer,
)
.with_session_id(Some("session-123".to_string())),
..base_state
};
assert_eq!(
state.agent_chain.last_session_id,
Some("session-123".to_string())
);
let new_state = reduce(
state,
PipelineEvent::agent_timed_out(
AgentRole::Developer,
AgentName::from("agent-a"),
TimeoutOutputKind::PartialResult,
Some(".agent/logs/developer_0.log".to_string()),
None,
),
);
assert!(
!new_state.continuation.xsd_retry_pending,
"Timeout retry should not set xsd_retry_pending (XSD retry is only for invalid XML)"
);
assert_eq!(
new_state.agent_chain.last_session_id,
Some("session-123".to_string()),
"PartialResult timeout should preserve session ID for context reuse"
);
assert!(
new_state.continuation.xsd_retry_session_reuse_pending,
"PartialResult timeout should set xsd_retry_session_reuse_pending"
);
}
#[test]
fn test_timed_out_no_output_clears_session_id_for_immediate_switch() {
let base_state = create_test_state();
let state = PipelineState {
agent_chain: base_state
.agent_chain
.with_agents(
vec!["agent-a".to_string(), "agent-b".to_string()],
vec![vec![], vec![]],
AgentRole::Developer,
)
.with_session_id(Some("session-123".to_string())),
..base_state
};
assert_eq!(
state.agent_chain.last_session_id,
Some("session-123".to_string())
);
let new_state = reduce(
state,
PipelineEvent::agent_timed_out(
AgentRole::Developer,
AgentName::from("agent-a"),
TimeoutOutputKind::NoResult,
None,
None,
),
);
assert_eq!(
new_state.agent_chain.last_session_id, None,
"NoResult timeout should clear session ID (immediate agent switch)"
);
assert!(
!new_state.continuation.xsd_retry_session_reuse_pending,
"NoResult timeout should not set xsd_retry_session_reuse_pending"
);
}
#[test]
fn test_timed_out_from_last_agent_increments_retry_cycle_when_budget_exhausted() {
let base_state = create_test_state();
let state = PipelineState {
continuation: crate::reducer::state::ContinuationState::with_limits(1, 3, 2)
.with_max_same_agent_retry(2), agent_chain: base_state
.agent_chain
.with_agents(
vec!["agent-a".to_string(), "agent-b".to_string()],
vec![vec![], vec![]],
AgentRole::Developer,
)
.switch_to_next_agent(), ..base_state
};
assert_eq!(
state.agent_chain.current_agent().map(String::as_str),
Some("agent-b")
);
assert_eq!(state.agent_chain.retry_cycle, 0);
assert_eq!(state.continuation.same_agent_retry_count, 0);
let after_first_timeout = reduce(
state,
PipelineEvent::agent_timed_out(
AgentRole::Developer,
AgentName::from("agent-b"),
TimeoutOutputKind::PartialResult,
Some(".agent/logs/developer_0.log".to_string()),
None,
),
);
assert!(
!after_first_timeout.continuation.xsd_retry_pending,
"Timeout retry should not set xsd_retry_pending (XSD retry is only for invalid XML)"
);
assert_eq!(
after_first_timeout
.agent_chain
.current_agent()
.map(String::as_str),
Some("agent-b"),
"First timeout should retry same agent, not fall back"
);
assert_eq!(after_first_timeout.continuation.same_agent_retry_count, 1);
assert!(after_first_timeout.continuation.same_agent_retry_pending);
let after_second_timeout = reduce(
after_first_timeout,
PipelineEvent::agent_timed_out(
AgentRole::Developer,
AgentName::from("agent-b"),
TimeoutOutputKind::PartialResult,
Some(".agent/logs/developer_0.log".to_string()),
None,
),
);
assert_eq!(
after_second_timeout
.agent_chain
.current_agent()
.map(String::as_str),
Some("agent-a"),
"Should wrap back to first agent after exhausting retry budget on last agent"
);
assert_eq!(
after_second_timeout.agent_chain.retry_cycle, 1,
"Should increment retry cycle when wrapping"
);
assert_eq!(after_second_timeout.continuation.same_agent_retry_count, 0);
assert!(!after_second_timeout.continuation.same_agent_retry_pending);
}
#[test]
fn test_backoff_wait_does_not_cause_infinite_loop_in_event_loop_simulation() {
use crate::reducer::effect::Effect;
use crate::reducer::orchestration::determine_next_effect;
use crate::reducer::state::{AgentChainState, ContinuationState};
let state = PipelineState {
phase: PipelinePhase::Development,
iteration: 1,
agent_chain: AgentChainState::initial()
.with_agents(
vec!["claude".to_string()],
vec![vec![]],
AgentRole::Developer,
)
.with_max_cycles(2),
continuation: ContinuationState::default(),
development_context_prepared_iteration: Some(1),
development_prompt_prepared_iteration: Some(1),
development_required_files_cleaned_iteration: Some(1),
..create_test_state()
};
let state = PipelineState {
agent_chain: AgentChainState {
backoff_pending_ms: Some(100),
..state.agent_chain
},
..state
};
let max_iterations = 20;
let mut current_state = state;
let mut backoff_cycles = 0u32;
let mut iterations = 0;
let final_state = loop {
if iterations >= max_iterations {
panic!("exceeded max iterations waiting for a non-backoff effect");
}
iterations += 1;
let effect = determine_next_effect(¤t_state);
match effect {
Effect::BackoffWait { role, cycle, .. } => {
assert!(backoff_cycles < 2, "backoff wait repeated more than twice");
backoff_cycles += 1;
current_state = reduce(
current_state,
PipelineEvent::agent_retry_cycle_started(role, cycle),
);
}
Effect::InvokeDevelopmentAgent { .. } => break current_state,
other => panic!("unexpected effect during backoff simulation: {other:?}"),
}
};
assert!(
final_state.agent_chain.backoff_pending_ms.is_none(),
"backoff_pending_ms should be cleared after RetryCycleStarted event"
);
}