//! Finalizing a turn from a terminal turn signal. //! //! The pager learns a turn reached its terminal outcome from two rails: the //! fire-and-forget `kigi/session/prompt_complete` broadcast (the one-release //! compat path for not-yet-upgraded leaders) and the durable, persisted+replayed //! `XaiSessionUpdate::TurnCompleted`. Both converge on //! [`finalize_turn_from_terminal`] so the turn-finalize behavior lives in one //! place — and so a viewer that re-attaches mid-turn can finalize the turn from //! replay instead of staying stuck on "Waiting…". use crate::scrollback::blocks::SessionEvent; use super::agent::AgentId; use super::agent_view::AgentView; use super::app_view::AppView; /// Push a turn-terminal marker ("Turn completed/cancelled/failed"), folding /// any pending stop/stop_failure hook runs into it so they render inline /// (right-justified) on the marker line instead of as a standalone block. /// /// All four marker rails route through here: the driver's `PromptResponse`, /// the lost-RPC reconcile, the viewer finalize, and wake turns' /// `push_wake_end_marker` (acp_handler). `event == None` (bash turns, /// rate-limit / re-auth UX that replaces the marker) flushes the held hooks /// as the legacy standalone lifecycle block so failures stay visible. /// /// A stamped stash folds only on an exact ending-id match. On a mismatch the /// real-turn rails flush it standalone (the ending turn is THE turn — an /// older stash has no marker coming); the wake rail instead passes /// `preserve_mismatched_stash` so a REAL turn's leftover stash stays pending /// for its own marker rail rather than flushing on an unrelated wake. An /// unstamped stash keeps the legacy stashed-during-this-turn heuristic. /// /// The marker carries a snapshot of the background work still running /// ("Worked for X. N commands still running") when any exists — a /// workless marker renders the legacy text unchanged. Announcing work opens /// the between-turns status window: completions landing before the next /// turn re-emit a fresh work-only status line after their chip. pub(super) fn push_turn_terminal_marker( agent: &mut AgentView, event: Option, ending_prompt_id: Option<&str>, preserve_mismatched_stash: bool, ) { let pending = agent.pending_stop_hooks.take(); let groups = match pending { None => Vec::new(), Some(pending) => { let stale = match (pending.prompt_id.as_deref(), ending_prompt_id) { (Some(stashed), Some(ending)) => stashed != ending, (Some(_), None) => true, (None, _) => false, }; if stale { if preserve_mismatched_stash { // Wake rail: the stash belongs to a real turn whose own // marker rail will fold it — leave it pending. agent.pending_stop_hooks = Some(pending); } else { for (name, runs) in pending.groups { agent.scrollback.push_lifecycle_hooks(name, runs); } } Vec::new() } else { pending.groups } } }; match event { Some(event) => { agent.push_end_marker_block(event, groups, ending_prompt_id.map(str::to_string)); } None => { for (name, runs) in groups { agent.scrollback.push_lifecycle_hooks(name, runs); } } } } /// What applying a terminal turn signal did to one agent. pub(super) enum TerminalApply { /// No change: a driver turn the signal does not provably match, or a /// duplicate/stale terminal for an already-finished viewer turn. Ignored, /// Driver: the lost-RPC reconcile was armed. The turn is NOT finished — the /// `PromptResponse` RPC owns the driver's lifecycle. Reported as a state /// change so the reconcile sweep's animation tick stays scheduled. ReconcileArmed, /// Viewer: the turn was finished and (for non-rate-limit reasons) a terminal /// marker pushed. The caller drops any stale running-prompt adoption. ViewerFinalized, } /// Arm lost-`PromptResponse` reconcile for the driver turn we own. /// /// - **Exact** `prompt_id` match → arm (canonical). /// - **Missing** wire `promptId` (`None` or empty) → arm on `current_prompt_id` /// only when the turn is not mid-tool/thinking/compact/retry (legacy / /// broken `TurnCompleted` payloads). /// - **Non-empty mismatch** → ignore (stale/peer terminal must not kill a /// newer live turn after grace). /// /// Never clobber an existing arm for a different pid; keep earliest /// `received_at` when re-arming the same pid. fn arm_driver_turn_end_reconcile( agent: &mut AgentView, session_id: &str, prompt_id: Option<&str>, stop_reason: Option<&str>, agent_result: Option<&str>, cancel_trigger: Option<&str>, ) -> bool { if agent.session.loading_replay { return false; } if !(agent.session.state.is_turn_running() || agent.session.state.is_cancelling()) { return false; } let Some(current) = agent.session.current_prompt_id.clone() else { return false; }; let (arm_pid, arm_via) = match prompt_id { Some(pid) if pid == current.as_str() => (current, "exact"), Some("") => { if driver_mid_active_work(agent) { return false; } (current, "empty_wire_pid") } Some(_) => return false, None => { if driver_mid_active_work(agent) { return false; } (current, "missing_wire_pid") } }; if let Some(pending) = agent.pending_turn_end_reconcile.as_ref() { if pending.prompt_id != arm_pid { return false; } // Same pid already armed — keep earliest received_at; refresh outcome. let received_at = pending.received_at; agent.pending_turn_end_reconcile = Some(super::agent_view::PendingTurnEnd { prompt_id: arm_pid.clone(), stop_reason: stop_reason.map(str::to_string), agent_result: agent_result.map(str::to_string), cancel_trigger: cancel_trigger.map(str::to_string), received_at, }); crate::unified_log::info( "turn.end_reconcile.armed", Some(session_id), Some(serde_json::json!({ "prompt_id": arm_pid, "wire_prompt_id": prompt_id, "arm_via": arm_via, "stop_reason": stop_reason, "refreshed": true, })), ); return true; } crate::unified_log::info( "turn.end_reconcile.armed", Some(session_id), Some(serde_json::json!({ "prompt_id": arm_pid, "wire_prompt_id": prompt_id, "arm_via": arm_via, "stop_reason": stop_reason, })), ); agent.pending_turn_end_reconcile = Some(super::agent_view::PendingTurnEnd { prompt_id: arm_pid, stop_reason: stop_reason.map(str::to_string), agent_result: agent_result.map(str::to_string), cancel_trigger: cancel_trigger.map(str::to_string), received_at: std::time::Instant::now(), }); true } fn driver_mid_active_work(agent: &AgentView) -> bool { matches!( agent.session.tracker.activity(), Some(crate::acp::tracker::TurnActivity::ToolRunning { .. }) | Some(crate::acp::tracker::TurnActivity::Thinking) | Some(crate::acp::tracker::TurnActivity::AutoCompacting) | Some(crate::acp::tracker::TurnActivity::Retrying { .. }) ) } /// Finalize a turn from a terminal signal, shared by the `prompt_complete` /// broadcast and the durable `TurnCompleted` update so both behave identically. /// /// DRIVER (`!attached_as_viewer`): the `PromptResponse` RPC owns the turn /// lifecycle (it carries context this signal lacks: error classes, rewind /// bookkeeping, adoption hand-off), so do NOT finish the turn here — that would /// race/double-finish on every normal turn end (the signal is emitted BEFORE the /// RPC response is written). But the RPC response can be LOST in transit (leader /// response routing / reconnect races), and /// it is the ONLY exit from `TurnRunning`/`TurnCancelling`. So when the signal /// refers to the turn this client is driving (exact pid, or missing/empty pid /// while not mid-tool), arm a deferred reconcile: if the RPC lands within the /// grace window it disarms this (see `TaskResult::PromptResponse`); otherwise /// the event loop finishes the turn from it (`reconcile_overdue_turn_ends`). /// /// VIEWER (`attached_as_viewer`): a viewer adopts the driver's turn and never /// receives its `PromptResponse`, so this is its only non-interactive exit from /// `TurnRunning`. Finish the turn and push the "Turn completed/cancelled/failed" /// marker mapped from `stop_reason`. Idempotent: a duplicate/stale terminal for /// an already-finished turn pushes nothing and returns [`TerminalApply::Ignored`]. /// /// `cancel_trigger` is the signal's `_meta.cancelTrigger`, when stamped: /// `"send_now"` marks the cancel as the silent half of a cancel-and-send, so /// the `TurnCancelled` marker is suppressed (the sender's new prompt renders /// as the next turn). Absent meta means a normal cancel — except when this /// client just dispatched the send-now (`AgentView::expect_send_now_cancel`, /// the older-shell fallback, consumed here on the viewer finalize). pub(super) fn finalize_turn_from_terminal( agent: &mut AgentView, session_id: &str, prompt_id: Option<&str>, stop_reason: Option<&str>, agent_result: Option<&str>, cancel_trigger: Option<&str>, ) -> TerminalApply { if !agent.attached_as_viewer { if arm_driver_turn_end_reconcile( agent, session_id, prompt_id, stop_reason, agent_result, cancel_trigger, ) { return TerminalApply::ReconcileArmed; } return TerminalApply::Ignored; } // Viewer: the driver's turn ended — exit TurnRunning. Only act when a turn // is actually in progress so a stray/duplicate signal is harmless (a // duplicate finds the turn already finished here and pushes no marker). if !agent.session.state.is_busy() && agent.session.current_prompt_id.is_none() { return TerminalApply::Ignored; } // Capture elapsed BEFORE `mark_turn_finished()` clears `turn_started_at`. The // anchor was back-dated from the authoritative `turnStartMs` on adoption, so // this reads the same wall-clock duration the driver shows. let elapsed = agent.turn_elapsed().unwrap_or_default(); // Read before `finish_turn()` clears it; keys the pending stop-hook stash. let ending_prompt_id = agent .session .current_prompt_id .clone() .or_else(|| prompt_id.map(str::to_string)); agent.session.finish_turn(&mut agent.scrollback); // Wire meta wins; else the client-side expectation (older-shell fallback). // Taken at every viewer finalize so it can't go stale. let expected_send_now = agent.expect_send_now_cancel.take(); let send_now_cancel = match cancel_trigger { Some(trigger) => trigger == "send_now", None => expected_send_now.is_some(), }; // A viewer never receives the driver's `PromptResponse` RPC — the source of // the driver's "Worked for X" marker. Surface the equivalent here. // The signal only carries a coarse `stop_reason` (no doom-loop category, no // driver-local rate-limit / re-auth context), so map it to the closest event: let event = match stop_reason { // Send-now cancel: no marker (the sender's new prompt renders as the // next turn; neither cancelled nor a substitute completed). Some("cancelled") if send_now_cancel => None, Some("cancelled") => Some(SessionEvent::TurnCancelled { elapsed }), // Rate limits drive a dedicated UX on the driver and are not actionable // from a viewer — don't surface a stray "Turn failed" line. Some("rate_limit") => None, Some("error") => Some(SessionEvent::TurnFailed { error: agent_result .map(str::to_string) .unwrap_or_else(|| "unknown error".to_string()), elapsed: Some(elapsed), }), // end_turn / max_tokens / max_turn_requests / refusal / unknown → done. _ => Some(SessionEvent::TurnCompleted { elapsed: Some(elapsed), }), }; push_turn_terminal_marker(agent, event, ending_prompt_id.as_deref(), false); agent.mark_turn_finished(); TerminalApply::ViewerFinalized } /// Map a [`finalize_turn_from_terminal`] outcome to the redraw/tick bool that /// BOTH terminal rails (`prompt_complete` and the live `TurnCompleted`) RETURN /// DIRECTLY, applying the viewer-finalize side effect. Keeping this mapping in /// one place is load-bearing: the live `TurnCompleted` arm must return this /// instead of routing through `changed && is_active` (see below). /// /// - `Ignored` -> `false`. /// - `ReconcileArmed` -> `true` UNCONDITIONALLY (not gated on visibility). The /// lost-RPC reconcile sweep rides the animation tick, and the event loop only /// re-arms the tick when a batch reports a change. A background-tab driver /// (`is_active == false`) that armed the reconcile must still report the change /// or `reconcile_overdue_turn_ends` never fires and the turn strands on /// "Waiting…" — the exact bug this rail fixes. /// - `ViewerFinalized` -> `true` only when `is_active` (drop pending adoption). pub(super) fn apply_terminal_outcome( outcome: TerminalApply, app: &mut AppView, agent_id: AgentId, is_active: bool, ) -> bool { match outcome { TerminalApply::Ignored => false, TerminalApply::ReconcileArmed => true, TerminalApply::ViewerFinalized => { if let Some(p) = app.pending_running_adoptions.remove(&agent_id) && let Some(agent) = app.agents.get_mut(&agent_id) { agent.discard_pending_adoption_updates(&p.prompt_id); } is_active } } } #[cfg(test)] #[path = "turn_completion/tests.rs"] mod tests;