use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoppedReason {
pub code: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
impl StoppedReason {
#[must_use]
pub fn new(code: impl Into<String>) -> Self {
Self {
code: code.into(),
detail: None,
}
}
#[must_use]
pub fn with_detail(code: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
code: code.into(),
detail: Some(detail.into()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", content = "value", rename_all = "snake_case")]
pub enum TerminationReason {
NaturalEnd,
#[serde(alias = "plugin_requested")]
BehaviorRequested,
Stopped(StoppedReason),
Cancelled,
Blocked(String),
Suspended,
Error(String),
}
impl TerminationReason {
#[must_use]
pub fn stopped(code: impl Into<String>) -> Self {
Self::Stopped(StoppedReason::new(code))
}
#[must_use]
pub fn stopped_with_detail(code: impl Into<String>, detail: impl Into<String>) -> Self {
Self::Stopped(StoppedReason::with_detail(code, detail))
}
pub fn from_done_reason(reason: &str) -> Self {
match reason {
"natural" => Self::NaturalEnd,
"behavior_requested" => Self::BehaviorRequested,
"cancelled" => Self::Cancelled,
s if s.starts_with("blocked:") => {
Self::Blocked(s.trim_start_matches("blocked:").to_string())
}
s if s.starts_with("stopped:") => {
Self::Stopped(StoppedReason::new(s.trim_start_matches("stopped:")))
}
s if s.starts_with("error:") => Self::Error(s.trim_start_matches("error:").to_string()),
other => Self::Error(other.to_string()),
}
}
pub fn to_run_status(&self) -> (RunStatus, Option<String>) {
match self {
Self::Suspended => (RunStatus::Waiting, None),
Self::NaturalEnd => (RunStatus::Done, Some("natural".to_string())),
Self::BehaviorRequested => (RunStatus::Done, Some("behavior_requested".to_string())),
Self::Cancelled => (RunStatus::Done, Some("cancelled".to_string())),
Self::Blocked(reason) => (RunStatus::Done, Some(format!("blocked:{reason}"))),
Self::Error(_) => (RunStatus::Done, Some("error".to_string())),
Self::Stopped(stopped) => (RunStatus::Done, Some(format!("stopped:{}", stopped.code))),
}
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RunStatus {
Created,
#[default]
Running,
Waiting,
Done,
}
impl RunStatus {
pub fn is_terminal(self) -> bool {
matches!(self, RunStatus::Done)
}
pub fn can_transition_to(self, next: Self) -> bool {
if self == next {
return true;
}
match self {
RunStatus::Created => matches!(next, RunStatus::Running | RunStatus::Done),
RunStatus::Running => matches!(next, RunStatus::Waiting | RunStatus::Done),
RunStatus::Waiting => matches!(next, RunStatus::Running | RunStatus::Done),
RunStatus::Done => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum StopConditionSpec {
MaxRounds { rounds: usize },
Timeout { seconds: u64 },
TokenBudget { max_total: usize },
ConsecutiveErrors { max: usize },
StopOnTool { tool_name: String },
ContentMatch { pattern: String },
LoopDetection { window: usize },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_status_transitions_match_state_machine() {
assert!(RunStatus::Created.can_transition_to(RunStatus::Running));
assert!(RunStatus::Created.can_transition_to(RunStatus::Done));
assert!(RunStatus::Running.can_transition_to(RunStatus::Waiting));
assert!(RunStatus::Running.can_transition_to(RunStatus::Done));
assert!(RunStatus::Waiting.can_transition_to(RunStatus::Running));
assert!(RunStatus::Waiting.can_transition_to(RunStatus::Done));
assert!(RunStatus::Running.can_transition_to(RunStatus::Running));
}
#[test]
fn run_status_rejects_done_reopen_transitions() {
assert!(!RunStatus::Done.can_transition_to(RunStatus::Running));
assert!(!RunStatus::Done.can_transition_to(RunStatus::Waiting));
}
#[test]
fn run_status_terminal_matches_done_only() {
assert!(!RunStatus::Created.is_terminal());
assert!(!RunStatus::Running.is_terminal());
assert!(!RunStatus::Waiting.is_terminal());
assert!(RunStatus::Done.is_terminal());
}
#[test]
fn termination_reason_to_run_status_mapping() {
let cases = vec![
(TerminationReason::Suspended, RunStatus::Waiting, None),
(
TerminationReason::NaturalEnd,
RunStatus::Done,
Some("natural"),
),
(
TerminationReason::BehaviorRequested,
RunStatus::Done,
Some("behavior_requested"),
),
(
TerminationReason::Cancelled,
RunStatus::Done,
Some("cancelled"),
),
(
TerminationReason::Blocked("unsafe tool".to_string()),
RunStatus::Done,
Some("blocked:unsafe tool"),
),
(
TerminationReason::Error("test error".to_string()),
RunStatus::Done,
Some("error"),
),
(
TerminationReason::stopped("max_turns"),
RunStatus::Done,
Some("stopped:max_turns"),
),
];
for (reason, expected_status, expected_done) in cases {
let (status, done) = reason.to_run_status();
assert_eq!(status, expected_status, "status mismatch for {reason:?}");
assert_eq!(
done.as_deref(),
expected_done,
"done_reason mismatch for {reason:?}"
);
}
}
#[test]
fn termination_reason_serde_roundtrip() {
let reasons = vec![
TerminationReason::NaturalEnd,
TerminationReason::BehaviorRequested,
TerminationReason::stopped_with_detail("max_turns", "reached 10 rounds"),
TerminationReason::Cancelled,
TerminationReason::Blocked("unsafe".into()),
TerminationReason::Suspended,
TerminationReason::Error("oops".into()),
];
for reason in reasons {
let json = serde_json::to_string(&reason).unwrap();
let parsed: TerminationReason = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, reason);
}
}
#[test]
fn stopped_reason_helpers_build_expected_values() {
let simple = StoppedReason::new("budget");
assert_eq!(simple.code, "budget");
assert!(simple.detail.is_none());
let detailed = StoppedReason::with_detail("budget", "limit reached");
assert_eq!(detailed.code, "budget");
assert_eq!(detailed.detail.as_deref(), Some("limit reached"));
assert_eq!(
TerminationReason::stopped("budget"),
TerminationReason::Stopped(simple)
);
assert_eq!(
TerminationReason::stopped_with_detail("budget", "limit reached"),
TerminationReason::Stopped(detailed)
);
}
#[test]
fn stop_condition_spec_serde_roundtrip() {
let specs = vec![
StopConditionSpec::MaxRounds { rounds: 10 },
StopConditionSpec::Timeout { seconds: 300 },
StopConditionSpec::TokenBudget { max_total: 100_000 },
StopConditionSpec::ConsecutiveErrors { max: 3 },
StopConditionSpec::StopOnTool {
tool_name: "done".into(),
},
StopConditionSpec::ContentMatch {
pattern: r"\bDONE\b".into(),
},
StopConditionSpec::LoopDetection { window: 5 },
];
for spec in specs {
let json = serde_json::to_string(&spec).unwrap();
let parsed: StopConditionSpec = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, spec);
}
}
#[test]
fn run_status_serde_roundtrip() {
for status in [
RunStatus::Created,
RunStatus::Running,
RunStatus::Waiting,
RunStatus::Done,
] {
let json = serde_json::to_string(&status).unwrap();
let parsed: RunStatus = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, status);
}
}
#[test]
fn termination_reason_from_done_reason_natural() {
let reason = TerminationReason::from_done_reason("natural");
assert_eq!(reason, TerminationReason::NaturalEnd);
}
#[test]
fn termination_reason_from_done_reason_behavior_requested() {
let reason = TerminationReason::from_done_reason("behavior_requested");
assert_eq!(reason, TerminationReason::BehaviorRequested);
}
#[test]
fn termination_reason_from_done_reason_cancelled() {
let reason = TerminationReason::from_done_reason("cancelled");
assert_eq!(reason, TerminationReason::Cancelled);
}
#[test]
fn termination_reason_from_done_reason_blocked() {
let reason = TerminationReason::from_done_reason("blocked:unsafe tool");
assert_eq!(
reason,
TerminationReason::Blocked("unsafe tool".to_string())
);
}
#[test]
fn termination_reason_from_done_reason_stopped() {
let reason = TerminationReason::from_done_reason("stopped:max_turns");
assert_eq!(
reason,
TerminationReason::Stopped(StoppedReason::new("max_turns"))
);
}
#[test]
fn termination_reason_from_done_reason_error() {
let reason = TerminationReason::from_done_reason("error:boom");
assert_eq!(reason, TerminationReason::Error("boom".to_string()));
}
#[test]
fn termination_reason_from_done_reason_unknown_becomes_error() {
let reason = TerminationReason::from_done_reason("unknown_value");
assert_eq!(
reason,
TerminationReason::Error("unknown_value".to_string())
);
}
#[test]
fn run_status_done_self_transition() {
assert!(RunStatus::Done.can_transition_to(RunStatus::Done));
}
#[test]
fn termination_reason_stopped_with_detail_serde_roundtrip() {
let reason = TerminationReason::stopped_with_detail("budget", "limit reached");
let json = serde_json::to_string(&reason).unwrap();
let parsed: TerminationReason = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, reason);
if let TerminationReason::Stopped(s) = &parsed {
assert_eq!(s.code, "budget");
assert_eq!(s.detail.as_deref(), Some("limit reached"));
} else {
panic!("expected Stopped variant");
}
}
#[test]
fn stopped_reason_omits_none_detail_in_serde() {
let reason = StoppedReason::new("budget");
let json = serde_json::to_string(&reason).unwrap();
assert!(!json.contains("detail"));
}
#[test]
fn termination_reason_from_done_reason_malformed_input() {
let empty = TerminationReason::from_done_reason("");
assert_eq!(empty, TerminationReason::Error(String::new()));
let unknown = TerminationReason::from_done_reason("gibberish_no_prefix");
assert_eq!(
unknown,
TerminationReason::Error("gibberish_no_prefix".to_string())
);
let extra_colons = TerminationReason::from_done_reason("blocked:reason:with:colons");
assert_eq!(
extra_colons,
TerminationReason::Blocked("reason:with:colons".to_string())
);
let stopped_colons = TerminationReason::from_done_reason("stopped:code:extra");
assert_eq!(
stopped_colons,
TerminationReason::Stopped(StoppedReason::new("code:extra"))
);
let error_colons = TerminationReason::from_done_reason("error:msg:detail");
assert_eq!(
error_colons,
TerminationReason::Error("msg:detail".to_string())
);
let blocked_empty = TerminationReason::from_done_reason("blocked:");
assert_eq!(blocked_empty, TerminationReason::Blocked(String::new()));
let stopped_empty = TerminationReason::from_done_reason("stopped:");
assert_eq!(
stopped_empty,
TerminationReason::Stopped(StoppedReason::new(""))
);
let error_empty = TerminationReason::from_done_reason("error:");
assert_eq!(error_empty, TerminationReason::Error(String::new()));
}
#[test]
fn stop_condition_all_variants_serde() {
use serde_json::json;
let cases: Vec<(StopConditionSpec, serde_json::Value)> = vec![
(
StopConditionSpec::MaxRounds { rounds: 10 },
json!({"type": "max_rounds", "rounds": 10}),
),
(
StopConditionSpec::Timeout { seconds: 300 },
json!({"type": "timeout", "seconds": 300}),
),
(
StopConditionSpec::TokenBudget { max_total: 50_000 },
json!({"type": "token_budget", "max_total": 50000}),
),
(
StopConditionSpec::ConsecutiveErrors { max: 3 },
json!({"type": "consecutive_errors", "max": 3}),
),
(
StopConditionSpec::StopOnTool {
tool_name: "finish".into(),
},
json!({"type": "stop_on_tool", "tool_name": "finish"}),
),
(
StopConditionSpec::ContentMatch {
pattern: r"(?i)complete".into(),
},
json!({"type": "content_match", "pattern": "(?i)complete"}),
),
(
StopConditionSpec::LoopDetection { window: 4 },
json!({"type": "loop_detection", "window": 4}),
),
];
for (spec, expected_json) in &cases {
let serialized = serde_json::to_value(spec).unwrap();
assert_eq!(
&serialized, expected_json,
"Serialization mismatch for {spec:?}"
);
let parsed: StopConditionSpec = serde_json::from_value(expected_json.clone()).unwrap();
assert_eq!(
&parsed, spec,
"Deserialization mismatch for {expected_json}"
);
}
}
#[test]
fn run_status_all_valid_transitions() {
let valid = [
(RunStatus::Created, RunStatus::Created),
(RunStatus::Created, RunStatus::Running),
(RunStatus::Created, RunStatus::Done),
(RunStatus::Running, RunStatus::Running),
(RunStatus::Running, RunStatus::Waiting),
(RunStatus::Running, RunStatus::Done),
(RunStatus::Waiting, RunStatus::Running),
(RunStatus::Waiting, RunStatus::Waiting),
(RunStatus::Waiting, RunStatus::Done),
(RunStatus::Done, RunStatus::Done),
];
for (from, to) in &valid {
assert!(
from.can_transition_to(*to),
"Expected valid transition: {from:?} -> {to:?}"
);
}
let invalid = [
(RunStatus::Created, RunStatus::Waiting),
(RunStatus::Done, RunStatus::Running),
(RunStatus::Done, RunStatus::Waiting),
(RunStatus::Done, RunStatus::Created),
];
for (from, to) in &invalid {
assert!(
!from.can_transition_to(*to),
"Expected invalid transition: {from:?} -> {to:?}"
);
}
}
}