M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,261 @@
//! Code-navigation eligibility gating and codebase-index management for [`MvpAgent`].
//! Co-located child of `mvp_agent` (`use super::*`).
use super::*;
impl MvpAgent {
/// Parse the `x.ai/codeNavigation.enabled` capability from an initialize
/// request. Returns `false` if the field is absent or not `true`.
pub(crate) fn parse_code_nav_capability(init: &acp::InitializeRequest) -> bool {
init.client_capabilities
.meta
.as_ref()
.and_then(|m| m.get("x.ai/codeNavigation"))
.and_then(|v| v.get("enabled"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
/// Start (or reuse) the codebase index for an eligible code-nav request.
///
/// Returns `Some((handle, was_newly_started))` on success or `None` when
/// config/git-root checks prevent starting. The bool is the authoritative
/// "first spawn vs reuse" signal threaded up from `CodebaseIndexManager`.
///
/// This is the narrow `pub(crate)` entry point for lazy index startup
/// from `extensions/code_nav.rs`. Callers must verify eligibility with
/// [`code_nav_eligibility_for_request`] before calling this.
pub(crate) fn start_codebase_index_for_code_nav(
&self,
session_id: Option<&acp::SessionId>,
cwd: &std::path::Path,
) -> Option<(std::sync::Arc<kigi_codebase_graph::IndexManagerHandle>, bool)> {
let (handle, was_newly_started) = self.resolve_codebase_index(cwd)?;
// Pin the index to the requesting session so the Weak in
// CodebaseIndexManager doesn't orphan it immediately.
if let Some(sid) = session_id {
self.session_index_claims
.borrow_mut()
.insert(sid.clone(), std::sync::Arc::clone(&handle));
}
Some((handle, was_newly_started))
}
/// Core eligibility check — pure function that accepts explicit client
/// context rather than reading global agent state.
///
/// This is the single place that applies all four gates. Call it via
/// [`code_nav_eligibility_for_request`] (leader-mode safe) or
/// [`code_nav_eligibility`] (global state, non-leader use only).
pub(super) fn code_nav_eligibility_inner(
&self,
cwd: &std::path::Path,
client_type: ClientType,
code_nav_enabled: bool,
) -> Result<(), CodeNavEligibility> {
use crate::agent::config::CodebaseIndexingSetting;
// Gate 1: client type
if !matches!(client_type, ClientType::GrokWeb) {
tracing::info!(
client_type = ?client_type,
gate = "client_type",
skip_reason = "client_not_web",
"code-nav eligibility check: skipping (client type not eligible)"
);
return Err(CodeNavEligibility::ClientNotWeb);
}
// Gate 2: capability advertised
if !code_nav_enabled {
tracing::info!(
gate = "capability",
skip_reason = "capability_not_advertised",
"code-nav eligibility check: skipping (x.ai/codeNavigation.enabled not advertised)"
);
return Err(CodeNavEligibility::CapabilityNotAdvertised);
}
// Gate 3: config
let setting = self.cfg.borrow().features.codebase_indexing.clone();
if let CodebaseIndexingSetting::Enabled(false) = &setting {
tracing::info!(
gate = "config",
skip_reason = "disabled_by_config",
"code-nav eligibility check: skipping (codebase_indexing disabled in config)"
);
return Err(CodeNavEligibility::DisabledByConfig);
}
// Gate 4: git root / config globs
let git_root = kigi_workspace::session::git::find_git_root_from_path(cwd).ok();
match &setting {
CodebaseIndexingSetting::Enabled(true) => {
if git_root.is_none() {
tracing::info!(
cwd = %cwd.display(),
gate = "git_root",
skip_reason = "not_git_repo",
"code-nav eligibility check: skipping (not inside a git repo)"
);
return Err(CodeNavEligibility::NotGitRepo);
}
}
CodebaseIndexingSetting::Patterns(_) => {
let check_path = git_root.as_deref().unwrap_or(cwd);
if !setting.should_index(check_path) {
tracing::info!(
cwd = %cwd.display(),
gate = "config_globs",
skip_reason = "disabled_by_config",
"code-nav eligibility check: skipping (not matched by config globs)"
);
return Err(CodeNavEligibility::DisabledByConfig);
}
}
CodebaseIndexingSetting::Enabled(false) => {} // handled above
}
Ok(())
}
/// Check eligibility using per-session context (leader-mode safe).
///
/// When `session_id` is provided, reads the session's own client type
/// and code-nav capability — the values that were in effect when that
/// specific client created the session. This is correct in leader mode
/// where multiple clients share one agent process and `initialize()` is
/// called once per connection; the global fields on `MvpAgent` reflect
/// only the **last** client to call `initialize()`.
///
/// Falls back to global agent state when no session_id is given.
pub fn code_nav_eligibility_for_request(
&self,
session_id: Option<&acp::SessionId>,
cwd: &std::path::Path,
) -> Result<(), CodeNavEligibility> {
let session_id = match session_id {
Some(sid) => sid,
// No session_id: per-client capability cannot be determined without a
// session. Reject with SessionRequired rather than fall back to shared
// global state. Callers must provide sessionId for x.ai/code/* requests.
None => return Err(CodeNavEligibility::SessionRequired),
};
let sessions = self.sessions.borrow();
let (client_type, code_nav_enabled) = if let Some(handle) = sessions.get(session_id) {
let ct = crate::http::client_type_from_origin(handle.origin_client.as_ref());
(ct, handle.code_nav_enabled)
} else {
// Session not found (evicted/unknown): reject rather than silently
// falling back to shared global state — that would reintroduce the
// last-client-wins bug for stale session IDs in leader mode.
return Err(CodeNavEligibility::SessionRequired);
};
drop(sessions);
self.code_nav_eligibility_inner(cwd, client_type, code_nav_enabled)
}
/// Check eligibility using the stored initialize_request context.
///
/// **Not safe in leader mode** — reads the last `initialize()` call's
/// client_type and capability. Prefer [`code_nav_eligibility_for_request`]
/// when a session_id is available.
pub fn code_nav_eligibility(&self, cwd: &std::path::Path) -> Result<(), CodeNavEligibility> {
let client_type = *self.client_type.borrow();
let code_nav_enabled = self.code_nav_enabled.get();
self.code_nav_eligibility_inner(cwd, client_type, code_nav_enabled)
}
/// Resolve and get-or-create the codebase index for `cwd`, applying config
/// and git-root eligibility checks.
///
/// Returns `Some((handle, was_newly_started))` when an index is available,
/// `None` when config or git-root checks rule it out. The bool is the
/// authoritative "was this a first spawn?" signal from the manager.
pub(super) fn resolve_codebase_index(
&self,
cwd: &std::path::Path,
) -> Option<(std::sync::Arc<kigi_codebase_graph::IndexManagerHandle>, bool)> {
use crate::agent::config::CodebaseIndexingSetting;
let setting = self.cfg.borrow().features.codebase_indexing.clone();
let git_root = kigi_workspace::session::git::find_git_root_from_path(cwd).ok();
match (&setting, &git_root) {
(CodebaseIndexingSetting::Enabled(false), _) => {
tracing::info!(
cwd = %cwd.display(),
skip_reason = "disabled_by_config",
"code-nav: skipping index creation (disabled in config)"
);
return None;
}
(CodebaseIndexingSetting::Enabled(true), None) => {
tracing::info!(
cwd = %cwd.display(),
skip_reason = "not_git_repo",
"code-nav: skipping index creation (not inside a git repo)"
);
return None;
}
(CodebaseIndexingSetting::Patterns(_), _) => {
let check_path = git_root.as_deref().unwrap_or(cwd);
if !setting.should_index(check_path) {
tracing::info!(
cwd = %cwd.display(),
skip_reason = "disabled_by_config",
"code-nav: skipping index creation (not matched by config globs)"
);
return None;
}
}
(CodebaseIndexingSetting::Enabled(true), Some(_)) => {}
}
let target = git_root.unwrap_or_else(|| cwd.to_path_buf());
// get_or_create returns the authoritative (handle, was_newly_started) pair.
// Log only on actual first spawn so reuse requests are not misleadingly
// labelled as "starting".
let (handle, was_newly_started) = self.get_or_create_codebase_index(target.clone());
if was_newly_started {
tracing::info!(
cwd = %cwd.display(),
index_target = %target.display(),
event = "index_first_spawn",
"code-nav: first lazy spawn of codebase index"
);
}
Some((handle, was_newly_started))
}
pub(super) fn indexed_roots_for(&self, cwd: &std::path::Path) -> Vec<String> {
if self.get_codebase_index(cwd).is_some() {
return vec![cwd.to_string_lossy().into_owned()];
}
if let Ok(git_root) = kigi_workspace::session::git::find_git_root_from_path(cwd)
&& self.get_codebase_index(&git_root).is_some()
{
return vec![git_root.to_string_lossy().into_owned()];
}
Vec::new()
}
/// Returns `(handle, was_newly_started)` — the bool is the authoritative
/// "did this call spawn a new actor?" bit from `CodebaseIndexManager::get_or_create`.
pub(super) fn get_or_create_codebase_index(
&self,
cwd: PathBuf,
) -> (std::sync::Arc<kigi_codebase_graph::IndexManagerHandle>, bool) {
self.codebase_indexes.lock().get_or_create(cwd)
}
/// Get an existing codebase index for the given cwd.
/// Returns None if no index exists for this cwd.
pub fn get_codebase_index(
&self,
cwd: &std::path::Path,
) -> Option<std::sync::Arc<kigi_codebase_graph::IndexManagerHandle>> {
self.codebase_indexes.lock().get(cwd)
}
}
@@ -0,0 +1,453 @@
//! Interactive folder-trust prompt: a dormant agent→GUI-client ACP round-trip
//! (`x.ai/folder_trust/request`) that asks a GUI client (grok-desktop) to decide
//! trust for an untrusted-with-configs workspace, then grants + reloads the
//! now-trusted project servers without a restart.
//!
//! DORMANT in production: it only fires when the connected client advertised
//! `x.ai/folderTrust.interactive` AND the folder-trust feature flag is on AND the
//! verdict is [`kigi_workspace::folder_trust::TrustOutcome::Prompt`]. No
//! client advertises the capability until the desktop UI ships — so this is
//! inert by default even with the feature flag on. The TUI/headless clients never
//! advertise it (they self-gate trust client-side), so they are never
//! double-prompted. Co-located child of `mvp_agent` (`use super::*`).
//!
//! Post-grant reload scope: MCP, plugins, and each session's own project hooks
//! are hot-reloaded in place — for EVERY session sharing the granted workspace
//! (same `workspace_key`), each reloaded against its OWN cwd. Project LSP is NOT
//! hot-reloaded — the LSP backend is baked into the agent's tool bridge at build
//! time (one-shot startup coordinator, no in-place reconfigure API), so repo-local
//! `.kigi/lsp.json` servers start on the NEXT session open (the durable grant
//! makes the re-spawn trusted). `lsp` is still REPORTED in the prompt's
//! `configKinds` (it is a real reason the folder is gated) — only the post-grant
//! hot-reload skips it.
use super::*;
/// Max wait for a GUI client's trust decision before giving up (fail-closed).
/// Generous because it is a human decision, but bounds the detached task so a
/// connected-but-silent client (modal left open / client bug) can't leak it for
/// the whole connection lifetime.
const TRUST_PROMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30 * 60);
/// ACP `x.ai/folder_trust/request` payload (agent → GUI client). Serialized as
/// `camelCase` for the ACP JSON-RPC wire format.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct FolderTrustRequest {
/// The session this prompt belongs to. REQUIRED for leader Tier-2 routing:
/// non-interaction reverse-requests are delivered to the driver keyed on
/// `params.sessionId`; omitting it makes the leader silently drop the message,
/// so the prompt would never reach the client.
pub session_id: String,
/// The session cwd whose workspace is being gated.
pub cwd: String,
/// Display path of the canonical workspace key (the trust grant's scope).
pub workspace: String,
/// Detected repo-local config kinds (e.g. `mcp`, `hooks`, `lsp`) — the
/// reasons the folder is gated — for the prompt UI. Display-only, NOT the
/// trust gate; derived from the same scan as the gate. `lsp` may appear: it
/// is a real reason to prompt, but project LSP applies on the NEXT session
/// open rather than hot-reloading on grant (see module docs).
pub config_kinds: Vec<String>,
}
/// Outcome of the trust prompt (GUI client → agent). Fail-closed: any value
/// other than `"trust"` (including unknown strings, via `#[serde(other)]`)
/// decodes to [`FolderTrustOutcome::Reject`], so only an explicit grant unblocks.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum FolderTrustOutcome {
Trust,
#[serde(other)]
Reject,
}
/// ACP `x.ai/folder_trust/request` response (GUI client → agent).
#[derive(Debug, Clone, serde::Deserialize)]
pub(crate) struct FolderTrustResponse {
pub outcome: FolderTrustOutcome,
}
impl MvpAgent {
/// Parse the `x.ai/folderTrust.interactive` capability from an initialize
/// request. Returns `false` if absent or not `true`. Mirrors
/// [`Self::parse_code_nav_capability`].
pub(crate) fn parse_interactive_trust_capability(init: &acp::InitializeRequest) -> bool {
init.client_capabilities
.meta
.as_ref()
.and_then(|m| m.get("x.ai/folderTrust"))
.and_then(|v| v.get("interactive"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
/// Ask a GUI client to decide trust for `session_id`'s workspace, then grant
/// + reload on accept. DORMANT no-op unless the client advertised
/// `x.ai/folderTrust.interactive` AND [`folder_trust::prompt_warranted`]
/// (feature on + untrusted + repo configs present).
///
/// Non-blocking: the session was already created with project servers GATED
/// (the untrusted resolve in `new_session`/`load_session`), so nothing
/// repo-local spawns while the prompt is open. The round-trip + reload run in
/// a detached `spawn_local` task, so the `new_session` response is not
/// delayed by the (potentially long) user decision. At most one outstanding
/// request per workspace per process (dedup), and the await is bounded by
/// [`TRUST_PROMPT_TIMEOUT`].
pub(crate) fn maybe_spawn_interactive_trust_prompt(
&self,
session_id: &acp::SessionId,
cwd: &std::path::Path,
remote: Option<&crate::util::config::RemoteSettings>,
) {
if !self.interactive_trust_client.get() {
return;
}
if !folder_trust::prompt_warranted(cwd, remote) {
return;
}
let key = kigi_workspace::trust::workspace_key(cwd);
// Dedup: skip if this workspace was already prompted/decided (reconnect)
// or has a prompt in flight (concurrent same-workspace session). `insert`
// returns false when already present. Agent-owned set (no process
// global), captured into the task for release on failure/timeout.
let prompted = self.interactive_trust_prompted.clone();
if !prompted.borrow_mut().insert(key.clone()) {
return;
}
// Capture EVERY session sharing the GRANTED WORKSPACE (same
// `workspace_key` — the grant's actual scope, aligned with the dedup key),
// each with its OWN cwd, so a grant reloads every sibling against its own
// project config — exactly like the per-cwd `handle_reload_project_mcp_servers`
// / `broadcast_plugin_registry_to_sessions`. `&self` can't be borrowed
// across the `spawn_local` boundary, so capture owned clones now.
//
// INTENTIONAL fail-safe limitation: this is a one-time snapshot taken at
// prompt-spawn. A same-workspace session created WHILE the modal is open is
// deduped (no second prompt) and is not in this set, so the grant won't
// reload it — it stays GATED until its own next session (secure, never
// over-exposed). Re-querying at grant time would need the `sessions` map
// (a non-`Rc` `RefCell` field) shared into the detached task, which isn't
// available here; the fail-safe stale-session window is accepted instead.
let targets: Vec<ReloadTarget> = self
.sessions
.borrow()
.values()
.filter(|h| {
kigi_workspace::trust::workspace_key(std::path::Path::new(&h.info.cwd)) == key
})
.map(|h| ReloadTarget {
cmd_tx: h.cmd_tx.clone(),
initial_client_mcp_servers: h.initial_client_mcp_servers.clone(),
cwd: PathBuf::from(&h.info.cwd),
})
.collect();
if targets.is_empty() {
prompted.borrow_mut().remove(&key);
return;
}
let gateway = self.gateway.clone();
let plugin_handle = self.plugin_registry_handle.clone();
let managed_mcp_cache = self.managed_mcp_cache.clone();
let auth_manager = self.auth_manager.clone();
let can_fetch_managed = self.can_fetch_managed_mcps();
let proxy_url = self.cfg.borrow().endpoints.proxy_url();
let compat = self.cfg.borrow().compat_resolved;
let remote = remote.cloned();
let cwd = cwd.to_path_buf();
let workspace = key.display().to_string();
let config_kinds = folder_trust::detected_config_kinds(&cwd);
let session_id = session_id.0.to_string();
// Regression guard: every reverse-request must carry a
// non-empty sessionId or leader Tier-2 routing silently drops it.
debug_assert!(
!session_id.is_empty(),
"folder_trust reverse-request must carry a non-empty sessionId (design §5.4)"
);
tokio::task::spawn_local(async move {
let request = FolderTrustRequest {
session_id,
cwd: cwd.to_string_lossy().into_owned(),
workspace,
config_kinds,
};
// Non-panicking: a struct of String/Vec<String> can't fail to
// serialize, but avoid `expect` in prod — bail (and release the dedup
// key) on the impossible error rather than aborting the task thread.
let raw_params = match serde_json::value::to_raw_value(&request) {
Ok(p) => p,
Err(e) => {
tracing::error!(error = %e, "folder trust: request serialization failed");
prompted.borrow_mut().remove(&key);
return;
}
};
let ext_request = acp::ExtRequest::new("x.ai/folder_trust/request", raw_params.into());
use agent_client_protocol::Client as _;
let outcome = match tokio::time::timeout(
TRUST_PROMPT_TIMEOUT,
gateway.ext_method(ext_request),
)
.await
{
// A decodable response carries the user's decision. An
// undecodable success payload is a client/protocol error, not a
// decision: stay gated (fail-closed) but release the dedup key so
// a later session can re-prompt — same as transport/timeout below.
Ok(Ok(raw)) => match serde_json::from_str::<FolderTrustResponse>(raw.0.get()) {
Ok(r) => r.outcome,
Err(e) => {
tracing::debug!(error = %e, "folder trust: undecodable trust response; staying gated, releasing dedup key");
prompted.borrow_mut().remove(&key);
return;
}
},
Ok(Err(e)) => {
// Client disconnected / transport error: not a decision —
// release the key so a later session can re-prompt.
tracing::debug!(error = %e, "folder trust: client trust request failed");
prompted.borrow_mut().remove(&key);
return;
}
Err(_elapsed) => {
// Connected but silent past the deadline: stay gated, release
// the key so a future session may re-prompt.
tracing::info!(
cwd = %cwd.display(),
"folder trust: no client decision before timeout; staying gated"
);
prompted.borrow_mut().remove(&key);
return;
}
};
if outcome != FolderTrustOutcome::Trust {
// Decided "reject": keep the dedup key so the user is not
// re-prompted for this workspace on every reconnect.
tracing::info!(
cwd = %cwd.display(),
"folder trust: GUI client declined; workspace stays gated"
);
return;
}
// Re-check the dedup key before granting. `HooksAction::Untrust`
// removes this workspace's key (and revokes asynchronously) when the
// user untrusts. If that fired while the modal was open, the key is
// gone — honor the untrust and drop this now-stale "trust" rather
// than re-persisting a grant the user just revoked. The single-
// threaded LocalSet makes this check + grant atomic w.r.t. the
// untrust task (no await in between).
if !prompted.borrow().contains(&key) {
tracing::info!(
cwd = %cwd.display(),
"folder trust: workspace untrusted while prompt was open; ignoring stale grant"
);
return;
}
// Persist the grant, then flip the cached untrusted verdict to trusted
// (the `Some(false)` arm of `resolve_and_record` re-reads the store).
folder_trust::grant_folder_trust(&cwd);
folder_trust::resolve_and_record(&cwd, remote.as_ref(), false);
reload_project_servers_after_grant(ReloadAfterGrant {
gateway: &gateway,
targets,
plugin_handle: &plugin_handle,
managed_mcp_cache: &managed_mcp_cache,
auth_manager: &auth_manager,
can_fetch_managed,
proxy_url: &proxy_url,
compat: &compat,
prompt_cwd: &cwd,
})
.await;
tracing::info!(
cwd = %cwd.display(),
"folder trust: granted via GUI client; reloaded project servers"
);
});
}
}
/// One session to reload after a grant, with ITS OWN cwd (so the MCP merge +
/// plugin build use the session's own project config — matching the per-cwd
/// canonical reloaders, not the prompt's cwd).
struct ReloadTarget {
cmd_tx: tokio::sync::mpsc::UnboundedSender<crate::session::SessionCommand>,
initial_client_mcp_servers: Vec<acp::McpServer>,
cwd: PathBuf,
}
/// Inputs for [`reload_project_servers_after_grant`], bundled to keep the
/// orchestrator free of a long positional arg list.
struct ReloadAfterGrant<'a> {
gateway: &'a GatewaySender,
/// Every session sharing the granted workspace, each with its own cwd.
targets: Vec<ReloadTarget>,
plugin_handle: &'a kigi_agent::plugins::SharedPluginRegistryHandle,
managed_mcp_cache: &'a crate::session::managed_mcp::ManagedMcpStateHandle,
auth_manager: &'a std::sync::Arc<AuthManager>,
can_fetch_managed: bool,
proxy_url: &'a str,
compat: &'a kigi_tools::types::CompatConfig,
/// The prompting session's cwd — used only for the client catalog push.
prompt_cwd: &'a std::path::Path,
}
/// Reload each granted-workspace session's now-trusted project servers in place
/// (no restart), driving the canonical primitives the normal spawn/reload paths
/// use — PER SESSION CWD, like `handle_reload_project_mcp_servers` /
/// `broadcast_plugin_registry_to_sessions`: `fetch_managed_mcp_configs` +
/// `merge_managed_mcp_servers` (`SessionCommand::UpdateMcpServers`), `build_for_cwd`
/// (`SessionCommand::ReloadPlugins`), and `reload_hooks_impl`
/// (`SessionCommand::ReloadHooks`), then push the refreshed MCP catalog. LSP is
/// spawn-baked and applies on the next session open (see module docs). Caller
/// must have granted + recorded trust first.
async fn reload_project_servers_after_grant(ctx: ReloadAfterGrant<'_>) {
// Managed (gateway/Toolbox) servers must survive the re-merge; fetch them once
// (cwd-independent) via the shared helper (single-sources the auth-key dance
// with `MvpAgent::get_managed_mcp_configs`). The plugin MCP snapshot is also
// global, so it is fine to reuse across cwds for the merge.
let managed = if ctx.can_fetch_managed {
crate::session::managed_mcp::fetch_managed_mcp_configs(
ctx.managed_mcp_cache,
ctx.proxy_url,
ctx.auth_manager,
)
.await
} else {
vec![]
};
let plugin_snapshot = ctx.plugin_handle.snapshot();
for target in ctx.targets {
// Per-session cwd: a sibling session in a subdir of the granted workspace
// must get ITS OWN project config, not the prompt's.
let session_cwd = target.cwd.as_path();
// MCP: `merge_managed_mcp_servers` re-reads disk + runs
// `filter_untrusted_project_mcp`, which now KEEPS project servers because
// the cached verdict was flipped to trusted (same workspace key).
let merged = crate::session::managed_mcp::merge_managed_mcp_servers(
target.initial_client_mcp_servers,
session_cwd,
&managed,
plugin_snapshot.as_deref(),
ctx.compat,
);
let (tx, _rx) = tokio::sync::oneshot::channel();
let _ = target
.cmd_tx
.send(crate::session::SessionCommand::UpdateMcpServers {
mcp_servers: merged,
respond_to: tx,
});
// Plugins (+ plugin-contributed hooks) built for this session's own cwd
// on the folder-trust verdict (mirrors `broadcast_plugin_registry_to_sessions`);
// the grant + resolve_and_record above flipped the cached verdict to trusted.
let disk_cfg =
crate::config::resolve_effective_plugins_config(session_cwd).to_discovery_config();
let project_trusted = folder_trust::project_scope_allowed(session_cwd);
// Session `_meta.pluginDirs` are re-merged by the receiving actor
// (`preserve_session_plugin_dirs` on `ReloadPlugins`).
let registry =
ctx.plugin_handle
.build_for_cwd(session_cwd, &disk_cfg, &[], project_trusted);
let _ = target
.cmd_tx
.send(crate::session::SessionCommand::ReloadPlugins { registry });
// The session's OWN project hooks (`.kigi/hooks`, `.cursor/hooks.json`),
// which `ReloadPlugins` does NOT touch — re-discovered against the actor's
// own `session_info.cwd` on the now-trusted verdict by `reload_hooks_impl`.
let _ = target
.cmd_tx
.send(crate::session::SessionCommand::ReloadHooks);
}
// Push the refreshed MCP catalog (for the prompting session's cwd) so the
// client UI reflects the now-trusted repo-local servers.
let local = folder_trust::filter_untrusted_project_mcp(
ctx.prompt_cwd,
crate::util::config::load_mcp_servers(ctx.prompt_cwd, ctx.compat),
);
crate::extensions::mcp::notify_servers_updated(ctx.gateway, &managed, &local).await;
}
#[cfg(test)]
mod tests {
use super::*;
fn init_with_meta(meta: Option<serde_json::Value>) -> acp::InitializeRequest {
// Production reads `client_capabilities.meta`, not top-level request meta.
let mut caps = acp::ClientCapabilities::new()
.fs(acp::FileSystemCapabilities::new())
.terminal(false);
if let Some(m) = meta
&& let Some(map) = m.as_object().cloned()
{
caps = caps.meta(map);
}
acp::InitializeRequest::new(acp::ProtocolVersion::V1).client_capabilities(caps)
}
#[test]
fn parse_interactive_trust_capability_present_and_true() {
let mut meta = serde_json::Map::new();
meta.insert(
"x.ai/folderTrust".to_string(),
serde_json::json!({ "interactive": true }),
);
let init = init_with_meta(Some(serde_json::Value::Object(meta)));
assert!(MvpAgent::parse_interactive_trust_capability(&init));
}
#[test]
fn parse_interactive_trust_capability_absent_returns_false() {
let init = init_with_meta(None);
assert!(!MvpAgent::parse_interactive_trust_capability(&init));
}
#[test]
fn parse_interactive_trust_capability_false_returns_false() {
let mut meta = serde_json::Map::new();
meta.insert(
"x.ai/folderTrust".to_string(),
serde_json::json!({ "interactive": false }),
);
let init = init_with_meta(Some(serde_json::Value::Object(meta)));
assert!(!MvpAgent::parse_interactive_trust_capability(&init));
}
#[test]
fn request_serializes_camel_case_with_session_id() {
let req = FolderTrustRequest {
session_id: "sess-1".into(),
cwd: "/repo".into(),
workspace: "/repo".into(),
config_kinds: vec!["mcp".into()],
};
let json = serde_json::to_value(&req).unwrap();
assert!(json.get("configKinds").is_some());
assert!(json.get("config_kinds").is_none());
// Leader Tier-2 routing reads `params.sessionId`; it must be present and
// non-empty (regression guard for the silently-dropped-in-leader bug).
assert_eq!(json["sessionId"], "sess-1");
assert!(!json["sessionId"].as_str().unwrap().is_empty());
}
#[test]
fn response_decodes_trust_reject_and_unknown_fail_closed() {
let trust: FolderTrustResponse = serde_json::from_str(r#"{"outcome":"trust"}"#).unwrap();
assert_eq!(trust.outcome, FolderTrustOutcome::Trust);
let reject: FolderTrustResponse = serde_json::from_str(r#"{"outcome":"reject"}"#).unwrap();
assert_eq!(reject.outcome, FolderTrustOutcome::Reject);
// Unknown outcome must fail closed to Reject (never silently "trust").
let unknown: FolderTrustResponse = serde_json::from_str(r#"{"outcome":"banana"}"#).unwrap();
assert_eq!(unknown.outcome, FolderTrustOutcome::Reject);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,147 @@
use super::{PromptResponseMetaArgs, build_prompt_response_meta};
use kigi_sampling_types::TokenUsage;
/// Baseline args with no usage, cancellation, or structured output.
fn args<'a>(
session_id: &'a str,
prompt_id: &'a str,
total_tokens: u64,
model_id: &'a str,
) -> PromptResponseMetaArgs<'a> {
PromptResponseMetaArgs {
session_id,
prompt_id,
total_tokens,
model_id,
last_turn_usage: None,
prompt_usage: None,
cancellation_category: None,
cancel_trigger: None,
structured_output: None,
}
}
#[test]
fn includes_baseline_keys_without_usage() {
let meta = build_prompt_response_meta(args("sess-1", "prompt-1", 42_000, "grok-4.5"));
assert_eq!(meta["sessionId"], "sess-1");
assert_eq!(meta["requestId"], "prompt-1");
assert_eq!(meta["promptId"], "prompt-1");
assert_eq!(meta["totalTokens"], 42_000);
assert_eq!(meta["modelId"], "grok-4.5");
// No per-turn keys when usage is absent.
assert!(meta.get("inputTokens").is_none());
assert!(meta.get("outputTokens").is_none());
assert!(meta.get("cachedReadTokens").is_none());
}
#[test]
fn enriches_meta_with_camelcase_token_keys() {
let usage = TokenUsage {
prompt_tokens: 1500,
completion_tokens: 200,
total_tokens: 1700,
reasoning_tokens: 75,
cached_prompt_tokens: 1000,
};
let meta = build_prompt_response_meta(PromptResponseMetaArgs {
last_turn_usage: Some(&usage),
..args("sess-1", "prompt-1", 1_700, "grok-4.5")
});
// Bot's _META_TOKEN_KEY_MAP expects exactly these camelCase keys.
assert_eq!(meta["inputTokens"], 1500);
assert_eq!(meta["outputTokens"], 200);
assert_eq!(meta["cachedReadTokens"], 1000);
// Reasoning tokens carried through for diagnostic visibility.
assert_eq!(meta["reasoningTokens"], 75);
}
#[test]
fn preserves_zero_token_values() {
// Responses API hits with no cache return cached_prompt_tokens=0.
// The key is still emitted as 0 so the bot can distinguish "no cache
// hit" from "no usage data". (The bot's _merge_meta_usage requires
// the key to be present and integer-typed.)
let usage = TokenUsage {
prompt_tokens: 100,
completion_tokens: 10,
total_tokens: 110,
reasoning_tokens: 0,
cached_prompt_tokens: 0,
};
let meta = build_prompt_response_meta(PromptResponseMetaArgs {
last_turn_usage: Some(&usage),
..args("s", "p", 110, "m")
});
assert_eq!(meta["cachedReadTokens"], 0);
assert_eq!(meta["reasoningTokens"], 0);
}
#[test]
fn usage_object_lands_on_meta() {
let mut ledger = kigi_chat_state::UsageLedger::default();
ledger.record_main_loop_call(
"m",
&TokenUsage {
prompt_tokens: 100,
completion_tokens: 10,
total_tokens: 999_999,
reasoning_tokens: 0,
cached_prompt_tokens: 0,
},
None,
None,
);
let meta = build_prompt_response_meta(PromptResponseMetaArgs {
prompt_usage: Some(crate::extensions::notification::PromptUsage::from(&ledger)),
..args("s", "p", 110, "m")
});
assert_eq!(meta["usage"]["totalTokens"], 110);
assert_eq!(meta["usage"]["modelUsage"]["m"]["inputTokens"], 100);
assert!(
build_prompt_response_meta(args("s", "p", 0, "m"))
.get("usage")
.is_none()
);
}
#[test]
fn cancel_trigger_lands_as_camelcase_meta_key() {
// A send-now cancelled turn's PromptResponse `_meta` carries `cancelTrigger: "send_now"`.
let meta = build_prompt_response_meta(PromptResponseMetaArgs {
cancel_trigger: Some("send_now".to_string()),
..args("s", "p", 0, "m")
});
assert_eq!(meta["cancelTrigger"], "send_now");
// Absent for non-cancel completions — the key must not appear.
let none = build_prompt_response_meta(args("s", "p", 0, "m"));
assert!(none.get("cancelTrigger").is_none());
}
#[test]
fn structured_output_maps_to_camelcase_meta_keys() {
// Success carries the validated value under `structuredOutput`; no error key.
let ok = build_prompt_response_meta(PromptResponseMetaArgs {
structured_output: Some(Ok(serde_json::json!({"name": "ada"}))),
..args("s", "p", 0, "m")
});
assert_eq!(ok["structuredOutput"]["name"], "ada");
assert!(ok.get("structuredOutputError").is_none());
// Failure carries the message under `structuredOutputError`; no value key.
let err = build_prompt_response_meta(PromptResponseMetaArgs {
structured_output: Some(Err("output does not match the required schema".to_string())),
..args("s", "p", 0, "m")
});
assert_eq!(
err["structuredOutputError"],
"output does not match the required schema"
);
assert!(err.get("structuredOutput").is_none());
// No schema requested → neither key present.
let none = build_prompt_response_meta(args("s", "p", 0, "m"));
assert!(none.get("structuredOutput").is_none());
assert!(none.get("structuredOutputError").is_none());
}
@@ -0,0 +1,406 @@
//! Session lifecycle, roster deltas, and the idle-session supervisor for [`MvpAgent`].
//! Co-located `#[path]`-style child of `mvp_agent` (`use super::*`) so the `impl`
//! block keeps access to `MvpAgent`'s private fields.
use super::*;
impl MvpAgent {
/// Ask a live session actor to shut down.
pub(crate) fn request_session_shutdown(&self, id: &acp::SessionId) {
if let Some(handle) = self.sessions.borrow().get(id) {
let _ = handle.cmd_tx.send(SessionCommand::Shutdown);
}
}
/// Finalize the cloud session replica (fire-and-forget, "Hook 4").
///
/// Marks the session **done** upstream, so this MUST only run on a genuine
/// session end — a terminal/explicit close (`x.ai/session/close`). It must
/// NOT run on a mere client disconnect or a dead-actor reap: those leave the
/// conversation resumable on disk, and finalizing would wrongly mark a still
/// running/resumable session "done".
pub(super) fn finalize_session_replica(&self, id: &acp::SessionId) {
#[cfg(test)]
self.finalize_spy.borrow_mut().push(id.0.to_string());
if let Some(client) = self.session_registry_client() {
let sid = id.0.to_string();
tokio::spawn(async move {
if let Err(e) = client.finalize(&sid).await {
tracing::warn!(
error = % e, "session registry finalize failed (non-fatal)"
);
}
});
}
}
/// Remove a session and its thread handle **without** finalizing the cloud
/// replica.
///
/// Used for dead-actor reaping and idle-unload: the conversation stays
/// resumable on disk, so it must NOT be marked "done" upstream. Genuine
/// terminal closes go through [`MvpAgent::close_session_explicit`]. Also
/// drops the `session_live_state` entry so that map stays bounded.
pub(crate) fn remove_session(&self, id: &acp::SessionId) {
self.sessions.borrow_mut().remove(id);
self.prompt_intake_locks.borrow_mut().remove(id);
self.session_threads.borrow_mut().remove(id);
self.session_index_claims.borrow_mut().remove(id);
self.require_gateway_sessions.borrow_mut().remove(id);
self.session_live_state.borrow_mut().remove(id);
}
/// Get-or-create the per-session prompt-intake lock (see
/// [`Self::prompt_intake_locks`]). Cheap clone of the shared `Rc`.
pub(super) fn prompt_intake_lock(
&self,
id: &acp::SessionId,
) -> std::rc::Rc<tokio::sync::Mutex<()>> {
self.prompt_intake_locks
.borrow_mut()
.entry(id.clone())
.or_default()
.clone()
}
/// Close a session in response to an **explicit** terminal close
/// (`x.ai/session/close`). Finalizes the cloud replica (genuine session
/// end), then removes the session terminally as `Completed`.
pub(crate) fn close_session_explicit(&self, id: &acp::SessionId) {
self.finalize_session_replica(id);
self.remove_session_terminal(id, SessionLiveState::Completed);
}
/// Record the coarse lifecycle state for a session.
pub(super) fn set_session_live_state(&self, id: &acp::SessionId, state: SessionLiveState) {
self.session_live_state
.borrow_mut()
.insert(id.clone(), state);
}
/// Read the recorded lifecycle state for a session (test observability).
#[cfg(test)]
pub(super) fn session_live_state_for(&self, id: &acp::SessionId) -> Option<SessionLiveState> {
self.session_live_state.borrow().get(id).copied()
}
/// Roster-delta hook for a terminally removed session. Broadcasts an
/// `x.ai/sessions/changed` notification with the session in `removed` so
/// every attached dashboard drops the row promptly. Also
/// records the call site (and the terminal state) for test observability,
/// since the `session_live_state` entry is dropped on removal.
pub(super) fn record_roster_delta(&self, id: &acp::SessionId, final_state: SessionLiveState) {
#[cfg(test)]
self.roster_delta_spy
.borrow_mut()
.push((id.0.to_string(), final_state));
tracing::debug!(
session_id = % id.0, ? final_state, "roster delta: session removed"
);
self.emit_roster_changed(Vec::new(), vec![id.0.to_string()]);
}
/// Roster-delta hook for a newly-resident / changed session. Broadcasts an
/// `x.ai/sessions/changed` notification with the current entry in
/// `upserted` so dashboards add/refresh the row.
pub(crate) fn push_roster_delta_upserted(&self, id: &acp::SessionId) {
if let Some(entry) = self.resident_roster_entry(id) {
self.emit_roster_changed(vec![entry], Vec::new());
}
}
/// Emit an `x.ai/sessions/changed` upsert for a resident session with an
/// explicit `activity`, so every attached dashboard reflects a
/// turn-boundary transition (Working / Idle / NeedsInput) *immediately*
/// rather than waiting for the ≤1s roster poll (deltas are emitted
/// at turn-start/turn-end). Without this, a viewer client that holds no
/// local `AgentView` for the session only learns its activity from the
/// poll, so a turn driven by another client shows as `Idle` for up to a
/// poll interval — and not at all while that viewer's poll is dormant.
///
/// The `activity` is supplied by the caller rather than read from
/// `resident_activity` because at turn-start the actor may not have
/// published `current_prompt_id` yet (it is set asynchronously once the
/// actor dequeues the `SessionCommand::Prompt`), so a natural read would
/// still observe `Idle`. The authoritative entry (cwd / worktree / model /
/// yolo) is built by `resident_roster_entry`, so it never diverges from
/// the polled entry; only the `activity` field is overridden.
pub(super) fn push_roster_activity_delta(
&self,
id: &acp::SessionId,
activity: crate::agent::roster::RosterActivity,
) {
if let Some(mut entry) = self.resident_roster_entry(id) {
entry.activity = activity;
self.emit_roster_changed(vec![entry], Vec::new());
}
}
/// Fan an `x.ai/sessions/changed` delta out to every attached client.
///
/// This is a roster-wide notification (no `sessionId`), so the leader IPC
/// server broadcasts it to all clients rather than routing by session (see
/// the `x.ai/sessions/changed` special-case in `leader/server.rs`).
pub(super) fn emit_roster_changed(
&self,
upserted: Vec<crate::agent::roster::RosterEntry>,
removed: Vec<String>,
) {
if upserted.is_empty() && removed.is_empty() {
return;
}
let payload = crate::agent::roster::RosterChanged { upserted, removed };
if let Ok(params) = serde_json::value::to_raw_value(&payload) {
self.gateway
.forward_fire_and_forget(acp::ExtNotification::new(
crate::agent::roster::SESSIONS_CHANGED_METHOD,
params.into(),
));
}
}
/// Coarse activity of a resident session for the dashboard status column.
///
/// Precedence: a non-empty pending-interaction map →
/// `NeedsInput` (wins even over a running turn — a session awaiting a
/// permission *mid-turn* is "needs input"); else a running turn →
/// `Working`; else map the coarse `SessionLiveState`.
pub(super) fn resident_activity(
&self,
id: &acp::SessionId,
) -> crate::agent::roster::RosterActivity {
use crate::agent::roster::RosterActivity;
let (needs_input, turn_running) = self
.sessions
.borrow()
.get(id)
.map(|h| {
let needs_input = h
.pending_interactions
.lock()
.map(|g| !g.is_empty())
.unwrap_or(false);
let turn_running = h
.current_prompt_id
.lock()
.map(|g| g.is_some())
.unwrap_or(false);
(needs_input, turn_running)
})
.unwrap_or((false, false));
if needs_input {
return RosterActivity::NeedsInput;
}
if turn_running {
return RosterActivity::Working;
}
match self.session_live_state.borrow().get(id).copied() {
Some(SessionLiveState::Completed) => RosterActivity::Completed,
Some(SessionLiveState::DeadFailed) => RosterActivity::Dead,
Some(SessionLiveState::Dormant) => RosterActivity::Dormant,
_ => RosterActivity::Idle,
}
}
/// Build a single roster entry for a resident session, or `None` if it is
/// not currently resident.
pub(super) fn resident_roster_entry(
&self,
id: &acp::SessionId,
) -> Option<crate::agent::roster::RosterEntry> {
let session_id = id.0.to_string();
let (cwd, is_worktree, model_id, reasoning_effort, yolo) = {
let sessions = self.sessions.borrow();
let h = sessions.get(id)?;
(
h.display_cwd.clone().unwrap_or_else(|| h.info.cwd.clone()),
h.display_cwd.is_some(),
Some(h.model_id.0.to_string()),
h.reasoning_effort,
h.yolo_mode,
)
};
Some(crate::agent::roster::RosterEntry {
title: self
.resident_roster_titles
.borrow()
.get(&session_id)
.cloned(),
session_id,
cwd,
is_worktree,
model_id,
reasoning_effort,
yolo,
activity: self.resident_activity(id),
resident: true,
last_change_unix_ms: chrono::Utc::now().timestamp_millis(),
origin: crate::agent::roster::RosterOrigin::Local,
})
}
/// Snapshot all resident sessions as roster entries (synchronous; no disk).
pub(super) fn resident_roster_entries(&self) -> Vec<crate::agent::roster::RosterEntry> {
let ids: Vec<acp::SessionId> = self.sessions.borrow().keys().cloned().collect();
ids.iter()
.filter_map(|id| self.resident_roster_entry(id))
.collect()
}
/// Build the full roster: resident actors plus recently-touched on-disk
/// (`Dormant`) sessions. Resident wins on an id collision; hidden sessions
/// are excluded.
pub(crate) async fn build_roster(&self) -> Vec<crate::agent::roster::RosterEntry> {
let resident = self.resident_roster_entries();
let summaries = crate::session::persistence::list_recent_summaries(200)
.await
.unwrap_or_default();
let entries = crate::agent::roster::merge_roster(resident, summaries);
self.cache_resident_titles(&entries);
entries
}
/// Refresh `resident_roster_titles` from the freshly-built roster.
pub(super) fn cache_resident_titles(&self, entries: &[crate::agent::roster::RosterEntry]) {
*self.resident_roster_titles.borrow_mut() = entries
.iter()
.filter(|e| e.resident)
.filter_map(|e| Some((e.session_id.clone(), e.title.clone()?)))
.collect();
}
/// Terminally remove a session: emit the roster delta with its final state,
/// then drop it from all maps (no finalize — callers that need finalize do
/// it first, see `close_session_explicit`).
pub(super) fn remove_session_terminal(
&self,
id: &acp::SessionId,
final_state: SessionLiveState,
) {
self.record_roster_delta(id, final_state);
self.remove_session(id);
}
/// Reap a session whose **resident** actor thread exited unexpectedly
/// (panic / load failure). Demotes it to `DeadFailed`, emits the roster
/// delta, and removes it WITHOUT finalize — the conversation persists on
/// disk and stays resumable (reaping a dead actor is harmless;
/// it demotes to Dormant).
pub(super) fn reap_dead_session(&self, id: &acp::SessionId) {
self.remove_session_terminal(id, SessionLiveState::DeadFailed);
}
/// Sweep `session_threads` for finished threads and clean them up.
///
/// A finished thread has two distinct meanings, and conflating them
/// corrupts the `SessionLiveState` roster source:
///
/// - **Still resident in `sessions`** → the actor exited unexpectedly while
/// the session was hosted (panic / load failure). Reap as `DeadFailed`.
/// - **Not resident** (already idle-unloaded → `Dormant`, or explicitly
/// closed) → this is the *expected* clean exit. The `SessionThread` was
/// kept only so `drain_old_session_thread` could wait on it; now that it
/// has finished there is nothing left to drain, so just drop the leftover
/// `SessionThread`/state entries. Do **not** demote to `DeadFailed` and do
/// **not** emit a second roster delta.
///
/// `JoinHandle::is_finished()` is non-blocking and cannot distinguish a
/// clean exit from a panic on its own, which is exactly why the residency
/// check is required. Runs both opportunistically and from the join-handle
/// supervisor (`ensure_session_supervisor`).
pub(super) fn sweep_dead_sessions(&self) {
let dead: Vec<acp::SessionId> = self
.session_threads
.borrow()
.iter()
.filter(|(_, t)| t.is_finished())
.map(|(id, _)| id.clone())
.collect();
for id in dead {
if self.sessions.borrow().contains_key(&id) {
tracing::warn!(
session_id = % id.0,
"Resident session actor exited unexpectedly; reaping as DeadFailed"
);
self.reap_dead_session(&id);
} else {
self.session_threads.borrow_mut().remove(&id);
self.session_live_state.borrow_mut().remove(&id);
tracing::debug!(
session_id = % id.0,
"Reaped finished thread for non-resident session (clean exit)"
);
}
}
}
/// Start the join-handle supervisor. **Idempotent.**
///
/// A single `spawn_local` task periodically reaps actor threads that have
/// exited (panicked or finished) so a dead actor never lingers as a roster
/// zombie. `std::thread::JoinHandle` is not awaitable, so we poll
/// `is_finished()` on a tick — the same mechanism `drain_old_session_thread`
/// and `sweep_dead_sessions` already use. A panicked actor is therefore
/// reaped within one [`SESSION_SUPERVISOR_TICK`].
///
/// The sweep body is wrapped in `catch_unwind` so a single panicking sweep
/// can never terminate the loop (which would silently disable reaping for
/// the rest of the process). The task holds a `LocalRef` (raw pointer) to
/// `self` for the lifetime of the `LocalSet`; this is sound because the
/// agent owns the `LocalSet` and outlives it (same contract as
/// `start_subagent_coordinator`), and `LocalRef` is `!Send`.
pub(super) fn ensure_session_supervisor(&self) {
if self.supervisor_started.replace(true) {
return;
}
#[cfg(test)]
self.supervisor_spawn_count
.set(self.supervisor_spawn_count.get() + 1);
let agent_ref = LocalRef::new(self);
tokio::task::spawn_local(async move {
loop {
tokio::time::sleep(SESSION_SUPERVISOR_TICK).await;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
agent_ref.get().sweep_dead_sessions();
}));
if result.is_err() {
tracing::error!("session supervisor sweep panicked; continuing supervision");
}
}
});
}
/// Coarse "any work pending" check for the idle-unload stub.
/// Returns `true` while the session has work in flight.
///
/// Three layers:
/// 1. **Fast path (sync):** the shared `current_prompt_id` slot, which the
/// actor sets while a turn is running (`maybe_start_running_task`) and
/// clears via its RAII guard. A poisoned lock is treated as busy → never
/// unload.
/// 1b. **Parked plan-approval (sync):** the shared `pending_interactions`
/// slot. The parked plan-approval resume re-park is the one outstanding work with no
/// running turn, so it needs its own sync check (the same shared-`Arc`
/// idiom as `current_prompt_id`) rather than the async round-trip below.
/// 2. **Queue check (async):** when no turn is running, the actor is between
/// turns and responsive, so we ask it whether `pending_inputs` is
/// non-empty (a prompt queued at the turn boundary). This closes the
/// sub-tick window where `current_prompt_id` is momentarily `None` but a
/// queued input is about to be drained. On timeout we keep the session
/// resident (conservative).
///
/// TODO(PR-4): once the aggregate `SessionActivity` signal exists, also
/// consult the autonomous background sources so a detached session is never
/// idle-unloaded (→ `Shutdown` → `KillOnDrop`) while they are live:
/// `monitor_event_buffer`, pending scheduler fires,
/// `ToolContext.background_tasks`, and background subagent sessions. Until
/// then those background-only sessions rely on the keep-resident default and
/// the `current_prompt_id` auto-wake turn being active.
///
/// TODO(PR-4): this is also inherently a *check-then-act* across the
/// actor-thread boundary — work can arrive (a new `Prompt`/auto-wake) in the
/// gap between this `IsBusy` answer and the caller's subsequent `Shutdown`,
/// so an idle-unload can still race a just-arrived turn. The actor processes
/// its mailbox in order, so the lost work is bounded and recoverable on
/// reload; PR-4 closes the gap properly by gating the unload inside the
/// actor (a single `Unload`-if-idle command) rather than check-then-send.
pub(super) async fn session_has_live_work(&self, id: &acp::SessionId) -> bool {
let Some(handle) = self.sessions.borrow().get(id).cloned() else {
return false;
};
let turn_running = handle
.current_prompt_id
.lock()
.map(|g| g.is_some())
.unwrap_or(true);
if turn_running {
return true;
}
if crate::session::pending_interaction::has_parked_plan_approval(
&handle.pending_interactions,
) {
return true;
}
tokio::time::timeout(IDLE_QUERY_TIMEOUT, handle.is_busy())
.await
.unwrap_or(true)
}
}
@@ -0,0 +1,552 @@
//! Subagent coordinator drain task and spawn-context construction for [`MvpAgent`].
//! Co-located child of `mvp_agent` (`use super::*`); tested by `tests/subagent_spawn_context_tests.rs`.
use super::*;
impl MvpAgent {
/// Start the subagent coordinator drain task.
///
/// Takes the `subagent_event_rx` receiver (once) and spawns a `spawn_local` task
/// that receives `SubagentRequest`s and delegates each to
/// `handle_subagent_request()` on its own `spawn_local` task.
///
/// Uses `LocalRef` to reference `self` from
/// `spawn_local` closures. Idempotent: subsequent calls are no-ops.
pub(super) fn start_subagent_coordinator(&self) {
let Some(mut rx) = self.subagent_event_rx.borrow_mut().take() else {
return;
};
let agent_ref = LocalRef::new(self);
use crate::agent::subagent::{BlockWaitSlot, is_running, resolve_snapshot};
use kigi_tools::implementations::grok_build::task::types::{
SubagentCancelOutcome, SubagentCancelTarget, SubagentEvent,
};
tokio::task::spawn_local({
let agent_ref = agent_ref.clone();
async move {
while let Some(event) = rx.recv().await {
match event {
SubagentEvent::Spawn(boxed) => {
let request = *boxed;
let agent_ref = agent_ref.clone();
tokio::task::spawn_local(async move {
let this = agent_ref.get();
let parent_sid = request.parent_session_id.clone();
let mut ctx = this.build_subagent_spawn_context(&parent_sid);
let parent_handle = {
let parent_sid_acp = acp::SessionId::new(parent_sid.clone());
this.sessions.borrow().get(&parent_sid_acp).cloned()
};
if let Some(handle) = parent_handle {
ctx.parent_mcp_pool = handle.snapshot_mcp_pool().await;
ctx.client_hooks = handle.snapshot_client_hooks().await;
let parent_tools = handle.snapshot_tool_definitions().await;
ctx.parent_tool_snapshot =
(!parent_tools.is_empty()).then_some(parent_tools);
}
crate::agent::subagent::handle_subagent_request(
request,
ctx,
&this.subagent_coordinator,
&this.gateway,
)
.await;
});
}
SubagentEvent::Query(query) => {
let agent_ref = agent_ref.clone();
tokio::task::spawn_local(async move {
let subagent_id = query.subagent_id;
let block = query.block;
let timeout_ms = query.timeout_ms;
let slot: BlockWaitSlot = std::rc::Rc::new(
std::cell::RefCell::new(Some(query.respond_to)),
);
let send_via_slot =
|slot: &BlockWaitSlot, snap| match slot.borrow_mut().take() {
Some(tx) => tx.send(snap).is_ok(),
None => false,
};
let lookup = {
let this = agent_ref.get();
let result =
this.subagent_coordinator.borrow().lookup(&subagent_id);
if block && result.is_some() {
this.subagent_coordinator
.borrow_mut()
.register_block_wait(&subagent_id, slot.clone());
}
this.subagent_coordinator
.borrow_mut()
.evict_stale_completed();
result
};
let snapshot = resolve_snapshot(lookup).await;
let should_block =
block && snapshot.as_ref().is_some_and(is_running);
if should_block {
let timeout_ms = timeout_ms.unwrap_or(30_000);
let deadline = tokio::time::Instant::now()
+ tokio::time::Duration::from_millis(timeout_ms);
loop {
tokio::time::sleep(tokio::time::Duration::from_millis(200))
.await;
let receiver_gone =
slot.borrow().as_ref().is_none_or(|tx| tx.is_closed());
if receiver_gone {
let this = agent_ref.get();
let mut coord = this.subagent_coordinator.borrow_mut();
coord.clear_block_waited(&subagent_id);
coord.unregister_block_wait(&subagent_id, &slot);
return;
}
let lookup = {
let this = agent_ref.get();
this.subagent_coordinator.borrow().lookup(&subagent_id)
};
let snap = resolve_snapshot(lookup).await;
let still_running = snap.as_ref().is_some_and(is_running);
if !still_running || tokio::time::Instant::now() >= deadline
{
{
let this = agent_ref.get();
let mut coord =
this.subagent_coordinator.borrow_mut();
if still_running {
coord.clear_block_waited(&subagent_id);
}
coord.unregister_block_wait(&subagent_id, &slot);
}
if !send_via_slot(&slot, snap) && !still_running {
let this = agent_ref.get();
this.subagent_coordinator
.borrow_mut()
.clear_block_waited(&subagent_id);
}
return;
}
}
} else {
let delivered = send_via_slot(&slot, snapshot);
if block {
let this = agent_ref.get();
let mut coord = this.subagent_coordinator.borrow_mut();
coord.unregister_block_wait(&subagent_id, &slot);
if !delivered {
coord.clear_block_waited(&subagent_id);
}
}
}
});
}
SubagentEvent::Cancel(request) => {
let this = agent_ref.get();
let outcome = {
let mut coord = this.subagent_coordinator.borrow_mut();
match request.target {
SubagentCancelTarget::SubagentId(ref subagent_id) => {
coord.mark_explicitly_killed(subagent_id);
coord.cancel_with_outcome(subagent_id)
}
SubagentCancelTarget::ParentPromptId(ref parent_prompt_id) => {
coord.cancel_by_parent_prompt_id(parent_prompt_id);
SubagentCancelOutcome::Cancelled
}
}
};
let _ = request.respond_to.send(outcome);
}
SubagentEvent::ListActive(request) => {
let this = agent_ref.get();
let summaries = this
.subagent_coordinator
.borrow()
.active_summaries_for(&request.parent_session_id);
let _ = request.respond_to.send(summaries);
}
SubagentEvent::Completions(request) => {
let this = agent_ref.get();
let mut completions = this
.subagent_coordinator
.borrow_mut()
.drain_pending_completions();
completions.retain(|c| !request.suppress_ids.contains(&c.subagent_id));
let _ = request.respond_to.send(completions);
}
SubagentEvent::Outstanding(request) => {
let this = agent_ref.get();
let reply = this
.subagent_coordinator
.borrow()
.outstanding_reply_for_prompt(&request.prompt_id);
let _ = request.respond_to.send(reply);
}
SubagentEvent::ClearUsageNotApplied(request) => {
let this = agent_ref.get();
this.subagent_coordinator
.borrow_mut()
.clear_subagent_usage_not_applied(&request.prompt_id);
}
SubagentEvent::MarkUsageNotApplied(request) => {
let this = agent_ref.get();
this.subagent_coordinator
.borrow_mut()
.mark_subagent_usage_not_applied(&request.prompt_id);
let _ = request.respond_to.send(());
}
SubagentEvent::ValidateType(request) => {
let agent_ref = agent_ref.clone();
tokio::task::spawn_local(async move {
let this = agent_ref.get();
let ctx = this
.build_subagent_validation_context(&request.parent_session_id);
let outcome = crate::agent::subagent::validate_subagent_type(
&request.subagent_type,
&ctx,
);
let _ = request.respond_to.send(outcome);
});
}
SubagentEvent::DescribeType(request) => {
let agent_ref = agent_ref.clone();
tokio::task::spawn_local(async move {
use kigi_tools::implementations::grok_build::task::types::SubagentDescribeOutcome;
let this = agent_ref.get();
let outcome = match this
.try_build_subagent_spawn_context(&request.parent_session_id)
{
Some(ctx) => crate::agent::subagent::describe_subagent_type(
&request.subagent_type,
request.harness_agent_type.as_deref(),
&ctx,
),
None => {
tracing::warn!(
parent_session_id = % request.parent_session_id,
subagent_type = % request.subagent_type,
"DescribeType for unknown/evicted parent session, replying Unavailable",
);
SubagentDescribeOutcome::Unavailable
}
};
let _ = request.respond_to.send(outcome);
});
}
}
}
}
});
}
/// Lightweight context for the `SubagentEvent::ValidateType` drain arm;
/// tolerates evicted parent sessions (returns built-in defaults + warns).
pub(super) fn build_subagent_validation_context(
&self,
parent_session_id: &str,
) -> crate::agent::subagent::SubagentValidationContext {
let parent_sid = acp::SessionId::new(parent_session_id);
let (parent_cwd, allowed_subagent_types) = {
let sessions = self.sessions.borrow();
let ps = sessions.get(&parent_sid);
warn_on_missing_parent_session_for_validate_type(parent_session_id, ps.is_some());
(
ps.map(|h| std::path::PathBuf::from(&h.info.cwd))
.unwrap_or_default(),
ps.and_then(|h| h.allowed_subagent_types.clone()),
)
};
let cli_agent_names: Vec<String> = {
let cfg = self.cfg.borrow();
cfg.cli_agents.iter().map(|d| d.name.clone()).collect()
};
crate::agent::subagent::SubagentValidationContext {
parent_cwd,
plugin_registry: self.plugin_registry_handle.snapshot(),
subagent_toggle: self.subagent_toggle.clone(),
allowed_subagent_types,
cli_agent_names,
}
}
/// Build a `SubagentSpawnContext` from the current agent state and the
/// parent session's shared resources.
///
/// This is the ONLY subagent-related method on MvpAgent besides the
/// coordinator startup.
/// Build a spawn context for a real subagent spawn. The parent session is
/// guaranteed present here because the parent just issued the spawn request,
/// so a missing parent is a real invariant violation and panics. Read-only
/// callers that can race a parent teardown (e.g. `DescribeType`) must use
/// [`Self::try_build_subagent_spawn_context`] instead.
pub(super) fn build_subagent_spawn_context(
&self,
parent_session_id: &str,
) -> crate::agent::subagent::SubagentSpawnContext {
self.try_build_subagent_spawn_context(parent_session_id)
.expect("parent session must exist when spawning subagents")
}
/// Fallible variant of [`Self::build_subagent_spawn_context`]: returns
/// `None` when the parent `SessionHandle` is absent (evicted / torn down)
/// instead of panicking, so read-only paths that can race a teardown can
/// fail open.
pub(super) fn try_build_subagent_spawn_context(
&self,
parent_session_id: &str,
) -> Option<crate::agent::subagent::SubagentSpawnContext> {
let parent_sid = acp::SessionId::new(parent_session_id);
let (
parent_model_id,
parent_chat_state,
parent_cmd_tx,
parent_cwd,
yolo_mode,
parent_depth,
hunk_tracker_handle,
hunk_tracking_enabled,
fs,
terminal,
session_env,
parent_attribution_callback,
parent_agent_name,
parent_managed_mcp_proxy_base_url,
) = {
let sessions = self.sessions.borrow();
let ps = sessions.get(&parent_sid);
(
ps.map(|h| h.model_id.clone())
.unwrap_or_else(|| self.models_manager.current_model_id()),
ps.map(|h| h.chat_state_handle.clone()),
ps.map(|h| h.cmd_tx.clone()),
ps.map(|h| std::path::PathBuf::from(&h.info.cwd))
.unwrap_or_default(),
ps.map(|h| h.yolo_mode).unwrap_or(self.default_yolo_mode),
ps.map(|h| h.tool_context.subagent_depth).unwrap_or(0),
ps.map(|h| h.tool_context.hunk_tracker_handle.clone())
.unwrap_or_else(kigi_hunk_tracker::HunkTrackerHandle::noop),
ps.map(|h| h.tool_context.hunk_tracking_enabled)
.unwrap_or(false),
ps.map(|h| h.tool_context.fs.inner().clone())
.unwrap_or_else(|| {
let cwd = ps
.map(|h| std::path::PathBuf::from(&h.info.cwd))
.unwrap_or_default();
std::sync::Arc::new(kigi_workspace::file_system::LocalFs::new(cwd))
}),
ps.map(|h| h.tool_context.terminal.clone())
.unwrap_or_else(|| {
std::sync::Arc::new(crate::terminal::TerminalRunner::new(
std::sync::Arc::new(self.gateway.clone()),
parent_sid.clone(),
))
}),
ps.map(|h| h.tool_context.session_env.clone())
.unwrap_or_else(|| std::sync::Arc::new(std::collections::HashMap::new())),
ps.and_then(|h| h.attribution_callback.clone()),
ps.map(|h| h.agent_name.clone()),
ps.map(|h| h.managed_mcp_proxy_base_url.clone()),
)
};
let (
parent_workspace_ops,
parent_terminal_backend,
parent_notification_handle,
parent_scheduler_handle,
) = {
let sessions = self.sessions.borrow();
sessions.get(&parent_sid).map(|ps| {
(
ps.workspace_ops.clone(),
ps.terminal_backend.clone(),
ps.tools_notification_handle.clone(),
ps.scheduler_handle.clone(),
)
})
}?;
let available_models = self.models_manager.models();
let parent_lsp = {
let sessions = self.sessions.borrow();
sessions
.get(&parent_sid)
.and_then(|h| h.tool_context.lsp.clone())
};
let am = self.auth_manager.clone();
let inference_idle_timeout_secs = {
let per_model = config::find_model_by_id(&available_models, parent_model_id.0.as_ref())
.and_then(|e| e.info.inference_idle_timeout_secs);
let cfg = self.cfg.borrow();
let remote = cfg
.remote_settings
.as_ref()
.and_then(|s| s.inference_idle_timeout_secs);
per_model.or(remote).unwrap_or(600).max(10)
};
let parent_hook_registry = {
let sessions = self.sessions.borrow();
sessions
.get(&parent_sid)
.and_then(|h| h.hook_registry.clone())
};
let parent_max_turns = {
let sessions = self.sessions.borrow();
sessions.get(&parent_sid).and_then(|h| h.max_turns)
};
let parent_model_agent_type =
config::find_model_by_id(&available_models, parent_model_id.0.as_ref())
.map(|e| e.info.agent_type.clone());
let ask_user_question_enabled = {
let sessions = self.sessions.borrow();
sessions
.get(&parent_sid)
.map(|h| h.ask_user_question_enabled)
.unwrap_or_else(|| self.cfg.borrow().resolve_ask_user_question().value)
};
Some(crate::agent::subagent::SubagentSpawnContext {
lsp: parent_lsp,
gateway: self.gateway.clone(),
client_hooks: Default::default(),
sampling_config: self.sampling_config.borrow().clone(),
managed_mcp_proxy_base_url: parent_managed_mcp_proxy_base_url
.unwrap_or_else(|| self.cli_chat_proxy_base_url()),
alpha_test_key: self.alpha_test_key(),
auth_method_id: self
.auth_method_id
.load()
.as_deref()
.cloned()
.unwrap_or_else(|| acp::AuthMethodId::new("default")),
model_id: parent_model_id,
storage_mode: self.storage_mode,
auth: self.current_or_buffered_auth(),
parent_cwd: parent_cwd.clone(),
parent_session_id: parent_session_id.to_string(),
yolo_mode,
subagent_event_tx: self.subagent_event_tx.clone(),
parent_depth,
inference_idle_timeout_secs,
auto_compact_threshold_tiers:
crate::agent::subagent::AutoCompactThresholdTiers::capture(&self.cfg.borrow()),
hunk_tracker_handle,
hunk_tracking_enabled,
fs,
terminal,
session_env,
memory_config: self.memory_config.clone(),
web_search_sampling_config: self.prepare_web_search_sampling_config(),
web_fetch_config: self.prepare_web_fetch_config(),
image_gen_config: self.prepare_image_gen_config(),
video_gen_config: self.prepare_video_gen_config(),
app_builder_deployer_config: self.prepare_app_builder_deployer_config(),
write_file_enabled: self.cfg.borrow().resolve_write_file().value,
goal_enabled: self.cfg.borrow().resolve_goal().value,
ask_user_question_enabled,
parent_cmd_tx: parent_cmd_tx.clone(),
parent_session_info: {
let sessions = self.sessions.borrow();
sessions
.get(&parent_sid)
.map(|h| crate::session::info::Info {
id: parent_sid.clone(),
cwd: h.info.cwd.clone(),
})
},
parent_chat_state,
parent_max_turns,
available_models,
subagent_model_overrides: self.subagent_model_overrides.clone(),
subagent_toggle: self.subagent_toggle.clone(),
subagent_roles: self.subagent_roles.clone(),
subagent_personas: self.subagent_personas.clone(),
persona_io_summaries: self.persona_io_summaries.clone(),
disable_web_search: self.cfg.borrow().disable_web_search,
todo_gate: self.cfg.borrow().todo_gate,
remote_settings: self.cfg.borrow().remote_settings.clone(),
laziness_debug_log: self.cfg.borrow().laziness_debug_log.clone(),
backend_tools_enabled: self.cfg.borrow().resolve_backend_tools().value,
respect_gitignore: self.cfg.borrow().respect_gitignore,
path_not_found_hints: self.cfg.borrow().path_not_found_hints,
plugin_registry: self.plugin_registry_handle.snapshot(),
models_manager: self.models_manager.clone(),
file_tool_overrides: {
let cfg = self.cfg.borrow();
let effective = cfg
.toolset
.resolve_file_toolset(cfg.remote_settings.as_ref());
if effective != crate::tools::FileToolset::Standard {
effective.tool_configs(&cfg.toolset.hashline).ok()
} else {
None
}
},
agent_config: Some(self.cfg.borrow().clone()),
hook_registry: parent_hook_registry,
hook_workspace_root: String::new(),
permission_handle: {
let sessions = self.sessions.borrow();
sessions
.get(&parent_sid)
.map(|h| h.permission_handle.clone())
},
worktree_type: self.worktree_type,
api_key_provider: Some(Arc::new(crate::auth::manager::SharedAuthKeyProvider(
am.clone(),
))),
image_description_model: self.resolve_image_description_model(),
workspace_ops: parent_workspace_ops.clone(),
auth_manager: am.clone(),
attribution_callback: parent_attribution_callback,
parent_agent_name,
parent_model_agent_type,
allowed_subagent_types: {
let sessions = self.sessions.borrow();
sessions
.get(&parent_sid)
.and_then(|h| h.allowed_subagent_types.clone())
},
parent_mcp_configs: {
let sessions = self.sessions.borrow();
sessions
.get(&parent_sid)
.map(|h| h.mcp_servers.clone())
.unwrap_or_default()
},
managed_mcp_state: self.managed_mcp_cache.clone(),
parent_mcp_pool: None,
parent_tool_snapshot: None,
parent_skills: None,
parent_skills_config: self.cfg.borrow().skills.clone(),
parent_compat: self.cfg.borrow().compat_resolved,
auto_wake_delivered: {
let sessions = self.sessions.borrow();
sessions
.get(&parent_sid)
.and_then(|h| h.tool_context.auto_wake_delivered.clone())
},
task_output_tool_name: {
let sessions = self.sessions.borrow();
sessions
.get(&parent_sid)
.map(|h| h.tool_context.task_output_tool_name.clone())
.unwrap_or_else(|| {
kigi_tools::reminders::task_completion::DEFAULT_TASK_OUTPUT_TOOL
.to_string()
})
},
auto_wake_enabled: self.cfg.borrow().auto_wake_enabled,
goal_loop_active: {
let sessions = self.sessions.borrow();
sessions
.get(&parent_sid)
.map(|h| h.tool_context.goal_loop_active_gate.clone())
.unwrap_or_else(|| {
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false))
})
},
parent_blocking_wait_depth: {
let sessions = self.sessions.borrow();
sessions
.get(&parent_sid)
.map(|h| h.tool_context.blocking_wait_depth.clone())
.unwrap_or_else(|| std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)))
},
parent_terminal_backend: parent_terminal_backend.clone(),
parent_notification_handle: parent_notification_handle.clone(),
parent_scheduler_handle: parent_scheduler_handle.clone(),
})
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,135 @@
//! Subagent spawn-context inheritance: a child session must inherit the parent's
//! permission handle and goal-loop gate so policy and run-state can't be bypassed
//! by delegating to a subagent.
use super::{build_minimal_agent_for_tests, make_test_handle};
use agent_client_protocol as acp;
use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender;
/// Subagents inherit the parent permission handle, so a managed `Read(**/.env)`
/// deny still blocks the child — direct read and the `cat .env` shell equivalent.
#[tokio::test]
async fn subagent_spawn_context_inherits_parent_permission_handle() {
use kigi_workspace::permission::types::{
PatternMode, PermissionConfig, PermissionRule, RuleAction, ToolFilter,
};
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let agent = build_minimal_agent_for_tests();
let sid = acp::SessionId::new("parent-permission");
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let gateway = GatewaySender::new(tx);
let cwd = kigi_paths::AbsPathBuf::new(std::path::PathBuf::from("/tmp"))
.expect("absolute cwd");
let (permission_handle, _events_rx) =
kigi_workspace::permission::spawn_permission_manager(
sid.clone(),
gateway,
cwd,
kigi_workspace::permission::types::ClientType::Generic,
Some(PermissionConfig::new(vec![PermissionRule {
action: RuleAction::Deny,
tool: ToolFilter::Read,
pattern: Some("**/.env".to_owned()),
pattern_mode: PatternMode::Glob,
}])),
Vec::new(), // deny_read_globs
Vec::new(),
false,
None,
);
let mut handle = make_test_handle("test-model", false, None);
handle.permission_handle = permission_handle;
agent.sessions.borrow_mut().insert(sid.clone(), handle);
let ctx = agent.build_subagent_spawn_context(sid.0.as_ref());
let inherited = ctx
.permission_handle
.expect("subagent context must inherit parent permission handle");
// Direct file read and the shell equivalent both hit the parent deny.
for access in [
kigi_workspace::permission::AccessKind::Read(Some(".env".into())),
kigi_workspace::permission::AccessKind::Bash("cat .env".into()),
] {
let decision = inherited
.request(
access.clone(),
acp::ToolCallUpdate::new(acp::ToolCallId::new("tc"), Default::default()),
Some("child-session".to_owned()),
Some("general-purpose".to_owned()),
Some("permission inheritance regression".to_owned()),
)
.await;
assert!(
matches!(
decision,
kigi_workspace::permission::Decision::PolicyDeny(_)
),
"subagent-inherited handle must enforce parent deny for {access:?}, got {decision:?}"
);
}
})
.await;
}
/// A subagent shares the parent's `goal_loop_active_gate` Arc, so flipping the
/// parent gate is observed through the child context (same allocation).
#[tokio::test]
async fn subagent_spawn_context_shares_parent_goal_loop_gate() {
use std::sync::atomic::Ordering::Relaxed;
let agent = build_minimal_agent_for_tests();
let sid = acp::SessionId::new("parent-goal");
let handle = make_test_handle("test-model", false, None);
// Clone the parent's live gate before the handle moves into `sessions`.
let parent_gate = handle.tool_context.goal_loop_active_gate.clone();
agent.sessions.borrow_mut().insert(sid.clone(), handle);
let ctx = agent.build_subagent_spawn_context(sid.0.as_ref());
// Flipping the parent gate must surface through the child flag (shared Arc).
assert!(!ctx.goal_loop_active.load(Relaxed));
parent_gate.store(true, Relaxed);
assert!(
ctx.goal_loop_active.load(Relaxed),
"subagent context must observe the parent's goal-loop gate (same Arc)"
);
}
/// A subagent inherits the parent session's `ask_user_question` gate, so
/// `--no-ask-user` strips the tool from subagents too, while the default keeps it.
#[tokio::test]
async fn subagent_spawn_context_inherits_parent_ask_user_question_gate() {
let agent = build_minimal_agent_for_tests();
// Parent with the tool disabled (the `--no-ask-user` case) → child off.
let sid_off = acp::SessionId::new("parent-no-ask");
let mut handle_off = make_test_handle("test-model", false, None);
handle_off.ask_user_question_enabled = false;
agent
.sessions
.borrow_mut()
.insert(sid_off.clone(), handle_off);
let ctx_off = agent.build_subagent_spawn_context(sid_off.0.as_ref());
assert!(
!ctx_off.ask_user_question_enabled,
"subagent must inherit the parent's disabled ask_user_question gate (--no-ask-user)"
);
// Parent with the tool enabled (the default) → child on.
let sid_on = acp::SessionId::new("parent-ask");
let handle_on = make_test_handle("test-model", false, None);
agent
.sessions
.borrow_mut()
.insert(sid_on.clone(), handle_on);
let ctx_on = agent.build_subagent_spawn_context(sid_on.0.as_ref());
assert!(
ctx_on.ask_user_question_enabled,
"subagent must inherit the parent's enabled ask_user_question gate"
);
}