Files
Kigi-CLI/crates/codegen/kigi-tui/src/app/turn_completion.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

344 lines
14 KiB
Rust

//! 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<SessionEvent>,
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;