use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum RunStatus {
Starting,
Running,
WaitingInput,
Complete,
CompleteInteractive,
Paused,
Error,
Cancelled,
}
impl std::fmt::Display for RunStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RunStatus::Starting => write!(f, "Starting"),
RunStatus::Running => write!(f, "Running"),
RunStatus::WaitingInput => write!(f, "WaitingInput"),
RunStatus::Complete => write!(f, "Complete"),
RunStatus::CompleteInteractive => write!(f, "CompleteInteractive"),
RunStatus::Paused => write!(f, "Paused"),
RunStatus::Error => write!(f, "Error"),
RunStatus::Cancelled => write!(f, "Cancelled"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "reason")]
pub enum WaitReason {
ToolApproval,
UserPrompt,
TaintGate,
InteractionPoint,
FanOutWorkers {
outstanding: usize,
},
Children {
outstanding: usize,
},
NeedsSetup {
blocker: SetupBlocker,
remedy: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SetupBlocker {
ProviderMissing,
CreditsExhausted,
AuthFailed,
Forbidden,
ProvidersUnavailable,
}
impl std::fmt::Display for SetupBlocker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ProviderMissing => f.write_str("provider"),
Self::CreditsExhausted => f.write_str("credits"),
Self::AuthFailed => f.write_str("key"),
Self::Forbidden => f.write_str("access"),
Self::ProvidersUnavailable => f.write_str("providers"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetupNeeded {
pub blocker: SetupBlocker,
pub remedy: String,
}
impl WaitReason {
pub fn needs_a_person(&self) -> bool {
!matches!(self, Self::FanOutWorkers { .. } | Self::Children { .. })
}
}
impl std::fmt::Display for WaitReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ToolApproval => f.write_str("tool approval"),
Self::UserPrompt => f.write_str("user prompt"),
Self::TaintGate => f.write_str("taint gate"),
Self::InteractionPoint => f.write_str("checkpoint"),
Self::FanOutWorkers { outstanding } => write!(f, "workers({outstanding})"),
Self::Children { outstanding } => write!(f, "children({outstanding})"),
Self::NeedsSetup { blocker, .. } => write!(f, "needs {blocker}"),
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct WaitMarkers {
pub gate_prompt: bool,
pub interaction_point: bool,
pub fan_out_outstanding: Option<usize>,
pub children_outstanding: Option<usize>,
pub interaction: Option<crate::interaction::InteractionKind>,
pub awaiting_interaction: bool,
pub needs_setup: Option<SetupNeeded>,
}
pub fn wait_reason_from(parked: bool, markers: &WaitMarkers) -> Option<WaitReason> {
if !parked {
return None;
}
if let Some(need) = &markers.needs_setup {
return Some(WaitReason::NeedsSetup {
blocker: need.blocker,
remedy: need.remedy.clone(),
});
}
if markers.gate_prompt {
return Some(WaitReason::TaintGate);
}
if markers.interaction_point {
return Some(WaitReason::InteractionPoint);
}
if let Some(outstanding) = markers.fan_out_outstanding {
return Some(WaitReason::FanOutWorkers { outstanding });
}
if let Some(outstanding) = markers.children_outstanding {
return Some(WaitReason::Children { outstanding });
}
if markers.awaiting_interaction {
return Some(match markers.interaction {
Some(crate::interaction::InteractionKind::ToolApproval) => WaitReason::ToolApproval,
_ => WaitReason::UserPrompt,
});
}
None
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RunMeta {
pub run_id: String,
pub agent_name: String,
pub agent_path: String,
pub task: String,
pub model: Option<String>,
#[serde(default)]
pub pid: u32,
pub status: RunStatus,
pub current_stage: String,
pub stage_index: usize,
pub num_stages: usize,
pub iteration: usize,
pub prompt_tokens: usize,
pub completion_tokens: usize,
#[serde(default)]
pub cached_tokens: usize,
#[serde(default)]
pub cache_write_tokens: usize,
#[serde(default)]
pub tool_calls: usize,
pub workdir: String,
pub started_at: i64,
pub updated_at: i64,
#[serde(default)]
pub last_progress_at: Option<i64>,
pub error: Option<String>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub metadata: HashMap<String, String>,
#[serde(default)]
pub callback_url: Option<String>,
#[serde(default)]
pub callback_secret: Option<String>,
#[serde(default)]
pub parent_run_id: Option<String>,
#[serde(default)]
pub children: Vec<String>,
#[serde(default)]
pub depth: usize,
#[serde(default)]
pub max_child_depth: usize,
#[serde(default)]
pub flags: RunFlags,
#[serde(default)]
pub yolo: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub read_paths: Option<ReadPathGrantCounts>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_output: Option<crate::output::FinalOutputDescriptor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub waiting_on: Option<WaitReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_request: Option<crate::output::OutputSpec>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReadPathGrantCounts {
pub declared: usize,
pub granted: usize,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct RunFlags {
#[serde(default)]
pub modified_files: Vec<String>,
#[serde(default)]
pub modified_file_count: usize,
#[serde(default)]
pub empty_output: bool,
#[serde(default)]
pub no_output_tools: bool,
#[serde(default)]
pub max_iterations_hit: usize,
#[serde(default)]
pub gates_forced: usize,
#[serde(default)]
pub required_regions_abandoned: Vec<String>,
#[serde(default)]
pub workspace_lost: bool,
#[serde(default)]
pub produced_output: bool,
#[serde(default)]
pub output_forced: usize,
}
pub const MAX_TRACKED_MODIFIED_FILES: usize = 200;
impl RunFlags {
pub fn record_modification(&mut self, path: &str) {
self.modified_file_count += 1;
if self.modified_files.len() < MAX_TRACKED_MODIFIED_FILES
&& !self.modified_files.iter().any(|p| p == path)
{
self.modified_files.push(path.to_string());
}
}
}
impl RunMeta {
#[must_use]
pub fn redacted(&self) -> Self {
Self {
callback_secret: None,
..self.clone()
}
}
pub fn new(
run_id: String,
agent_name: String,
agent_path: String,
task: String,
model: Option<String>,
workdir: String,
num_stages: usize,
) -> Self {
let now = now_secs();
Self {
run_id,
agent_name,
agent_path,
task,
model,
pid: 0,
status: RunStatus::Starting,
current_stage: String::new(),
stage_index: 0,
num_stages,
iteration: 0,
prompt_tokens: 0,
completion_tokens: 0,
cached_tokens: 0,
cache_write_tokens: 0,
tool_calls: 0,
workdir,
started_at: now,
updated_at: now,
last_progress_at: None,
error: None,
title: None,
metadata: HashMap::new(),
callback_url: None,
callback_secret: None,
parent_run_id: None,
children: Vec::new(),
depth: 0,
max_child_depth: 0,
final_output: None,
waiting_on: None,
output_request: None,
flags: RunFlags::default(),
yolo: false,
read_paths: None,
}
}
pub fn touch(&mut self) {
self.updated_at = now_secs();
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RegionEntrySnapshot {
pub content: String,
pub tokens: usize,
#[serde(default)]
pub kind: crate::region::EntryKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
#[serde(default)]
pub taint: crate::taint::TaintLevel,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RegionSnapshot {
pub name: String,
pub kind: String,
pub current_tokens: usize,
pub max_tokens: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entries: Vec<RegionEntrySnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ContextSnapshot {
pub stage_name: String,
pub total_tokens: usize,
pub max_tokens: usize,
pub regions: Vec<RegionSnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum StageRunStatus {
Pending,
Active,
WaitingInput,
Complete,
Error,
Skipped,
}
impl std::fmt::Display for StageRunStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StageRunStatus::Pending => write!(f, "Pending"),
StageRunStatus::Skipped => write!(f, "Skipped"),
StageRunStatus::Active => write!(f, "Active"),
StageRunStatus::WaitingInput => write!(f, "WaitingInput"),
StageRunStatus::Complete => write!(f, "Complete"),
StageRunStatus::Error => write!(f, "Error"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StageRecord {
pub name: String,
pub index: usize,
pub status: StageRunStatus,
#[serde(default)]
pub entered: bool,
pub prompt_tokens: usize,
pub completion_tokens: usize,
#[serde(default)]
pub cached_tokens: usize,
#[serde(default)]
pub cache_write_tokens: usize,
#[serde(default)]
pub region_tokens: std::collections::BTreeMap<String, usize>,
#[serde(default)]
pub first_call_prompt_tokens: Option<usize>,
#[serde(default)]
pub runaway_warned: bool,
pub started_at: Option<i64>,
pub ended_at: Option<i64>,
}
impl StageRecord {
pub fn new(name: String, index: usize) -> Self {
Self {
name,
index,
status: StageRunStatus::Pending,
entered: false,
prompt_tokens: 0,
completion_tokens: 0,
cached_tokens: 0,
cache_write_tokens: 0,
region_tokens: std::collections::BTreeMap::new(),
first_call_prompt_tokens: None,
runaway_warned: false,
started_at: None,
ended_at: None,
}
}
}
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_meta() -> RunMeta {
RunMeta::new(
"run-1".to_string(),
"agent".to_string(),
"/agents/agent".to_string(),
"do the thing".to_string(),
Some("claude-sonnet-4-6".to_string()),
"/work".to_string(),
3,
)
}
#[test]
fn redacted_drops_the_callback_secret_and_keeps_everything_else() {
let mut m = sample_meta();
m.callback_secret = Some("shhh".to_string());
m.callback_url = Some("https://example.com/hook".to_string());
let r = m.redacted();
assert_eq!(r.callback_secret, None);
assert_eq!(r.callback_url.as_deref(), Some("https://example.com/hook"));
assert_eq!(r.run_id, m.run_id);
assert_eq!(r.task, m.task);
let json = serde_json::to_string(&r).unwrap();
assert!(!json.contains("shhh"), "{json}");
assert_eq!(m.callback_secret.as_deref(), Some("shhh"));
}
#[test]
fn run_meta_new_sets_defaults() {
let m = sample_meta();
assert_eq!(m.run_id, "run-1");
assert_eq!(m.agent_name, "agent");
assert_eq!(m.agent_path, "/agents/agent");
assert_eq!(m.task, "do the thing");
assert_eq!(m.model.as_deref(), Some("claude-sonnet-4-6"));
assert_eq!(m.workdir, "/work");
assert_eq!(m.num_stages, 3);
assert_eq!(m.pid, 0);
assert_eq!(m.status, RunStatus::Starting);
assert_eq!(m.stage_index, 0);
assert_eq!(m.iteration, 0);
assert_eq!(m.prompt_tokens, 0);
assert_eq!(m.completion_tokens, 0);
assert_eq!(m.cached_tokens, 0);
assert_eq!(m.cache_write_tokens, 0);
assert_eq!(m.tool_calls, 0);
assert!(m.error.is_none());
assert!(m.title.is_none());
assert!(m.metadata.is_empty());
assert!(m.callback_url.is_none());
assert!(m.callback_secret.is_none());
assert!(m.parent_run_id.is_none());
assert!(m.children.is_empty());
assert_eq!(m.depth, 0);
assert_eq!(m.max_child_depth, 0);
assert!(m.current_stage.is_empty());
assert_eq!(m.started_at, m.updated_at);
}
#[test]
fn run_meta_touch_advances_updated_at() {
let mut m = sample_meta();
m.updated_at = 0;
m.touch();
assert!(m.updated_at > 0);
}
#[test]
fn a_run_written_before_waiting_on_existed_still_loads() {
let mut original = sample_meta();
original.status = RunStatus::WaitingInput;
let mut value = serde_json::to_value(&original).unwrap();
value
.as_object_mut()
.expect("meta is an object")
.remove("waiting_on");
assert!(value.get("waiting_on").is_none(), "the old shape");
let back: RunMeta = serde_json::from_value(value).unwrap();
assert_eq!(back.waiting_on, None);
assert_eq!(back.status, RunStatus::WaitingInput);
assert_eq!(back.run_id, original.run_id);
}
#[test]
fn a_run_that_is_not_parked_writes_no_waiting_on_key() {
let mut m = sample_meta();
m.status = RunStatus::Running;
m.waiting_on = None;
let json = serde_json::to_value(&m).unwrap();
assert!(json.get("waiting_on").is_none(), "{json}");
m.waiting_on = Some(WaitReason::FanOutWorkers { outstanding: 3 });
let json = serde_json::to_value(&m).unwrap();
assert_eq!(
json["waiting_on"],
serde_json::json!({"reason": "fan_out_workers", "outstanding": 3})
);
}
#[test]
fn wait_reason_serializes_in_snake_case() {
for (variant, wire) in [
(WaitReason::ToolApproval, "tool_approval"),
(WaitReason::UserPrompt, "user_prompt"),
(WaitReason::TaintGate, "taint_gate"),
(WaitReason::InteractionPoint, "interaction_point"),
(
WaitReason::FanOutWorkers { outstanding: 3 },
"fan_out_workers",
),
(WaitReason::Children { outstanding: 1 }, "children"),
] {
let json = serde_json::to_value(&variant).unwrap();
assert_eq!(json["reason"], serde_json::json!(wire));
let back: WaitReason = serde_json::from_value(json).unwrap();
assert_eq!(back, variant);
}
}
#[test]
fn each_marker_names_its_own_reason_specific_first() {
let cases = [
(
WaitMarkers {
gate_prompt: true,
awaiting_interaction: true,
..Default::default()
},
WaitReason::TaintGate,
),
(
WaitMarkers {
interaction_point: true,
awaiting_interaction: true,
..Default::default()
},
WaitReason::InteractionPoint,
),
(
WaitMarkers {
fan_out_outstanding: Some(5),
..Default::default()
},
WaitReason::FanOutWorkers { outstanding: 5 },
),
(
WaitMarkers {
children_outstanding: Some(4),
..Default::default()
},
WaitReason::Children { outstanding: 4 },
),
];
for (markers, expected) in cases {
assert_eq!(
wait_reason_from(true, &markers),
Some(expected),
"{markers:?}"
);
}
assert_eq!(
wait_reason_from(
true,
&WaitMarkers {
fan_out_outstanding: Some(2),
children_outstanding: Some(9),
..Default::default()
}
),
Some(WaitReason::FanOutWorkers { outstanding: 2 })
);
}
#[test]
fn needing_setup_outranks_every_other_reason() {
let need = SetupNeeded {
blocker: SetupBlocker::ProviderMissing,
remedy: "add it to config.toml".to_string(),
};
let reason = wait_reason_from(
true,
&WaitMarkers {
needs_setup: Some(need.clone()),
gate_prompt: true,
interaction_point: true,
fan_out_outstanding: Some(2),
children_outstanding: Some(3),
awaiting_interaction: true,
interaction: Some(crate::interaction::InteractionKind::ToolApproval),
},
);
assert_eq!(
reason,
Some(WaitReason::NeedsSetup {
blocker: SetupBlocker::ProviderMissing,
remedy: "add it to config.toml".to_string(),
})
);
assert!(
reason.unwrap().needs_a_person(),
"nothing resolves this without somebody"
);
}
#[test]
fn every_blocker_has_its_own_wire_name_and_label() {
for (blocker, wire, label) in [
(
SetupBlocker::ProviderMissing,
"provider_missing",
"provider",
),
(
SetupBlocker::CreditsExhausted,
"credits_exhausted",
"credits",
),
(SetupBlocker::AuthFailed, "auth_failed", "key"),
(SetupBlocker::Forbidden, "forbidden", "access"),
(
SetupBlocker::ProvidersUnavailable,
"providers_unavailable",
"providers",
),
] {
assert_eq!(serde_json::to_value(blocker).unwrap(), wire);
assert_eq!(blocker.to_string(), label);
let back: SetupBlocker = serde_json::from_value(serde_json::json!(wire)).unwrap();
assert_eq!(back, blocker);
assert_eq!(
WaitReason::NeedsSetup {
blocker,
remedy: "a whole sentence that would not fit".to_string(),
}
.to_string(),
format!("needs {label}")
);
}
}
#[test]
fn a_hub_block_reports_the_kind_of_prompt_holding_it() {
let held = |kind| WaitMarkers {
awaiting_interaction: true,
interaction: kind,
..Default::default()
};
assert_eq!(
wait_reason_from(
true,
&held(Some(crate::interaction::InteractionKind::ToolApproval))
),
Some(WaitReason::ToolApproval)
);
assert_eq!(
wait_reason_from(
true,
&held(Some(crate::interaction::InteractionKind::FreeText))
),
Some(WaitReason::UserPrompt)
);
assert_eq!(
wait_reason_from(true, &held(None)),
Some(WaitReason::UserPrompt)
);
}
#[test]
fn nothing_claiming_a_parked_run_reports_no_reason() {
assert_eq!(wait_reason_from(true, &WaitMarkers::default()), None);
assert_eq!(
wait_reason_from(
false,
&WaitMarkers {
gate_prompt: true,
..Default::default()
}
),
None,
"a run that is not waiting is not waiting on anything"
);
}
#[test]
fn every_reason_renders_for_a_narrow_column() {
assert_eq!(WaitReason::ToolApproval.to_string(), "tool approval");
assert_eq!(WaitReason::UserPrompt.to_string(), "user prompt");
assert_eq!(WaitReason::TaintGate.to_string(), "taint gate");
assert_eq!(WaitReason::InteractionPoint.to_string(), "checkpoint");
assert_eq!(
WaitReason::FanOutWorkers { outstanding: 3 }.to_string(),
"workers(3)"
);
assert_eq!(
WaitReason::Children { outstanding: 2 }.to_string(),
"children(2)"
);
}
#[test]
fn only_the_engine_side_reasons_need_nobody() {
assert!(WaitReason::ToolApproval.needs_a_person());
assert!(WaitReason::UserPrompt.needs_a_person());
assert!(WaitReason::TaintGate.needs_a_person());
assert!(WaitReason::InteractionPoint.needs_a_person());
assert!(!WaitReason::FanOutWorkers { outstanding: 2 }.needs_a_person());
assert!(!WaitReason::Children { outstanding: 2 }.needs_a_person());
}
#[test]
fn run_meta_serde_roundtrip() {
let mut m = sample_meta();
m.status = RunStatus::Running;
m.metadata.insert("k".to_string(), "v".to_string());
m.title = Some("A title".to_string());
m.callback_secret = Some("shh".to_string());
m.parent_run_id = Some("parent-1".to_string());
m.children = vec!["child-a".to_string(), "child-b".to_string()];
m.depth = 2;
m.max_child_depth = 5;
let json = serde_json::to_string(&m).unwrap();
let back: RunMeta = serde_json::from_str(&json).unwrap();
assert_eq!(back.run_id, m.run_id);
assert_eq!(back.status, RunStatus::Running);
assert_eq!(back.metadata.get("k").map(String::as_str), Some("v"));
assert_eq!(back.title.as_deref(), Some("A title"));
assert_eq!(back.callback_secret.as_deref(), Some("shh"));
assert_eq!(back.parent_run_id.as_deref(), Some("parent-1"));
assert_eq!(
back.children,
vec!["child-a".to_string(), "child-b".to_string()]
);
assert_eq!(back.depth, 2);
assert_eq!(back.max_child_depth, 5);
}
#[test]
fn run_status_display_all_variants() {
assert_eq!(RunStatus::Starting.to_string(), "Starting");
assert_eq!(RunStatus::Running.to_string(), "Running");
assert_eq!(RunStatus::WaitingInput.to_string(), "WaitingInput");
assert_eq!(RunStatus::Complete.to_string(), "Complete");
assert_eq!(
RunStatus::CompleteInteractive.to_string(),
"CompleteInteractive"
);
assert_eq!(RunStatus::Paused.to_string(), "Paused");
assert_eq!(RunStatus::Error.to_string(), "Error");
assert_eq!(RunStatus::Cancelled.to_string(), "Cancelled");
}
#[test]
fn run_status_serde_snake_case_roundtrip() {
for s in [
RunStatus::Starting,
RunStatus::Running,
RunStatus::WaitingInput,
RunStatus::Complete,
RunStatus::CompleteInteractive,
RunStatus::Paused,
RunStatus::Error,
RunStatus::Cancelled,
] {
let json = serde_json::to_string(&s).unwrap();
let back: RunStatus = serde_json::from_str(&json).unwrap();
assert_eq!(back, s);
}
assert_eq!(
serde_json::to_string(&RunStatus::WaitingInput).unwrap(),
"\"waiting_input\""
);
assert_eq!(
serde_json::to_string(&RunStatus::Paused).unwrap(),
"\"paused\""
);
}
#[test]
fn context_snapshot_serde_roundtrip() {
let snap = ContextSnapshot {
stage_name: "plan".to_string(),
total_tokens: 42,
max_tokens: 100,
regions: vec![RegionSnapshot {
name: "history".to_string(),
kind: "sliding".to_string(),
current_tokens: 10,
max_tokens: 50,
entries: vec![RegionEntrySnapshot {
content: "hi".to_string(),
tokens: 1,
kind: crate::region::EntryKind::UserMessage,
metadata: Some(serde_json::json!({"a": 1})),
key: Some("k".to_string()),
taint: Default::default(),
}],
}],
};
let json = serde_json::to_string(&snap).unwrap();
let back: ContextSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(back.stage_name, "plan");
assert_eq!(back.regions.len(), 1);
assert_eq!(back.regions[0].entries.len(), 1);
assert_eq!(back.regions[0].entries[0].content, "hi");
assert_eq!(back.regions[0].entries[0].key.as_deref(), Some("k"));
}
#[test]
fn region_snapshot_skips_empty_entries_in_json() {
let snap = RegionSnapshot {
name: "r".to_string(),
kind: "pinned".to_string(),
current_tokens: 0,
max_tokens: 0,
entries: vec![],
};
let json = serde_json::to_string(&snap).unwrap();
assert!(!json.contains("entries"));
}
#[test]
fn stage_run_status_display_all_variants() {
assert_eq!(StageRunStatus::Pending.to_string(), "Pending");
assert_eq!(StageRunStatus::Skipped.to_string(), "Skipped");
assert_eq!(StageRunStatus::Active.to_string(), "Active");
assert_eq!(StageRunStatus::WaitingInput.to_string(), "WaitingInput");
assert_eq!(StageRunStatus::Complete.to_string(), "Complete");
assert_eq!(StageRunStatus::Error.to_string(), "Error");
}
#[test]
fn run_flags_record_modification_dedups_paths_and_caps_the_list() {
let mut flags = RunFlags::default();
flags.record_modification("src/a.rs");
flags.record_modification("src/a.rs");
flags.record_modification("src/b.rs");
assert_eq!(flags.modified_file_count, 3);
assert_eq!(flags.modified_files, vec!["src/a.rs", "src/b.rs"]);
for i in 0..MAX_TRACKED_MODIFIED_FILES {
flags.record_modification(&format!("f{i}.rs"));
}
assert_eq!(flags.modified_files.len(), MAX_TRACKED_MODIFIED_FILES);
assert_eq!(flags.modified_file_count, 3 + MAX_TRACKED_MODIFIED_FILES);
}
#[test]
fn run_meta_flags_default_for_older_files() {
let mut meta = RunMeta::new(
"r".to_string(),
"a".to_string(),
"/p".to_string(),
"t".to_string(),
None,
"/w".to_string(),
1,
);
meta.flags.empty_output = true;
let mut json = serde_json::to_value(&meta).unwrap();
json.as_object_mut().unwrap().remove("flags").unwrap();
assert!(!json.to_string().contains("flags"));
let back: RunMeta = serde_json::from_value(json).unwrap();
assert_eq!(back.flags, RunFlags::default());
}
#[test]
fn stage_record_new_and_serde_roundtrip() {
let rec = StageRecord::new("analyze".to_string(), 2);
assert_eq!(rec.name, "analyze");
assert_eq!(rec.index, 2);
assert_eq!(rec.status, StageRunStatus::Pending);
assert_eq!(rec.prompt_tokens, 0);
assert_eq!(rec.completion_tokens, 0);
assert_eq!(rec.cached_tokens, 0);
assert!(rec.started_at.is_none());
assert!(rec.ended_at.is_none());
let json = serde_json::to_string(&rec).unwrap();
let back: StageRecord = serde_json::from_str(&json).unwrap();
assert_eq!(back.name, "analyze");
assert_eq!(back.status, StageRunStatus::Pending);
}
}