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.
344 lines
14 KiB
Rust
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;
|