diff --git a/swarm-controller/src/subagent_term.rs b/swarm-controller/src/subagent_term.rs index 54a9a9e7a..b662f7986 100644 --- a/swarm-controller/src/subagent_term.rs +++ b/swarm-controller/src/subagent_term.rs @@ -472,9 +472,102 @@ fn read_failed(agent: &str, e: &dyn std::error::Error) -> problem_details::Probl #[cfg(test)] mod tests { - use super::header; + use super::{NewestRow, SubagentEntry, SubagentState, entry_of, header}; + use swarm_wire::subagent_status::{STALE_AFTER_S, State, Status}; use swarm_wire::subagent_term::{HEADER_EFFORT, HEADER_MODEL, subagent_names}; + const NOW: i64 = 1_000_000; + + fn status(state: State, since: i64, ts: i64) -> Status { + Status { + state, + since, + exit: None, + model: Some("opus".to_owned()), + effort: Some("high".to_owned()), + ts, + } + } + + fn row() -> NewestRow { + NewestRow { + unix: NOW - 30, + model: Some("haiku".to_owned()), + effort: None, + } + } + + /// A running status counts as running until three heartbeats are missed, + /// then stale; a finished one is finished however fresh it is. + #[test] + fn a_state_is_running_until_its_heartbeats_stop() { + let fresh = status(State::Running, NOW - 500, NOW - 10); + assert_eq!(SubagentState::of(&fresh, NOW), SubagentState::Running); + let edge = status(State::Running, NOW - 500, NOW - STALE_AFTER_S); + assert_eq!(SubagentState::of(&edge, NOW), SubagentState::Running); + let silent = status(State::Running, NOW - 500, NOW - STALE_AFTER_S - 1); + assert_eq!(SubagentState::of(&silent, NOW), SubagentState::Stale); + let done = status(State::Finished, NOW - 500, NOW - 1); + assert_eq!(SubagentState::of(&done, NOW), SubagentState::Finished); + } + + /// With a status the entry takes the state, the start, the exit status + /// and the model and effort from it, and ignores any newest row. + #[test] + fn a_status_decides_the_entry_over_the_newest_row() { + let mut done = status(State::Finished, NOW - 500, NOW - 1); + done.exit = Some(2); + let entry = entry_of("scout".to_owned(), Some(done), Some(Ok(row())), NOW); + assert_eq!( + entry, + SubagentEntry { + name: "scout".to_owned(), + last_row_unix: None, + model: Some("opus".to_owned()), + effort: Some("high".to_owned()), + state: Some(SubagentState::Finished), + since_unix: Some(NOW - 500), + exit: Some(2), + } + ); + } + + /// Without a status, as from an older daemon, the entry is what the newest + /// row says and has no state, so the swarm UI judges it from the row's age. + #[test] + fn without_a_status_the_entry_is_the_newest_row() { + let entry = entry_of("scout".to_owned(), None, Some(Ok(row())), NOW); + assert_eq!( + entry, + SubagentEntry { + name: "scout".to_owned(), + last_row_unix: Some(NOW - 30), + model: Some("haiku".to_owned()), + effort: None, + state: None, + since_unix: None, + exit: None, + } + ); + } + + /// A row that could not be read leaves the time and headers out and + /// still lists the subagent. + #[test] + fn a_failed_row_lookup_still_lists_the_subagent() { + let failed = entry_of( + "scout".to_owned(), + None, + Some(Err("timeout".to_owned())), + NOW, + ); + assert_eq!(failed.name, "scout"); + assert_eq!(failed.last_row_unix, None); + assert_eq!(failed.state, None); + let skipped = entry_of("scout".to_owned(), None, None, NOW); + assert_eq!(skipped, failed); + } + /// The list is what `STREAM.INFO` reports for the stream's subjects, by /// name: a subagent that published once and one that published many rows /// list the same way, and the order is the name's, not the stream's.