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.
231 lines
8.2 KiB
Rust
231 lines
8.2 KiB
Rust
//! Per-session pending-interaction registry.
|
|
//!
|
|
//! Permissions, `ask_user_question`, and plan approval are **blocking ACP
|
|
//! reverse-requests**: the agent parks a tool-loop future on an in-memory
|
|
//! oneshot and waits for the driver to answer. While such a request is open we
|
|
//! record it here, keyed by `tool_call_id` (stable, lives in the transcript →
|
|
//! survives reconnect). This registry is the single source of truth for "what
|
|
//! is pending right now" and is read by the roster to surface
|
|
//! [`crate::agent::roster::RosterActivity::NeedsInput`].
|
|
//!
|
|
//! Pending interactions are **requests, not notifications** — they are never
|
|
//! persisted. We broadcast `pending_interaction` / `interaction_resolved`
|
|
//! **fire-and-forget** via the gateway (same idiom as
|
|
//! [`crate::session::summary`]); the routing layer fans them to every
|
|
//! subscriber because they carry a `sessionId`.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use agent_client_protocol as acp;
|
|
use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender;
|
|
|
|
use crate::extensions::notification::{SessionNotification, SessionUpdate as XaiSessionUpdate};
|
|
|
|
/// Shared per-session map of open reverse-requests, keyed by `tool_call_id`.
|
|
///
|
|
/// Mirrors the `current_prompt_id` signal on
|
|
/// [`crate::session::handle::SessionHandle`]: the same `Arc` is shared between
|
|
/// the session actor (which mutates it) and the handle (which the roster reads
|
|
/// synchronously).
|
|
pub type PendingInteractions = Arc<Mutex<HashMap<String, PendingKind>>>;
|
|
|
|
/// Which kind of blocking reverse-request is pending.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum PendingKind {
|
|
/// `request_permission` for a tool action.
|
|
Permission,
|
|
/// `kigi/ask_user_question`.
|
|
Question,
|
|
/// `kigi/exit_plan_mode` plan approval.
|
|
PlanApproval,
|
|
}
|
|
|
|
/// Whether a blocking plan-approval reverse-request is parked in `pending`.
|
|
///
|
|
/// The resume re-park issues `kigi/exit_plan_mode` from a detached task
|
|
/// with no running turn, making it the one parked interaction that also carries a
|
|
/// persisted gate (`awaiting_plan_approval`). `session_has_live_work` consults
|
|
/// this to keep such a session resident until the decision is answered or a real
|
|
/// disconnect `Err`s the reverse-request — otherwise an idle-unload drops the
|
|
/// parked future and its guard clears the on-disk gate. Permission/question parks
|
|
/// carry no persisted gate, so they are intentionally not counted here. Poisoned
|
|
/// lock → recover the map (module idiom) and read it.
|
|
pub(crate) fn has_parked_plan_approval(pending: &PendingInteractions) -> bool {
|
|
pending
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.values()
|
|
.any(|k| *k == PendingKind::PlanApproval)
|
|
}
|
|
|
|
/// Fire-and-forget broadcast of a session notification carrying a `sessionId`
|
|
/// (so the routing layer fans it out to every subscriber). Never persisted.
|
|
fn broadcast(gateway: &GatewaySender, session_id: &acp::SessionId, update: XaiSessionUpdate) {
|
|
let notification = SessionNotification {
|
|
session_id: session_id.clone(),
|
|
update,
|
|
meta: None,
|
|
};
|
|
if let Ok(params) = serde_json::value::to_raw_value(¬ification) {
|
|
gateway.forward_fire_and_forget(acp::ExtNotification::new(
|
|
"kigi/session_notification",
|
|
params.into(),
|
|
));
|
|
}
|
|
}
|
|
|
|
/// RAII guard registering an open reverse-request for the lifetime of the
|
|
/// parked oneshot.
|
|
///
|
|
/// On construction it inserts `(tool_call_id, kind)` into the registry and
|
|
/// broadcasts `pending_interaction`. On drop — which happens whether the await
|
|
/// returns normally, is cancelled, or errors — it removes the entry and (if it
|
|
/// actually removed one) broadcasts `interaction_resolved`. The
|
|
/// remove-or-no-op makes resolution **idempotent / first-answer-wins**: a
|
|
/// second drop / already-removed key is silent.
|
|
pub struct PendingInteractionGuard {
|
|
pending: PendingInteractions,
|
|
gateway: GatewaySender,
|
|
session_id: acp::SessionId,
|
|
tool_call_id: String,
|
|
}
|
|
|
|
impl PendingInteractionGuard {
|
|
/// Register a pending interaction and broadcast `pending_interaction`.
|
|
pub fn new(
|
|
pending: PendingInteractions,
|
|
gateway: GatewaySender,
|
|
session_id: acp::SessionId,
|
|
tool_call_id: String,
|
|
kind: PendingKind,
|
|
) -> Self {
|
|
{
|
|
let mut map = pending.lock().unwrap_or_else(|e| e.into_inner());
|
|
map.insert(tool_call_id.clone(), kind);
|
|
}
|
|
broadcast(
|
|
&gateway,
|
|
&session_id,
|
|
XaiSessionUpdate::PendingInteraction {
|
|
tool_call_id: tool_call_id.clone(),
|
|
kind,
|
|
},
|
|
);
|
|
Self {
|
|
pending,
|
|
gateway,
|
|
session_id,
|
|
tool_call_id,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for PendingInteractionGuard {
|
|
fn drop(&mut self) {
|
|
let removed = {
|
|
let mut map = self.pending.lock().unwrap_or_else(|e| e.into_inner());
|
|
map.remove(&self.tool_call_id).is_some()
|
|
};
|
|
// First-answer-wins: only announce resolution if this guard actually
|
|
// owned the live entry. An already-resolved id is a silent no-op.
|
|
if removed {
|
|
broadcast(
|
|
&self.gateway,
|
|
&self.session_id,
|
|
XaiSessionUpdate::InteractionResolved {
|
|
tool_call_id: self.tool_call_id.clone(),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn new_registry() -> PendingInteractions {
|
|
Arc::new(Mutex::new(HashMap::new()))
|
|
}
|
|
|
|
#[test]
|
|
fn guard_inserts_then_removes() {
|
|
let reg = new_registry();
|
|
// No gateway round-trip is exercised here (broadcast is best-effort and
|
|
// a dead sender simply drops). We only assert the registry mutation.
|
|
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let gateway = GatewaySender::new(tx);
|
|
{
|
|
let _g = PendingInteractionGuard::new(
|
|
reg.clone(),
|
|
gateway,
|
|
acp::SessionId::new("sess-1"),
|
|
"call-1".to_string(),
|
|
PendingKind::Permission,
|
|
);
|
|
assert_eq!(reg.lock().unwrap().len(), 1);
|
|
assert_eq!(
|
|
reg.lock().unwrap().get("call-1").copied(),
|
|
Some(PendingKind::Permission)
|
|
);
|
|
}
|
|
assert!(reg.lock().unwrap().is_empty());
|
|
}
|
|
|
|
/// `has_parked_plan_approval` counts ONLY a parked plan-approval; other
|
|
/// kinds (permission / question) carry no persisted gate and must not, by
|
|
/// themselves, report the session live.
|
|
#[test]
|
|
fn has_parked_plan_approval_only_counts_plan_approval() {
|
|
let reg = new_registry();
|
|
assert!(!has_parked_plan_approval(®));
|
|
|
|
reg.lock()
|
|
.unwrap()
|
|
.insert("perm".to_string(), PendingKind::Permission);
|
|
reg.lock()
|
|
.unwrap()
|
|
.insert("q".to_string(), PendingKind::Question);
|
|
assert!(
|
|
!has_parked_plan_approval(®),
|
|
"permission/question parks must not count as a parked approval"
|
|
);
|
|
|
|
reg.lock()
|
|
.unwrap()
|
|
.insert("plan".to_string(), PendingKind::PlanApproval);
|
|
assert!(has_parked_plan_approval(®));
|
|
|
|
reg.lock().unwrap().remove("plan");
|
|
assert!(!has_parked_plan_approval(®));
|
|
}
|
|
|
|
/// A poisoned registry lock must not panic the predicate: it recovers the
|
|
/// inner map (module idiom) and reports the parked approval truthfully.
|
|
#[test]
|
|
fn has_parked_plan_approval_recovers_poisoned_lock() {
|
|
let reg = new_registry();
|
|
reg.lock()
|
|
.unwrap()
|
|
.insert("plan".to_string(), PendingKind::PlanApproval);
|
|
|
|
let reg_poison = reg.clone();
|
|
let _ = std::thread::spawn(move || {
|
|
let _g = reg_poison.lock().unwrap();
|
|
panic!("poison pending_interactions");
|
|
})
|
|
.join();
|
|
assert!(
|
|
reg.lock().is_err(),
|
|
"precondition: the lock must be poisoned"
|
|
);
|
|
|
|
assert!(
|
|
has_parked_plan_approval(®),
|
|
"a poisoned lock must still surface the parked approval, not panic"
|
|
);
|
|
}
|
|
}
|