From 21a217c75cbd736164396f92d832ee7661e55074 Mon Sep 17 00:00:00 2001 From: damocles Date: Tue, 27 Oct 2026 08:14:19 +0100 Subject: [PATCH] hive-agent: test the turn-end events and the claude error mapping --- hive-agent/src/turn.rs | 88 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/hive-agent/src/turn.rs b/hive-agent/src/turn.rs index 6c228504..dc83de50 100644 --- a/hive-agent/src/turn.rs +++ b/hive-agent/src/turn.rs @@ -857,8 +857,9 @@ fn archive_session(bus: &Bus, session: &AgentSession) { #[cfg(test)] mod tests { use super::{ - PermissionAsk, TurnError, acp_permits, apply_telemetry, nonzero_or, parse_u64, - report_compact, runtime_error_to_turn, with_turn_end_compact, + PermissionAsk, TurnError, TurnOutcome, acp_permits, apply_telemetry, emit_turn_end, + error_to_turn, nonzero_or, parse_u64, report_compact, runtime_error_to_turn, + with_turn_end_compact, }; use crate::events::{Bus, LiveEvent}; use hive_runtime::{AcpError, Error}; @@ -1041,6 +1042,89 @@ mod tests { assert_eq!(notes, ["/compact done"]); } + /// The `TurnEnd` events one `emit_turn_end` call puts on a fresh bus, as + /// `(ok, note)`. + fn turn_ends(outcome: &TurnOutcome) -> Vec<(bool, Option)> { + crate::paths::with_harness_dir(|| { + let bus = Bus::new(); + let mut rx = bus.subscribe(); + emit_turn_end(&bus, outcome); + let mut ends = Vec::new(); + while let Ok(ev) = rx.try_recv() { + if let LiveEvent::TurnEnd { ok, note } = ev.event { + ends.push((ok, note)); + } + } + ends + }) + } + + /// The single `(ok, note)` a failed outcome must produce. + fn failed_note(error: TurnError) -> String { + let ends = turn_ends(&Err(error)); + assert_eq!(ends.len(), 1, "one TurnEnd per turn: {ends:?}"); + let (ok, note) = ends.into_iter().next().expect("one end"); + assert!(!ok, "a failed outcome never reports ok"); + note.expect("a failed outcome always carries a note") + } + + #[test] + fn a_finished_turn_ends_ok_with_no_note() { + assert_eq!(turn_ends(&Ok(true)), [(true, None)]); + assert_eq!(turn_ends(&Ok(false)), [(true, None)]); + } + + #[test] + fn each_recoverable_failure_names_what_the_serve_loop_will_do() { + assert!(failed_note(TurnError::PromptTooLong).contains("retrying")); + assert!(failed_note(TurnError::RateLimited).contains("parking until quota resets")); + assert!(failed_note(TurnError::AuthFailed).contains("waiting for re-login")); + assert!(failed_note(TurnError::SessionNotFound).contains("requeueing")); + assert!(failed_note(TurnError::ApiStall).contains("API stall suspected")); + } + + #[test] + fn an_agent_stall_note_keeps_the_watchdogs_own_text() { + assert_eq!( + failed_note(TurnError::AgentStall("no output for 600s".to_owned())), + "no output for 600s — parking + requeueing" + ); + } + + #[test] + fn a_hard_failure_note_is_the_full_error_chain() { + let error = anyhow::anyhow!("inner cause").context("outer step"); + assert_eq!( + failed_note(TurnError::Failed(error)), + "outer step: inner cause" + ); + } + + #[test] + fn claude_errors_map_to_the_turn_error_the_serve_loop_reacts_to() { + use hive_claude::Error as C; + assert!(matches!( + error_to_turn(C::PromptTooLong), + Err(TurnError::PromptTooLong) + )); + assert!(matches!( + error_to_turn(C::RateLimited), + Err(TurnError::RateLimited) + )); + assert!(matches!( + error_to_turn(C::AuthFailed), + Err(TurnError::AuthFailed) + )); + assert!(matches!( + error_to_turn(C::SessionNotFound), + Err(TurnError::SessionNotFound) + )); + assert!(matches!( + error_to_turn(C::IdleTimeout), + Err(TurnError::ApiStall) + )); + } + #[test] fn a_failed_turn_end_compact_keeps_the_turns_own_compaction() { assert!(matches!(with_turn_end_compact(Ok(true), false), Ok(true)));