//! Goal mode support — notification helpers and state formatters. //! //! The model drives goal orchestration directly via the `update_goal` tool. //! This module provides notification helpers used by `SessionActor` to emit //! `GoalUpdated` events and format elapsed time. use crate::extensions::notification::{ SessionNotification as XaiSessionNotification, SessionUpdate as XaiSessionUpdate, }; use crate::session::goal_tracker::{GoalOrchestration, GoalPhase, GoalStatus, GoalTracker}; use crate::session::persistence::PersistenceMsg; // --------------------------------------------------------------------------- // GoalNotifySender — fire-and-forget notification sender // --------------------------------------------------------------------------- /// Lightweight notification sender for goal progress updates. /// /// Used by `SessionActor` to emit `GoalUpdated` notifications when the /// `update_goal` tool reports progress. pub(crate) struct GoalNotifySender { session_id: agent_client_protocol::SessionId, gateway: kigi_acp_lib::AcpAgentGatewaySender, persistence_tx: tokio::sync::mpsc::UnboundedSender, } impl GoalNotifySender { pub(crate) fn new( session_id: agent_client_protocol::SessionId, gateway: kigi_acp_lib::AcpAgentGatewaySender, persistence_tx: tokio::sync::mpsc::UnboundedSender, ) -> Self { Self { session_id, gateway, persistence_tx, } } /// Send a `GoalUpdated` built from the snapshot; token args come /// from [`SessionActor::goal_tokens`]. pub(crate) fn emit_goal_updated( &self, tracker: &mut GoalTracker, tokens_used: i64, finished_subagent_tokens: i64, ) { tracker.account_elapsed(); let Some(o) = tracker.snapshot() else { return }; self.send_update(build_goal_updated(o, tokens_used, finished_subagent_tokens)); } /// Like [`Self::emit_goal_updated`] but fire-and-forget to the gateway /// ONLY — the update is not appended to the session JSONL. Used by the /// high-frequency `SubagentProgress` live-token path: those ticks recur /// (~every `PROGRESS_PUBLISH_INTERVAL`) while a subagent runs, so /// persisting each one would grow the updates log without bound. The /// durable goal total survives via `PersistenceMsg::GoalModeState` and /// the next persisted state-transition `GoalUpdated`; the pager /// self-heals the live figure on the next tick. Mirrors the gateway-only /// `scheduled_task_fired` convention. pub(crate) fn emit_goal_updated_ephemeral( &self, tracker: &mut GoalTracker, tokens_used: i64, finished_subagent_tokens: i64, ) { tracker.account_elapsed(); let Some(o) = tracker.snapshot() else { return }; self.dispatch_update( build_goal_updated(o, tokens_used, finished_subagent_tokens), false, ); } /// Persist + fire-and-forget a notification to the gateway. Used for /// snapshot-derived payloads and for the "planning…" / "Verifying…" /// latch updates that must not run the `send_xai_notification` /// rewind-window-close side effect. pub(crate) fn send_update(&self, update: XaiSessionUpdate) { self.dispatch_update(update, true); } /// Stamp, optionally persist, and fire-and-forget a notification. /// `persist == false` ships the update to the gateway only (no JSONL /// append) for recurring/transient ticks — see /// [`Self::emit_goal_updated_ephemeral`]. fn dispatch_update(&self, update: XaiSessionUpdate, persist: bool) { // Stamped before the persist/broadcast fork — see `ensure_event_id_meta`. let mut meta = None; crate::util::event_id::ensure_event_id_meta(&self.session_id.0, &mut meta); let notification = XaiSessionNotification { session_id: self.session_id.clone(), update, meta: meta.map(serde_json::Value::Object), }; let raw = serde_json::to_value(¬ification) .and_then(|v| serde_json::value::to_raw_value(&v)) .ok(); if persist { let _ = self.persistence_tx.send(PersistenceMsg::Update( crate::session::storage::SessionUpdate::Xai(Box::new(notification)), )); } if let Some(raw) = raw { let ext = agent_client_protocol::ExtNotification::new( "kigi/session_notification", raw.into(), ); self.gateway.forward_fire_and_forget(ext); } } } /// Map a `GoalEvent` to its snake_case wire name. fn goal_event_as_str(event: &crate::session::goal_tracker::GoalEvent) -> &'static str { use crate::session::goal_tracker::GoalEvent; match event { GoalEvent::GoalCreated => "goal_created", GoalEvent::PlanningStarted => "planning_started", GoalEvent::PlanningCompleted => "planning_completed", GoalEvent::PlanningFailed => "planning_failed", GoalEvent::WorkerStarted => "worker_started", GoalEvent::WorkerCompleted => "worker_completed", GoalEvent::WorkerFailed => "worker_failed", GoalEvent::ContextRotated => "context_rotated", GoalEvent::GoalPaused => "goal_paused", GoalEvent::GoalResumed => "goal_resumed", GoalEvent::GoalCompleted => "goal_completed", GoalEvent::GoalCleared => "goal_cleared", GoalEvent::BudgetExceeded => "budget_exceeded", GoalEvent::PrematureStopDetected => "premature_stop_detected", GoalEvent::Unknown => "unknown", } } /// Build a `GoalUpdated` from an orchestration snapshot. `tokens_used` /// already includes every subagent's marginal (live progress ticks /// advance the records), so `o.live_subagent_tokens` is NOT folded in — /// it ships only as the separate Active-subagent wire field. pub(crate) fn build_goal_updated( o: &GoalOrchestration, tokens_used: i64, finished_subagent_tokens: i64, ) -> XaiSessionUpdate { let last_entry = o.history.last(); let status_str = match o.status { GoalStatus::Active => "active", GoalStatus::UserPaused => "user_paused", GoalStatus::BackOffPaused => "back_off_paused", GoalStatus::NoProgressPaused => "no_progress_paused", GoalStatus::InfraPaused => "infra_paused", GoalStatus::Blocked => "blocked", GoalStatus::BudgetLimited => "budget_limited", GoalStatus::Complete => "complete", }; let phase_str = match o.phase { GoalPhase::Idle => "idle", GoalPhase::Planning => "planning", GoalPhase::Executing => "executing", }; let last_event = last_entry.map(|e| goal_event_as_str(&e.event).to_owned()); XaiSessionUpdate::GoalUpdated { goal_id: o.goal_id.clone(), objective: o.objective.clone(), status: status_str.to_owned(), phase: phase_str.to_owned(), token_budget: o.token_budget, tokens_used, elapsed_ms: o.elapsed_ms, total_deliverables: 0, completed_deliverables: 0, current_deliverable_id: None, current_deliverable_title: None, current_subagent_role: o.current_subagent_role.clone(), total_worker_rounds: o.total_worker_rounds, total_verify_rounds: o.total_verify_rounds, token_baseline: o.token_baseline, finished_subagent_tokens, live_subagent_tokens: (o.live_subagent_tokens > 0).then_some(o.live_subagent_tokens), // Only transmit the breakdown when ≥2 distinct models appear; a // single-model (or all-inherit) goal collapses to the single // tokens line, so a 1-element vec is never sent. This makes the // wire-field doc literally true and is the single source of the // collapse rule (the pager re-checks ≥2 as defence in depth). // `o.live_tokens_by_model` is a live active-subagent-window field // (cleared on `SubagentFinished`, like `live_subagent_tokens`); the // pager renders it only under the "Active subagent" block, so this // producer must keep its populate gate on that same axis. live_tokens_by_model: if o.live_tokens_by_model.len() >= 2 { o.live_tokens_by_model.clone() } else { Vec::new() }, live_context_pct: (o.live_context_pct > 0).then_some(o.live_context_pct), live_turn_count: (o.live_turn_count > 0).then_some(o.live_turn_count), live_tool_call_count: (o.live_tool_call_count > 0).then_some(o.live_tool_call_count), last_event, last_event_detail: last_entry.and_then(|e| e.detail.clone()), last_event_timestamp: last_entry.map(|e| e.timestamp.clone()), deliverables: Vec::new(), pause_message: o.pause_message.clone(), // Suppress the counter on the wire when no classifier run has // happened yet — mirrors the `total_worker_rounds`-style // convention used elsewhere for "no activity" counters. classifier_runs_attempted: (o.classifier_runs_attempted > 0) .then_some(o.classifier_runs_attempted), classifier_max_runs: o.classifier_max_runs, last_classifier_verdict: o.last_classifier_verdict, last_classifier_details_path: o.last_classifier_details_path.clone(), // Both badges are sourced from their `*_in_flight` latches (see the // field docs), NOT one-shot wire flags — so the token-accounting / // continuation `GoalUpdated`s that fire mid-run keep carrying the // correct flag instead of clearing it. verifying_completion: o.verifying_in_flight.then_some(true), planning: o.planning_in_flight.then_some(true), } } /// Build a `GoalUpdated` with `status: "cleared"` to tell the pager to /// drop its goal state. pub(crate) fn build_goal_cleared() -> XaiSessionUpdate { XaiSessionUpdate::GoalUpdated { goal_id: String::new(), objective: String::new(), status: "cleared".to_owned(), phase: "idle".to_owned(), token_budget: None, tokens_used: 0, elapsed_ms: 0, total_deliverables: 0, completed_deliverables: 0, current_deliverable_id: None, current_deliverable_title: None, current_subagent_role: None, total_worker_rounds: 0, total_verify_rounds: 0, token_baseline: 0, finished_subagent_tokens: 0, live_subagent_tokens: None, live_tokens_by_model: Vec::new(), live_context_pct: None, live_turn_count: None, live_tool_call_count: None, last_event: None, last_event_detail: None, last_event_timestamp: None, deliverables: Vec::new(), pause_message: None, classifier_runs_attempted: None, classifier_max_runs: None, last_classifier_verdict: None, last_classifier_details_path: None, verifying_completion: None, planning: None, } } /// Format elapsed milliseconds as a compact human-readable duration. pub(crate) fn format_elapsed(ms: u64) -> String { let total_secs = ms / 1000; let hours = total_secs / 3600; let mins = (total_secs % 3600) / 60; let secs = total_secs % 60; if hours > 0 { format!("{hours}h{mins:02}m") } else if mins > 0 { format!("{mins}m{secs:02}s") } else { format!("{secs}s") } } #[cfg(test)] mod tests { use super::*; use crate::session::goal_tracker::make_base_orchestration; #[test] fn format_elapsed_seconds() { assert_eq!(format_elapsed(5_000), "5s"); } #[test] fn format_elapsed_minutes() { assert_eq!(format_elapsed(125_000), "2m05s"); } #[test] fn format_elapsed_hours() { assert_eq!(format_elapsed(3_661_000), "1h01m"); } #[test] fn build_goal_updated_empty_orchestration() { let o = make_base_orchestration(); let update = build_goal_updated(&o, 0, 0); match update { XaiSessionUpdate::GoalUpdated { total_deliverables, completed_deliverables, classifier_runs_attempted, classifier_max_runs, last_classifier_verdict, last_classifier_details_path, verifying_completion, planning, .. } => { assert_eq!(total_deliverables, 0); assert_eq!(completed_deliverables, 0); assert_eq!(classifier_runs_attempted, None); assert_eq!(classifier_max_runs, None); assert_eq!(last_classifier_verdict, None); assert_eq!(last_classifier_details_path, None); assert_eq!(verifying_completion, None); assert_eq!(planning, None); } _ => panic!("expected GoalUpdated"), } } #[test] fn build_goal_updated_emits_classifier_fields() { use crate::session::goal_tracker::GoalClassifierVerdict; let mut o = make_base_orchestration(); o.classifier_runs_attempted = 2; o.classifier_max_runs = Some(3); o.last_classifier_verdict = Some(GoalClassifierVerdict::NotAchieved); o.last_classifier_details_path = Some("/tmp/details.md".into()); let update = build_goal_updated(&o, 0, 0); match update { XaiSessionUpdate::GoalUpdated { classifier_runs_attempted, classifier_max_runs, last_classifier_verdict, last_classifier_details_path, verifying_completion, .. } => { assert_eq!(classifier_runs_attempted, Some(2)); assert_eq!(classifier_max_runs, Some(3)); assert_eq!( last_classifier_verdict, Some(GoalClassifierVerdict::NotAchieved) ); assert_eq!( last_classifier_details_path.as_deref(), Some("/tmp/details.md") ); assert_eq!(verifying_completion, None); } _ => panic!("expected GoalUpdated"), } } #[test] fn build_goal_updated_planning_flag_reflects_latch() { let mut o = make_base_orchestration(); assert_eq!( planning_field(&build_goal_updated(&o, 0, 0)), None, "latch off → no badge", ); o.planning_in_flight = true; assert_eq!( planning_field(&build_goal_updated(&o, 0, 0)), Some(true), "latch on → every snapshot-derived update keeps the badge", ); } fn planning_field(update: &XaiSessionUpdate) -> Option { match update { XaiSessionUpdate::GoalUpdated { planning, .. } => *planning, _ => panic!("expected GoalUpdated"), } } #[test] fn build_goal_updated_verifying_flag_reflects_latch() { // Regression: a one-shot verifying flag flickered off the moment any // mid-verification GoalUpdated fired. Sourced from the latch, every // snapshot-derived update keeps the "Verifying…" badge. let mut o = make_base_orchestration(); let verifying = |u: &XaiSessionUpdate| match u { XaiSessionUpdate::GoalUpdated { verifying_completion, .. } => *verifying_completion, _ => panic!("expected GoalUpdated"), }; assert_eq!(verifying(&build_goal_updated(&o, 0, 0)), None, "latch off"); o.verifying_in_flight = true; assert_eq!( verifying(&build_goal_updated(&o, 0, 0)), Some(true), "latch on → badge persists across mid-verification updates", ); } #[test] fn build_goal_updated_propagates_tokens_to_wire() { let mut o = make_base_orchestration(); o.live_subagent_tokens = 5_000; let update = build_goal_updated(&o, 60_000, 40_000); match update { XaiSessionUpdate::GoalUpdated { tokens_used, finished_subagent_tokens, live_subagent_tokens, .. } => { // Live tokens are already inside `tokens_used` via the // records; folding them in again would double-count. assert_eq!(tokens_used, 60_000); assert_eq!(finished_subagent_tokens, 40_000); assert_eq!(live_subagent_tokens, Some(5_000)); } _ => panic!("expected GoalUpdated"), } } #[test] fn build_goal_updated_gates_per_model_breakdown_on_two_or_more_models() { // A single-model (or all-inherit) breakdown must NOT be transmitted // — the wire field stays empty so the doc contract holds and we // don't ship a 1-element vec the pager would collapse anyway. let mut o = make_base_orchestration(); o.live_tokens_by_model = vec![("kigi-4".into(), 5_000)]; match build_goal_updated(&o, 0, 0) { XaiSessionUpdate::GoalUpdated { live_tokens_by_model, .. } => assert!( live_tokens_by_model.is_empty(), "single model must not be transmitted" ), _ => panic!("expected GoalUpdated"), } // ≥2 distinct models are transmitted verbatim. o.live_tokens_by_model = vec![("kigi-4".into(), 5_000), ("kigi-3".into(), 3_000)]; match build_goal_updated(&o, 0, 0) { XaiSessionUpdate::GoalUpdated { live_tokens_by_model, .. } => assert_eq!( live_tokens_by_model, vec![("kigi-4".to_owned(), 5_000), ("kigi-3".to_owned(), 3_000)] ), _ => panic!("expected GoalUpdated"), } } #[test] fn build_goal_cleared_is_cleared() { let update = build_goal_cleared(); match update { XaiSessionUpdate::GoalUpdated { status, classifier_runs_attempted, classifier_max_runs, last_classifier_verdict, last_classifier_details_path, verifying_completion, live_tokens_by_model, .. } => { assert_eq!(status, "cleared"); assert_eq!(classifier_runs_attempted, None); assert_eq!(classifier_max_runs, None); assert_eq!(last_classifier_verdict, None); assert_eq!(last_classifier_details_path, None); assert_eq!(verifying_completion, None); assert!(live_tokens_by_model.is_empty()); } _ => panic!("expected GoalUpdated"), } } }