docs(comments): rewrite comments across all crates to the guidelines

Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
This commit is contained in:
2026-07-23 16:55:39 -04:00
parent ff0fb56c67
commit a02b555e66
1458 changed files with 10729 additions and 21750 deletions
-1
View File
@@ -10,7 +10,6 @@ use std::path::{Path, PathBuf};
const RG_VER: &str = "15.0.0";
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Only bundle in release builds to avoid slowing down cargo check.
println!("cargo:rerun-if-env-changed=KIGI_SHELL_BUNDLE_RG_PATH");
println!("cargo:rerun-if-env-changed=KIGI_SHELL_RG_DOWNLOAD_BASE");
// Declare our custom cfg to the compiler so cfg(bundle_rg) is recognized by lints
@@ -23,8 +23,6 @@ const DATA_FILENAME: &str = "active_sessions.json";
const LOCK_FILENAME: &str = "active_sessions.lock";
const TMP_FILENAME: &str = "active_sessions.json.tmp";
// -- Public API (delegates to `_in` variants with default kigi home) --------
/// Register a session as active (idempotent by session_id).
pub fn register(session: ActiveSession) -> io::Result<()> {
register_in(&crate::util::kigi_home::kigi_home(), session)
@@ -46,8 +44,6 @@ pub fn collect_crashed() -> io::Result<Vec<ActiveSession>> {
collect_crashed_in(&crate::util::kigi_home::kigi_home())
}
// -- Injectable-root variants (`_in`) for testing ---------------------------
pub fn register_in(root: &Path, session: ActiveSession) -> io::Result<()> {
with_locked_state(root, |sessions| {
sessions.retain(|s| s.session_id != session.session_id);
@@ -81,8 +77,6 @@ pub fn list_in(root: &Path) -> io::Result<Vec<ActiveSession>> {
read_data_file(&data_path)
}
// -- Internal: locked read-modify-write -------------------------------------
fn with_locked_state<F, R>(root: &Path, mutate: F) -> io::Result<R>
where
F: FnOnce(&mut Vec<ActiveSession>) -> R,
@@ -155,7 +155,8 @@ impl AgentActivity {
}
if signaled.iter().all(|(_, tx)| tx.is_closed()) {
return; // nothing to flush, or all actors exited
// nothing to flush, or all actors exited
return;
}
if tokio::time::Instant::now() >= deadline {
for (id, tx) in &signaled {
+70 -87
View File
@@ -41,10 +41,6 @@ use indexmap::IndexMap;
pub struct LeaderAutoUpdateConfig {
/// Interval between update checks (default: 1 hour).
pub check_interval: Duration,
/// Async function that checks for, downloads, and installs an update.
/// Returns `true` if the update was installed successfully and the leader
/// should shut down. Returns `false` to stay alive (no update, or download
/// failed).
pub check_fn:
Box<dyn Fn() -> Pin<Box<dyn std::future::Future<Output = bool> + Send>> + Send + Sync>,
}
@@ -55,8 +51,6 @@ pub struct LeaderAutoUpdateConfig {
/// download request timeout (20 minutes) so the leader does not abandon a
/// transfer that is still within the HTTP client's budget. If the call takes
/// longer than this, we abandon the attempt and retry on the next interval.
/// The select! with the cancellation token ensures the loop remains
/// responsive to shutdown signals even while waiting.
const AUTO_UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs(20 * 60);
/// How long the auto-update shutdown waits for session actors to flush
@@ -98,8 +92,8 @@ const MAX_AUTO_UPDATE_BUSY_DEFERRALS: u32 = 24;
/// and a timeout so that a stalled download cannot block the loop from
/// responding to shutdown signals.
///
/// This is extracted as a standalone function so it can be unit-tested
/// independently from the full leader infrastructure.
/// A standalone function so it can be unit-tested independently from the full
/// leader infrastructure.
pub(crate) async fn run_auto_update_checker(
config: LeaderAutoUpdateConfig,
agent_busy: Arc<AtomicBool>,
@@ -120,9 +114,6 @@ pub(crate) async fn run_auto_update_checker(
info!("Leader auto-update: running update check");
// Run check_fn inside a select! with cancellation and a timeout so a
// stalled network call cannot block the loop from responding to shutdown.
// The check_fn may include a binary download, so the timeout is generous.
let update_installed = tokio::select! {
biased;
_ = cancel.cancelled() => break,
@@ -226,15 +217,14 @@ fn spawn_agent_local(
/// `kigi/...` extension method, for injection into the agent's inbound ACP
/// stream by the leader's own watcher tasks (config hot-reload, skills).
///
/// The wire method is written **`_`-prefixed** (`_kigi/internal/...`):
/// The wire method must be written `_`-prefixed (`_kigi/internal/...`):
/// `agent-client-protocol`'s inbound decoder routes a non-built-in method to
/// `ext_method` only when it carries the `_` extension prefix and rejects
/// bare custom methods with `-32601 method_not_found`. These injections were
/// historically sent un-prefixed, so every watcher-driven hot-reload
/// (models, skills, MCP servers) was silently rejected at decode — the
/// watcher-side "change detected" logs fired but the reload handlers never
/// ran. Keep `method` here as the un-prefixed name; the prefix is a wire
/// detail added in one place.
/// bare custom methods with `-32601 method_not_found`. Without the prefix
/// every watcher-driven hot-reload (models, skills, MCP servers) is silently
/// rejected at decode — the watcher-side "change detected" logs fire but the
/// reload handlers never run. Keep `method` here as the un-prefixed name; the
/// prefix is a wire detail applied in one place.
fn internal_reload_request_line(id: &str, method: &str, params: serde_json::Value) -> String {
let msg = serde_json::json!({
"jsonrpc": "2.0",
@@ -419,11 +409,6 @@ pub async fn run_stdio_agent(
/// error — login is deferred to ACP.
/// 7. `ready_tx.send(true)` — unblocks ACP forwarding in the IPC server.
/// 8. LocalSet: agent, IPC↔agent bridges, config watcher.
///
/// # Arguments
///
/// * `agent_config` - The agent configuration
/// * `no_exit_on_disconnect` - If true, the leader will not exit when all clients disconnect
pub async fn run_leader(
agent_config: &AgentConfig,
no_exit_on_disconnect: bool,
@@ -476,11 +461,11 @@ pub async fn run_leader(
Err(e) => return Err(anyhow::anyhow!("Failed to check leader lock: {}", e)),
};
// ── Phase 1: Clean up stale socket ────────────────────────────────────────
// Phase 1: Clean up stale socket
lock.cleanup_socket()?;
info!("Leader server starting");
// ── Phase 2: Create all channels + readiness watch ────────────────────────
// Phase 2: Create all channels + readiness watch
//
// All channels are created here so the IPC server can start receiving
// client connections immediately, before auth/prefetch begin.
@@ -526,7 +511,7 @@ pub async fn run_leader(
leader_binary_version: kigi_version::VERSION.to_string(),
});
// ── Phase 3: Bind socket and start IPC server (BEFORE auth/prefetch) ──────
// Phase 3: Bind socket and start IPC server (BEFORE auth/prefetch)
//
// Starting the server here means connect_or_spawn sees the socket in < 100 ms
// regardless of how long auth + model prefetch take. The `ready_rx` gate inside
@@ -550,7 +535,8 @@ pub async fn run_leader(
agent_activity_for_server,
ready_rx,
shutdown_tx_for_server,
None, // use LEADER_VERSION constant
// None: use the LEADER_VERSION constant.
None,
control_state,
)
.await
@@ -559,7 +545,7 @@ pub async fn run_leader(
}
});
// ── Phase 4: Wait for socket to appear (fast: < 100 ms now) ──────────────
// Phase 4: Wait for socket to appear (fast: < 100 ms)
let socket_ready_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
while !crate::leader::listener_is_ready(&socket_path) {
if tokio::time::Instant::now() >= socket_ready_deadline {
@@ -572,7 +558,7 @@ pub async fn run_leader(
}
debug!("IPC socket created");
// ── Phase 5: Lock handoff ─────────────────────────────────────────────────
// Phase 5: Lock handoff
//
// (a) lock_already_held=true: We acquired the lock at startup. Keep it.
// (b) lock_already_held=false: spawner holds lock, waiting for our socket.
@@ -609,7 +595,7 @@ pub async fn run_leader(
}
};
// ── Phase 6: Auth + model prefetch ───────────────────────────────────────
// Phase 6: Auth + model prefetch
//
// The IPC server is already accepting connections. Clients that send ACP
// messages during this window receive a `leader_starting` error and can retry.
@@ -635,14 +621,14 @@ pub async fn run_leader(
.await
.unwrap_or(None);
// ── Phase 7: Signal readiness ─────────────────────────────────────────────
// Phase 7: Signal readiness
//
// Unblocks ACP forwarding inside the IPC server. From this point on, client
// ACP messages are forwarded to the agent as normal.
let _ = ready_tx.send(true);
info!("Leader ready: auth and model prefetch complete, ACP forwarding enabled");
// ── Phase 8: LocalSet — agent, bridges, config watcher ───────────────────
// Phase 8: LocalSet — agent, bridges, config watcher
let local_set = tokio::task::LocalSet::new();
let mut agent_config_for_spawn = agent_config.clone();
@@ -672,13 +658,10 @@ pub async fn run_leader(
let models_manager_for_agent = shared_models_manager.clone();
let models_manager_for_config = shared_models_manager;
// Resolve `mcp.recursive_config_watch`
// ONCE here, before the channel is created, so a kill-switch
// value of `false` skips channel construction entirely. Previously
// the channel was always created and `tx` always installed on
// the agent; the drain task only ran when the flag was on, so
// every `notify_session_cwd_for_watch` call leaked a `PathBuf`
// into a never-drained channel.
// Resolve `mcp.recursive_config_watch` once here, before the channel is
// created, so a kill-switch value of `false` skips channel construction
// entirely. Otherwise every `notify_session_cwd_for_watch` call would leak
// a `PathBuf` into a channel whose drain task never runs.
let recursive_config_watch_enabled = {
let user_cfg = crate::config::load_from_disk().ok();
let requirements = crate::agent::config::read_requirements_toml();
@@ -691,19 +674,17 @@ pub async fn run_leader(
local_set
.run_until(async move {
// Channel for fanning new session cwds from
// the agent (each `spawn_and_register_session` call) into
// the leader's `ConfigFileWatcher::watch_path`. Both ends
// live inside the `LocalSet` so neither needs `Send`. The
// tx is installed on the agent before `AgentSideConnection`
// moves it; the rx is drained by a small task spawned
// alongside the watcher below.
// Channel for fanning new session cwds from the agent (each
// `spawn_and_register_session` call) into the leader's
// `ConfigFileWatcher::watch_path`. Both ends live inside the
// `LocalSet` so neither needs `Send`. The tx is installed on the
// agent before `AgentSideConnection` moves it; the rx is drained by
// a small task spawned alongside the watcher below.
//
// Only create the channel when the kill-
// switch is `true`. With the flag off,
// `notify_session_cwd_for_watch` becomes a no-op (no
// `tx` installed) and no memory leaks regardless of how
// many sessions spawn over the leader's lifetime.
// Only create the channel when the kill-switch is `true`. With the
// flag off, `notify_session_cwd_for_watch` becomes a no-op (no `tx`
// installed) and no memory leaks regardless of how many sessions
// spawn over the leader's lifetime.
let (config_watcher_path_tx, config_watcher_path_rx_opt) =
if recursive_config_watch_enabled {
let (tx, rx) = mpsc::unbounded_channel::<std::path::PathBuf>();
@@ -823,12 +804,10 @@ pub async fn run_leader(
let (config_update_tx, mut config_update_rx) =
mpsc::unbounded_channel::<crate::config::reloader::ConfigUpdate>();
// `mcp.recursive_config_watch` (default
// `true`) was resolved above (before the async block) so
// the per-session-cwd channel could be gated. The
// watcher passes `Some(cwd)` here only when the flag is
// on. When disabled, behavior reverts to the prior
// default: only explicit `extra_paths` are watched (kill
// `mcp.recursive_config_watch` (default `true`) was resolved above,
// before the async block, so the per-session-cwd channel could be
// gated. The watcher passes `Some(cwd)` here only when the flag is
// on; when disabled, only explicit `extra_paths` are watched (kill
// switch for the rollout).
let watcher_cwd = recursive_config_watch_enabled.then_some(cwd_for_watcher.as_path());
@@ -839,19 +818,16 @@ pub async fn run_leader(
watcher_cwd,
None,
) {
// Share ownership between the leader's
// long-lived binding and the per-cwd dynamic
// registration drain task. `Rc<RefCell<>>` is safe
// because both ends live inside the leader's
// `LocalSet` — the watcher type is not `Sync`-needed.
// Share ownership between the leader's long-lived binding and
// the per-cwd dynamic registration drain task. `Rc<RefCell<>>`
// is safe because both ends live inside the leader's `LocalSet`
// — the watcher type is not `Sync`-needed.
let watcher = std::rc::Rc::new(std::cell::RefCell::new(watcher));
// Dynamic registration drain. Lives only
// when the recursive_config_watch flag is on AND the
// OS watcher started. With the flag
// off the channel itself was never created, so
// there's no rx to drain and no `PathBuf` ever
// queued (no leak).
// Dynamic registration drain. Lives only when the
// recursive_config_watch flag is on AND the OS watcher started.
// With the flag off the channel itself is never created, so
// there's no rx to drain and no `PathBuf` ever queued (no leak).
if let Some(mut rx) = config_watcher_path_rx.take() {
let cancel_for_drain = cancel_clone.clone();
let watcher_for_drain = watcher.clone();
@@ -945,14 +921,11 @@ pub async fn run_leader(
}
}
ConfigUpdate::ProjectMcpServersChanged { cwd } => {
// Scope the reload to
// sessions whose cwd matches `cwd` (or is
// a descendant). The actual filtering
// happens in
// `handle_reload_project_mcp_servers`
// (extensions/session_admin.rs) — this
// arm just injects the ACP method with
// the cwd as a param.
// Scope the reload to sessions whose cwd matches
// `cwd` (or is a descendant). The actual filtering
// happens in `handle_reload_project_mcp_servers`
// (extensions/session_admin.rs) — this arm just
// injects the ACP method with the cwd as a param.
info!(
cwd = %cwd.display(),
"project MCP config change detected — reloading matching sessions"
@@ -1170,10 +1143,12 @@ mod tests {
#[tokio::test]
async fn auto_update_defers_when_agent_busy() {
let agent_busy = Arc::new(AtomicBool::new(true)); // agent is processing a prompt
// agent is processing a prompt
let agent_busy = Arc::new(AtomicBool::new(true));
let cancel = CancellationToken::new();
let config = delayed_update_config(0); // always returns true
// always returns true
let config = delayed_update_config(0);
let cancel_clone = cancel.clone();
let checker = tokio::spawn(run_auto_update_checker(
@@ -1229,7 +1204,8 @@ mod tests {
#[tokio::test]
async fn auto_update_cancels_after_agent_becomes_idle() {
let agent_busy = Arc::new(AtomicBool::new(true)); // agent processing initially
// agent processing initially
let agent_busy = Arc::new(AtomicBool::new(true));
let cancel = CancellationToken::new();
// Update is always available, but agent is busy initially
@@ -1299,7 +1275,8 @@ mod tests {
let call_count = Arc::new(AtomicU32::new(0));
let call_count_clone = call_count.clone();
let agent_busy = Arc::new(AtomicBool::new(true)); // agent busy, so it defers
// agent busy, so it defers
let agent_busy = Arc::new(AtomicBool::new(true));
let cancel = CancellationToken::new();
let config = LeaderAutoUpdateConfig {
@@ -1308,7 +1285,8 @@ mod tests {
let cc = call_count_clone.clone();
Box::pin(async move {
cc.fetch_add(1, Ordering::Relaxed);
true // update always available, but won't cancel because agent is busy
// update always available, but won't cancel because agent is busy
true
})
}),
};
@@ -1381,13 +1359,15 @@ mod tests {
/// pending interaction, or live subagent).
#[tokio::test]
async fn auto_update_defers_when_agent_activity_busy() {
let agent_busy = Arc::new(AtomicBool::new(false)); // IPC view: idle
// IPC view: idle
let agent_busy = Arc::new(AtomicBool::new(false));
let activity = crate::agent::activity::AgentActivity::default();
// Agent view: a subagent is running (e.g. spawned by a relay prompt).
activity.subagent_gauge().store(1, Ordering::Relaxed);
let cancel = CancellationToken::new();
let config = always_config(true); // update always "installed"
// update always "installed"
let config = always_config(true);
let cancel_clone = cancel.clone();
let checker = tokio::spawn(run_auto_update_checker(
@@ -1423,7 +1403,8 @@ mod tests {
activity.subagent_gauge().store(1, Ordering::Relaxed);
let cancel = CancellationToken::new();
let config = always_config(true); // update always "installed"
// update always "installed"
let config = always_config(true);
// 10ms interval × (24 deferrals + 1) ≈ 250ms — well within timeout.
tokio::time::timeout(
@@ -1500,7 +1481,8 @@ mod tests {
let cancel = CancellationToken::new();
let (shutdown_tx, mut shutdown_rx) = watch::channel(crate::leader::ShutdownReason::Manual);
let config = always_config(true); // update always available
// update always available
let config = always_config(true);
tokio::time::timeout(
Duration::from_secs(2),
@@ -1517,8 +1499,9 @@ mod tests {
assert!(cancel.is_cancelled(), "cancel token should be triggered");
// The shutdown_tx must have been updated to AutoUpdate before cancel fired.
shutdown_rx.mark_changed(); // ensure borrow sees latest value
// The shutdown_tx must carry AutoUpdate before the cancel fired.
// ensure borrow sees latest value
shutdown_rx.mark_changed();
assert_eq!(
*shutdown_rx.borrow(),
crate::leader::ShutdownReason::AutoUpdate,
@@ -13,7 +13,7 @@ use crate::auth::credential_authority::CredentialClass;
/// cell is correct.
pub(crate) type SharedAuthMethodId = std::sync::Arc<arc_swap::ArcSwapOption<acp::AuthMethodId>>;
/// Construct a [`SharedAuthMethodId`]. `None` is the pre-`authenticate` state.
/// `None` is the pre-`authenticate` state.
pub(crate) fn new_shared_auth_method_id(initial: Option<acp::AuthMethodId>) -> SharedAuthMethodId {
std::sync::Arc::new(arc_swap::ArcSwapOption::new(
initial.map(std::sync::Arc::new),
@@ -21,17 +21,15 @@ pub(crate) fn new_shared_auth_method_id(initial: Option<acp::AuthMethodId>) -> S
}
/// Primary env var that, when set, advertises `xai.api_key` as a viable auth
/// method. NOTE: `xai.api_key` is the *house* bring-your-own-key method (the
/// upstream product is house-branded "xai"), unrelated to the x.ai/Grok
/// provider. That collision is why the primary env moved here to `KIGI_API_KEY`
/// `XAI_API_KEY` is now the x.ai/Grok provider key (see `XAI_SPEC`).
///
/// Kept as a constant so test code and the production check stay in sync.
/// method. `xai.api_key` is the *house* bring-your-own-key method (the upstream
/// product is house-branded "xai"), unrelated to the x.ai/Grok provider — that
/// name collision is why the house primary env is `KIGI_API_KEY` while
/// `XAI_API_KEY` is the x.ai/Grok provider key (see `XAI_SPEC`).
pub const HOUSE_API_KEY_ENV_VAR: &str = "KIGI_API_KEY";
/// Back-compat fallback env: `XAI_API_KEY` was the house BYOK key before it
/// became the x.ai/Grok provider key. Still honored so existing house-BYOK
/// deployments keep working (they share the key with the Grok provider).
/// Back-compat fallback env, also honored as a house BYOK key so existing
/// house-BYOK deployments keep working — they share `XAI_API_KEY` with the
/// x.ai/Grok provider.
pub const XAI_API_KEY_ENV_VAR: &str = "XAI_API_KEY";
/// Legacy env var name (pre-`XAI_API_KEY`). Checked last so the oldest
@@ -39,17 +37,13 @@ pub const XAI_API_KEY_ENV_VAR: &str = "XAI_API_KEY";
pub const LEGACY_XAI_API_KEY_ENV_VAR: &str = "KIGI_CODE_XAI_API_KEY";
/// Read the house BYOK API key from the environment.
///
/// Checks `KIGI_API_KEY` first, then the back-compat `XAI_API_KEY`, then the
/// legacy `KIGI_CODE_XAI_API_KEY`.
pub fn read_xai_api_key_env() -> Result<String, std::env::VarError> {
std::env::var(HOUSE_API_KEY_ENV_VAR)
.or_else(|_| std::env::var(XAI_API_KEY_ENV_VAR))
.or_else(|_| std::env::var(LEGACY_XAI_API_KEY_ENV_VAR))
}
/// Returns `true` if any house BYOK env is set: `KIGI_API_KEY` (primary) or the
/// back-compat `XAI_API_KEY` / `KIGI_CODE_XAI_API_KEY`.
/// Whether any house BYOK env var is set.
pub fn has_xai_api_key_env() -> bool {
read_xai_api_key_env().is_ok()
}
@@ -58,8 +52,9 @@ pub fn has_xai_api_key_env() -> bool {
/// the `auth_methods` list at `initialize()` time.
///
/// Regression: `xai.api_key` must stay first when only per-model credentials
/// exist (no global `XAI_API_KEY`). Deferring it made BYOK users hit the login
/// screen because the pager uses `auth_methods.first()` for startup metadata.
/// exist (no global `XAI_API_KEY`). Deferring it past `first()` sends BYOK
/// users to the login screen, since the pager reads `auth_methods.first()` for
/// startup metadata.
///
/// [`build_auth_methods`] consumes this predicate and pins the ordering;
/// its tests catch call-site and predicate regressions.
@@ -107,9 +102,9 @@ pub struct BuiltAuthMethods {
/// pre-computed inputs.
///
/// REGRESSION GUARD: when `has_external_api_key` is true, the **first** entry
/// MUST be `xai.api_key`. A prior change deferred it to the END for per-model
/// credentials, which made the pager send per-model-key users to the login
/// screen. Unit tests lock this.
/// MUST be `xai.api_key`. If it is deferred past `first()`, the pager sends
/// per-model-key users to the login screen (it reads `auth_methods.first()`
/// for startup metadata). Unit tests lock this.
///
/// Ordering (when each method is enabled):
/// 1. `xai.api_key` (if `has_external_api_key`)
@@ -207,9 +202,7 @@ impl AuthMethodKind {
CACHED_TOKEN_AUTH_METHOD_ID => Self::CachedToken,
KIMI_CODE_METHOD_ID => Self::KimiCode,
other => match kigi_models::PlatformId::parse(other) {
// A generic device-code OAuth platform (xai-grok).
Some(p) if p.oauth().is_some() => Self::OAuthPlatform(p),
// A non-OAuth API-key registry platform.
Some(p) if !p.uses_oauth() => Self::ApiKeyPlatform(p),
_ => Self::Unknown,
},
@@ -377,9 +370,8 @@ pub fn cached_token_auth_method() -> acp::AuthMethod {
)
}
/// Interactive login method id, advertised over ACP by this agent and
/// selected by the in-repo pager. Both sides of the ACP boundary live in
/// this repo, so the id is renamed in lockstep everywhere.
/// Interactive login method id, advertised over ACP by this agent and matched
/// by the in-repo pager — both sides of the ACP boundary share this constant.
pub const KIMI_CODE_METHOD_ID: &str = "kimi-code";
/// The Kimi Code device-code login.
@@ -1115,8 +1107,8 @@ mod tests {
assert_eq!(read_xai_api_key_env().unwrap(), "new-key");
}
/// After the migration, `KIGI_API_KEY` is the house BYOK primary and wins
/// over the back-compat `XAI_API_KEY` (now the x.ai/Grok provider key).
/// `KIGI_API_KEY` is the house BYOK primary and wins over the back-compat
/// `XAI_API_KEY` (the x.ai/Grok provider key).
#[test]
#[serial]
fn house_env_var_takes_precedence_over_xai() {
@@ -1,16 +1,15 @@
//! Legacy `--chat` gateway gate.
//!
//! The kimi.com chat-product model picker (`/rest/modes`, `ChatModesManager`)
//! was removed with the xAI proxy: those "modes" came from a kigi backend with
//! no Kimi counterpart. Only the process-mode gate survives so the `--chat`
//! frontend path stays a compile-time-off no-op across crates without a
//! has no Kimi counterpart, so only this process-mode gate remains: it keeps
//! the `--chat` frontend path a compile-time-off no-op across crates without a
//! cross-crate churn to delete every reference.
/// Process-wide flag set by the pager when started with `--chat`.
pub const KIGI_CHAT_MODE_ENV: &str = "KIGI_CHAT_MODE";
/// True when the process is a gateway light-frontend (`--chat`) agent.
/// Hard-off: the kigi chat-modes backend is gone, so this is always `false`.
/// Whether the process is a gateway light-frontend (`--chat`) agent. Always
/// `false`: the kigi chat-modes backend has no Kimi counterpart.
pub fn process_chat_mode_enabled() -> bool {
false
}
+15 -17
View File
@@ -994,7 +994,7 @@ pub struct StorageConfig {
///
/// These supplement the built-in scan locations (`.kigi/skills/`,
/// `.agents/skills/`, `~/.kigi/skills/`). They're written by `/import-claude`
/// to preserve previously-discovered Claude directories after the runtime
/// to preserve already-discovered Claude directories after the runtime
/// `.claude/` cutoff (see `[claude_compat] imported`).
///
/// Example:
@@ -4015,28 +4015,17 @@ pub(crate) fn resolve_aux_model_sampling_config(
);
None
}
/// Finalize image-describe model + sampler config for user attachments.
/// Shared so the aux resolve happy path and the
/// `None` fallback cannot diverge between those entry points.
///
/// On aux resolve `Some`, stamp session-local fields (attribution, bearer,
/// retries) onto the helper config. On `None`, fall back to the active session model and
/// full config (not forcing `image_description_model` onto the agent endpoint, which 404s
/// on BYOK / non-proxy routes for internal slugs).
/// Stamp the session-local fields (attribution, bearer resolver, retries)
/// from the active session onto a routed aux `SamplerConfig` so a
/// helper model keeps the session's auth/attribution. Shared by image-describe
/// and the auto-mode classifier so the two can't drift.
/// Stamp the session-local fields onto a routed aux `SamplerConfig`.
///
/// `bearer_resolver` is an EXPLICIT parameter, not a copy of the session's:
/// this helper used to clone `active_session_config.bearer_resolver`
/// unconditionally and rely on every call site remembering to re-point it
/// afterwards. `SamplingClient::post` REPLACES the request's auth header from
/// the resolver, so a forgotten re-point overwrote the aux model's own key on
/// the AUX host with the session bearer. Callers obtain the value from
/// `SamplingClient::post` REPLACES the request's auth header from the
/// resolver, so handing it the session bearer would overwrite the aux
/// model's own key on the AUX host. Callers obtain the value from
/// [`SessionActor::aux_bearer_resolver`](crate::session::acp_session::SessionActor)
/// — the chokepoint — so "forgot to re-point" is no longer expressible.
/// — the chokepoint that keeps the two hosts' credentials from crossing.
pub(crate) fn stamp_session_local_sampler_fields(
cfg: &mut SamplerConfig,
active_session_config: &SamplerConfig,
@@ -4047,6 +4036,14 @@ pub(crate) fn stamp_session_local_sampler_fields(
cfg.bearer_resolver = bearer_resolver;
cfg.max_retries = max_retries;
}
/// Finalize image-describe model + sampler config for user attachments.
/// Shared so the aux resolve happy path and the
/// `None` fallback cannot diverge between those entry points.
///
/// On aux resolve `Some`, stamp session-local fields (attribution, bearer,
/// retries) onto the helper config. On `None`, fall back to the active session model and
/// full config (not forcing `image_description_model` onto the agent endpoint, which 404s
/// on BYOK / non-proxy routes for internal slugs).
pub(crate) fn finalize_image_describe_sampler_config(
resolved_aux: Option<SamplerConfig>,
active_session_config: &SamplerConfig,
@@ -5985,7 +5982,8 @@ reasoning_effort = "low"
fn byok_custom_entries_default_to_passthrough_except_house_endpoint() {
let make_cfg = |base_url: &str| {
let entry_cfg = ModelEntryConfig {
id: None, // BYOK: no managed platform key
// BYOK: no managed platform key
id: None,
model: "my-custom-model".to_string(),
base_url: base_url.to_string(),
name: None,
@@ -17,7 +17,6 @@ use serde::Serialize;
use super::config::ConfigModelOverride;
/// Category for a [`ModelOverrideWarning`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ModelOverrideWarningKind {
@@ -48,7 +47,6 @@ pub struct ModelOverrideWarning {
pub reason: String,
}
/// Result of [`parse_model_overrides`].
pub(crate) struct ParsedModelOverrides {
pub models: IndexMap<String, ConfigModelOverride>,
pub warnings: Vec<ModelOverrideWarning>,
@@ -44,12 +44,10 @@ fn current_keep_set() -> Vec<String> {
.collect()
}
/// Whether any of `platforms` needs enrichment at all.
pub(crate) fn any_platform_needs_enrichment(platforms: &[kigi_models::PlatformId]) -> bool {
platforms.iter().any(|p| !p.wire_serves_metadata())
}
/// The registry's models.dev provider ids (the refresh filter).
fn registry_models_dev_ids() -> BTreeSet<&'static str> {
kigi_models::PlatformId::ALL
.into_iter()
@@ -206,7 +204,7 @@ mod tests {
/// Wire-served-only platform sets never trigger IO — and never force the
/// bundled parse (empty owned catalog; the merge branch is gated off).
/// Kimi/Moonshot users therefore keep a zero-egress, zero-cache fetch
/// path even now that enrichment-needing platforms (OpenAI) exist.
/// path even though enrichment-needing platforms (OpenAI) exist.
#[test]
fn wire_served_platforms_get_empty_catalog_without_io() {
let wire_served = [
@@ -217,7 +215,7 @@ mod tests {
assert!(!any_platform_needs_enrichment(&wire_served));
let catalog = load_enrichment_catalog(&wire_served);
assert!(catalog.is_empty());
// The full registry now DOES need enrichment (OpenAI is
// The full registry DOES need enrichment (OpenAI is
// wire_serves_metadata=false) — the fast path must not hide that.
assert!(any_platform_needs_enrichment(&kigi_models::PlatformId::ALL));
}
@@ -24,7 +24,6 @@ pub struct FeedbackApiError {
}
impl FeedbackApiError {
/// Returns `true` if this is a 401 Unauthorized response.
pub fn is_unauthorized(&self) -> bool {
self.status == reqwest::StatusCode::UNAUTHORIZED
}
@@ -49,7 +48,6 @@ enum BearerSource {
Static(String),
}
/// Client for the Kimi Code feedback endpoint.
#[derive(Clone)]
pub struct FeedbackClient {
http: reqwest::Client,
@@ -73,7 +71,6 @@ impl FeedbackClient {
}
}
/// Client with a fixed Bearer token (tests).
#[cfg(test)]
pub(crate) fn with_static_token(base_url: impl Into<String>, token: impl Into<String>) -> Self {
Self {
@@ -31,8 +31,8 @@ use agent_client_protocol as acp;
use kigi_workspace::trust::{TrustStore, is_unsafe_trust_root, workspace_key};
use parking_lot::Mutex;
// Decision-side (scan/decide/prompt/store) relocated to `kigi-workspace`
// (client crate). `grant_folder_trust` is the ONLY moved item referenced from
// The decision-side (scan/decide/prompt/store) lives in `kigi-workspace`
// (client crate). `grant_folder_trust` is the ONLY item referenced from
// OUTSIDE this module (shell call sites + the pager's
// `kigi_shell::agent::folder_trust::grant_folder_trust`), so only it is
// re-published; the rest are private imports used within this module. A glob
@@ -50,11 +50,11 @@ use crate::session::managed_mcp::mcp_server_name;
use crate::util::config::{MCP_SCOPE_PROJECT, RemoteSettings};
// NOTE: this folder-trust store (`~/.kigi/trusted_folders.toml`) is SEPARATE
// from the pre-existing per-plugin trust store
// from the per-plugin trust store
// (`kigi_agent::plugins::TrustStore` at `~/.kigi/trusted-plugins`, plus the
// hooks' own project-trust gating). Trusting a folder here does NOT imply plugin
// trust and vice versa; the two are independent and non-contradicting.
// Unifying them is a tracked follow-up (out of scope for this PR).
// Unifying them is a tracked follow-up.
/// Per-workspace resolved decision: `true` = repo-local (project-scoped)
/// servers are allowed to spawn. Keyed by canonical workspace key.
@@ -788,7 +788,7 @@ mod tests {
)
.unwrap();
// Detection now walks cwd→root, so the subdir-only `.claude` is detected
// Detection walks cwd→root, so the subdir-only `.claude` is detected
// and the folder resolves untrusted.
assert!(
repo_configs_present(&subdir),
@@ -938,7 +938,8 @@ mod tests {
// KIGI_TEST_VERSION unset so `is_local_build()` is genuinely true.
let _unset_ver = EnvGuard::unset(kigi_version::TEST_VERSION_ENV);
if option_env!("KIGI_VERSION").is_some() {
return; // a release-stamped test binary is not a local build
// a release-stamped test binary is not a local build
return;
}
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path());
@@ -1507,7 +1508,8 @@ mod tests {
// real store is never touched.
let _sim = EnvGuard::unset(kigi_version::TEST_VERSION_ENV);
if option_env!("KIGI_VERSION").is_some() {
return; // a release-stamped test binary is not a local build
// a release-stamped test binary is not a local build
return;
}
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path());
@@ -1,7 +1,4 @@
//! Session meta-information handlers.
//!
//! Router pattern: single `handle()` dispatches by method name.
//! Business logic delegates to pure functions or MvpAgent methods.
use std::collections::BTreeMap;
use std::path::PathBuf;
@@ -28,7 +25,6 @@ fn backfill_session_summary(summary: &mut Summary) {
}
}
/// Router for kigi/session/* and kigi/session_summaries/* methods.
pub async fn handle(
agent: &MvpAgent,
args: &acp::ExtRequest,
@@ -105,7 +101,6 @@ async fn handle_session_info(
.send(SessionCommand::GetSessionInfo { responds_to: tx });
let info = rx.await.ok();
// Construct display data for `/session-info`.
let mut data = info.unwrap_or_else(|| SessionInfoData {
agent_name: None,
model: None,
@@ -124,21 +119,18 @@ async fn handle_session_info(
},
});
// Calculate the model's display name.
data.model_display_name = agent
.models_manager
.models()
.get(session.model_id.0.as_ref())
.and_then(|entry| entry.info.name.clone());
// Construct `SessionInfoResponse`.
let response = SessionInfoResponse {
session_id,
cwd: session.info.cwd.clone(),
data,
};
// Wrap `SessionInfoResponse` in `ExtMethodResult` and return it.
ExtMethodResult::success(serde_json::to_value(&response).unwrap_or_default())
.to_ext_response()
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
@@ -239,7 +231,6 @@ async fn handle_session_summaries(
}
}
/// Group summaries by cwd and serialize into an [`AllSessionOverviewResponse`].
fn summaries_to_overview_response(summaries: Vec<Summary>) -> Result<acp::ExtResponse, acp::Error> {
let mut by_cwd: BTreeMap<String, Vec<Summary>> = Default::default();
for mut s in summaries {
@@ -259,7 +250,6 @@ fn summaries_to_overview_response(summaries: Vec<Summary>) -> Result<acp::ExtRes
Ok(acp::ExtResponse::new(value))
}
// ── Merged session list (local + remote) ─────────────────────────────
async fn handle_session_list(
agent: &MvpAgent,
@@ -1,7 +1,4 @@
//! Agent bootstrap and lifecycle hooks.
//!
//! [`bootstrap`] runs the full init sequence (config resolution, process
//! singletons, model catalog) and returns a resolved config + `ModelsManager`.
use std::sync::Arc;
@@ -12,8 +9,6 @@ use crate::agent::models::ModelsManager;
use crate::auth::AuthManager;
use crate::config::StorageMode;
/// Resolve config, init process singletons, build the model catalog.
///
/// The `ModelsManager` is `Clone + Send`, so callers that need a handle
/// for the config watcher can clone it before passing it to
/// `MvpAgent::with_models`.
@@ -47,8 +42,6 @@ pub(crate) fn exit_on_config_error<T>(e: String) -> T {
std::process::exit(1);
}
/// Config transform: apply managed settings, fetch remote settings,
/// resolve storage mode.
fn resolve_config(cfg: &AgentConfig, auth_manager: &AuthManager) -> AgentConfig {
let mut cfg = cfg.clone();
@@ -99,7 +92,6 @@ fn resolve_config(cfg: &AgentConfig, auth_manager: &AuthManager) -> AgentConfig
cfg
}
/// Initialize process-level singletons (deployment sync, bundled files).
/// `Once`-guarded: only the first call takes effect.
fn init_process(cfg: &AgentConfig, auth_manager: &AuthManager) {
use std::sync::Once;
+15 -69
View File
@@ -29,10 +29,7 @@ pub struct BaselineSamplingConfig {
pub platform: Option<kigi_models::PlatformId>,
}
// ── Auth method for model fetching ──────────────────────────────────────────
/// How the model catalog is fetched (PRD F4). The old xAI tier-gated proxy
/// fetch is gone; there are exactly two shapes now.
/// How the model catalog is fetched (PRD F4).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ModelFetchAuth {
/// Fixed platform registry: `kimi-code` via the F1 OAuth bearer plus the
@@ -44,7 +41,6 @@ pub(crate) enum ModelFetchAuth {
}
impl ModelFetchAuth {
/// Custom endpoint when configured, else the platform registry.
pub(crate) fn resolve(endpoints: &config::EndpointsConfig) -> Self {
if endpoints.has_custom_endpoint() {
Self::CustomEndpoint
@@ -214,7 +210,6 @@ struct Inner {
/// time) or `reselect_current_model_if_missing` (subsequent).
/// Reset in `clear()` for identity changes.
has_fetched_real_catalog: RwLock<bool>,
// ── Owned context for self-contained refresh ────────────────
auth_manager: Arc<AuthManager>,
cfg: RwLock<config::Config>,
fetch_auth: RwLock<ModelFetchAuth>,
@@ -241,10 +236,8 @@ struct Inner {
/// `maybe_fire_laziness_check`'s polling loop to detect a
/// switch that occurred during the idle wait or sampler call.
///
/// `watch::Sender` natively fans out to every subscriber, so this
/// replaces the previous `RwLock<Vec<Arc<Notify>>>` listener
/// registry — no manual fan-out, no listener-leak risk, no
/// `unregister` API to maintain.
/// `watch::Sender` natively fans out to every subscriber — no manual
/// fan-out, no listener-leak risk, no `unregister` API to maintain.
model_switch_watch: tokio::sync::watch::Sender<u64>,
}
@@ -295,8 +288,7 @@ impl ModelsManager {
/// Subscribe to model-switch events. Returns a `watch::Receiver`
/// carrying the monotonic generation counter. `.changed()` only
/// resolves on switches that occur **after** subscription, so
/// there is no stored-permit hazard (the bug that motivated
/// replacing the previous `Arc<Notify>` design).
/// there is no stored-permit hazard.
pub fn subscribe_model_switch(&self) -> tokio::sync::watch::Receiver<u64> {
self.inner.model_switch_watch.subscribe()
}
@@ -469,8 +461,6 @@ impl ModelsManager {
self.notify_models_updated();
}
// ── Accessors ───────────────────────────────────────────────────
pub fn models(&self) -> IndexMap<String, ModelEntry> {
self.inner.models.read().clone()
}
@@ -704,8 +694,6 @@ impl ModelsManager {
*self.inner.has_fetched_real_catalog.read()
}
// ── Mutations ───────────────────────────────────────────────────
fn rebuild(&self, cfg: &config::Config, prefetched: Option<IndexMap<String, ModelEntry>>) {
*self.inner.models.write() =
resolve_model_catalog(cfg, prefetched, &PlatformApiKeys::resolve(&cfg.platforms));
@@ -779,7 +767,8 @@ impl ModelsManager {
// Deliberate no-fetch state, not a failure: no warn-class log.
tracing::debug!("model catalog: bundled defaults in use (remote_fetch disabled)");
}
self.rebuild(&config, None); // first-time only: no fetched catalog, use bundled defaults
// first-time only: no fetched catalog, use bundled defaults
self.rebuild(&config, None);
self.reselect_current_model_if_missing(&config);
// Schedule background retries so we recover once the network is
@@ -794,7 +783,6 @@ impl ModelsManager {
self.notify_models_updated();
}
/// Notify clients about the current model catalog.
fn notify_models_updated(&self) {
let available = self.available();
let current = self.current_model_id();
@@ -899,7 +887,7 @@ impl ModelsManager {
}
// Recompute the prompt-block flag (mirrors `apply_refresh_result`) so
// a corrective external cache write unlatches a previously latched
// a corrective external cache write unlatches an
// "allowlist excludes everything" state instead of keeping prompts
// blocked against a stale catalog.
let excludes_all = allowlist_matches_nothing(&cfg, &self.inner.models.read());
@@ -1111,9 +1099,9 @@ impl ModelsManager {
};
// H1: the session bearer comes from the ONE credential chokepoint,
// resolved against the CURRENT MODEL's own platform AND endpoint. This
// used to be a local re-derivation that fell through to
// `auth_manager.current_or_expired()` for every non-OAuth platform
// resolved against the CURRENT MODEL's own platform AND endpoint. A
// local re-derivation that fell through to
// `auth_manager.current_or_expired()` for every non-OAuth platform is
// byte-for-byte the round-1 defect, reachable with ZERO configuration
// (`default_models.json` bundles `moonshot-cn/*` entries a Kimi
// subscription user sees on first launch / offline, and this config is
@@ -1448,8 +1436,6 @@ impl ModelsManager {
}
}
// ── Refresh strategy ────────────────────────────────────────────────────────
/// How to resolve the model list.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshStrategy {
@@ -1461,8 +1447,6 @@ pub enum RefreshStrategy {
OnlineIfUncached,
}
// ── Disk cache ──────────────────────────────────────────────────────────────
const MODELS_CACHE_FILE: &str = "models_cache.json";
const CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(300);
@@ -1650,7 +1634,7 @@ impl ModelsCacheManager {
/// Sync; see `load_fresh` note. Best-effort, but NEVER silent: a failed
/// cache write leaves a stale catalog on disk, which on Windows (sharing
/// violations) previously diverged picker behavior with zero trace.
/// violations) diverged picker behavior with zero trace.
fn atomic_write(&self, cache: &ModelsCache) {
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
@@ -1692,8 +1676,6 @@ impl ModelsCacheManager {
}
}
// ── Fetch ───────────────────────────────────────────────────────────────────
/// Build the prefetched model map from a flat list of entries.
///
/// Each entry is keyed by its `id` field (falling back to the `model` slug
@@ -1885,15 +1867,6 @@ fn resolve_prefetch_env_with_auth(auth: Option<KimiAuth>) -> Option<PrefetchEnv>
)
}
/// Decision core of [`resolve_prefetch_env_with_auth`], split from the config
/// loading so the gate is unit-testable.
///
/// `remote_fetch_enabled = false` wins over every credential shape AND over
/// `has_custom_endpoint()` (which otherwise forces the prefetch to run): the
/// explicit off switch must hold even when a stray login, a platform API key,
/// or a `deployment_key` would re-arm the prefetch — and with it the
/// deployment-config sync on the prefetch thread.
///
/// Decision core of [`ModelsManager::on_auth_changed`]'s wipe guard, split
/// from the disk probes so it is unit-testable: `true` = no fetch source
/// exists, wipe the previous identity's catalog.
@@ -2072,12 +2045,6 @@ pub(crate) fn resolve_catalog_key(
/// The one caller that legitimately passes `current_model_id` is the SHARED
/// `MvpAgent::sampling_config`, which `ModelsManager::sampling_config` builds
/// from exactly that key — there the two lookups must agree (H-a).
///
/// (L: this rule used to be stated on a `managed_key_for_slug` wrapper that no
/// caller needed once [`platform_for_slug`] resolved the entry itself. The
/// crate-level `#![allow(dead_code)]` in `lib.rs` means an unused helper on a
/// credential path raises no warning, so dead ones are deleted on sight rather
/// than left as a second, unexercised way to answer the same question.)
pub(crate) fn entry_for_slug<'a>(
models: &'a IndexMap<String, ModelEntry>,
current_key: Option<&str>,
@@ -2709,7 +2676,6 @@ mod tests {
#[tokio::test]
async fn refresh_if_new_etag_skips_when_same() {
let mgr = test_manager();
// Set initial etag
*mgr.inner.etag.write() = Some("\"abc123\"".to_string());
// Same etag — should be a no-op (etag stays the same)
@@ -3078,8 +3044,6 @@ mod tests {
.collect()
}
// ── auth-change refresh: has_fetched_real_catalog flag ─────────────
#[test]
fn first_apply_refresh_reselects_default_model() {
let mgr = test_manager();
@@ -3153,8 +3117,6 @@ mod tests {
);
}
// ── apply_config: honor changed preferred model from config ────────
#[test]
fn apply_config_honors_new_preferred_model() {
let mgr = test_manager();
@@ -3267,8 +3229,6 @@ mod tests {
);
}
// ── end-to-end: auth refresh + config reload compose correctly ───
#[test]
fn auth_refresh_then_config_reload_preserves_user_model() {
let mgr = test_manager();
@@ -3298,8 +3258,6 @@ mod tests {
assert_eq!(mgr.current_model_id().0.as_ref(), "kigi-4");
}
// ── disk-cache hot-reload (external models_cache.json writes) ────
fn test_cache_manager(dir: &std::path::Path) -> ModelsCacheManager {
ModelsCacheManager {
path: dir.join(MODELS_CACHE_FILE),
@@ -3539,8 +3497,6 @@ mod tests {
assert!(!mgr.models().contains_key("kigi-legacy"));
}
// ── clear() resets has_fetched_real_catalog ──────────────────────
#[test]
fn clear_resets_has_fetched_real_catalog() {
let mgr = test_manager();
@@ -3610,12 +3566,14 @@ mod tests {
let mut cfg = config::Config::default();
cfg.models.default = Some("alpha".to_string());
mgr.apply_refresh_result(&cfg, Some(make_prefetched(&["alpha", "beta"])), None);
*mgr.inner.cfg.write() = cfg.clone(); // old_preferred = "alpha"
// old_preferred = "alpha"
*mgr.inner.cfg.write() = cfg.clone();
assert_eq!(mgr.current_model_id().0.as_ref(), "alpha");
let mut new_cfg = config::Config::default();
new_cfg.models.default = Some("beta".to_string());
new_cfg.models.default_is_campaign_driven = true; // campaign overriding
// campaign overriding
new_cfg.models.default_is_campaign_driven = true;
mgr.apply_config(new_cfg);
assert_eq!(
mgr.current_model_id().0.as_ref(),
@@ -3694,8 +3652,6 @@ mod tests {
);
}
// ── ModelFetchAuth::resolve + PlatformApiKeys tests ─────────────
use kigi_test_support::EnvGuard;
use serial_test::serial;
@@ -3800,8 +3756,6 @@ mod tests {
assert!(dbg.contains("true") && dbg.contains("false"));
}
// ── remote_fetch gate: resolve_prefetch_env_from_parts ───────────
/// remote_fetch=false must return `None` against every re-arming shape at
/// once — session auth, a moonshot platform key, AND a custom models
/// endpoint (which normally forces the prefetch to run).
@@ -3942,8 +3896,6 @@ mod tests {
);
}
// ── supported_in_api tests ──────────────────────────────────────
#[test]
fn default_model_skips_oauth_only_for_api_key_users() {
let cfg = config::Config::default();
@@ -4048,8 +4000,6 @@ mod tests {
);
}
// ── duplicate model slug re-keying (A/B experiment "auto" alias) ──
fn make_entry_config(model: &str, name: Option<&str>) -> config::ModelEntryConfig {
make_entry_config_with_id(None, model, name)
}
@@ -4175,8 +4125,6 @@ mod tests {
assert!(map.contains_key("kigi"));
}
// ── persisted model id → catalog key (session resume) ─────────────
#[test]
fn resolve_catalog_key_maps_routing_slug_to_config_key() {
let mut models = IndexMap::new();
@@ -4325,8 +4273,6 @@ mod tests {
.collect()
}
// ── PRD F2/F4 wiremock suite ─────────────────────────────────────
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -4,16 +4,13 @@
//! (the subscription platform via the OAuth session, the open platforms via
//! their API keys), plus the custom-endpoint OpenAI-compatible listing path.
//!
//! This is the network surface relocated out of the deleted xAI-proxy
//! backend client (`remote/`); it talks only to the configured platform
//! model endpoints (plus the models.dev metadata refresh when an enabled
//! platform needs enrichment — see `enrichment_fetch`), never to a proxy
//! backend.
//! It talks only to the configured platform model endpoints (plus the
//! models.dev metadata refresh when an enabled platform needs enrichment —
//! see `enrichment_fetch`), never to a proxy backend.
use crate::auth::KimiAuth;
use indexmap::IndexMap;
use serde::Deserialize;
/// Errors from a model-catalog fetch.
#[derive(Debug, thiserror::Error)]
pub(crate) enum BackendError {
#[error("Network error: {0}")]
@@ -571,12 +568,6 @@ fn fetch_one_platform_models(
.collect();
Ok((models, etag))
}
/// Map one F4 wire model to a catalog entry config.
///
/// SECURITY: the entry carries only env-var NAMES (`env_key`) for the open
/// platforms — never key values — because raw fetched entries are persisted
/// to the models disk cache. Config-file keys are stamped in-memory later by
/// `resolve_model_list`'s platform-credentials layer.
/// Map a live `think_efforts` block to catalog effort options. The wire
/// token stays the option id/label (`"max"` → label `"Max"`) so the UI
/// mirrors the server's vocabulary, while the canonical value maps through
@@ -612,6 +603,12 @@ fn think_efforts_to_options(
.collect()
}
/// Map one F4 wire model to a catalog entry config.
///
/// SECURITY: the entry carries only env-var NAMES (`env_key`) for the open
/// platforms — never key values — because raw fetched entries are persisted
/// to the models disk cache. Config-file keys are stamped in-memory later by
/// `resolve_model_list`'s platform-credentials layer.
pub(crate) fn platform_wire_model_to_entry(
platform: kigi_models::PlatformId,
wire: kigi_models::WireModel,
@@ -1,13 +1,9 @@
#![cfg_attr(rustfmt, rustfmt::skip)]
#![allow(unused_imports)]
//! [`acp::Agent`] trait implementation for [`MvpAgent`].
//! Co-located child of `mvp_agent` (`use super::*`).
use super::*;
#[async_trait::async_trait(?Send)]
impl acp::Agent for MvpAgent {
/// In the meta, we provide
/// - model_state: the model state, useful for the client to display available models and the default model.
///
/// SINGLE-CALL INVARIANT: this method is the sole writer of
/// `self.auth_method_id` during initialization. It is called exactly once
/// per agent process by the ACP server before any session-creating
@@ -527,7 +523,7 @@ impl acp::Agent for MvpAgent {
})?;
// C1: hot-swap FIRST, then let the authority read the fresh
// token back where it belongs. Nothing hand-carries `auth.key`
// to the shared config any more — the stamp is whatever
// to the shared config — the stamp is whatever
// credential governs that config's own model + endpoint, which
// for a session whose current model is another provider's
// subscription model is that provider's pooled token, and for a
@@ -890,8 +886,7 @@ impl acp::Agent for MvpAgent {
Some(serde_json::json!({ "cwd" : cwd.as_str() })),
);
let models = if is_chat_kind {
// The kimi.com chat-mode model picker was removed with the xAI
// proxy; a chat-kind session has no managed catalog to offer.
// A chat-kind session has no managed catalog to offer.
chat_new_session_model_state(
acp::SessionModelState::new(acp::ModelId::from(String::new()), Vec::new()),
session_initial_model
@@ -72,13 +72,12 @@ impl MvpAgent {
/// ([`crate::session::acp_session::sampler_turn::aux_bearer_resolver_for`]),
/// not a second copy of it.
///
/// M3 completed: the aux path was gated on the session-token gate and this
/// one was not, so an api-key / house-key session whose
/// `[model.session-summary]` block carries its OWN `env_key` on the
/// session's own coding endpoint had that key REPLACED on the wire by the
/// Without the shared session-token gate, an api-key / house-key session
/// whose `[model.session-summary]` block carries its OWN `env_key` on the
/// session's own coding endpoint has that key REPLACED on the wire by the
/// primary bearer on every summary request. Named (rather than inlined
/// above) so the gate is reachable from a test — the ungated version stayed
/// green because the resolver is consumed by `OaiCompatClient::new`.
/// above) so the gate is reachable from a test — the resolver is otherwise
/// consumed by `OaiCompatClient::new`.
///
/// Aux slugs are not the session's selection, so `current_key = None`
/// (H-b then refuses a collided slug rather than guessing its OAuth twin).
@@ -97,7 +96,6 @@ impl MvpAgent {
base_url,
)
}
/// `true` for session-based ACP auth methods.
fn is_session_based_auth(&self) -> bool {
self.auth_method_id
.load()
@@ -109,7 +107,6 @@ impl MvpAgent {
pub(super) fn set_auth_method(&self, id: acp::AuthMethodId) {
self.auth_method_id.store(Some(std::sync::Arc::new(id)));
}
/// Return auth for sync config construction.
pub(super) fn current_or_buffered_auth(&self) -> Option<crate::auth::KimiAuth> {
self.auth_manager
.current()
@@ -134,11 +131,10 @@ impl MvpAgent {
///
/// Memoizes the single [`folder_trust::resolve_launch_dir_trust`] gather (see
/// it for the dedup + TOCTOU contract) so the two one-shot init helpers
/// (`ensure_plugin_registry` and `ensure_local_workspace_ops`) share it
/// instead of each re-scanning. They share a single point-in-time verdict
/// rather than two independent re-scans; the sub-millisecond, startup-only
/// window between them is intentional (the cross-session TOCTOU re-scan is
/// preserved per the contract).
/// (`ensure_plugin_registry` and `ensure_local_workspace_ops`) share one
/// point-in-time verdict instead of each re-scanning. The sub-millisecond,
/// startup-only window between them is intentional (the cross-session TOCTOU
/// re-scan is preserved per the contract).
fn prime_launch_dir_trust(&self) -> (&std::path::Path, bool) {
let trust = *self
.launch_dir_trust
@@ -195,7 +191,7 @@ impl MvpAgent {
}
/// Build the launch-dir plugin registry snapshot on first use.
///
/// Boot-time discovery was deferred past ACP `initialize` (the cwd→git-root
/// Boot-time discovery is deferred past ACP `initialize` (the cwd→git-root
/// plus user/marketplace walks stalled kigi-desktop's first `initialize`),
/// leaving `plugin_registry_handle` empty. That shared snapshot still backs
/// the launch-dir plugin MCP/LSP merges read in `resolve_mcp_servers` and
@@ -232,7 +228,6 @@ impl MvpAgent {
&self.cfg.borrow().compat_resolved,
)
}
/// Set the memory configuration (called from TUI after config resolution).
pub fn set_memory_config(&mut self, config: crate::config::MemoryConfig) {
self.memory_config = if config.enabled { Some(config) } else { None };
}
@@ -583,7 +578,7 @@ impl MvpAgent {
reauth = auth_meta.reauth,
"auth: generic oauth device login",
);
// M4: the SAME home the pool reads from
// The SAME home the pool reads from
// (`oauth_registry::pool_home()`), not `kigi_home()` directly —
// identical in production, but a login driven from a lib test would
// otherwise write into the developer's real `~/.kigi` while every
@@ -637,8 +632,7 @@ impl MvpAgent {
/// Re-resolve eagerly-resolved config fields from the local config.
///
/// Called on `/new` session creation so feature flags reflect the latest
/// on-disk config without requiring a TUI restart. (Formerly this also
/// re-fetched the xAI proxy's remote settings; that endpoint is gone.)
/// on-disk config without requiring a TUI restart.
///
/// In-flight sessions are unaffected — they snapshot config at creation.
pub(super) async fn refresh_settings_and_reapply(&self) {
@@ -819,7 +813,6 @@ impl MvpAgent {
c
}
}
/// Resolve `AgentDefinition.model` override for the parent session.
/// Apply a profile's pinned-model override to the session's sampling config.
///
/// `pinned_model` is resolved once by the caller (shared with harness
@@ -841,7 +834,6 @@ impl MvpAgent {
);
(id.clone(), new_config)
}
/// Build deploy-service config. The tool talks directly to the deployer service.
pub(super) fn prepare_app_builder_deployer_config(
&self,
) -> kigi_tools::implementations::kigi::deploy_app::AppBuilderDeployerConfig {
@@ -1062,7 +1054,7 @@ impl MvpAgent {
/// client disconnected and these sessions lost their IPC owner.
///
/// **This is the no-evict keystone.** A disconnect must
/// NOT destroy a session. The behavior is now *detach + keep-resident +
/// NOT destroy a session. The behavior is *detach + keep-resident +
/// idle-unload*:
///
/// - **Sessions with live work stay resident.** We do NOT send `Shutdown`
@@ -1254,15 +1246,12 @@ impl MvpAgent {
let _ = tokio::time::timeout(deadline - now, rx.changed()).await;
}
}
/// Returns the default YOLO mode setting for new sessions
pub fn default_yolo_mode(&self) -> bool {
self.default_yolo_mode
}
/// Returns the storage mode configured for this agent
pub fn storage_mode(&self) -> StorageMode {
self.storage_mode
}
/// Returns the background copy context for managing background file copy tasks.
pub fn background_copy_context(&self) -> BackgroundCopyContext {
self.background_copy_context.clone()
}
@@ -1326,7 +1315,6 @@ impl MvpAgent {
) -> Vec<crate::agent::subagent::RunningSubagentListSeed> {
self.subagent_coordinator.borrow().list_running_for_parent(parent_session_id)
}
/// Return fork provenance metadata for a subagent.
pub(crate) fn provenance_for_subagent(
&self,
subagent_id: &str,
@@ -1393,14 +1381,10 @@ impl MvpAgent {
Err(_) => Err("timeout"),
}
}
/// Get a session's cwd by session_id.
/// Returns None if the session is not found.
pub fn get_session_cwd(&self, session_id: &acp::SessionId) -> Option<PathBuf> {
let sessions = self.sessions.borrow();
sessions.get(session_id).map(|handle| PathBuf::from(&handle.info.cwd))
}
/// Get a session handle by session_id.
/// Returns None if the session is not found.
pub fn get_session_handle(
&self,
session_id: &acp::SessionId,
@@ -1626,25 +1610,25 @@ impl MvpAgent {
/// returns it verbatim whenever a model id fails to resolve, and
/// `SubagentSpawnContext` clones it as every subagent's baseline — so an
/// `api_key` stamped here reaches the wire against whatever `base_url` the
/// config carries. The login/seed sites used to stamp it unconditionally,
/// exactly the mistake `authenticate_oauth_platform` already documents ("Do
/// NOT stamp this token onto the shared sampling_config").
/// config carries. Stamping it unconditionally is exactly the mistake
/// `authenticate_oauth_platform` documents ("Do NOT stamp this token onto
/// the shared sampling_config").
///
/// C1: this function does NOT take a credential. The previous shape took
/// `key: String` — always the primary Kimi bearer — and guarded it with a
/// predicate asking whether **a** session credential may ride. For a
/// subscription-OAuth platform at its own host that is correctly `true`, but
/// the credential that may ride there is that platform's POOLED token: a
/// Claude Pro/Max user running `kigi login` stamped the Kimi subscription
/// bearer onto a config routed at `api.anthropic.com`. Asking
/// `credential_for` instead makes the question and the credential the same
/// object, so the pairing cannot be wrong — and there is no primary handle
/// here to hand-carry (M2).
/// C1: this function does NOT take a credential — it asks `credential_for`,
/// so the question ("may a session credential ride here?") and the
/// credential itself are the same object and cannot be mispaired. Taking a
/// `key: String` (always the primary Kimi bearer) guarded by a separate
/// predicate mispairs them: for a subscription-OAuth platform at its own
/// host the predicate is correctly `true`, but the credential that may ride
/// there is that platform's POOLED token, not the primary — so a Claude
/// Pro/Max user running `kigi login` stamped the Kimi subscription bearer
/// onto a config routed at `api.anthropic.com`. There is no primary handle
/// here to hand-carry either.
///
/// `overwrite = false` keeps the historical "only if missing" seeding
/// behaviour; the login handlers pass `true` because a fresh login must
/// replace a stale bearer, and they call this AFTER the manager holds the
/// new token so it is read back through the authority.
/// `overwrite = false` seeds only when `api_key` is missing; the login
/// handlers pass `true` because a fresh login must replace a stale bearer,
/// and they call this AFTER the manager holds the new token so it is read
/// back through the authority.
///
/// SECURITY: the token is never logged.
pub(super) fn stamp_session_credential(&self, overwrite: bool) -> bool {
@@ -2448,7 +2432,7 @@ impl MvpAgent {
// Classify the credential's auth_type against the bearer this
// model's endpoint would ACTUALLY receive, not the raw primary:
// an API-key-platform model has no session credential at all, and
// reading the primary here reported one.
// reading the primary here would report one.
//
// H-a: the platform is resolved with the SAME catalog key the
// actor about to be spawned seeds itself with
@@ -1,5 +1,4 @@
//! Code-navigation eligibility gating and codebase-index management for [`MvpAgent`].
//! Co-located child of `mvp_agent` (`use super::*`).
use super::*;
@@ -41,10 +40,11 @@ impl MvpAgent {
Some((handle, was_newly_started))
}
/// Core eligibility check — pure function that accepts explicit client
/// context rather than reading global agent state.
/// Core eligibility check — takes client type and capability as explicit
/// arguments instead of the last-initialize global fields, so it is correct
/// under leader mode.
///
/// This is the single place that applies all four gates. Call it via
/// 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(
@@ -55,7 +55,6 @@ impl MvpAgent {
) -> Result<(), CodeNavEligibility> {
use crate::agent::config::CodebaseIndexingSetting;
// Gate 1: client type
if !matches!(client_type, ClientType::KigiWeb) {
tracing::info!(
client_type = ?client_type,
@@ -66,7 +65,6 @@ impl MvpAgent {
return Err(CodeNavEligibility::ClientNotWeb);
}
// Gate 2: capability advertised
if !code_nav_enabled {
tracing::info!(
gate = "capability",
@@ -76,7 +74,6 @@ impl MvpAgent {
return Err(CodeNavEligibility::CapabilityNotAdvertised);
}
// Gate 3: config
let setting = self.cfg.borrow().features.codebase_indexing.clone();
if let CodebaseIndexingSetting::Enabled(false) = &setting {
tracing::info!(
@@ -87,7 +84,6 @@ impl MvpAgent {
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) => {
@@ -113,7 +109,8 @@ impl MvpAgent {
return Err(CodeNavEligibility::DisabledByConfig);
}
}
CodebaseIndexingSetting::Enabled(false) => {} // handled above
// Enabled(false) already returned above.
CodebaseIndexingSetting::Enabled(false) => {}
}
Ok(())
@@ -128,7 +125,8 @@ impl MvpAgent {
/// 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.
/// Rejects with `SessionRequired` when no `session_id` is given or the
/// session is unknown, rather than falling back to global state.
pub fn code_nav_eligibility_for_request(
&self,
session_id: Option<&acp::SessionId>,
@@ -214,7 +212,6 @@ impl MvpAgent {
}
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());
@@ -250,8 +247,6 @@ impl MvpAgent {
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,
@@ -9,7 +9,7 @@
//! 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::*`).
//! double-prompted.
//!
//! 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
@@ -22,8 +22,6 @@ pub(crate) struct LocalRef<T> {
ptr: *const T,
}
impl<T> LocalRef<T> {
/// Create a `LocalRef` from a shared reference.
///
/// # Safety contract (enforced by the caller, not by the type system)
///
/// The referenced `T` must live for the entire duration of the `LocalSet`
@@ -31,8 +29,6 @@ impl<T> LocalRef<T> {
pub(crate) fn new(val: &T) -> Self {
Self { ptr: val as *const T }
}
/// Dereference back to `&T`.
///
/// # Safety
///
/// Safe because the caller of `new()` guarantees the pointee is alive
@@ -29,7 +29,6 @@ fn includes_baseline_keys_without_usage() {
assert_eq!(meta["promptId"], "prompt-1");
assert_eq!(meta["totalTokens"], 42_000);
assert_eq!(meta["modelId"], "kigi-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());
@@ -107,21 +106,18 @@ fn usage_object_lands_on_meta() {
#[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")
@@ -129,7 +125,6 @@ fn structured_output_maps_to_camelcase_meta_keys() {
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")
@@ -140,7 +135,6 @@ fn structured_output_maps_to_camelcase_meta_keys() {
);
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());
@@ -3,7 +3,6 @@
//! 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);
@@ -46,7 +45,7 @@ impl MvpAgent {
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`.
/// [`Self::prompt_intake_locks`]).
pub(super) fn prompt_intake_lock(
&self,
id: &acp::SessionId,
@@ -64,13 +63,11 @@ impl MvpAgent {
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()
@@ -188,8 +185,6 @@ impl MvpAgent {
_ => 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,
@@ -224,7 +219,6 @@ impl MvpAgent {
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()
@@ -243,7 +237,6 @@ impl MvpAgent {
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()
@@ -265,8 +258,7 @@ impl MvpAgent {
/// 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).
/// disk and stays resumable.
pub(super) fn reap_dead_session(&self, id: &acp::SessionId) {
self.remove_session_terminal(id, SessionLiveState::DeadFailed);
}
@@ -2,14 +2,9 @@
//! 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.
/// Start the subagent coordinator drain task: receive `SubagentEvent`s and
/// dispatch each. Idempotent — the `subagent_event_rx` receiver is taken
/// once, so 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;
@@ -264,16 +259,12 @@ impl MvpAgent {
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.
/// Build a `SubagentSpawnContext` 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,
@@ -186,7 +186,6 @@ async fn broadcast_refresh_skill_baseline_tolerates_dropped_receiver() {
}
/// The monotonic turn counter must never wrap on the DB-bound i32 path.
/// `allocate_turn_number` returns u64; the AB submission casts to i32.
/// Verify we saturate instead of wrapping.
#[test]
fn trace_turn_to_i32_saturates_at_max() {
let small: u64 = 42;
@@ -221,7 +220,6 @@ fn allocate_turn_number_advances_counter() {
assert_eq!(allocate(&sid), 2);
assert_eq!(*counters.borrow().get(&sid).unwrap(), 3);
}
/// With no overrides and model_agent_type = None, the default agent is used.
#[test]
#[serial_test::serial]
fn resolve_agent_definition_defaults_to_kigi() {
@@ -242,8 +240,6 @@ fn resolve_agent_definition_defaults_to_kigi() {
unsafe { std::env::set_var("KIGI_AGENT", v) }
}
}
/// When model_agent_type = Some("codex"), the codex agent is selected even
/// though the default chain would return kigi.
#[test]
#[serial_test::serial]
fn resolve_agent_definition_model_agent_type_overrides_default() {
@@ -319,8 +315,7 @@ fn resolve_agent_definition_acp_profile_wins_when_model_agent_type_is_default()
unsafe { std::env::set_var("KIGI_AGENT", v) }
}
}
/// Regression: after `DEFAULT_AGENT_TYPE` flipped to
/// `kigi-plan`, models in the catalog that still declare
/// Regression: models in the catalog that still declare
/// `agent_type = "kigi"` explicitly must NOT preempt an ACP
/// profile. Any value in the `kigi*` family is the stock harness
/// with no strict requirement.
@@ -406,7 +401,6 @@ fn resolve_agent_definition_cli_agent_profile_wins_when_model_agent_type_is_defa
unsafe { std::env::set_var("KIGI_AGENT", v) }
}
}
/// Agent profile with `model: Override(id)` preserves the field through resolution.
#[test]
#[serial_test::serial]
fn resolve_agent_definition_agent_profile_with_model_override() {
@@ -679,7 +673,6 @@ fn file_toolset_override_invalid_config_returns_error() {
assert!(err.is_err());
assert!(err.unwrap_err().contains("unknown"));
}
/// Helper: creates a real SessionHandle with the given model, yolo, and client id.
/// Requires a tokio runtime for SessionSignalsHandle::new().
fn make_test_handle(
model: &str,
@@ -751,7 +744,6 @@ fn make_test_handle(
scheduler_handle: None,
}
}
/// lookup_session_model returns the per-session model for each session.
#[tokio::test]
async fn lookup_session_model_returns_per_session_model() {
let sid_a = acp::SessionId::new("sess-a");
@@ -775,7 +767,6 @@ async fn lookup_session_model_returns_per_session_model() {
"codex-mini"
);
}
/// lookup_session_model falls back to the default when session_id is None.
#[tokio::test]
async fn lookup_session_model_fallback_no_session() {
let default_model = acp::ModelId::new("kigi-3");
@@ -787,7 +778,6 @@ async fn lookup_session_model_fallback_no_session() {
"kigi-3"
);
}
/// Mutating session A's model_id via the handle does not affect session B.
#[tokio::test]
async fn set_session_model_does_not_cross_contaminate() {
let sid_a = acp::SessionId::new("sess-a");
@@ -895,7 +885,6 @@ async fn session_config_options_resolves_routing_slug_to_catalog_model() {
"resolved catalog model must be selected"
);
}
/// YOLO toggle scoped by client_identifier: only matching sessions are updated.
#[tokio::test]
async fn yolo_toggle_scoped_by_client_identifier() {
let sid_tui = acp::SessionId::new("sess-tui");
@@ -922,8 +911,6 @@ async fn yolo_toggle_scoped_by_client_identifier() {
"VS Code session must NOT be affected by TUI's yolo toggle"
);
}
/// A client can explicitly disable YOLO for its own sessions after startup,
/// even if those sessions were initially created with yolo=true.
#[tokio::test]
async fn yolo_toggle_can_disable_session_started_with_yolo_enabled() {
let sid_tui = acp::SessionId::new("sess-tui");
@@ -950,8 +937,6 @@ async fn yolo_toggle_can_disable_session_started_with_yolo_enabled() {
"other client's session must keep its previous yolo state"
);
}
/// `drain_old_session_thread` returns immediately when the thread has
/// already finished.
#[tokio::test]
async fn drain_finished_thread_returns_immediately() {
let session_threads: RefCell<HashMap<acp::SessionId, crate::session::SessionThread>> =
@@ -967,7 +952,6 @@ async fn drain_finished_thread_returns_immediately() {
assert!(thread.is_finished(), "thread should be finished");
assert!(!session_threads.borrow().contains_key(&sid));
}
/// `drain_old_session_thread` waits for a slow thread to finish.
#[tokio::test]
async fn drain_waits_for_slow_thread() {
let session_threads: RefCell<HashMap<acp::SessionId, crate::session::SessionThread>> =
@@ -994,7 +978,6 @@ async fn drain_waits_for_slow_thread() {
}
assert!(thread.is_finished());
}
/// Drain respects the 5s deadline and returns even if the thread is still running.
#[tokio::test]
async fn drain_respects_deadline() {
let session_threads: RefCell<HashMap<acp::SessionId, crate::session::SessionThread>> =
@@ -1133,7 +1116,6 @@ fn test_sessionless_request_requires_session_id() {
"cwd-only requests with no sessionId must return SessionRequired"
);
}
/// Build a minimal MvpAgent suitable for testing extension methods.
fn build_minimal_agent_for_tests() -> MvpAgent {
use crate::agent::config::Config as AgentConfig;
use crate::auth::{AuthManager, KimiCodeConfig};
@@ -1145,7 +1127,6 @@ fn build_minimal_agent_for_tests() -> MvpAgent {
let cfg = AgentConfig::default();
MvpAgent::new(gateway, &cfg, auth_manager, None).expect("valid test config")
}
/// Build a minimal MvpAgent with pre-loaded auth for gate tests.
fn build_agent_with_auth(auth: crate::auth::KimiAuth) -> MvpAgent {
use crate::agent::config::Config as AgentConfig;
use crate::auth::{AuthManager, KimiCodeConfig};
@@ -1201,12 +1182,6 @@ async fn prepare_sampling_config_never_stamps_kimi_key_on_grok_model() {
// api_key — the byte-identical primary path, and proof the Kimi token is
// live (so it WOULD leak if mis-routed onto a grok request). This assertion
// also confirms the session-based primary path is active.
//
// This used to use `moonshot-cn/kimi-k2-0905-preview` and assert the SAME
// thing, which encoded the C1 defect: moonshot-cn is an API-key registry
// platform on `api.moonshot.cn`, NOT first-party, so "must carry the primary
// session key" was asserting the leak. `api_key_channel_leak_tests` now pins
// the opposite for every moonshot entry.
let mut kimi_model = ModelEntry::fallback("kimi-for-coding", &endpoints);
kimi_model.info.id = Some("kimi-code/kimi-for-coding".to_string());
kimi_model.info.base_url = kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url.to_string();
@@ -1478,7 +1453,6 @@ async fn push_roster_activity_delta_broadcasts_overridden_activity() {
let changed = drain_roster_changed(&mut rx).expect("turn-end delta emitted");
assert_eq!(changed.upserted[0].activity, RosterActivity::Idle);
}
/// Extract the inner payload from an ExtResponse.
#[expect(
dead_code,
reason = "unused in production; remove expect when wired or delete the item"
@@ -1528,7 +1502,6 @@ async fn test_web_session_with_capability_is_eligible() {
"web session with code-nav capability must be eligible"
);
}
/// TUI session is rejected at gate 1 (client type) regardless of capability.
#[tokio::test]
async fn test_tui_session_is_rejected() {
let sid = acp::SessionId::new("sess-tui");
@@ -1541,7 +1514,6 @@ async fn test_tui_session_is_rejected() {
"TUI client must be rejected at gate 1 (client type)"
);
}
/// Web session without capability is rejected at gate 2.
#[tokio::test]
async fn test_web_session_without_capability_is_rejected() {
let sid = acp::SessionId::new("sess-web-no-cap");
@@ -1554,8 +1526,6 @@ async fn test_web_session_without_capability_is_rejected() {
"web client without capability must be rejected at gate 2"
);
}
/// Leader-mode isolation: two sessions with different code-nav state return
/// independent results.
#[tokio::test]
async fn test_leader_mode_two_sessions_stay_isolated() {
let web_sid = acp::SessionId::new("web");
@@ -1948,7 +1918,7 @@ async fn auth_type_no_method_id_no_current_returns_api_key() {
/// in-memory bearer takes precedence: this is the order observed during
/// `initialize()` silent refresh -- a token is hot-swapped in before
/// `authenticate()` writes the method id. Reporting `SessionToken`
/// here matches pre-fix behavior and keeps logging stable.
/// here keeps logging stable.
#[tokio::test(flavor = "current_thread")]
async fn auth_type_no_method_id_with_current_returns_session_token() {
use crate::auth::KimiAuth;
@@ -2593,9 +2563,8 @@ fn session_live_state_map_is_bounded_across_cycles() {
}
/// Finalize fires on a genuine terminal close — driven through the **real**
/// `kigi/session/close` dispatch (`ext_method` → `handle_session_close`),
/// not the internal helper. Proves finalize was *moved* (not removed) and
/// guards the handler's `existed` gate. (Finalize assertion is
/// invocation-level; see note in `finalize_session_replica`.)
/// not the internal helper. Guards the handler's `existed` gate. (Finalize
/// assertion is invocation-level; see note in `finalize_session_replica`.)
#[test]
fn explicit_close_finalizes_the_replica() {
run_local_for_bridge_test(|| async {
@@ -2697,8 +2666,6 @@ fn supervisor_reaps_panicked_resident_actor() {
);
});
}
/// `ensure_session_supervisor` is idempotent: calling it repeatedly spawns
/// the sweeper loop exactly once.
#[test]
fn ensure_session_supervisor_is_idempotent() {
run_local_for_bridge_test(|| async {
@@ -150,7 +150,7 @@ async fn api_key_platform_models_never_carry_the_kimi_bearer_as_api_key() {
}
/// C2, the `[model.*]` repro. A `[model.gpt-4o]` block has `info.id == None`, so
/// it has no platform at all — which used to be a blanket allow. BYOK is
/// it has no platform at all — the blanket-allow gap the guard closes. BYOK is
/// `has_own_credentials()`, which probes `std::env::var` AT CALL TIME, so an
/// unset (or mistyped) `env_key` classifies the model NotByok and the Kimi
/// bearer went to `api.openai.com` on BOTH channels.
@@ -165,7 +165,8 @@ async fn config_model_with_an_unset_env_key_never_carries_the_kimi_bearer() {
let (_dir, agent) = kimi_session_agent();
let mut entry = ModelEntry::fallback("gpt-4o", &EndpointsConfig::default());
entry.info.id = None; // a `[model.gpt-4o]` config block
// a `[model.gpt-4o]` config block
entry.info.id = None;
entry.info.base_url = "https://api.openai.com/v1".to_string();
entry.env_key = Some(EnvKeys::single("OPENAI_API_KEY_TYPO"));
assert!(
@@ -43,9 +43,7 @@ fn rebuild_shared_config(agent: &MvpAgent) {
/// `base_url`. Zero-config repro: a Kimi subscription + a bundled
/// `moonshot-cn/*` default.
///
/// Revert-to-red (L: this edit COMPILES — the previous wording named a
/// `Option<String>` argument that the `Option<&SessionCredential>` signature
/// rejects, so it could never have been run): in
/// Revert-to-red: in
/// `ModelsManager::sampling_config`, ask the authority about the SESSION's
/// endpoint instead of the current model's own —
/// `.credential_for(None, &config.endpoints.proxy_url())` in place of
@@ -331,9 +329,6 @@ async fn oauth_platform_shared_config_never_receives_the_primary_on_login() {
/// unresolved-model fallback and every subagent baseline turn 401'd until
/// restart.
///
/// The switch happens AFTER the config is built — the previous version of this
/// test left both on the same model, so it could not catch this.
///
/// Revert-to-red (production, compiles): make `MvpAgent::shared_config_platform`
/// re-resolve from the live cell instead of returning the captured value —
/// ```ignore
@@ -35,7 +35,8 @@ async fn subagent_spawn_context_inherits_parent_permission_handle() {
pattern: Some("**/.env".to_owned()),
pattern_mode: PatternMode::Glob,
}])),
Vec::new(), // deny_read_globs
// deny_read_globs
Vec::new(),
Vec::new(),
false,
None,
@@ -3,11 +3,8 @@
//! into the JSON shape emitted by `LoadSession` on `_meta.codeRestore`.
use kigi_workspace::session::git::{CheckoutSessionOutcome, RestoreKind, build_restore_decision};
use serde_json::Value;
/// Build the `codeRestore` JSON meta, or `None` when no restore should
/// be reported (no checkout AND no archive applied). The shared
/// [`build_restore_decision`] is the source of truth; this function
/// only adapts the result into the wire JSON shape used by the
/// non-worktree path.
/// Build the `codeRestore` JSON meta for the non-worktree path, or `None`
/// when no restore should be reported (no checkout AND no archive applied).
pub(crate) fn build_code_restore_meta(
target_sha: &str,
outcome: &CheckoutSessionOutcome,
@@ -18,11 +18,6 @@ use serde::{Deserialize, Serialize};
use crate::session::persistence::Summary;
/// Coarse activity of a session as rendered in the dashboard's status column.
///
/// Mirrors the design's `SessionActivity` at dashboard granularity. A full
/// background-work breakdown (bg tasks / monitors / scheduler / subagents)
/// lands with a richer `SessionActivity`; the dashboard only needs this
/// coarse signal to pick a status glyph.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RosterActivity {
+1 -12
View File
@@ -48,9 +48,7 @@ use indexmap::IndexMap;
/// Swappable destination for the relay task.
///
/// Points at the current ACP connection's gateway sender. When no client is
/// connected, the value is `None` and outbound messages are silently dropped
/// (matching the old behaviour where the gateway channel's receiver was simply
/// gone).
/// connected, the value is `None` and outbound messages are silently dropped.
type RelayDest = Rc<RefCell<Option<mpsc::UnboundedSender<AcpClientMessage>>>>;
const MAX_BUFFER_SIZE: usize = 8 * 1024 * 1024;
@@ -59,7 +57,6 @@ const KEEPALIVE_INTERVAL_SECS: u64 = 15;
/// Configuration for the agent WebSocket server.
#[derive(Debug, Clone)]
pub struct ServerConfig {
/// Address to bind the server to
pub bind_addr: SocketAddr,
/// Secret token for client authentication (required)
pub secret: String,
@@ -90,7 +87,6 @@ pub struct WsQueryParams {
/// Validate the bearer token from request headers or query parameters.
fn validate_auth(headers: &HeaderMap, query: &WsQueryParams, expected_secret: &str) -> bool {
// Try Authorization header
if let Some(token) = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
@@ -115,7 +111,6 @@ async fn ws_handler(
headers: HeaderMap,
Query(query): Query<WsQueryParams>,
) -> Response {
// Validate secret token from header or query param
if !validate_auth(&headers, &query, &state.secret) {
warn!("Unauthorized connection attempt from {}", addr);
return (
@@ -139,7 +134,6 @@ async fn handle_connection(ws: WebSocket, state: Arc<ServerState>, peer_addr: So
let (mut ws_write, mut ws_read) = ws.split();
// Channels for bridging WS <-> Agent thread
let (to_agent_tx, to_agent_rx) = mpsc::unbounded_channel::<String>();
let (from_agent_tx, mut from_agent_rx) = mpsc::unbounded_channel::<String>();
@@ -149,7 +143,6 @@ async fn handle_connection(ws: WebSocket, state: Arc<ServerState>, peer_addr: So
{
let mut agent_tx_guard = state.agent_conn_tx.lock().await;
// Check if existing sender is still alive (receiver not dropped)
if let Some(ref tx) = *agent_tx_guard
&& tx.is_closed()
{
@@ -203,7 +196,6 @@ async fn handle_connection(ws: WebSocket, state: Arc<ServerState>, peer_addr: So
info!("Persistent agent thread spawned");
}
// Send new WS channels to the agent thread
if let Some(ref tx) = *agent_tx_guard
&& tx
.send(NewConnectionChannels {
@@ -339,7 +331,6 @@ async fn run_persistent_agent(
}
});
// Accept new connections in a loop
while let Some(channels) = connection_rx.recv().await {
info!("Agent thread: setting up new ACP connection (reconnect)");
setup_acp_connection(agent.clone(), channels, relay_dest.clone());
@@ -361,7 +352,6 @@ fn setup_acp_connection(
to_ws_tx,
} = channels;
// Create new simplex IO streams for this ACP connection
let (agent_read_rx, mut agent_read_tx) = simplex(MAX_BUFFER_SIZE);
let (agent_write_rx, agent_write_tx) = simplex(MAX_BUFFER_SIZE);
@@ -372,7 +362,6 @@ fn setup_acp_connection(
// The relay task will forward persistent-channel messages here.
let (conn_gw_tx, conn_gw_rx) = tokio::sync::mpsc::unbounded_channel::<AcpClientMessage>();
// Point the relay at this new connection's channel
*relay_dest.borrow_mut() = Some(conn_gw_tx);
// Create new ACP connection reusing the same MvpAgent (via Rc clone).
@@ -66,7 +66,6 @@ fn effort_label(effort: ReasoningEffort) -> String {
}
/// The built-in session-picker modes used when the model has no server list.
/// Reproduces the historical five rows and their labels.
pub(crate) fn legacy_session_effort_options() -> Vec<ReasoningEffortOption> {
SELECTABLE_REASONING_EFFORTS
.iter()
@@ -9,10 +9,8 @@ use anyhow::{Context, Result};
use reqwest::RequestBuilder;
use serde::{Deserialize, Serialize};
// ============================================================================
// Request / response types (local — not in cli-chat-proxy since these
// are only used by the agent, not consumed by other crates)
// ============================================================================
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -35,7 +33,7 @@ pub struct RegisterRequest {
pub device_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_session_id: Option<String>,
// --- Subagent-specific fields (optional, backward-compatible) ---
// Subagent-specific fields (optional, backward-compatible).
#[serde(skip_serializing_if = "Option::is_none")]
pub session_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -67,10 +65,6 @@ pub struct UpdateRequest {
pub restorable_turn_number: Option<i32>,
}
// ============================================================================
// Response types
// ============================================================================
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionRecord {
@@ -132,10 +126,6 @@ pub struct DownloadResponse {
pub turn: i32,
}
// ============================================================================
// Client
// ============================================================================
#[derive(Clone)]
pub struct SessionRegistryClient {
raw_client: reqwest::Client,
@@ -387,7 +377,7 @@ impl SessionRegistryClient {
mod tests {
use super::*;
// ── UpdateRequest wire shapes ────────────────────────────────────────────
// UpdateRequest wire shapes.
//
// The writer split relies on two distinct update payloads being sent at
// different times:
@@ -495,7 +485,7 @@ mod tests {
assert!(json.get("device_id").is_none());
}
// ── SessionRecord backward compatibility ─────────────────────────────────
// SessionRecord backward compatibility.
//
// Older servers do not include `restorable_turn_number` in their response.
// The field is `#[serde(default)]` so it must deserialize as `None` when
@@ -69,7 +69,6 @@ impl SubagentCoordinator {
std::sync::atomic::Ordering::Relaxed,
);
}
/// Returns a handle to the completion [`Notify`].
#[cfg_attr(
not(test),
expect(
@@ -80,11 +79,9 @@ impl SubagentCoordinator {
pub fn completion_notify(&self) -> Arc<Notify> {
Arc::clone(&self.completion_notify)
}
/// Returns a shared handle to the turn-active flag.
pub fn turn_active_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
Arc::clone(&self.is_turn_active)
}
/// Whether the model's turn is currently active.
#[cfg_attr(
not(test),
expect(
@@ -163,7 +160,6 @@ impl SubagentCoordinator {
subagent_usage_not_applied: self.subagent_usage_not_applied(prompt_id),
}
}
/// Drain all buffered completion summaries, returning them and clearing the buffer.
pub fn drain_pending_completions(&mut self) -> Vec<SubagentCompletionSummary> {
std::mem::take(&mut self.pending_completions)
}
@@ -235,8 +231,6 @@ impl SubagentCoordinator {
cancelled: false,
});
}
/// Insert a synthetic failed entry, push a completion summary, notify waiters.
/// Clears any stale pending entry for the same id.
fn record_failure_completion(&mut self, c: FailureCompletion<'_>) {
self.pending.remove(&c.subagent_id);
self.sync_running_gauge();
@@ -303,8 +297,6 @@ impl SubagentCoordinator {
self.active.insert(tracker.subagent_id.clone(), tracker);
self.sync_running_gauge();
}
/// Move a finished subagent from `active` to `completed`.
/// Returns the tracker if it was active.
pub fn move_to_completed(
&mut self,
id: &str,
@@ -448,7 +440,6 @@ impl SubagentCoordinator {
}
SubagentCancelOutcome::NotFound
}
/// Internal: send Cancel + Shutdown to a tracked subagent.
fn cancel_tracker(tracker: &SubagentTracker) {
tracker.cancel_token.cancel();
let _ = tracker
@@ -91,9 +91,6 @@ impl SubagentCoordinator {
}
None
}
/// Return `(parent_session_id, child_session_id)` for a given subagent.
///
/// Checks active first, then completed. Returns `None` if not found.
pub(crate) fn session_ids_for(&self, id: &str) -> Option<(String, String)> {
if let Some(t) = self.active.get(id) {
return Some((t.parent_session_id.clone(), t.child_session_id.0.to_string()));
@@ -135,7 +132,7 @@ impl SubagentCoordinator {
self.mark_block_waited(id);
self.block_wait_slots.entry(id.to_string()).or_default().push(slot);
}
/// Drop a previously registered reply slot (query poll loop exited).
/// Drop a registered reply slot (query poll loop exited).
pub(crate) fn unregister_block_wait(&mut self, id: &str, slot: &BlockWaitSlot) {
if let Some(slots) = self.block_wait_slots.get_mut(id) {
slots.retain(|s| !std::rc::Rc::ptr_eq(s, slot));
@@ -181,7 +178,6 @@ impl SubagentCoordinator {
self.active.get(id).is_some_and(|t| t.explicitly_killed)
|| self.completed.get(id).is_some_and(|c| c.explicitly_killed)
}
/// Return fork provenance for a given subagent.
pub(crate) fn provenance_for(&self, id: &str) -> SubagentProvenance {
if let Some(t) = self.active.get(id) {
return SubagentProvenance {
@@ -254,7 +250,6 @@ impl SubagentCoordinator {
model_id: meta.effective_model_id,
})
}
/// Check whether an ID refers to a currently-active (running) subagent.
pub(crate) fn is_active(&self, id: &str) -> bool {
self.active.contains_key(id)
}
@@ -294,7 +289,6 @@ impl SubagentCoordinator {
}
/// Snapshot all currently-running subagents for compaction state context.
///
/// Returns one `ActiveSubagentSummary` per entry in the `active` map.
/// Completed/failed/cancelled subagents are NOT included — they live in
/// the `completed` map and are irrelevant for post-compaction reminders
/// (the model already saw their tool results before compaction).
@@ -36,11 +36,6 @@ pub(super) fn task_model_override_error(
is_session_auth,
)
}
/// This is a free async function, NOT a method on MvpAgent. It receives
/// a `SubagentSpawnContext` with everything it needs, and a mutable
/// reference to the coordinator for tracking.
///
/// Returns when the child session completes (or fails/cancels).
#[tracing::instrument(
name = "subagent.handle_request",
skip_all,
@@ -43,7 +43,7 @@ pub(crate) enum InitialContextSource {
New,
/// Parent history as `<background_context>` (harness-only chat-prefix fork).
Forked,
/// Resumed from a previously completed peer subagent. The child inherits
/// Resumed from a completed peer subagent. The child inherits
/// the source's raw transcript, tool state, and model. System prompt and
/// prompt context are freshly rendered from the current agent definition.
Resumed,
@@ -211,7 +211,6 @@ pub(crate) struct SubagentSpawnContext {
/// Resolved config for the deploy service.
pub app_builder_deployer_config:
kigi_tools::implementations::kigi::deploy_app::AppBuilderDeployerConfig,
/// Whether the write_file tool is enabled.
pub write_file_enabled: bool,
/// Whether goal mode (`/goal`) is enabled.
pub goal_enabled: bool,
@@ -423,7 +422,7 @@ impl SubagentSpawnContext {
/// session-level config, so it is resolved from the same tiers as the
/// parent (requirements/env/user/managed from disk; remote from the
/// parent's snapshot) and follows the session into subagents. Bash stays
/// on tool defaults, as before that knob existed.
/// on tool defaults.
pub fn resolve_tool_params_json(
&self,
) -> crate::session::agent_rebuild::ResolvedToolParamsJson {
@@ -619,7 +618,6 @@ pub(crate) async fn resolve_snapshot(lookup: Option<SnapshotLookup>) -> Option<S
}
}
}
/// Check whether a resolved snapshot is still in the `Running` state.
pub(crate) fn is_running(snap: &SubagentSnapshot) -> bool {
matches!(
snap.status,
@@ -2208,7 +2206,6 @@ fn emit_subagent_notification(
gateway.forward_fire_and_forget(ext_notification);
}
}
/// Progress notification emission interval.
const PROGRESS_PUBLISH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
/// Change signature for the progress-publisher dedupe:
/// `(turn_count, tool_call_count, context_usage_pct, error_count, tokens_used)`.
@@ -126,8 +126,6 @@ fn end_to_end_normalized_conversation_shape() {
assert_eq!(prefix_len, 2);
assert!(prefix_len < conv.len(), "prefix should not cover the task");
}
/// Verify that the task prompt (not background context) would be the
/// cached prompt text in the session pipeline.
#[test]
fn cached_prompt_text_is_task_not_background() {
use kigi_sampling_types::conversation::ConversationItem;
@@ -162,7 +160,6 @@ fn cached_prompt_text_is_task_not_background() {
"background should be the inherited context"
);
}
/// Verify extract_last_real_user_query would return the task.
#[test]
fn last_user_message_is_task_after_normalization() {
use kigi_sampling_types::conversation::ConversationItem;
@@ -286,7 +283,6 @@ fn compaction_preserves_inherited_prefix() {
bg_count, 1, "should have exactly one background_context after compaction"
);
}
/// Verify that compaction with prefix_len=0 (non-forked) passes through unchanged.
#[test]
fn compaction_no_prefix_passes_through() {
use kigi_sampling_types::conversation::ConversationItem;
@@ -623,7 +619,6 @@ fn subagent_worktree_snapshot_gate_defaults_off() {
let ctx = ctx_with_toggle(std::collections::HashMap::new());
assert!(! ctx.resolve_subagent_worktree_snapshot_enabled());
}
/// Remote remote settings value enables the gate when no local override exists.
#[test]
fn subagent_worktree_snapshot_gate_remote_enables() {
let mut ctx = ctx_with_toggle(std::collections::HashMap::new());
@@ -649,7 +644,6 @@ fn subagent_worktree_snapshot_gate_local_overrides_remote() {
"local [features] subagent_worktree_snapshot=false must override remote enable"
);
}
/// Local config alone enables the gate (the per-deployment rollout lever).
#[test]
fn subagent_worktree_snapshot_gate_local_enables() {
let mut config = crate::agent::config::Config::default();
@@ -2380,7 +2374,7 @@ async fn resolve_subagent_inherits_parent_model_without_pins() {
/// An explicit `[subagents.models]` pin routes the subagent to that
/// model regardless of the parent model — both a light parent
/// (`kigi-4.5`) and a custom parent (`composer-2-fast`)
/// honor the pin identically now that the heavy-model gate is gone.
/// honor the pin identically.
#[tokio::test]
async fn resolve_subagent_config_override_pin_applies_for_any_parent() {
use kigi_agent::config::ModelOverride;
@@ -2541,9 +2535,7 @@ async fn subagent_override_first_party_model_still_gets_primary_token() {
/// LEAK guard (C1, subagent-override `api_key` channel): an API-key registry
/// platform override must NOT receive the parent's primary Kimi session token —
/// `resolve_credentials` would stamp it as the child's `api_key` on
/// `api.moonshot.cn`. This test previously asserted the opposite
/// (`subagent_override_non_oauth_model_still_gets_primary_token`), which encoded
/// the defect.
/// `api.moonshot.cn`.
///
/// Revert-to-red: make `CredentialAuthority::governing_manager`'s
/// `Some(platform) => None` arm return `self.primary.clone()` and `api_key`
@@ -5,17 +5,14 @@ use serde_json;
use crate::session::replay_events::SessionNotification;
/// Controls how sampling/output chunks are buffered before being delivered to the client.
/// Parsed from `InitializeRequest.meta.bufferingSettings` and preserved for later use.
/// Parsed from `InitializeRequest.meta.bufferingSettings`.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub(crate) struct BufferingSettings {
/// Maximum number of items to accumulate before flushing
#[serde(default = "default_max_items")]
pub max_items: u64,
/// Maximum total bytes to accumulate before flushing
#[serde(default = "default_max_bytes")]
pub max_bytes: u64,
/// Maximum time in milliseconds to wait before flushing buffered items
#[serde(default = "default_max_duration_ms")]
pub max_duration_ms: u64,
}
@@ -25,20 +22,15 @@ fn default_max_items() -> u64 {
}
fn default_max_bytes() -> u64 {
1024 * 2 // 2 KB
1024 * 2
}
fn default_max_duration_ms() -> u64 {
10 // 10 ms
10
}
/// Low-level buffer for ACP text chunks (agent message/thought chunks).
///
/// API:
/// - `consume_chunk(...) -> Option<SessionNotification>` returns a notification that should be sent now
/// (typically the previously buffered one), or `None` if we keep buffering.
/// - `flush() -> Option<SessionNotification>` returns any pending buffered notification to send.
///
/// Example of session notification:
/// ```json
/// {
@@ -133,7 +125,6 @@ impl ReplayBuffer {
// can't merge, we need to send both chunks immediately to preserve current chunk order.
match self.pending.take() {
Some(pending) => {
// No buffered item after this call.
self.pending_count = 0;
self.pending_bytes = 0;
return Some((pending, Some(incoming)));
@@ -145,7 +136,7 @@ impl ReplayBuffer {
}
if !incoming_notification_timestamp_in_range {
// need to pop previously pending notification and send it immediately
// need to pop the pending notification and send it immediately
let prev = self.pending.replace(incoming);
{
let prev = prev?;
@@ -156,7 +147,6 @@ impl ReplayBuffer {
let pending = self.pending.take();
let had_prev = pending.is_some();
let prev_count = self.pending_count;
// pending is now empty; we'll either refill it (and set counts) or send immediately.
self.pending_count = 0;
self.pending_bytes = 0;
@@ -165,7 +155,6 @@ impl ReplayBuffer {
match (force_send, first, second) {
(_, pending, Some(next)) => {
// Merge wasn't allowed; send both immediately to preserve current chunk order.
// (Nothing remains buffered.)
self.pending_count = 0;
self.pending_bytes = 0;
Some((pending, Some(next)))
@@ -221,10 +210,7 @@ impl ReplayBuffer {
second.map(|s| SessionNotification::Xai(Box::new(s))),
)
}
// Different kinds: can't merge. Force-flush prev, return incoming as second.
(Some(prev), incoming) => (true, prev, Some(incoming)),
// No pending: buffer if the new chunk is a streaming kind,
// force-send otherwise (e.g. ToolCall, Plan, etc.).
(None, incoming) => {
let bufferable = incoming.is_streaming_chunk();
(!bufferable, incoming, None)
@@ -974,7 +960,7 @@ mod tests {
assert!(replay_buffer.flush().is_none());
}
// ── Xai / ToolCallDeltaChunk tests ──────────────────────────────
// Xai / ToolCallDeltaChunk tests
fn delta_chunk(
session: &str,
@@ -58,7 +58,6 @@ use crate::auth::{AuthManager, TOKEN_TTL, token_suffix};
#[cfg(test)]
static EMIT_COUNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Read the test-only emit counter.
#[cfg(test)]
pub(crate) fn test_emit_count() -> u64 {
EMIT_COUNT.load(std::sync::atomic::Ordering::SeqCst)
@@ -341,7 +340,7 @@ pub(crate) fn record_auth_401(
EMIT_COUNT.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
/// Pure (no I/O) computation of the attribution payload. Extracted
/// Pure (no I/O) computation of the attribution payload, kept separate
/// from [`record_auth_401`] so unit tests can assert each field
/// directly without reaching into `unified_log`'s file writer or the
/// tracing layer.
@@ -467,8 +466,6 @@ mod tests {
payload_field(&payload, "current_key_prefix"),
"567890abcdef"
);
// mint_age_seconds: should be small and non-negative for a
// freshly-created auth.
let mint = payload_field(&payload, "mint_age_seconds")
.as_i64()
.unwrap();
@@ -476,8 +473,6 @@ mod tests {
(0..5).contains(&mint),
"mint_age_seconds should be 0-5 sec for a freshly-created auth, got {mint}"
);
// expires_at_seconds_from_now: should be just under 1 hour
// (3600s), with a tolerance for elapsed time during the test.
let expires = payload_field(&payload, "expires_at_seconds_from_now")
.as_i64()
.unwrap();
@@ -543,7 +538,6 @@ mod tests {
let payload = compute_attribution_payload(&am, "Test.legacy", Some("k"));
// mint_age_seconds: ~60.
let mint = payload_field(&payload, "mint_age_seconds")
.as_i64()
.unwrap();
@@ -551,8 +545,6 @@ mod tests {
(60..=70).contains(&mint),
"mint_age_seconds should be ~60 for a 60s-old auth, got {mint}"
);
// expires_at_seconds_from_now: TOKEN_TTL minus 60s = roughly
// 30 * 86400 - 60 = 2_591_940. Tolerate ~10s drift.
let expires = payload_field(&payload, "expires_at_seconds_from_now")
.as_i64()
.unwrap();
@@ -13,14 +13,11 @@ use serde::{Deserialize, Serialize};
pub const KIMI_CODE_OAUTH_SCOPE: &str = "oauth/kimi-code";
/// Auth configuration block (`[kimi_code_config]` in the agent config).
/// Currently empty: the OAuth host and client id are fixed by the
/// environment crate.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct KimiCodeConfig {}
impl KimiCodeConfig {
/// The persisted-credential scope key for this configuration.
pub fn auth_scope(&self) -> String {
KIMI_CODE_OAUTH_SCOPE.to_owned()
}
@@ -2,10 +2,10 @@
//!
//! One authority answers, for every outgoing inference request, the only
//! question that matters: **which credential — if any — may ride it?**
//! ([`CredentialAuthority::credential_class`]). Before this module the answer was
//! re-derived — differently — at every call site (`ModelsManager`, `MvpAgent`,
//! `SessionActor`, the aux/summary/subagent paths), and three separate rounds
//! of fixes each closed some sites and missed others.
//! ([`CredentialAuthority::credential_class`]). Every credential sink
//! (`ModelsManager`, `MvpAgent`, `SessionActor`, the aux/summary/subagent
//! paths) funnels through this one rule, so none can derive the answer
//! differently and leak a bearer.
//!
//! # How omission is structurally prevented
//!
@@ -27,9 +27,8 @@
//! through the identical rule as the `api_key` sink.
//! 4. A guard asks [`CredentialAuthority::credential_class`] and MATCHES on the
//! answer. There is no second, similarly-named boolean to pick by mistake:
//! the round-3 defect (C1) was `takes_session_credential` — *may **a**
//! session credential ride?* — paired with a hand-carried PRIMARY bearer,
//! and the two predicates that made that pairing expressible are gone.
//! C1 was `takes_session_credential` — *may **a** session credential
//! ride?* — misread as license to hand-carry the PRIMARY bearer.
//!
//! SECURITY: no token is ever logged, `Debug`-printed or `Display`ed here.
@@ -67,12 +66,12 @@ impl SessionCredential {
/// WHICH credential — if any — may ride a request routed to a given
/// `(platform, base_url)` pair.
///
/// ONE question with three answers, replacing the two look-alike booleans
/// `takes_session_credential` / `takes_primary_credential` (identical
/// ONE question with three answers, not two look-alike booleans
/// (`takes_session_credential` / `takes_primary_credential`: identical
/// signatures, near-identical names, opposite answers on a subscription host).
/// C1 was caused by asking the first and stamping the credential the second
/// describes; with a single classifier a call site must MATCH on the answer, so
/// that mistake is no longer expressible.
/// that mistake is not expressible.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CredentialClass {
/// `platform`'s OWN pooled subscription-OAuth token, at its own registry
@@ -135,7 +134,8 @@ impl CredentialAuthority {
/// github-copilot, xai-grok) rides ITS OWN pooled manager — never the
/// primary — and only to its own registry host (L10: a
/// `[model."claude-pro-max/x"]` override keeps `info.id` but can point
/// `base_url` anywhere, and used to ship the Claude OAuth bearer there);
/// `base_url` anywhere, and would otherwise ship the Claude OAuth bearer
/// there);
/// - `kimi-code` — the one `uses_oauth` platform with no `OAuthConfig` —
/// rides the PRIMARY session, and only at the session's own effective
/// coding endpoint;
@@ -273,15 +273,12 @@ impl CredentialAuthority {
/// M5: a slug that is NOT in the catalog resolves through the SAME endpoint
/// rule against the aux fallback endpoint
/// (`EndpointsConfig::resolve_inference_base_url`, which is exactly where
/// `resolve_aux_model_sampling_config`'s Tier-2 entry routes) instead of
/// being handed the primary unconditionally — the old "first-party by
/// construction" justification was false once `models_base_url` could point
/// anywhere.
/// `resolve_aux_model_sampling_config`'s Tier-2 entry routes), not handed
/// the primary unconditionally: `models_base_url` can point anywhere, so an
/// off-catalog slug is not first-party by construction.
///
/// M6: the platform and the base URL come from ONE
/// [`crate::agent::models::entry_for_slug`] lookup, so they can no longer
/// disagree (the aux path used to resolve the platform with `current_key`
/// and the credential with a separate `find_model_by_id`).
/// [`crate::agent::models::entry_for_slug`] lookup, so they cannot disagree.
pub(crate) fn credential_for_slug(
&self,
models: &indexmap::IndexMap<String, ModelEntry>,
@@ -491,9 +488,6 @@ mod tests {
/// C1 — a subscription platform's own host DOES take a session credential,
/// but it is that platform's POOLED token, never the primary / house key.
/// Two look-alike booleans used to encode this, and picking the wrong one is
/// the whole defect; one classifier makes the distinction impossible to
/// mis-read.
#[test]
fn a_subscription_host_classifies_pooled_never_primary() {
let (_d, kimi) = primary("kimi-tok");
@@ -200,8 +200,7 @@ mod tests {
);
assert!(snap.user_id.is_none());
}
/// 401 recovery routes through `unauthorized_recovery` (pre-fix
/// it no-oped because the refresher arg was hardcoded `None`).
/// 401 recovery routes through `unauthorized_recovery`.
#[tokio::test]
async fn refresh_after_unauthorized_drives_recovery_state_machine() {
let _guard = EarlyInvalidationGuard::pin_to_default();
@@ -304,8 +303,6 @@ mod tests {
.snapshot();
assert!(oidc.deployment_id.is_none() && oidc.api_key_id.is_none());
}
/// Bootstrap mode: `snapshot()` re-reads disk so sibling-rotated
/// tokens are picked up without a live AuthManager.
#[test]
fn deployment_key_wins_over_resolved_user_token() {
let _guard = EarlyInvalidationGuard::pin_to_default();
@@ -260,7 +260,6 @@ mod tests {
let created = load_or_create_device_id(&path).unwrap();
assert_eq!(created.len(), 32, "uuid4 hex is 32 chars: {created:?}");
assert!(created.chars().all(|c| c.is_ascii_hexdigit()));
// Second call reads the same id back.
let reread = load_or_create_device_id(&path).unwrap();
assert_eq!(created, reread);
#[cfg(unix)]
@@ -23,9 +23,9 @@ use crate::auth::{AuthChannels, AuthManager, AuthUrlInfo, AuthUrlMode, KimiAuth}
const SLOW_DOWN_INCREMENT_SECS: u64 = 5;
/// The wire behind a device-code login. The `Kimi` arm calls the bespoke Kimi
/// Code wire (X-Msh headers, `/api/oauth/*`) verbatim — byte-identical to the
/// pre-generalization path; the `Generic` arm drives a registry
/// [`OAuthConfig`] provider (xai-grok) through [`crate::auth::oauth_device`].
/// Code wire (X-Msh headers, `/api/oauth/*`) verbatim; the `Generic` arm
/// drives a registry [`OAuthConfig`] provider (xai-grok) through
/// [`crate::auth::oauth_device`].
enum DeviceFlowBackend<'a> {
Kimi {
host: &'a str,
@@ -280,7 +280,8 @@ mod tests {
"verification_uri": "https://www.kimi.com/code/authorize_device",
"verification_uri_complete": "https://www.kimi.com/code/authorize_device?user_code=WXYZ-6789",
"expires_in": 1800,
"interval": 0, // floored to 1s by the poll loop
// floored to 1s by the poll loop
"interval": 0,
})
}
@@ -6,7 +6,6 @@ pub enum AuthError {
#[error("Not logged in. Run `kigi login`.")]
NotLoggedIn,
/// Token expired and no refresh authority available.
#[error("Token expired. Run `kigi login` to re-authenticate.")]
TokenExpiredNoRefresh,
@@ -14,7 +13,6 @@ pub enum AuthError {
#[error("Authentication rejected by server. Run `kigi login` to re-authenticate.")]
ServerRejectedNoRecovery,
/// All recovery strategies exhausted.
#[error("Auth recovery exhausted; re-authentication required.")]
RecoveryExhausted,
@@ -405,7 +405,6 @@ pub async fn ensure_authenticated(
let kigi_home = kigi_home::kigi_home();
let auth_manager = Arc::new(AuthManager::new(&kigi_home, kimi_code_config.clone()));
// If not re-authing, accept any valid cached credential.
if !reauth && let Some(auth) = auth_manager.current() {
return Ok(auth);
}
@@ -587,8 +586,6 @@ mod tests {
assert!(expired_refreshable_session(&mgr).is_none());
}
// ── run_auth_flow: expired path with persisted token ─────────────
/// When the in-memory token is expired but the store has a valid token,
/// run_auth_flow should return the stored token without interactive login.
#[tokio::test]
@@ -119,7 +119,6 @@ fn oauth_url(host: &str, path: &str) -> String {
format!("{}{path}", host.trim_end_matches('/'))
}
/// Attach the device-identity headers to a request.
fn with_device_headers(
mut builder: reqwest::RequestBuilder,
) -> anyhow::Result<reqwest::RequestBuilder> {
+12 -33
View File
@@ -105,14 +105,6 @@ struct ScopedRefreshFailure {
/// minutes" (a suspend doesn't extend it).
const PERMANENT_FAILURE_TTL: StdDuration = StdDuration::from_secs(300);
/// Single source of truth for `auth.json` + the in-memory bearer.
///
/// Lock order: `refresh_lock` (async) -> the sync locks (`inner` / `refresher`
/// / `permanent_failure`), never co-held; `permanent_failure()`
/// reads `permanent_failure` first and only then `inner` (via
/// `attempted_tombstone_key`, when a tombstone is stored), never co-held. Never hold
/// a `parking_lot` guard across `.await`. Refreshers return [`RefreshOutcome`]
/// for `refresh_chain` to apply.
/// Whether a manager rooted at `path` may use the OS keyring for `scope`.
///
/// The keyring entry (`service kigi / oauth/kimi-code`) is global per OS
@@ -144,6 +136,14 @@ pub(crate) fn set_test_force_keyring_path_scope(on: bool) {
TEST_FORCE_KEYRING_PATH_SCOPE.with(|flag| flag.set(on));
}
/// Single source of truth for `auth.json` + the in-memory bearer.
///
/// Lock order: `refresh_lock` (async) -> the sync locks (`inner` / `refresher`
/// / `permanent_failure`), never co-held; `permanent_failure()`
/// reads `permanent_failure` first and only then `inner` (via
/// `attempted_tombstone_key`, when a tombstone is stored), never co-held. Never hold
/// a `parking_lot` guard across `.await`. Refreshers return [`RefreshOutcome`]
/// for `refresh_chain` to apply.
pub struct AuthManager {
/// In-memory bearer. Mutate via [`Self::with_inner_write`] or
/// [`Self::refresh_chain`]; the closure helpers' sync return type
@@ -274,8 +274,6 @@ enum LockOutcome {
Adopted(Box<KimiAuth>),
}
// ── Construction + builders ──────────────────────────────────────────
impl AuthManager {
pub fn new(kigi_home: &Path, kimi_code_config: KimiCodeConfig) -> Self {
let scope = kimi_code_config.auth_scope();
@@ -478,8 +476,6 @@ impl AuthManager {
}
}
// ── State mutation (clear, hot_swap, update) ──────────────────────
pub(crate) fn clear(&self) -> std::io::Result<()> {
self.remove_scope(&self.scope)
}
@@ -508,7 +504,7 @@ impl AuthManager {
tracing::warn!(error = %e, "auth: failed to remove session credential from keyring");
}
let disk_mutation = if let Some(_lock) = lock::try_lock_auth_file_nonblocking(&self.path) {
self.write_scope_removal(scope)? // lock released on drop
self.write_scope_removal(scope)?
} else {
ScopeRemoval::SkippedLockUnavailable
};
@@ -651,8 +647,6 @@ impl AuthManager {
self.clear_inner();
}
// ── Read methods ─────────────────────────────────────────────────
//
// | wire-bound bearer | `auth().await` / `get_valid_token().await` |
// | cached, no refresh | `current()` (5-min buffer) |
// | any in-memory bearer | `current_or_expired()` |
@@ -723,8 +717,6 @@ impl AuthManager {
is_expired_with_buffer(auth, Duration::zero())
}
// ── Persistence ───────────────────────────────────────────────────
/// Persist rotated tokens (keyring → file fallback) + cache.
///
/// Invariants:
@@ -781,7 +773,6 @@ impl AuthManager {
}
};
let mut map = map;
// One entry per scope.
tracing::debug!(scope = %self.scope, "auth: storing token");
map.insert(self.scope.clone(), auth.clone());
let write_result = write_auth_json(&self.path, &map);
@@ -821,7 +812,8 @@ impl AuthManager {
/// the auth-file lock on the mutation paths that matter.
fn strip_scope_from_file_best_effort(&self) {
let Ok(mut map) = read_auth_json(&self.path) else {
return; // missing/corrupt file: nothing to strip
// missing/corrupt file: nothing to strip
return;
};
if map.remove(&self.scope).is_none() {
return;
@@ -888,8 +880,6 @@ impl AuthManager {
self.clear_inner();
}
// ── Disk I/O helpers ──────────────────────────────────────────────
/// Accept a sibling-rotated disk token. On `ServerRejected`, the
/// disk key must differ from in-memory (else no one refreshed).
pub(crate) fn try_use_disk_token(
@@ -915,8 +905,7 @@ impl AuthManager {
/// Re-read disk and try to adopt a sibling-written token, emitting
/// telemetry on success. Combines `read_disk_auth` +
/// `try_use_disk_token` + the structured log that was previously
/// duplicated at each callsite in `refresh_chain`.
/// `try_use_disk_token` + the structured adoption log.
fn try_adopt_disk_token(&self, reason: RefreshReason, msg: &str) -> Option<KimiAuth> {
let disk_auth = self.read_disk_auth();
let refreshed = self.try_use_disk_token(disk_auth.as_ref(), reason)?;
@@ -1105,8 +1094,6 @@ impl AuthManager {
try_lock_auth_file_async(&self.path, timeout).await
}
// ── Refresher setup ─────────────────────────────────────────────
/// Set up refresh capability. Call once per `Arc<AuthManager>` at
/// startup; subsequent calls are no-op via an atomic guard (so
/// per-session call sites don't reset refresher-internal state).
@@ -1155,8 +1142,6 @@ impl AuthManager {
TokenType::from_auth(self.inner.read().as_ref())
}
// ── Pre-request dispatch ──────────────────────────────────────────
/// Pre-request entry point: per-`TokenType` dispatch. For just the key:
/// [`Self::get_valid_token`].
#[tracing::instrument(skip(self), fields(token_type = tracing::field::Empty))]
@@ -1259,8 +1244,6 @@ impl AuthManager {
self.auth().await.map(|a| a.key)
}
// ── Refresh chain (single mutation point) ─────────────────────────
/// Acquire lock, double-check, try disk, then active refresh via injected refresher.
///
/// This is the single place where auth state is mutated during refresh.
@@ -1779,8 +1762,6 @@ impl AuthManager {
}
}
// ── 401 recovery entry point ──────────────────────────────────────
/// 401 recovery state machine driven by the `rejected` credential. For
/// one-shot recovery off the live bearer, use `try_recover_unauthorized()`.
pub(crate) fn unauthorized_recovery(
@@ -1797,8 +1778,6 @@ impl AuthManager {
self.unauthorized_recovery(cached).next().await.is_ok()
}
// ── Proactive refresh ─────────────────────────────────────────────
/// Spawn the background refresh task (PRD F1): a fixed
/// [`PROACTIVE_REFRESH_INTERVAL`] (60s) tick that refreshes when the
/// remaining lifetime drops below `max(300, expires_in × 0.5)` seconds
@@ -23,8 +23,6 @@ use crate::unified_log;
/// Maximum age (seconds) of a lock holder before it is considered stuck.
const STALE_LOCK_TIMEOUT_SECS: u64 = 60;
// ── Holder-info helpers ──────────────────────────────────────────────
/// Write `PID:UNIX_TIMESTAMP` into the lock file so waiters can detect
/// staleness.
fn write_holder_info(file: &mut File) -> io::Result<()> {
@@ -46,8 +44,6 @@ fn parse_holder_info(content: &str) -> Option<(u32, u64)> {
Some((pid_str.parse().ok()?, ts_str.parse().ok()?))
}
// ── Platform-specific helpers ────────────────────────────────────────
/// Check whether the process that wrote the lock file is still running.
#[cfg(unix)]
fn is_process_alive(pid: u32) -> bool {
@@ -77,7 +73,8 @@ fn is_process_alive(pid: u32) -> bool {
#[cfg(not(unix))]
fn is_process_alive(_pid: u32) -> bool {
true // conservative fallback — skip liveness check on non-Unix
// conservative fallback — skip liveness check on non-Unix
true
}
/// `fstat(fd)` vs `stat(path)` inode comparison. Detects a concurrent
@@ -92,11 +89,10 @@ fn inodes_match(file: &File, path: &Path) -> io::Result<bool> {
#[cfg(not(unix))]
fn inodes_match(_file: &File, _path: &Path) -> io::Result<bool> {
Ok(true) // no inode concept; skip the check
// no inode concept; skip the check
Ok(true)
}
// ── Staleness check ──────────────────────────────────────────────────
/// Decide staleness when the lock file carries no usable `PID:TS` holder
/// info — it is empty, was truncated mid-write, or holds non-UTF-8
/// garbage. We have no PID to liveness-probe, so we fall back to the lock
@@ -106,9 +102,9 @@ fn inodes_match(_file: &File, _path: &Path) -> io::Result<bool> {
/// break. A lock caught in the sub-millisecond `set_len(0)`→write window
/// keeps a fresh mtime and is therefore never broken by this path.
///
/// Returning `false` here used to be unconditional ("assume alive"), which
/// turned a single empty/garbage lock file into an unbreakable lock and
/// wedged every refresh behind it.
/// Without this mtime arm, an unconditional "assume alive" turns a single
/// empty/garbage lock file into an unbreakable lock that wedges every refresh
/// behind it.
fn unidentified_holder_is_stale(file: &File, why: &str) -> bool {
let Ok(modified) = file.metadata().and_then(|m| m.modified()) else {
unified_log::debug(
@@ -154,7 +150,6 @@ fn is_holder_stale(file: &mut File) -> bool {
);
};
// Process dead?
if !is_process_alive(holder_pid) {
unified_log::info(
&format!("auth lock: holder pid={holder_pid} is dead, breaking stale lock"),
@@ -164,7 +159,6 @@ fn is_holder_stale(file: &mut File) -> bool {
return true;
}
// Process stuck (holding > STALE_LOCK_TIMEOUT_SECS)?
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
@@ -186,8 +180,6 @@ fn is_holder_stale(file: &mut File) -> bool {
false
}
// ── Single-iteration acquire logic ───────────────────────────────────
/// Outcome of one lock attempt.
enum LockAttempt {
/// Lock acquired; inner file holds the flock.
@@ -205,7 +197,6 @@ enum LockAttempt {
/// `lock_path` is the resolved path to `auth.json.lock` — computed once
/// by the caller to avoid re-deriving it on every poll iteration.
fn try_acquire_once(lock_path: &Path) -> LockAttempt {
// Step 1: open (create if missing) auth.json.lock
let mut file = match OpenOptions::new()
.read(true)
.write(true)
@@ -224,11 +215,9 @@ fn try_acquire_once(lock_path: &Path) -> LockAttempt {
}
};
// Step 2: flock(LOCK_EX | LOCK_NB)
match file.try_lock_exclusive() {
Ok(()) => {
let pid = std::process::id();
// Step 3: write holder info, then verify same inode.
if let Err(e) = write_holder_info(&mut file) {
unified_log::warn(
&format!("auth lock: failed to write holder info: {e}"),
@@ -271,7 +260,7 @@ fn try_acquire_once(lock_path: &Path) -> LockAttempt {
}
}
// Step 4: EWOULDBLOCK — lock is held by someone else.
// WouldBlock: another process holds the flock.
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
if is_holder_stale(&mut file) {
match std::fs::remove_file(lock_path) {
@@ -300,8 +289,6 @@ fn try_acquire_once(lock_path: &Path) -> LockAttempt {
}
}
// ── Blocking acquire (kernel FIFO wait queue) ────────────────────────
/// Attempt a blocking `flock(LOCK_EX)` on the lock file. Returns the
/// locked file on success, or an error on I/O failure / inode mismatch.
///
@@ -315,7 +302,6 @@ fn blocking_acquire(lock_path: &Path) -> io::Result<File> {
.truncate(false)
.open(lock_path)?;
// Blocking flock — waits in kernel until the lock is available.
file.lock_exclusive().map_err(|e| {
unified_log::warn(
&format!("auth lock: blocking flock failed: {e}"),
@@ -355,8 +341,6 @@ fn blocking_acquire(lock_path: &Path) -> io::Result<File> {
}
}
// ── Public API ───────────────────────────────────────────────────────
/// Best-effort **non-blocking** acquire for advisory cleanup call sites
/// (`AuthManager::new` WebLogin cleanup, `remove_scope`).
///
@@ -365,8 +349,8 @@ fn blocking_acquire(lock_path: &Path) -> io::Result<File> {
/// returns `None` so the caller simply skips its best-effort write.
/// Crucially it records `PID:TS` holder info after locking, so a waiter
/// that observes the flock can identify the holder (and break it once
/// stale). Taking the flock *without* writing holder info is what used to
/// leave an empty `auth.json.lock` that defeated stale-lock recovery.
/// stale). Taking the flock *without* writing holder info would leave an
/// empty `auth.json.lock` that defeats stale-lock recovery.
pub(crate) fn try_lock_auth_file_nonblocking(auth_json_path: &Path) -> Option<AuthFileLock> {
let lock_path = auth_json_path.with_file_name("auth.json.lock");
let mut file = OpenOptions::new()
@@ -433,7 +417,8 @@ pub(crate) async fn try_lock_auth_file_async(
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining == StdDuration::ZERO {
break; // fall through to Phase 3
// fall through to Phase 3
break;
}
let lp = lock_path.clone();
@@ -471,7 +456,7 @@ pub(crate) async fn try_lock_auth_file_async(
for _ in 0..2 {
match try_acquire_once(&lock_path) {
LockAttempt::Acquired(file) => return Some(AuthFileLock { _file: file }),
LockAttempt::StaleUnlinked => continue, // unlinked stale lock, retry once
LockAttempt::StaleUnlinked => continue,
LockAttempt::Busy | LockAttempt::Failed => break,
}
}
@@ -498,8 +483,6 @@ mod tests {
dir.path().join("auth.json")
}
// ── Pure-function unit tests (no runtime needed) ─────────────────
#[test]
fn test_write_and_parse_holder_info() {
let dir = TempDir::new().unwrap();
@@ -551,7 +534,7 @@ mod tests {
// an empty `auth.json.lock` was treated as alive forever.
let dir = TempDir::new().unwrap();
let lock_path = dir.path().join("test.lock");
std::fs::write(&lock_path, b"").unwrap(); // empty → unparseable
std::fs::write(&lock_path, b"").unwrap();
let file = OpenOptions::new()
.read(true)
@@ -758,8 +741,6 @@ mod tests {
);
}
// ── Async tests against the production code path ─────────────────
#[tokio::test]
async fn test_async_acquire_release_basic() {
let dir = TempDir::new().unwrap();
@@ -768,16 +749,13 @@ mod tests {
let lock = try_lock_auth_file_async(&path, StdDuration::from_secs(1)).await;
assert!(lock.is_some(), "should acquire lock");
// Verify lock file has holder info.
let lock_path = path.with_file_name("auth.json.lock");
let content = std::fs::read_to_string(&lock_path).unwrap();
let (pid, _ts) = parse_holder_info(&content).unwrap();
assert_eq!(pid, std::process::id());
// Release.
drop(lock);
// Re-acquire should succeed.
let lock2 = try_lock_auth_file_async(&path, StdDuration::from_secs(1)).await;
assert!(lock2.is_some(), "should re-acquire after release");
}
@@ -816,8 +794,6 @@ mod tests {
assert_eq!(pid, std::process::id());
}
// ── Real cross-process integration tests (async) ─────────────────
//
// These spawn a genuine second process so we exercise OS-level flock
// semantics that threads/extra FDs cannot model: a *dead* holder PID
// (flock auto-released on process death) and `is_process_alive()`
@@ -847,7 +823,8 @@ mod tests {
#[ignore = "spawned as a subprocess by the cross-process lock tests"]
fn subprocess_lock_holder() {
let Ok(spec) = std::env::var("KIGI_TEST_LOCK_HOLDER") else {
return; // normal test run — not a subprocess invocation
// normal test run — not a subprocess invocation
return;
};
let mut parts = spec.splitn(3, '|');
let lock_path = parts.next().expect("spec lock_path");
@@ -982,10 +959,10 @@ mod tests {
#[cfg(unix)]
#[tokio::test]
async fn test_async_breaks_old_empty_lock_held_by_live_holder() {
// Regression: a LIVE process holding the flock on an EMPTY lock
// file (no `PID:TS`) used to be "alive forever", wedging refresh.
// With the mtime fallback an old empty lock is broken even though
// the holder process is still running.
// Regression: without the mtime fallback a LIVE process holding the
// flock on an EMPTY lock file (no `PID:TS`) reads as "alive forever"
// and wedges refresh. The fallback breaks an old empty lock even
// though the holder process is still running.
let dir = TempDir::new().unwrap();
let path = auth_json_path(&dir);
let lock_path = path.with_file_name("auth.json.lock");
@@ -1022,7 +999,7 @@ mod tests {
let path = auth_json_path(&dir);
let lock_path = path.with_file_name("auth.json.lock");
let mut child = spawn_lock_holder_subprocess(&lock_path, "empty", 0); // fresh mtime
let mut child = spawn_lock_holder_subprocess(&lock_path, "empty", 0);
let lock = try_lock_auth_file_async(&path, StdDuration::from_millis(800)).await;
assert!(
@@ -1046,22 +1023,20 @@ mod tests {
let mut child = spawn_lock_holder_subprocess(&lock_path, "pid", 0);
let child_pid = child.id();
// Verify child's PID in the lock file.
let content_before = std::fs::read_to_string(&lock_path).unwrap();
let (written_pid, _) = parse_holder_info(&content_before).unwrap();
assert_eq!(written_pid, child_pid);
// Kill the child.
child.kill().unwrap();
child.wait().unwrap();
assert!(!is_process_alive(child_pid));
// Lock file still has the dead child's PID.
// The killed child's flock is released, but its stale `PID:TS` is
// still on disk — recovery must break it.
let content_after = std::fs::read_to_string(&lock_path).unwrap();
let (dead_pid, _) = parse_holder_info(&content_after).unwrap();
assert_eq!(dead_pid, child_pid);
// Acquire should succeed immediately.
let start = tokio::time::Instant::now();
let lock = try_lock_auth_file_async(&path, StdDuration::from_secs(2)).await;
let elapsed = start.elapsed();
@@ -1131,8 +1106,6 @@ mod tests {
assert!(!is_process_alive(pid), "child should be dead after kill");
}
// ── Blocking acquire unit tests ──────────────────────────────────
#[cfg(unix)]
#[test]
fn test_blocking_acquire_uncontended() {
@@ -156,8 +156,8 @@ impl SleepGate {
// Stale gate (missed/late wake). `sleep_straddle` = the monotonic clock
// is still under the bound but real (wall-clock) time is not: the
// machine slept through the gate without delivering a wake event. This
// is precisely the case the wall-clock arm was added to catch, so
// surface it explicitly to confirm the fix firing in the field.
// is precisely the case the wall-clock arm exists to catch, so surface
// it explicitly to confirm it fires in the field.
let sleep_straddle = mono < SLEEP_GATE_MAX;
*self.raised_at.write() = None;
kigi_log::unified_log::info(
@@ -67,8 +67,6 @@ fn install_ok_refresher(m: &Arc<AuthManager>) -> Arc<AtomicU32> {
calls
}
// ── Dynamic refresh threshold (PRD: max(300, expires_in × 0.5)) ─────────
/// A 7200s-lifetime token with 3000s left is inside the 3600s threshold:
/// `current()` hides it (refresh due) while `expired_auth()` still exposes
/// it (wire-valid bearer for senders).
@@ -87,7 +85,6 @@ fn threshold_hides_current_but_keeps_expired_auth() {
assert!(!m.is_expired());
}
/// Hard expiry: a genuinely past-expiry token is not usable.
#[test]
fn hard_expired_token_is_not_usable() {
let (_d, m) = mgr();
@@ -97,8 +94,6 @@ fn hard_expired_token_is_not_usable() {
assert!(!m.has_usable_token());
}
// ── auth() dispatch ─────────────────────────────────────────────────────
#[tokio::test]
async fn auth_returns_not_logged_in_when_empty() {
let (_d, m) = mgr();
@@ -135,7 +130,6 @@ async fn auth_expired_session_refreshes_via_chain() {
let auth = m.auth().await.unwrap();
assert_eq!(auth.key, "at-refreshed");
assert_eq!(calls.load(AtomicOrdering::SeqCst), 1);
// Refresh persisted: a fresh manager on the same home adopts it.
assert_eq!(m.current().map(|a| a.key), Some("at-refreshed".into()));
}
@@ -176,8 +170,6 @@ async fn refresh_notifies_waiters() {
);
}
// ── Tombstone semantics (PRD F1) ────────────────────────────────────────
/// A 401-rejected refresh sets a tombstone keyed by the rejected refresh
/// token; subsequent auth() calls short-circuit without hitting the wire.
#[tokio::test]
@@ -294,8 +286,6 @@ async fn tombstone_does_not_block_wire_valid_bearer() {
assert_eq!(auth.key, "at-usable");
}
// ── Persistence: file fallback + keyring ────────────────────────────────
#[tokio::test]
async fn update_persists_to_file_when_keyring_disabled() {
let (dir, m) = mgr();
@@ -457,8 +447,6 @@ mod keyring_integration {
}
}
// ── Sibling adoption + disk reload ──────────────────────────────────────
#[tokio::test]
async fn pick_up_sibling_token_adopts_different_valid_token() {
let (dir, m) = mgr();
@@ -481,7 +469,6 @@ fn force_reload_drops_credentials_on_readable_entry_missing() {
// A readable auth.json without our scope = trustworthy logout signal.
let store = AuthStore::new();
crate::auth::storage::write_auth_json(&dir.path().join("auth.json"), &store).unwrap();
// Non-empty map required for EntryMissing (empty map is still readable).
m.force_reload_from_disk();
assert!(
m.current_or_expired().is_none(),
@@ -504,8 +491,6 @@ fn force_reload_retains_refresh_token_on_disk_anomaly() {
);
}
// ── Proactive tick (loop body) ──────────────────────────────────────────
#[tokio::test]
async fn proactive_tick_skips_above_threshold() {
let (_d, m) = mgr();
@@ -576,8 +561,6 @@ async fn proactive_tick_respects_tombstone_cooldown() {
);
}
// ── Sleep gate integration ──────────────────────────────────────────────
#[tokio::test]
async fn refresh_chain_defers_when_sleep_imminent() {
let (_d, m) = mgr();
@@ -605,8 +588,6 @@ async fn refresh_chain_defers_when_sleep_imminent() {
);
}
// ── Idempotency guards ──────────────────────────────────────────────────
#[tokio::test]
async fn start_proactive_refresh_is_idempotent_per_arc() {
let (_d, m) = mgr();
@@ -137,7 +137,6 @@ pub(crate) fn token_suffix(t: &str) -> &str {
if len > 12 { &t[len - 12..] } else { t }
}
/// Look up auth from the store by scope key.
pub fn lookup_auth(map: &AuthStore, scope: &str) -> Option<KimiAuth> {
map.get(scope).cloned()
}
@@ -25,7 +25,6 @@ use super::kimi_oauth::{
const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
const REFRESH_GRANT_TYPE: &str = "refresh_token";
/// Refresh retry budget over the retryable statuses / network blips.
const MAX_REFRESH_RETRIES: u32 = 3;
/// HTTP statuses worth retrying a refresh for (kimi-cli parity).
const RETRYABLE_REFRESH_STATUSES: [u16; 5] = [429, 500, 502, 503, 504];
@@ -58,8 +57,6 @@ fn oauth_url(host: &str, path: &str) -> String {
format!("{}{path}", host.trim_end_matches('/'))
}
/// The device-authorization form fields: `client_id`, `scope`, and the
/// optional non-standard `extra_device_field`.
fn device_form(cfg: &OAuthConfig) -> Vec<(&'static str, &'static str)> {
let mut form = vec![("client_id", cfg.client_id), ("scope", cfg.scope)];
if let Some((name, value)) = cfg.extra_device_field {
@@ -257,8 +254,6 @@ mod tests {
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// An OAuthConfig pointed at a mock server (copies XAI's client_id/scope/
/// paths but overrides the host).
fn mock_cfg(host: &'static str) -> OAuthConfig {
OAuthConfig {
auth_host: host,
@@ -308,8 +303,6 @@ mod tests {
assert_eq!(auth.expires_in, Some(900));
}
/// A response with only `verification_uri` (no `_complete`) still yields a
/// valid display URI.
#[tokio::test]
async fn device_authorization_falls_back_to_verification_uri() {
let server = MockServer::start().await;
@@ -35,7 +35,6 @@ use super::model::KimiAuth;
const CODE_GRANT_TYPE: &str = "authorization_code";
const REFRESH_GRANT_TYPE: &str = "refresh_token";
/// Refresh retry budget over the retryable statuses / network blips.
const MAX_REFRESH_RETRIES: u32 = 3;
/// HTTP statuses worth retrying a refresh for (parity with the device wire).
const RETRYABLE_REFRESH_STATUSES: [u16; 5] = [429, 500, 502, 503, 504];
@@ -87,8 +86,6 @@ pub(crate) fn generate_pkce_random_state() -> PkceCodes {
}
}
/// The loopback redirect URI for a PKCE-localhost provider (claude `/callback`,
/// codex `/auth/callback`).
pub(crate) fn redirect_uri(redirect_port: u16, redirect_path: &str) -> String {
format!("http://localhost:{redirect_port}{redirect_path}")
}
@@ -159,12 +156,10 @@ pub(crate) fn parse_manual_paste(input: &str) -> anyhow::Result<CallbackParams>
if trimmed.is_empty() {
anyhow::bail!("empty paste");
}
// Full redirect URL.
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
let url = url::Url::parse(trimmed).context("pasted value is not a valid URL")?;
return parse_callback_query(url.query().unwrap_or_default());
}
// `code#state`.
if let Some((code, state)) = trimmed.split_once('#') {
if code.is_empty() {
anyhow::bail!("pasted code was empty");
@@ -174,7 +169,6 @@ pub(crate) fn parse_manual_paste(input: &str) -> anyhow::Result<CallbackParams>
state: (!state.is_empty()).then(|| state.to_owned()),
});
}
// Bare code.
Ok(CallbackParams {
code: trimmed.to_owned(),
state: None,
@@ -208,7 +202,6 @@ pub(crate) fn validate_pasted_state(
}
}
/// The token-endpoint URL (`{token_host}{token_path}`).
fn token_url(cfg: &OAuthConfig) -> String {
format!("{}{}", cfg.token_host.trim_end_matches('/'), cfg.token_path)
}
@@ -486,7 +479,6 @@ async fn handle_loopback_conn(
}
}
/// Write a minimal HTTP/1.1 response with an HTML body.
async fn write_http(
stream: &mut tokio::net::TcpStream,
status: u16,
@@ -516,8 +508,6 @@ mod tests {
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// A config pointed at a mock token host (copies Claude's client_id/scope/
/// paths but overrides the token host).
fn mock_cfg(token_host: &'static str) -> OAuthConfig {
OAuthConfig {
token_host,
@@ -525,8 +515,6 @@ mod tests {
}
}
/// PKCE codes: verifier/challenge are non-empty base64url (no padding), the
/// challenge is the base64url SHA-256 of the verifier, and state == verifier.
#[test]
fn generate_pkce_produces_valid_s256_codes() {
let pkce = generate_pkce();
@@ -540,16 +528,12 @@ mod tests {
);
assert!(!s.contains('='), "no padding: {s}");
}
// challenge == base64url(SHA-256(verifier)).
let expect = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(Sha256::digest(pkce.verifier.as_bytes()));
assert_eq!(pkce.challenge, expect);
// Fresh entropy each call.
assert_ne!(pkce.verifier, generate_pkce().verifier);
}
/// The authorize URL carries the fixed params + the PKCE state and S256
/// challenge, and targets `claude.ai/oauth/authorize`.
#[test]
fn authorize_url_has_state_and_s256_challenge() {
let pkce = generate_pkce();
@@ -580,15 +564,12 @@ mod tests {
q.get("redirect_uri").map(String::as_str),
Some(redirect.as_str())
);
// The verifier itself must NEVER appear in the browser URL.
assert!(
!url.contains("code_verifier"),
"the verifier must not ride the authorize URL"
);
}
/// STRICT state validation: an exact match passes; a mismatch or an absent
/// state is REJECTED (CSRF guard — the flow must never proceed).
#[test]
fn state_validation_is_strict() {
let ok = CallbackParams {
@@ -614,8 +595,6 @@ mod tests {
);
}
/// A loopback `/callback` with the WRONG state is rejected end-to-end (the
/// listener returns an error, never a code) — the CSRF guard on the wire.
#[tokio::test]
async fn loopback_rejects_state_mismatch() {
// Ephemeral port: bind, learn the port, then drive a client at it.
@@ -631,7 +610,6 @@ mod tests {
);
// Give the listener a moment to bind.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Attacker callback: valid code, WRONG state.
let _ = reqwest::get(format!(
"http://127.0.0.1:{port}/callback?code=stolen&state=wrong-state"
))
@@ -641,7 +619,6 @@ mod tests {
assert!(err.to_string().contains("state mismatch"), "{err}");
}
/// A loopback `/callback` with the MATCHING state yields the code.
#[tokio::test]
async fn loopback_returns_code_on_valid_state() {
let probe = tokio::net::TcpListener::bind(("127.0.0.1", 0))
@@ -661,7 +638,6 @@ mod tests {
assert_eq!(code, "auth-code-123");
}
/// Manual-paste parsing: full redirect URL, `code#state`, and bare code.
#[test]
fn manual_paste_parses_all_three_forms() {
let from_url =
@@ -678,12 +654,9 @@ mod tests {
assert_eq!(bare.state, None);
assert!(parse_manual_paste("").is_err());
// A pasted redirect that carries an error param surfaces the error.
assert!(parse_manual_paste("http://localhost/callback?error=access_denied").is_err());
}
/// Code → token exchange: JSON body carries the grant + verifier, response
/// materializes a `KimiAuth` with the rotating refresh token.
#[tokio::test]
async fn exchange_code_posts_json_and_returns_auth() {
let server = MockServer::start().await;
@@ -719,7 +692,6 @@ mod tests {
assert_eq!(auth.expires_in, Some(3600));
}
/// Refresh rotates the refresh token (JSON body, refresh grant).
#[tokio::test]
async fn refresh_rotates_refresh_token() {
let server = MockServer::start().await;
@@ -745,7 +717,6 @@ mod tests {
);
}
/// A 401 on refresh maps to Unauthorized (drives the permanent-failure path).
#[tokio::test]
async fn refresh_401_maps_to_unauthorized() {
let server = MockServer::start().await;
@@ -774,10 +745,6 @@ mod tests {
}
}
// ── ChatGPT/Codex PKCE (openai-codex) ────────────────────────────────────
/// Codex PKCE uses an INDEPENDENT fresh-random state (NOT `state ==
/// verifier`) so the verifier never rides the callback.
#[test]
fn codex_pkce_state_is_independent_of_the_verifier() {
let pkce = generate_pkce_random_state();
@@ -786,7 +753,6 @@ mod tests {
"codex state must be fresh-random, not the verifier"
);
assert!(!pkce.state.is_empty() && !pkce.verifier.is_empty());
// Challenge is still the S256 of the verifier.
let expect = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(Sha256::digest(pkce.verifier.as_bytes()));
assert_eq!(pkce.challenge, expect);
@@ -797,9 +763,6 @@ mod tests {
);
}
/// The codex authorize URL carries the PKCE state + S256 challenge AND the
/// three codex-only extra params, and targets `auth.openai.com/oauth/
/// authorize` with the `/auth/callback` redirect. The verifier never rides it.
#[test]
fn codex_authorize_url_has_state_challenge_and_three_extra_params() {
use kigi_models::CODEX_OAUTH_CONFIG;
@@ -823,7 +786,6 @@ mod tests {
q.get("code_challenge_method").map(String::as_str),
Some("S256")
);
// The three codex-only extra params.
assert_eq!(
q.get("id_token_add_organizations").map(String::as_str),
Some("true")
@@ -842,8 +804,6 @@ mod tests {
);
}
/// The claude authorize URL is UNCHANGED (no extra params) — its empty
/// `authorize_extra` keeps it byte-identical.
#[test]
fn claude_authorize_url_carries_no_extra_params() {
let pkce = generate_pkce();
@@ -864,9 +824,6 @@ mod tests {
}
}
/// Codex code→token exchange posts a FORM body carrying the grant + code +
/// verifier + redirect_uri, and NOTABLY NO `state` field (the codex token
/// endpoint does not expect it). Response materializes a `KimiAuth`.
#[tokio::test]
async fn codex_exchange_code_posts_form_without_state() {
use wiremock::matchers::{body_string_contains, header, method, path};
@@ -72,17 +72,17 @@ fn oauth_manager_pool() -> &'static Mutex<HashMap<&'static str, Arc<AuthManager>
/// production-readable env override is added here on purpose: a knob that
/// redirects where OAuth tokens are read from is not worth a test convenience.
///
/// M8: this used to be a `static OnceLock<TempDir>`. Statics are never dropped,
/// so that leaked one temp directory per test binary — against the project's
/// "tests are TempDir self-cleaning" discipline. Nothing is created here
/// instead, and nothing in the lib-test suite creates it: a manager reads a
/// missing `auth.json` as "no session", and the only two paths that WRITE one
/// are a successful token refresh (which needs a stored refresh token that by
/// Nothing is created here, and a `static OnceLock<TempDir>` would be wrong: a
/// static is never dropped, so it would leak one temp directory per test binary,
/// against the project's "tests are TempDir self-cleaning" discipline. Nothing
/// in the lib-test suite creates the directory either: a manager reads a missing
/// `auth.json` as "no session", and the only two paths that WRITE one are a
/// successful token refresh (which needs a stored refresh token that by
/// construction does not exist here) and a completed device login
/// ([`crate::agent::mvp_agent::MvpAgent::authenticate_oauth_platform`], which
/// M4 repointed at this same home). Both require the network, so no lib test
/// performs either. That is an observation about the suite, not an invariant of
/// this function — [`tests::test_pool_home_is_disposable_and_never_the_real_home`]
/// targets this same home). Both require the network, so no lib test performs
/// either. That is an observation about the suite, not an invariant of this
/// function — [`tests::test_pool_home_is_disposable_and_never_the_real_home`]
/// asserts the directory does not exist and is the tripwire if one ever does
/// (the path is per-PROCESS under the system temp dir, so the blast radius of a
/// future login-driving test is one disposable directory, never `~/.kigi`).
@@ -132,10 +132,6 @@ mod tests {
.expect("subscription-OAuth platform carries an OAuthConfig")
}
/// Each subscription-OAuth platform gets its OWN process-global pooled
/// manager, and no two share one. (Which credential governs a REQUEST is
/// not decided here — see
/// [`crate::auth::credential_authority::CredentialAuthority`].)
#[tokio::test]
async fn every_oauth_scope_gets_its_own_pooled_manager() {
let home = tempfile::tempdir().unwrap();
@@ -168,7 +164,7 @@ mod tests {
}
}
/// M8: the test pool home is a per-process path that is never created, so a
/// The test pool home is a per-process path that is never created, so a
/// test binary leaves nothing behind (and never resolves the developer's
/// real `~/.kigi`, whose stored OAuth tokens the pool would otherwise read
/// and proactively refresh over the network).
@@ -58,9 +58,7 @@ enum RecoveryStep {
/// State machine that walks through recovery strategies after a 401.
pub struct UnauthorizedRecovery {
auth_manager: Arc<AuthManager>,
/// The token that was rejected by the server.
rejected_token: String,
/// Current step in the recovery sequence.
step: RecoveryStep,
/// Error from `RefreshFromAuthority`, propagated on exhaustion.
authority_error: Option<AuthError>,
@@ -298,7 +296,6 @@ impl UnauthorizedRecovery {
}
}
/// Check if a candidate token is different from the rejected one.
fn is_different_token(&self, candidate: &KimiAuth) -> bool {
candidate.key != self.rejected_token
}
@@ -383,8 +380,6 @@ mod tests {
mgr.hot_swap(auth);
}
// -- TokenType dispatch matrix ----------------------------------------
#[tokio::test]
async fn dispatch_oauth_session_uses_refresh_chain() {
let (_d, m) = mgr();
@@ -401,8 +396,6 @@ mod tests {
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
// -- Fresh-mint guard --------------------------------------------------
/// Seed a *valid* (unexpired) in-memory token whose `create_time` lies
/// `mint_age` in the past (negative = clock stepped back since mint).
fn seed_valid(mgr: &AuthManager, mint_age: Duration) {
@@ -542,8 +535,6 @@ mod tests {
);
}
// -- ReloadFromDisk matrix --------------------------------------------
#[tokio::test]
async fn reload_from_disk_picks_up_different_token() {
let (dir, m) = mgr();
@@ -605,8 +596,6 @@ mod tests {
);
}
// -- Done state -------------------------------------------------------
/// With no stored authority error (the first `next()` succeeded), driving
/// past `Done` surfaces `RecoveryExhausted`. The transient-failure case is
/// pinned by `exhaustion_after_transient_failure_stays_transient`.
@@ -677,8 +666,6 @@ mod tests {
);
}
// -- Tombstone short-circuit (cross-check) ------------
#[tokio::test]
async fn refresh_authority_short_circuits_on_cached_tombstone() {
let (_d, m) = mgr();
@@ -707,8 +694,6 @@ mod tests {
);
}
// -- ReloadFromDisk rejects expired disk tokens -------------------------
/// Regression: disk holds a different but expired token. Recovery
/// must skip it and fall through to RefreshFromAuthority, not
/// return it for the caller to send on the wire (instant 401).
@@ -253,7 +253,6 @@ mod tests {
}
}
/// A successful refresh rotates the token via the generic wire.
#[tokio::test]
async fn refresh_success_returns_rotated_token() {
let server = MockServer::start().await;
+9 -11
View File
@@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
use super::model::{API_KEY_SCOPE, AuthMode, AuthStore, KimiAuth, lookup_auth};
// ── System-keyring storage for the Kimi Code OAuth session ─────────────
// System-keyring storage for the Kimi Code OAuth session.
//
// PRD F1: the OAuth token set lives in the system keyring (service `kigi`,
// entry `oauth/kimi-code`); when the keyring is unavailable we fall back to
@@ -111,7 +111,6 @@ fn keyring_entry() -> Result<&'static keyring::Entry, keyring::Error> {
}
}
/// Read the session credential from the system keyring.
#[cfg(any(target_os = "macos", windows))]
pub(crate) fn keyring_read_session() -> KeyringRead {
if !keyring_enabled() {
@@ -145,7 +144,6 @@ pub(crate) fn keyring_read_session() -> KeyringRead {
KeyringRead::Unavailable
}
/// Write the session credential to the system keyring.
#[cfg(any(target_os = "macos", windows))]
pub(crate) fn keyring_write_session(auth: &KimiAuth) -> anyhow::Result<()> {
anyhow::ensure!(keyring_enabled(), "keyring storage disabled");
@@ -386,8 +384,8 @@ fn write_auth_json_with(
"auth: disk full during atomic write, falling back to in-place write"
);
// Must reach unified.jsonl: a silent in-memory-only credential
// (the prior behavior) leaves sibling processes with a stale
// refresh token and no record of why. Surface it loudly.
// leaves sibling processes with a stale refresh token and no
// record of why. Surface it loudly.
kigi_log::unified_log::warn(
"auth: disk full, falling back to non-atomic in-place write",
None,
@@ -430,8 +428,7 @@ fn write_store_to(path: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
Ok(())
}
/// Atomic write: tmp + Windows-safe replace (see `util::fs::replace_file`,
/// which this site's inline delete-first pattern graduated into).
/// Atomic write: tmp + Windows-safe replace (see `util::fs::replace_file`).
fn write_auth_json_atomic(auth_file: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
let tmp = auth_file.with_extension(format!("json.{}.tmp", std::process::id()));
write_store_to(&tmp, auth_store)?;
@@ -447,8 +444,8 @@ fn write_auth_json_atomic(auth_file: &Path, auth_store: &AuthStore) -> std::io::
///
/// Truncation is destructive, so the prior bytes are snapshotted first and
/// restored best-effort if the rewrite fails partway — a failed fallback
/// must not leave an empty/torn file where a parseable (if stale) credential
/// used to be. A partial file that survives (because even the restore failed)
/// must not leave an empty/torn file in place of a parseable (if stale)
/// credential. A partial file that survives (because even the restore failed)
/// is healed on the next read via [`read_auth_json_or_empty_recovering_corrupt`].
fn write_auth_json_in_place(auth_file: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
write_auth_json_in_place_with(auth_file, auth_store, write_store_to)
@@ -719,7 +716,8 @@ mod write_fallback_tests {
/// old content, as `open_secure_file` does) and then fails partway — the
/// torn-write case the rollback must recover from.
fn fake_truncate_then_fail(path: &Path, _: &AuthStore) -> std::io::Result<()> {
crate::util::secure_file::open_secure_file(path)?; // truncates to 0 bytes
// `open_secure_file` truncates to 0 bytes.
crate::util::secure_file::open_secure_file(path)?;
Err(std::io::Error::from(std::io::ErrorKind::StorageFull))
}
@@ -778,7 +776,7 @@ mod write_fallback_tests {
/// A fallback write that truncates then fails must roll back to the prior
/// bytes instead of leaving an empty/torn file — otherwise a second
/// disk-full failure would destroy a previously-valid credential.
/// disk-full failure would destroy the still-valid credential.
#[test]
fn in_place_restores_prior_bytes_on_failure() {
let dir = tempfile::tempdir().unwrap();
@@ -1,5 +1,4 @@
//! normalize chat_history.jsonl, convert any v1 (ConversationItem) to v0 (ChatRequestMessage) format.
//! Used for data processing pipeline.
//!
//! Usage:
//! chat-history-downgrade <INPUT> <OUTPUT>
@@ -176,10 +175,8 @@ fn main() -> anyhow::Result<()> {
Ok(())
}
// ============================================================================
// Tests — hardcoded JSON fixtures so schema changes in either
// Hardcoded JSON fixtures so schema changes in either
// ConversationItem (v1) or ChatRequestMessage (v0) will break these.
// ============================================================================
#[cfg(test)]
mod tests {
@@ -195,7 +192,6 @@ mod tests {
.expect("convert_line should succeed")
.expect("v1 line should produce a v0 message (not a buffered Reasoning)");
let out = serde_json::to_string(&v0).expect("v0 should serialize");
// Verify the output is valid v0
let _: ChatRequestMessage =
serde_json::from_str(&out).expect("v0 output should round-trip");
out
@@ -13,11 +13,6 @@
//! [--include-reasoning true] \
//! [--kigi-home <path>]
//!
//! The binary name is `trace_classify` (underscore) — that's the file
//! name in `src/bin/`, which cargo's auto-discovery uses verbatim.
//! The task brief calls it `trace-classify` (hyphen) in prose; the
//! canonical CLI invocation is the underscore form.
//!
//! Each JSONL line carries the per-turn gate decision, the parsed
//! classifier verdict (or the abort/parse error if the call failed),
//! and the inputs that drove them.
@@ -61,7 +56,7 @@ struct Cli {
/// Override the LazinessDetector min-confidence threshold (default
/// matches production's `LAZINESS_DEFAULT_MIN_CONFIDENCE`). Must
/// be a finite float in `[0.0, 1.0]`. Use this to mirror a
/// per-model override from the production models catalog. (F6/N5)
/// per-model override from the production models catalog.
#[arg(long, value_parser = validate_min_confidence)]
min_confidence: Option<f32>,
+23 -66
View File
@@ -11,47 +11,22 @@ pub const CHECK_SKILL_MD: &str = include_str!("../skills/check-work/SKILL.md");
/// a bundled skill).
pub const BEST_OF_N_SKILL_MD: &str = include_str!("../skills/best-of-n/SKILL.md");
/// Legacy bundled skill names (renamed or removed).
///
/// These directories under `~/.kigi/skills/` will be deleted on startup
/// (during bundled file extraction). This ensures that when a bundled
/// skill is renamed (e.g. `check` → `check-work`), the old slash command
/// does not linger on users' machines after an upgrade.
///
/// Important behavior:
/// - Deletion happens **early** in `extract_bundled_files`, before we write
/// any current bundled skills.
/// - We **never** delete a name that is currently present in `BUNDLED_SKILLS`
/// (see `remove_legacy_bundled_skills`).
///
/// This means:
/// - If you later re-introduce a skill with a name that is still in this
/// legacy list (e.g. you ship a new "check" skill years later), the legacy
/// cleanup will **skip** it and the new skill will be created normally.
/// - The legacy list is a "delete old user copies of names we no longer ship",
/// not a permanent blacklist.
///
/// Lifecycle / maintenance:
/// - Add an old name here when you rename/remove a bundled skill.
/// - Once the directory is gone on a user's machine, further checks are
/// cheap no-ops.
/// - You do **not** have to remove entries immediately. It is safe to leave
/// them for many releases.
/// - After the rename has had time to propagate, you **may** clean old
/// strings out of this list for hygiene.
/// Names of bundled skills that were renamed or removed. Their directories
/// under `~/.kigi/skills/` are deleted early in `extract_bundled_files` so an
/// old slash command (e.g. `/check` after the rename to `/check-work`) does
/// not linger after an upgrade. A name still present in `BUNDLED_SKILLS` is
/// never deleted, so a skill name can be safely re-introduced without first
/// removing its legacy entry.
const LEGACY_BUNDLED_SKILL_NAMES: &[&str] =
&["check", "best-of-n", "docx", "pptx", "xlsx", "imagine"];
/// All bundled skill SKILL.md files. Single source of truth used by both
/// the full extraction path (version bump) and the missing-file fast path
/// (same version). Adding a new skill here is all that's needed.
/// All bundled skill SKILL.md files. Single source of truth for both the
/// full extraction path (version bump) and the missing-file fast path
/// (same version).
///
/// When renaming a bundled skill (e.g. "check" → "check-work"), also add the
/// old name to `LEGACY_BUNDLED_SKILL_NAMES` so `remove_legacy_bundled_skills`
/// will clean up the old directory on user machines on the next upgrade.
///
/// See the docs on `LEGACY_BUNDLED_SKILL_NAMES` for the full lifecycle
/// (including when it is safe/optional to remove old entries later).
/// When renaming a bundled skill, also add the old name to
/// `LEGACY_BUNDLED_SKILL_NAMES` so `remove_legacy_bundled_skills` cleans up
/// the old directory on the next upgrade.
const BUNDLED_SKILLS: &[(&str, &str)] = &[
("help", HELP_SKILL_MD),
("create-skill", CREATE_SKILL_MD),
@@ -60,11 +35,10 @@ const BUNDLED_SKILLS: &[(&str, &str)] = &[
];
/// True when a discovered skill is the copy `extract_bundled_files` wrote to
/// `<kigi_home>/skills/<name>/SKILL.md`. Exact-path (not prefix) so a
/// user-authored skill that reuses a bundled name — even elsewhere under
/// `<kigi_home>/skills/` — is never labeled bundled. Lives beside the
/// extraction code so the target layout and this predicate move together.
/// Used by inspect, which otherwise sees extracted copies as user skills.
/// `<kigi_home>/skills/<name>/SKILL.md`. Matches the exact path, not a prefix,
/// so a user-authored skill that reuses a bundled name is never labeled
/// bundled. Used by inspect, which otherwise sees extracted copies as user
/// skills.
pub(crate) fn is_extracted_bundled_skill(
name: &str,
path: &std::path::Path,
@@ -96,10 +70,8 @@ fn resolve_skill_content(name: &str, raw: &str, kigi_home: &std::path::Path) ->
/// always cleaned up first so that old slash commands disappear after
/// a rename (e.g. the previous `/check` after the move to `/check-work`).
pub fn extract_bundled_files(kigi_home: &std::path::Path) {
// Always remove legacy/renamed bundled skills first (e.g. the old
// `check` directory after the rename to `check-work`). This runs on
// every startup so users get cleaned up even without hitting a
// version-bump marker change.
// Runs before the version check so renamed skills are cleaned up on
// every startup, not only on a version bump.
remove_legacy_bundled_skills(kigi_home);
let version = kigi_version::VERSION;
@@ -128,7 +100,6 @@ pub fn extract_bundled_files(kigi_home: &std::path::Path) {
}
}
// Skill SKILL.md files.
for &(name, raw) in BUNDLED_SKILLS {
let skill_dir = kigi_home.join("skills").join(name);
let _ = std::fs::create_dir_all(&skill_dir);
@@ -156,15 +127,8 @@ fn extract_missing_skills(kigi_home: &std::path::Path) {
}
}
/// Remove directories for legacy/renamed bundled skills (e.g. old `check`
/// after it was renamed to `check-work`).
///
/// Called on every startup from `extract_bundled_files`. Safe and idempotent.
///
/// Key guarantees (see `LEGACY_BUNDLED_SKILL_NAMES` docs for details):
/// - If a name is still present in `BUNDLED_SKILLS`, we deliberately skip
/// deletion. This allows safe re-use of a skill name in the future.
/// - If the target directory no longer exists, this is a trivial no-op.
/// Delete directories for renamed/removed bundled skills. Runs on every
/// startup; idempotent. A name still in `BUNDLED_SKILLS` is never deleted.
fn remove_legacy_bundled_skills(kigi_home: &std::path::Path) {
remove_legacy_skills(kigi_home, LEGACY_BUNDLED_SKILL_NAMES, BUNDLED_SKILLS);
}
@@ -176,9 +140,8 @@ fn remove_legacy_skills(
bundled_skills: &[(&str, &str)],
) {
for name in legacy_names {
// Safety: Never delete a name that we are currently shipping.
// This protects against re-introducing a skill name that still has
// an entry in the legacy list.
// Never delete a name currently shipped in `bundled_skills`, so a
// re-introduced skill name can keep its legacy entry.
if bundled_skills.iter().any(|(n, _)| *n == *name) {
continue;
}
@@ -245,8 +208,6 @@ mod tests {
);
}
// Legacy skill directories must have been removed (the key part of
// supporting renames like check → check-work without leaving orphans).
for name in ["check", "best-of-n", "docx", "pptx", "xlsx", "imagine"] {
assert!(
!home.join(format!("skills/{name}")).exists(),
@@ -304,10 +265,6 @@ mod tests {
assert!(help.user_invocable);
}
// ---------------------------------------------------------------------
// Tests for legacy bundled skill removal (the rename migration system)
// ---------------------------------------------------------------------
#[test]
fn remove_legacy_deletes_old_skill_when_not_currently_shipped() {
let tmp = tempfile::tempdir().unwrap();
@@ -362,7 +319,7 @@ mod tests {
std::fs::create_dir_all(home.join("skills/another-legacy")).unwrap();
std::fs::write(home.join("skills/another-legacy/SKILL.md"), "old2").unwrap();
// Current bundled skills include one name that used to be legacy
// One currently-bundled name is also listed as legacy.
let current: &[(&str, &str)] = &[("another-legacy", "now shipping again")];
// Legacy list contains both the truly removed one and the reintroduced one
+2 -5
View File
@@ -1,10 +1,7 @@
//! Location of the local subagent-content cache (`~/.kigi/bundled/`).
//!
//! Formerly this module managed a synced bundle of personas/roles/agents/
//! skills fetched from the xAI cli-chat-proxy (`GET /v1/subagents/bundle`).
//! That backend is gone; the directory remains a passive, locally-populated
//! content root that role/persona discovery scans (see
//! `config::resolve_*` discovery in `config/mod.rs`).
//! A passive, locally-populated content root that role/persona discovery
//! scans (see `config::resolve_*` discovery in `config/mod.rs`).
use std::path::PathBuf;
+7 -49
View File
@@ -1,9 +1,4 @@
// claude_import.rs
// Scans Claude settings and generates TOML patches for .kigi/config.toml.
//
// This module reuses the existing discovery and parsing functions from
// claude_compat.rs and util/config.rs. It does NOT modify the runtime
// Claude compat layer — that continues to work as before.
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
@@ -19,8 +14,6 @@ use kigi_workspace::permission::claude_settings::{
use kigi_workspace::permission::rules::parse_permission_rule;
use kigi_workspace::permission::types::{PatternMode, PermissionRule, RuleAction, ToolFilter};
// Types
/// Scope for an import operation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImportScope {
@@ -78,7 +71,6 @@ impl ImportPlan {
self.global_items.len() + self.project_items.len()
}
/// Whether there's nothing to import.
pub fn is_empty(&self) -> bool {
self.global_items.is_empty() && self.project_items.is_empty()
}
@@ -112,7 +104,6 @@ impl ImportPlan {
fn format_item_summary(items: &[ImportableItem]) -> String {
let mut out = String::new();
// Permission rules
let perms: Vec<_> = items
.iter()
.filter_map(|i| match i {
@@ -156,7 +147,6 @@ fn format_item_summary(items: &[ImportableItem]) -> String {
}
}
// Env vars
let envs: Vec<_> = items
.iter()
.filter_map(|i| match i {
@@ -181,7 +171,6 @@ fn format_item_summary(items: &[ImportableItem]) -> String {
}
}
// MCP servers
let mcps: Vec<_> = items
.iter()
.filter_map(|i| match i {
@@ -196,7 +185,6 @@ fn format_item_summary(items: &[ImportableItem]) -> String {
}
}
// Hooks
let hooks: Vec<_> = items
.iter()
.filter_map(|i| match i {
@@ -224,7 +212,6 @@ fn format_item_summary(items: &[ImportableItem]) -> String {
}
}
// Path entries
let paths_iter = items.iter().filter_map(|i| match i {
ImportableItem::PathEntry { kind, path } => Some((*kind, path)),
_ => None,
@@ -321,8 +308,6 @@ fn extract_hooks_from_settings_file(path: &Path) -> Vec<ImportableItem> {
items
}
// Scanner
/// Scan all Claude settings sources and build an import plan.
///
/// Discovers:
@@ -352,7 +337,6 @@ pub fn scan_importable_settings(cwd: &Path) -> ImportPlan {
&mut plan.project_items
};
// Permission rules.
if let Some(perms) = settings.permissions {
for (action, entries) in [
(RuleAction::Allow, perms.allow),
@@ -375,7 +359,6 @@ pub fn scan_importable_settings(cwd: &Path) -> ImportPlan {
}
}
// Environment variables.
if let Some(env) = settings.env {
for (key, value) in env {
target.push(ImportableItem::EnvVar { key, value });
@@ -483,8 +466,6 @@ fn scan_mcp_json_servers(cwd: &Path, plan: &mut ImportPlan) {
}
}
// Repo Root Discovery
/// Find the git repo root for project config writes.
///
/// Uses `git2::Repository::discover` (matching `config/mod.rs:find_project_configs`)
@@ -496,14 +477,10 @@ pub fn find_project_root(cwd: &Path) -> PathBuf {
.unwrap_or_else(|| cwd.to_path_buf())
}
// Import Marker (Read Side)
//
// The marker `[claude_compat] imported = true` in `~/.kigi/config.toml` is
// the signal that runtime fallback paths should stop reading `.claude/`.
// The reader infrastructure lives here in the base layer so that gates
// added in subsequent layers (hooks, paths, perms) can all consult the
// same cached marker. The writer (`mark_claude_imported`) lives in the
// runtime-cutoff layer that activates the gates.
// The marker `[claude_compat] imported = true` in `~/.kigi/config.toml`
// signals that runtime fallback paths should stop reading `.claude/`. Every
// gate (hooks, paths, perms) consults the same cached marker; the writer is
// `mark_claude_imported`.
/// Cached result of [`is_claude_import_marked`]. See its doc for the
/// caching rationale and trade-offs.
@@ -590,8 +567,8 @@ pub fn expand_home(s: &str) -> std::path::PathBuf {
/// debugging which subsystem stopped reading `.claude/`).
///
/// Call sites are runtime fallback paths in `claude_compat.rs`,
/// `util/config.rs`, `util/hooks.rs`, and `agent/config.rs` that previously
/// read `.claude/`.
/// `util/config.rs`, `util/hooks.rs`, and `agent/config.rs` that fall back
/// to reading `.claude/`.
pub fn is_claude_import_marked_with_log(gate_name: &'static str) -> bool {
static LOGGED: OnceLock<()> = OnceLock::new();
let marked = is_claude_import_marked();
@@ -689,7 +666,6 @@ pub fn mark_claude_imported() -> anyhow::Result<()> {
refresh_marker_cache(true);
Ok(())
}
// TOML Patch Writer
/// Apply an import plan by writing TOML patches to the appropriate config files.
///
@@ -802,7 +778,6 @@ fn apply_items_to_config(config_path: &Path, items: &[ImportableItem]) -> anyhow
let mut count = 0usize;
// Group items by type.
let mut permissions: Vec<&PermissionRule> = Vec::new();
let mut env_vars: Vec<(&str, &str)> = Vec::new();
let mut mcp_servers: Vec<(&str, &McpServerConfig)> = Vec::new();
@@ -844,7 +819,6 @@ fn apply_items_to_config(config_path: &Path, items: &[ImportableItem]) -> anyhow
}
if count > 0 {
// Atomic write: write to .tmp, then rename.
let toml_str = toml::to_string_pretty(&root)?;
let tmp = config_path.with_extension("toml.tmp");
if let Some(parent) = config_path.parent() {
@@ -879,7 +853,6 @@ fn merge_permissions(
let mut count = 0;
// Group rules by action.
let mut allow_rules: Vec<String> = Vec::new();
let mut deny_rules: Vec<String> = Vec::new();
let mut ask_rules: Vec<String> = Vec::new();
@@ -909,7 +882,6 @@ fn merge_permissions(
.as_array_mut()
.ok_or_else(|| anyhow::anyhow!("permission.{key} is not an array"))?;
// Collect existing strings for dedup.
let existing_set: std::collections::HashSet<String> = existing
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
@@ -946,7 +918,6 @@ fn format_rule_string(rule: &PermissionRule) -> String {
};
match (&rule.pattern, &rule.tool) {
// Catch-all: any tool, no pattern → "*".
(None, ToolFilter::Any) => "*".to_string(),
(Some(pat), ToolFilter::Any) => pat.clone(),
(None, _) => tool_name.to_string(),
@@ -975,7 +946,6 @@ fn merge_env_vars(table: &mut TomlMap<String, TomlValue>, vars: &[(&str, &str)])
let mut count = 0;
for (key, value) in vars {
// Don't overwrite existing entries.
if !env_table.contains_key(*key) {
env_table.insert(key.to_string(), TomlValue::String(value.to_string()));
count += 1;
@@ -998,7 +968,6 @@ fn merge_mcp_servers(
let mut count = 0;
for (name, config) in servers {
// Don't overwrite existing server entries.
if !mcp_table.contains_key(*name) {
let serialized = toml::Value::try_from(*config)
.map_err(|e| anyhow::anyhow!("failed to serialize MCP server {name}: {e}"))?;
@@ -1067,7 +1036,6 @@ fn apply_hooks_to_dir(hooks_dir: &Path, items: &[ImportableItem]) -> anyhow::Res
let target = hooks_dir.join("imported-from-claude.json");
// Read existing JSON if present.
let mut root: serde_json::Value = match std::fs::read_to_string(&target) {
Ok(s) => serde_json::from_str(&s).unwrap_or_else(|e| {
warn!(
@@ -1121,8 +1089,7 @@ fn apply_hooks_to_dir(hooks_dir: &Path, items: &[ImportableItem]) -> anyhow::Res
//
// Invariant: `extract_hooks_from_settings_file` filters empty matcher
// strings to `None`, so the existing-matcher comparison only needs to
// distinguish `None` from `Some(s)`; we no longer need a defensive
// `(Some(""), None)` arm.
// distinguish `None` from `Some(s)`.
let mut updated = false;
for g in groups.iter_mut() {
let existing_matcher = g.get("matcher").and_then(|v| v.as_str());
@@ -1240,7 +1207,6 @@ mod tests {
#[test]
fn format_rule_any_none_is_star() {
// Catch-all rule: Any tool, no pattern → "*".
let rule = PermissionRule {
action: RuleAction::Allow,
tool: ToolFilter::Any,
@@ -1274,13 +1240,11 @@ mod tests {
#[test]
fn format_rule_round_trip() {
// Parse a Claude rule, format it back, and verify it produces the same rule.
let original = "Bash(npm run build)";
let parsed = parse_permission_rule(original, RuleAction::Allow).unwrap();
let formatted = format_rule_string(&parsed);
assert_eq!(formatted, original);
// Round-trip the formatted string.
let reparsed = parse_permission_rule(&formatted, RuleAction::Allow).unwrap();
assert_eq!(parsed.tool, reparsed.tool);
assert_eq!(parsed.pattern, reparsed.pattern);
@@ -1299,7 +1263,6 @@ mod tests {
#[test]
fn merge_permissions_dedup() {
let mut table = TomlMap::new();
// Pre-populate with one existing rule.
let mut perm = TomlMap::new();
perm.insert(
"allow".to_string(),
@@ -1321,7 +1284,6 @@ mod tests {
};
let count = merge_permissions(&mut table, &[&rule_existing, &rule_new]).unwrap();
// Only the new rule should be added (existing is deduped).
assert_eq!(count, 1);
let arr = table["permission"]["allow"].as_array().unwrap();
@@ -1344,7 +1306,6 @@ mod tests {
&mut table,
&[("EXISTING", "new_value"), ("NEW_VAR", "value")],
);
// Only NEW_VAR should be added.
assert_eq!(count, 1);
let env_table = table["env"].as_table().unwrap();
@@ -1452,9 +1413,6 @@ mod tests {
}
}
/// RAII guard that removes an env var on drop, preventing leaks when
/// an assertion panics before manual cleanup.
#[test]
fn extract_hooks_basic_command() {
let dir = tempfile::tempdir().unwrap();
@@ -1,4 +1,3 @@
// claude_import_state.rs
// Tracks what Claude settings have been imported/dismissed so we don't re-prompt.
//
// State is persisted to `~/.kigi/claude_import_state.json`.
@@ -14,8 +13,6 @@ use tracing::{debug, warn};
use kigi_workspace::permission::claude_settings::find_claude_settings_paths;
// Types
/// Persistent import state, loaded from / saved to `~/.kigi/claude_import_state.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportState {
@@ -48,14 +45,11 @@ impl Default for ImportState {
}
}
// Persistence
/// Path to the import state file.
fn state_path() -> PathBuf {
crate::util::kigi_home::kigi_home().join("claude_import_state.json")
}
/// Load the import state from disk. Returns default if missing or unreadable.
/// Returns default if the state file is missing or unreadable.
pub fn load_import_state() -> ImportState {
let path = state_path();
match std::fs::read_to_string(&path) {
@@ -79,7 +73,7 @@ pub fn load_import_state() -> ImportState {
}
}
/// Save the import state to disk (atomic write via tmp + rename).
/// Atomic write via a tmp file + rename.
pub fn save_import_state(state: &ImportState) -> std::io::Result<()> {
let path = state_path();
if let Some(parent) = path.parent() {
@@ -94,8 +88,6 @@ pub fn save_import_state(state: &ImportState) -> std::io::Result<()> {
Ok(())
}
// Hash Computation
/// Compute a SHA-256 hash over the contents of all Claude settings files for a
/// given set of paths. Files that don't exist or can't be read are skipped.
///
@@ -107,7 +99,6 @@ fn compute_settings_hash(paths: &[PathBuf]) -> String {
.filter_map(|p| std::fs::read(p).ok().map(|content| (p, content)))
.collect();
// Sort by path for determinism.
existing.sort_by(|a, b| a.0.cmp(b.0));
let mut hasher = Sha256::new();
@@ -142,8 +133,6 @@ fn compute_global_hash() -> (String, Vec<PathBuf>) {
/// Uses `dirs::home_dir()` to match the home directory resolution used by
/// the scanner in `claude_import.rs`.
fn compute_project_hash(cwd: &Path) -> (String, Vec<PathBuf>) {
// Use find_claude_settings_paths but filter to only project-level paths
// (exclude global ~/.claude/ paths).
let all_paths = find_claude_settings_paths(cwd);
let home = dirs::home_dir();
@@ -179,8 +168,6 @@ fn compute_project_hash(cwd: &Path) -> (String, Vec<PathBuf>) {
(hash, all)
}
// Change Detection
/// Check if any Claude settings files have changed since the last import/dismiss.
///
/// Returns `true` if:
@@ -190,7 +177,6 @@ fn compute_project_hash(cwd: &Path) -> (String, Vec<PathBuf>) {
pub fn has_new_changes(cwd: &Path) -> bool {
let state = load_import_state();
// Check global scope.
let (global_hash, global_paths) = compute_global_hash();
let global_files_exist = global_paths.iter().any(|p| p.exists());
if global_files_exist {
@@ -211,7 +197,6 @@ pub fn has_new_changes(cwd: &Path) -> bool {
}
}
// Check project scope.
let (project_hash, project_paths) = compute_project_hash(cwd);
let project_files_exist = project_paths.iter().any(|p| p.exists());
if project_files_exist {
@@ -236,8 +221,6 @@ pub fn has_new_changes(cwd: &Path) -> bool {
false
}
// State Updates
fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339()
}
@@ -287,12 +270,10 @@ mod tests {
std::fs::write(&f1, r#"{"allow": ["Bash"]}"#).unwrap();
std::fs::write(&f2, r#"{"env": {"FOO": "bar"}}"#).unwrap();
// Same order.
let h1 = compute_settings_hash(&[f1.clone(), f2.clone()]);
let h2 = compute_settings_hash(&[f1.clone(), f2.clone()]);
assert_eq!(h1, h2, "same order should produce same hash");
// Reversed order should also produce the same hash (sorted internally).
let h3 = compute_settings_hash(&[f2.clone(), f1.clone()]);
assert_eq!(
h1, h3,
@@ -328,7 +309,6 @@ mod tests {
#[test]
fn compute_settings_hash_empty_input() {
// No paths at all should produce a deterministic hash.
let h1 = compute_settings_hash(&[]);
let h2 = compute_settings_hash(&[]);
assert_eq!(h1, h2, "empty input should produce same hash");
+4 -27
View File
@@ -13,41 +13,24 @@ use serde::Deserialize;
/// `.kigi/config.toml`. Disabled by default; enabled via
/// `--experimental-memory` CLI flag or `KIGI_MEMORY=1` env var.
/// Force-disabled via `KIGI_MEMORY=0` (overrides TOML and remote settings).
///
/// All sub-configs are pre-populated with production-ready defaults so that
/// later PRs (indexing, search, flush, pruning) can read them without any
/// config migration.
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(default)]
pub struct MemoryConfig {
/// Whether memory is enabled for this session.
pub enabled: bool,
/// Index / chunking settings.
pub index: MemoryIndexConfig,
/// Embedding provider settings.
pub embedding: MemoryEmbeddingConfig,
/// Hybrid search scoring settings.
pub search: MemorySearchConfig,
/// First-turn memory injection behavior.
pub initial_injection: MemoryInitialInjectionConfig,
/// Session lifecycle settings.
pub session: MemorySessionConfig,
/// File watcher settings for detecting external memory edits.
pub watcher: MemoryWatcherConfig,
/// Garbage collection settings for orphaned workspace directories.
pub gc: MemoryGcConfig,
/// autoDream consolidation settings.
pub dream: MemoryDreamConfig,
/// Pre-compaction memory flush settings.
///
/// **Note:** Configured under `[compaction.memory_flush]` in config.toml,
/// not under `[memory]`. Flush is a compaction behavior.
/// Configured under `[compaction.memory_flush]` in config.toml, not under
/// `[memory]`. Flush is a compaction behavior.
#[serde(skip)]
pub flush: MemoryFlushConfig,
/// Tool-result pruning settings.
///
/// **Note:** Configured under `[compaction.pruning]` in config.toml,
/// not under `[memory]`. Pruning is a compaction behavior.
/// Configured under `[compaction.pruning]` in config.toml, not under
/// `[memory]`. Pruning is a compaction behavior.
#[serde(skip)]
pub pruning: PruningConfig,
/// Per-agent memory root override (e.g. `~/.kigi/agent-memory/<name>/`).
@@ -222,7 +205,6 @@ impl MemoryConfig {
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct SubagentsConfig {
/// Whether subagent support is enabled.
pub enabled: bool,
/// Per-subagent model ID overrides.
/// Keys are agent names, values are model IDs that must exist in the
@@ -369,16 +351,13 @@ impl SubagentsConfig {
}
}
}
/// Check if a subagent is enabled.
/// Returns `true` if the agent is not in the toggle map (default enabled).
pub fn is_subagent_enabled(&self, name: &str) -> bool {
self.toggle.get(name).copied().unwrap_or(true)
}
/// Look up a role by name.
pub fn get_role(&self, name: &str) -> Option<&SubagentRole> {
self.roles.get(name)
}
/// Look up a persona by name.
pub fn get_persona(&self, name: &str) -> Option<&SubagentPersona> {
self.personas.get(name)
}
@@ -670,7 +649,6 @@ impl StorageMode {
}
Self::Local
}
/// Returns true if this mode syncs to the backend.
pub fn is_writeback(&self) -> bool {
matches!(self, Self::Writeback)
}
@@ -775,7 +753,6 @@ impl std::fmt::Display for RequirementSource {
}
}
}
/// A value paired with the source it came from.
#[derive(Debug, Clone)]
pub struct Sourced<T> {
pub value: T,
+51 -107
View File
@@ -32,20 +32,17 @@ pub enum ConfigUpdate {
/// [`Self::ProjectMcpServersChanged`] instead so the reload can
/// be narrowed to matching cwds.
///
/// Deliberately kept as a unit variant.
/// Adding a payload here would force pattern-match updates across
/// (`<cwd>/.kigi/config.toml`, `<cwd>/.mcp.json`, or
/// `mvp_agent`, `app`, `session/handle`, etc.
/// Deliberately a unit variant — a payload would force pattern-match
/// updates across `mvp_agent`, `app`, `session/handle`, etc.
McpServersChanged,
/// A **project-scoped** MCP config file changed
/// `<cwd>/.claude.json`). Agent should reload MCP only for
/// sessions whose cwd matches `cwd` (or sits beneath it).
/// A **project-scoped** MCP config file (`<cwd>/.kigi/config.toml`,
/// `<cwd>/.mcp.json`, or `<cwd>/.claude.json`) changed. Agent should
/// reload MCP only for sessions whose cwd matches `cwd` (or sits
/// beneath it).
///
/// Strictly additive to [`Self::McpServersChanged`] — the unit
/// variant continues to fire for global-config edits. The two
/// cases are split so per-project reloads don't
/// kigi process sharing the home dir). The agent should consult the cache
/// thrash unrelated sessions.
/// Additive to [`Self::McpServersChanged`] — the unit variant still
/// fires for global-config edits. The two cases are split so
/// per-project reloads don't thrash unrelated sessions.
ProjectMcpServersChanged {
/// The project root whose `.kigi/`, `.mcp.json`, or
/// `.claude.json` file was edited. Sessions whose cwd equals
@@ -64,11 +61,10 @@ pub enum ConfigUpdate {
/// its model list (BYOK models added/removed, default or surprise changed).
ModelsChanged,
/// `~/.kigi/models_cache.json` was rewritten on disk (possibly by another
/// via `ModelsManager::reload_from_disk_cache`, which content-dedupes
/// self-writes (`persist` / `renew_ttl`) before applying. No payload
/// validation (TTL, version, auth method) requires `ModelsManager` state
/// drop redundant `ProjectMcpServersChanged` dispatches on
/// the reloader doesn't have.
/// kigi process sharing the home dir). No payload — validation (TTL,
/// version, auth method) requires `ModelsManager` state the reloader
/// doesn't have. The agent forwards to `ModelsManager::reload_from_disk_cache`,
/// which content-dedupes self-writes (`persist` / `renew_ttl`) before applying.
ModelsCacheChanged,
/// Updated UI settings — agent broadcasts `kigi/config_changed` to IPC clients.
Ui {
@@ -84,9 +80,9 @@ pub enum ConfigUpdate {
pub struct ConfigReloader {
last_auth_key_hash: u64,
last_global_config: toml::Value,
/// Per-cwd content hash of the project MCP config files, used to
/// to diff (the dedup lives in `ModelsManager::reload_from_disk_cache`),
/// mtime-only touches (see `hash_project_mcp_config`).
/// Per-cwd content hash of the project MCP config files, used to drop
/// redundant `ProjectMcpServersChanged` dispatches on mtime-only touches
/// (see `hash_project_mcp_config`).
last_project_mcp_hashes: HashMap<PathBuf, u64>,
kigi_home: PathBuf,
auth_scope: String,
@@ -153,11 +149,6 @@ impl ConfigReloader {
let has_project_config = batch
.iter()
.any(|e| matches!(e, ConfigChangeEvent::ProjectConfigChanged { .. }));
// `~/.claude.json` is loaded by every
// session (it does NOT live in a project root), so its
// reload must broadcast through the legacy unit
// `McpServersChanged` arm. Routing it through the per-
// cwd variant would silently miss sessions outside `$HOME`.
let has_home_claude_json = batch
.iter()
.any(|e| matches!(e, ConfigChangeEvent::HomeClaudeJsonChanged));
@@ -166,11 +157,6 @@ impl ConfigReloader {
.any(|e| matches!(e, ConfigChangeEvent::ModelsCacheChanged));
let has_config = has_global_config || has_project_config;
// Collect the unique cwds whose project
// files changed so we can emit one
// `ConfigUpdate::ProjectMcpServersChanged { cwd }` per
// project root (rather than the legacy unit
// `McpServersChanged` that swept every session).
let project_cwds = collect_project_cwds(&batch);
if has_auth {
@@ -214,42 +200,27 @@ impl ConfigReloader {
}
}
// NB: the legacy fall-through that emitted a unit
// `McpServersChanged` for any project `.mcp.json` /
// `.claude.json` change is replaced by the
// per-cwd fan-out below — `collect_project_cwds` already
// includes every `McpConfigChanged` path in `project_cwds`,
// so a separate emit here would double-dispatch. Global
// `[mcp_servers]` edits are dispatched inside `reload_config`.
// Home-level `~/.claude.json` must
// broadcast to every session through the unit variant —
// sessions outside `$HOME` would otherwise be silently
// skipped by the per-cwd `cwd_matches` filter.
// `~/.claude.json` is read by every session, so its reload must
// broadcast; the per-cwd filter would skip sessions outside `$HOME`.
if has_home_claude_json {
info!("~/.claude.json change detected — broadcasting MCP reload");
let _ = self.config_update_tx.send(ConfigUpdate::McpServersChanged);
}
// Pass-through (no toml diff possible here): the
// content-vs-in-memory dedup happens in
// Pass-through: no toml to diff here; the content dedup happens in
// `ModelsManager::reload_from_disk_cache`.
if has_models_cache {
debug!("models_cache.json change detected — forwarding to agent");
let _ = self.config_update_tx.send(ConfigUpdate::ModelsCacheChanged);
}
// Fan out one
// `ProjectMcpServersChanged { cwd }` per affected project
// root. The legacy unit `McpServersChanged` above stays
// for global-config edits — both variants can fire in the
// same tick (e.g. `~/.kigi/config.toml` AND
// `<cwd>/.mcp.json` edited together).
// One `ProjectMcpServersChanged { cwd }` per affected project root.
// Both variants can fire in the same tick (e.g. `~/.kigi/config.toml`
// and `<cwd>/.mcp.json` edited together).
for cwd in project_cwds {
// Skip the dispatch when the project config bytes are
// unchanged (the watcher fires on mtime-only touches).
// On any uncertainty we dispatch; see
// `hash_project_mcp_config`.
// Skip the dispatch when the config bytes are unchanged (the
// watcher fires on mtime-only touches). On any uncertainty
// `hash_project_mcp_config` returns `None` and we dispatch.
let new_hash = hash_project_mcp_config(&cwd);
let unchanged = match (new_hash, self.last_project_mcp_hashes.get(&cwd)) {
(Some(new), Some(&prev)) => new == prev,
@@ -315,11 +286,8 @@ impl ConfigReloader {
}
fn reload_config(&mut self) -> anyhow::Result<()> {
// `has_project_config` parameter dropped —
// project-scoped reloads are dispatched via
// `ProjectMcpServersChanged { cwd }` in the caller's
// `collect_project_cwds` fan-out, so this function only
// needs to diff the global toml.
// Diffs only the global toml; project-scoped reloads go through the
// caller's `collect_project_cwds` fan-out.
let new_global = match crate::config::load_from_disk() {
Ok(v) => v,
Err(e) => {
@@ -328,13 +296,6 @@ impl ConfigReloader {
}
};
// MCP servers — compare [mcp_servers] table in the **global**
// config (`~/.kigi/config.toml`) via toml::Value. Project-
// scoped changes (`<cwd>/.kigi/config.toml`,
// `<cwd>/.mcp.json`) are dispatched separately via
// `ConfigUpdate::ProjectMcpServersChanged { cwd }` (see
// `collect_project_cwds`) so they don't sweep
// unrelated sessions.
let old_mcp_table = self.last_global_config.get("mcp_servers");
let new_mcp_table = new_global.get("mcp_servers");
let mcp_changed = old_mcp_table != new_mcp_table;
@@ -343,7 +304,6 @@ impl ConfigReloader {
let _ = self.config_update_tx.send(ConfigUpdate::McpServersChanged);
}
// Memory config
let old_mem = crate::config::MemoryConfig::resolve(
self.experimental_memory,
self.no_memory,
@@ -363,7 +323,6 @@ impl ConfigReloader {
.send(ConfigUpdate::Memory(Box::new(new_mem)));
}
// Skills config
let old_skills = parse_skills_config(&self.last_global_config);
let new_skills = parse_skills_config(&new_global);
if old_skills != new_skills {
@@ -371,7 +330,6 @@ impl ConfigReloader {
let _ = self.config_update_tx.send(ConfigUpdate::Skills(new_skills));
}
// Compat config ([compat] vendor toggles)
let old_compat = parse_compat_config(&self.last_global_config);
let new_compat = parse_compat_config(&new_global);
if old_compat != new_compat {
@@ -381,8 +339,7 @@ impl ConfigReloader {
.send(ConfigUpdate::Compat(Box::new(new_compat)));
}
// Models — compare [model] (BYOK entries) and [models] (default, surprise) tables.
// Use toml::Value comparison (covers all fields including nested model entries).
// `[model]` = BYOK entries, `[models]` = default/surprise — two distinct tables.
let old_model_table = self.last_global_config.get("model");
let new_model_table = new_global.get("model");
let old_models_table = self.last_global_config.get("models");
@@ -392,7 +349,6 @@ impl ConfigReloader {
let _ = self.config_update_tx.send(ConfigUpdate::ModelsChanged);
}
// UI fields (theme, yolo, fork_secondary_model)
let old_ui = extract_ui_fields(&self.last_global_config);
let new_ui = extract_ui_fields(&new_global);
if old_ui != new_ui {
@@ -410,18 +366,9 @@ impl ConfigReloader {
}
/// Derive the unique project cwds whose files were touched in this
/// debounce window. Used to fan out one
/// debounce window, to fan out one
/// [`ConfigUpdate::ProjectMcpServersChanged`] per project root rather
/// than one legacy `McpServersChanged` that reloads every active
/// session.
///
/// Path-to-cwd mapping:
///
/// | `ConfigChangeEvent` | path shape | cwd |
/// |----------------------------|-------------------------|-------------------|
/// | `ProjectConfigChanged` | `<cwd>/.kigi/config.toml` | `<cwd>` |
/// | `McpConfigChanged` | `<cwd>/.mcp.json` | `<cwd>` |
/// | `McpConfigChanged` | `<cwd>/.claude.json` | `<cwd>` |
/// than one `McpServersChanged` that reloads every active session.
///
/// Order-preserving de-dup (a `Vec` rather than a `HashSet`) so the
/// downstream emit order is deterministic in tests.
@@ -473,13 +420,16 @@ fn hash_project_mcp_config(cwd: &Path) -> Option<u64> {
f.to_string_lossy().hash(&mut hasher);
match std::fs::read(f) {
Ok(bytes) => {
1u8.hash(&mut hasher); // present
// present
1u8.hash(&mut hasher);
bytes.hash(&mut hasher);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
0u8.hash(&mut hasher); // absent
// absent
0u8.hash(&mut hasher);
}
Err(_) => return None, // can't read confidently → dispatch
// can't read confidently → dispatch
Err(_) => return None,
}
}
Some(hasher.finish())
@@ -604,7 +554,7 @@ mod tests {
reloader.reload_auth().unwrap();
let update = rx.try_recv().expect("should send Auth update");
assert!(
matches!(update, ConfigUpdate::Auth(a) if a.key == "new-key"), // a is Box<KimiAuth>, Deref coercion
matches!(update, ConfigUpdate::Auth(a) if a.key == "new-key"),
"should contain new key"
);
}
@@ -690,9 +640,8 @@ mod tests {
);
}
/// `ModelsCacheChanged` is a pure pass-through: the reloader has no toml
/// so the event must surface as `ConfigUpdate::ModelsCacheChanged`
/// (walked to the git root by the loaders), not just files directly
/// `ModelsCacheChanged` is a pure pass-through: the reloader has no toml to
/// diff, so the event must surface as `ConfigUpdate::ModelsCacheChanged`
/// without touching auth or config state.
#[tokio::test]
async fn reloader_forwards_models_cache_changed() {
@@ -819,9 +768,7 @@ mod tests {
}
/// The hash must reflect ancestor `.kigi/config.toml` and `.mcp.json`
/// under `cwd` — otherwise an ancestor edit would be wrongly
/// must be a distinct variant from the unit `McpServersChanged`
/// suppressed.
/// under `cwd` — otherwise an ancestor edit would be wrongly suppressed.
#[test]
fn hash_project_mcp_config_covers_ancestors() {
let tmp = tempfile::TempDir::new().unwrap();
@@ -964,11 +911,10 @@ command = "/bin/test"
assert_ne!(a.get("mcp_servers"), b.get("mcp_servers"));
}
/// `ConfigUpdate::ProjectMcpServersChanged { cwd }`
/// so the two paths route through different match arms in
/// `app.rs`. Guards against an accidental merge that would force
/// fan-out — it must NOT contribute a cwd to
/// per-cwd reloads through the legacy sweep-all-sessions arm.
/// `ConfigUpdate::ProjectMcpServersChanged { cwd }` must be a distinct
/// variant from the unit `McpServersChanged` so the two paths route
/// through different match arms in `app.rs`. Guards against an accidental
/// merge that would force per-cwd reloads through the sweep-all-sessions arm.
#[test]
fn project_variant_dispatches_separately() {
let cwd = PathBuf::from("/tmp/proj-x");
@@ -994,11 +940,10 @@ command = "/bin/test"
}
/// `HomeClaudeJsonChanged` is **not** part of the per-cwd
/// `collect_project_cwds` (otherwise sessions outside `$HOME`
/// would be silently skipped). The reloader broadcasts it via
/// the unit `McpServersChanged` variant; this test locks that
/// `ProjectConfigChanged` (`<cwd>/.kigi/config.toml`) and
/// invariant at the helper layer.
/// `collect_project_cwds` (otherwise sessions outside `$HOME` would be
/// silently skipped). The reloader broadcasts it via the unit
/// `McpServersChanged` variant; this test locks that invariant at the
/// helper layer.
#[test]
fn collect_project_cwds_excludes_home_claude_json() {
let batch = vec![
@@ -1014,10 +959,9 @@ command = "/bin/test"
assert_eq!(cwds, vec![PathBuf::from("/repo/x")]);
}
/// `collect_project_cwds` extracts `<cwd>` from
/// `McpConfigChanged` (`<cwd>/.mcp.json`), de-duplicates while
/// `McpConfigChanged` (`<cwd>/.mcp.json`), de-duplicates while
/// preserving order.
/// `collect_project_cwds` extracts `<cwd>` from `ProjectConfigChanged`
/// (`<cwd>/.kigi/config.toml`) and `McpConfigChanged` (`<cwd>/.mcp.json`),
/// de-duplicates while preserving order.
#[test]
fn collect_project_cwds_dedupes_and_extracts() {
let batch = vec![
@@ -110,12 +110,10 @@ fn with_env_var_opt<T>(name: &str, value: Option<&str>, f: impl FnOnce() -> T) -
}
result.unwrap_or_else(|p| std::panic::resume_unwind(p))
}
/// Run `f` with KIGI_MEMORY explicitly unset.
fn without_kigi_memory<T>(f: impl FnOnce() -> T) -> T {
let _guard = MEMORY_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
with_env_var_opt("KIGI_MEMORY", None, f)
}
/// Run `f` with KIGI_MEMORY set to a specific value.
fn with_kigi_memory<T>(value: &str, f: impl FnOnce() -> T) -> T {
let _guard = MEMORY_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
with_env_var_opt("KIGI_MEMORY", Some(value), f)
@@ -346,10 +344,8 @@ fn memory_config_defaults_are_correct() {
assert_eq!(mem.dream.check_interval_secs, None);
});
}
/// `debounce_ms` was a dead field on `MemoryWatcherConfig` that was never
/// read by any watcher or search path. Verify that existing TOML config
/// files that contain `debounce_ms` are still parsed without error
/// (unknown fields are silently ignored by serde default).
/// A TOML config carrying the legacy `debounce_ms` key must still parse:
/// serde silently ignores fields absent from `MemoryWatcherConfig`.
#[test]
fn memory_config_watcher_debounce_ms_in_toml_is_silently_ignored() {
without_kigi_memory(|| {
@@ -961,12 +957,10 @@ max_results = 8
}
/// Mutex to serialize tests that touch the KIGI_SUBAGENTS env var.
static SUBAGENTS_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Run `f` with KIGI_SUBAGENTS explicitly unset.
fn without_kigi_subagents<T>(f: impl FnOnce() -> T) -> T {
let _guard = SUBAGENTS_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
with_env_var_opt("KIGI_SUBAGENTS", None, f)
}
/// Run `f` with KIGI_SUBAGENTS set to a specific value.
fn with_kigi_subagents<T>(value: &str, f: impl FnOnce() -> T) -> T {
let _guard = SUBAGENTS_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
with_env_var_opt("KIGI_SUBAGENTS", Some(value), f)
@@ -120,7 +120,7 @@ pub enum ConfigChangeEvent {
/// would walk `node_modules/`, `target/`, `.git/`, etc. and blow through
/// `fs.inotify.max_user_watches` on large repos. Use [`Self::watch_path`]
/// to register additional cwds at runtime when new sessions open in
/// previously-unwatched directories.
/// not-yet-watched directories.
pub struct ConfigFileWatcher {
debouncer: Debouncer<AccessFilteredWatcher>,
/// Project cwds currently registered (via [`Self::start`]'s `cwd`
@@ -138,7 +138,7 @@ impl ConfigFileWatcher {
///
/// `cwd`, when `Some`, adds two non-recursive watches: `<cwd>/` and
/// `<cwd>/.kigi/`. Use [`Self::watch_path`] later to register additional
/// project cwds for sessions that open in previously-unwatched
/// project cwds for sessions that open in not-yet-watched
/// directories.
pub fn start(
kigi_home: &Path,
@@ -242,8 +242,7 @@ impl ConfigFileWatcher {
}
}
// Add the two narrow non-recursive cwd watches
// promoted to first-class watch targets. Both are non-fatal —
// Add the two narrow non-recursive cwd watches. Both are non-fatal —
// a missing directory just means the corresponding files don't
// exist yet and will be picked up by `watch_path` on the next
// session that opens in this cwd.
@@ -310,7 +309,7 @@ impl ConfigFileWatcher {
}
/// Remove the two non-recursive watches (`<cwd>/` and
/// `<cwd>/.kigi/`) previously registered for `cwd` via
/// `<cwd>/.kigi/`) registered for `cwd` via
/// [`Self::start`] / [`Self::watch_path`].
///
/// Best-effort and idempotent: a `cwd` that was never registered
@@ -628,7 +627,8 @@ mod tests {
let watched = watch_skill_subdirs(&mut debouncer, global);
assert!(watched >= 1, "should watch the <dir>/skills subdir");
wait_ms(150);
while rx.try_recv().is_ok() {} // drain startup noise
// drain startup noise
while rx.try_recv().is_ok() {}
// Editing a SKILL.md under the unwatched worktrees/ subtree must NOT fire.
fs::write(wt_skill.join("SKILL.md"), "# beta v2").unwrap();
@@ -692,7 +692,8 @@ mod tests {
ConfigFileWatcher::start(tmp.path(), &[], None, Some(Duration::from_millis(50)))
.expect("watcher should start");
wait_ms(150);
while rx.try_recv().is_ok() {} // drain any startup noise
// drain any startup noise
while rx.try_recv().is_ok() {}
// Simulate what the leader does on every reload: read the watched
// files. Repeatedly, to defeat any incidental coalescing.
@@ -74,7 +74,6 @@ fn handle_set_api_key(args: &acp::ExtRequest) -> ExtResult {
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
}
/// Handle auth code submission from TUI.
fn handle_submit_code(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
#[derive(Deserialize)]
struct SubmitCodeParams {
@@ -4,8 +4,7 @@
//! `GET {coding_api_base_url}/usages` with the OAuth Bearer token, parsed into
//! display rows (`{usage: {...}, limits: [{detail, window, ...}]}` payload
//! shape). The TUI renders the rows as label + remaining-quota bar +
//! reset hint. The xAI credits/auto-topup surface this file used to serve is
//! gone with the xAI proxy.
//! reset hint.
use agent_client_protocol as acp;
use serde::{Deserialize, Serialize};
@@ -19,14 +19,9 @@ use crate::agent::mvp_agent::{CodeNavEligibility, MvpAgent};
use agent_client_protocol as acp;
use serde::{Deserialize, Serialize};
/// Record a structured telemetry event at the end of a code-nav handler call.
///
/// This is called once per request with the method name, triggering session,
/// cwd, whether the index was newly spawned or reused, and total elapsed time.
/// These fields make it possible to:
/// - identify first-use latency (newly spawned + high elapsed_ms)
/// - identify reuse latency (reused + low elapsed_ms)
/// - attribute slowness to index startup vs query processing
/// Emitted once per request. The `index_newly_started` and `elapsed_ms` fields
/// together separate first-use latency (index startup) from reuse latency and
/// from query-processing time.
fn log_code_nav_telemetry(
method: &str,
session_id: Option<&acp::SessionId>,
@@ -46,8 +41,6 @@ fn log_code_nav_telemetry(
type ExtResult = Result<acp::ExtResponse, acp::Error>;
// ========== Request Types ==========
/// Position-based query request (for goto-definition, goto-references).
/// Position parameters are 1-indexed (matching editor display).
///
@@ -80,7 +73,6 @@ pub struct FindSymbolRequest {
pub session_id: Option<acp::SessionId>,
/// Working directory (optional when session_id is provided).
pub cwd: Option<String>,
/// Symbol name to search for
pub symbol: String,
/// Optional context file path for ranking results
pub context_path: Option<String>,
@@ -98,15 +90,12 @@ pub struct StatusRequest {
pub cwd: Option<String>,
}
// ========== Response Types ==========
/// Response for goto-definition and goto-references queries.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CodeNavResponse {
/// The symbol that was queried
pub symbol: String,
/// List of locations where the symbol was found
pub locations: Vec<SymbolLocation>,
}
@@ -167,8 +156,6 @@ pub struct StatusResponse {
pub file_count: Option<usize>,
}
// ========== Handler ==========
/// Handle code navigation extension methods.
///
/// Routes through [`WorkspaceOps`]. Eligibility checks still run in shell since
@@ -303,7 +290,6 @@ pub async fn handle(
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))?;
let cwd = resolve_cwd(agent, req.cwd.clone(), req.session_id.as_ref())?;
// Check eligibility for the status response.
let (eligible, reason, indexed, file_count) = match agent
.code_nav_eligibility_for_request(req.session_id.as_ref(), &cwd)
{
@@ -387,7 +373,6 @@ fn ensure_eligible_and_started(
if let Err(reason) = agent.code_nav_eligibility_for_request(session_id, cwd) {
return Err(eligibility_error(reason));
}
// Start the index if not already running (lazy creation).
let was_newly_started = agent
.start_codebase_index_for_code_nav(session_id, cwd)
.map(|(_, was_new)| was_new)
@@ -395,20 +380,16 @@ fn ensure_eligible_and_started(
Ok(was_newly_started)
}
// ========== Helper Functions ==========
/// Resolve cwd from session_id or direct cwd parameter.
fn resolve_cwd(
agent: &MvpAgent,
cwd: Option<String>,
session_id: Option<&acp::SessionId>,
) -> Result<PathBuf, acp::Error> {
// Prefer direct cwd if provided
if let Some(cwd_str) = cwd {
return Ok(PathBuf::from(cwd_str));
}
// Fall back to session's cwd
if let Some(sid) = session_id
&& let Some(session_cwd) = agent.get_session_cwd(sid)
{
@@ -97,7 +97,6 @@ async fn handle_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult
match serde_json::from_str::<ClientFeedbackInput>(args.params.get()) {
Ok(input) => input,
Err(_) => {
// Fallback: parse simple FeedbackRequest from /feedback command
let simple: crate::session::FeedbackRequest = parse_params(args)?;
ClientFeedbackInput {
session_id: simple.session_id,
@@ -173,7 +172,6 @@ async fn handle_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult
}
}
// Track rating in session signals
if let (Some(session_handle), Some(rating_value)) =
(&session_handle, feedback_input.rating_value)
{
@@ -193,7 +191,6 @@ async fn handle_feedback(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult
}
}
// Log feedback type for debugging
if feedback_input.is_solicited() {
tracing::info!(
session_id = %feedback_input.session_id,
@@ -124,8 +124,6 @@ pub struct FsDeleteFileRequest {
pub session_id: Option<acp::SessionId>,
pub path: String,
}
/// Resolve path from explicit value or session lookup.
/// For absolute paths, use directly. For relative paths, resolve from session cwd.
fn resolve_path(
agent: &MvpAgent,
path: &str,
@@ -3,7 +3,7 @@
//! Routing: prefers explicit `gitRoot`, falls back to session lookup via `sessionId`.
//! Business logic delegated to `session::git::*` pure functions.
//!
//! **Phase 4 design note**: Git/JJ functions (`git_cli`, `status`,
//! Git/JJ functions (`git_cli`, `status`,
//! `detect_vcs_kind`, `find_git_root_from_path`, etc.) are stateless
//! utilities that take a `&Path` and shell out to `git`/`jj`. They do
//! not access workspace state and therefore remain direct calls rather
@@ -27,8 +27,7 @@ use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Instant;
/// Global cache for git status results, keyed by git_root path.
/// This provides caching at the extension API layer while keeping git::status pure.
/// Git status results keyed by `git_root`, cached at the extension layer to keep `git::status` pure.
static GIT_STATUS_CACHE: std::sync::LazyLock<Mutex<HashMap<PathBuf, GitStatusCacheEntry>>> =
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
struct GitStatusCacheEntry {
@@ -46,8 +45,7 @@ impl GitStatusCacheEntry {
&& self.cached_at.elapsed() < GIT_STATUS_CACHE_TTL
}
}
/// Invalidate the git status cache for a given git_root.
/// Should be called after any mutation operation (stage, unstage, discard, commit).
/// Call after any mutation (stage, unstage, discard, commit) to drop the cached status for `git_root`.
fn invalidate_status_cache(git_root: &PathBuf) {
let mut cache = GIT_STATUS_CACHE.lock();
cache.remove(git_root);
@@ -234,7 +232,6 @@ pub struct GitCurrentCommitRequest {
#[serde(default)]
pub git_root: Option<String>,
}
/// Request for kigi/git/checkout_commit extension method.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitCheckoutCommitRequest {
@@ -242,12 +239,10 @@ pub struct GitCheckoutCommitRequest {
pub session_id: Option<acp::SessionId>,
#[serde(default)]
pub git_root: Option<String>,
/// Commit hash or ref to checkout.
pub commit: String,
#[serde(default)]
pub stash_if_dirty: bool,
}
/// Resolve git_root from explicit value or session lookup via [`WorkspaceOps`].
async fn resolve_git_root(
agent: &MvpAgent,
ops: &kigi_workspace::WorkspaceOps,
@@ -25,23 +25,18 @@ pub fn hook_spec_to_info(spec: &kigi_hooks::config::HookSpec) -> HookInfo {
use kigi_hooks::event::HookEventName;
let event = match spec.event {
// Session lifecycle
HookEventName::SessionStart => HookEvent::SessionStart,
HookEventName::SessionEnd => HookEvent::SessionEnd,
HookEventName::Stop => HookEvent::Stop,
HookEventName::StopFailure => HookEvent::StopFailure,
// Tool events
HookEventName::PreToolUse => HookEvent::PreToolUse,
HookEventName::PostToolUse => HookEvent::PostToolUse,
HookEventName::PostToolUseFailure => HookEvent::PostToolUseFailure,
HookEventName::PermissionDenied => HookEvent::PermissionDenied,
// User / notification
HookEventName::UserPromptSubmit => HookEvent::UserPromptSubmit,
HookEventName::Notification => HookEvent::Notification,
// Subagent
HookEventName::SubagentStart => HookEvent::SubagentStart,
HookEventName::SubagentStop | HookEventName::SubagentEnd => HookEvent::SubagentStop,
// Compaction
HookEventName::PreCompact => HookEvent::PreCompact,
HookEventName::PostCompact => HookEvent::PostCompact,
};
@@ -334,9 +329,11 @@ mod tests {
let matcher = pre[0].matcher.as_ref().unwrap();
assert!(matcher.is_match("run_terminal_command"));
assert!(!matcher.is_match("read_file"));
assert!(pre[1].matcher.is_none()); // null / "*" = match-all
// null / "*" = match-all
assert!(pre[1].matcher.is_none());
assert!(pre[2].matcher.is_none());
assert!(hooks.contains_key(&HookEventName::PostToolUse)); // snake_case resolves
// snake_case resolves
assert!(hooks.contains_key(&HookEventName::PostToolUse));
}
#[test]
@@ -378,9 +375,12 @@ mod tests {
});
let groups = &parse_client_hooks(meta.as_object())[&HookEventName::PreToolUse];
assert_eq!(groups[0].timeout, Some(std::time::Duration::from_secs(5)));
assert_eq!(groups[1].timeout, None); // non-positive -> default
assert_eq!(groups[2].timeout, None); // absent -> default
assert_eq!(groups[3].timeout, Some(std::time::Duration::from_secs(300))); // capped
// non-positive -> default
assert_eq!(groups[1].timeout, None);
// absent -> default
assert_eq!(groups[2].timeout, None);
// capped
assert_eq!(groups[3].timeout, Some(std::time::Duration::from_secs(300)));
}
/// A registration under the `SubagentEnd` alias must land on the canonical
@@ -21,10 +21,6 @@ use kigi_workspace::workspace_ops::{
HunkGetSessionSummaryReq, HunkSingleActionReq, HunkTurnActionReq,
};
// ═══════════════════════════════════════════════════════════════════════
// Request Types
// ═══════════════════════════════════════════════════════════════════════
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetHunksRequest {
@@ -50,7 +46,8 @@ pub struct HunkActionRequest {
#[serde(default)]
pub session_id: Option<acp::SessionId>,
pub hunk_id: String,
pub action: String, // "accept" | "reject"
// "accept" | "reject"
pub action: String,
}
#[derive(Debug, Deserialize)]
@@ -59,7 +56,8 @@ pub struct FileActionRequest {
#[serde(default)]
pub session_id: Option<acp::SessionId>,
pub path: String,
pub action: String, // "accept" | "reject"
// "accept" | "reject"
pub action: String,
}
#[derive(Debug, Deserialize)]
@@ -68,7 +66,8 @@ pub struct TurnActionRequest {
#[serde(default)]
pub session_id: Option<acp::SessionId>,
pub prompt_index: usize,
pub action: String, // "accept" | "reject"
// "accept" | "reject"
pub action: String,
}
#[derive(Debug, Deserialize)]
@@ -76,7 +75,8 @@ pub struct TurnActionRequest {
pub struct AllActionRequest {
#[serde(default)]
pub session_id: Option<acp::SessionId>,
pub action: String, // "accept" | "reject"
// "accept" | "reject"
pub action: String,
}
#[derive(Debug, Deserialize)]
@@ -86,16 +86,11 @@ pub struct GetSummaryRequest {
pub session_id: Option<acp::SessionId>,
}
// ═══════════════════════════════════════════════════════════════════════
// Response Types
// ═══════════════════════════════════════════════════════════════════════
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetHunksResponse {
pub hunks: Vec<Arc<Hunk>>,
// === Explicit content status (new fields) ===
/// Baseline content with explicit status - only present when requesting a specific path
#[serde(skip_serializing_if = "Option::is_none")]
pub baseline: Option<FileContentView>,
@@ -103,7 +98,6 @@ pub struct GetHunksResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub current: Option<FileContentView>,
// === Legacy fields for backward compatibility ===
/// Baseline content (git HEAD) - legacy, use `baseline.content` instead
#[serde(skip_serializing_if = "Option::is_none")]
pub baseline_content: Option<String>,
@@ -145,10 +139,6 @@ pub struct ActionResponse {
pub affected_count: Option<usize>,
}
// ═══════════════════════════════════════════════════════════════════════
// Helper Functions
// ═══════════════════════════════════════════════════════════════════════
/// Bridge the workspace RPC's lean wire response type back to the
/// hunk-tracker's `FileContentEntry`: the RPC returns the wire type while the
/// shell's ACP DTOs use the hunk-tracker types.
@@ -207,7 +197,6 @@ impl HunkTrackerContext {
path.to_path_buf()
}
/// Rewrite paths in a list of hunks for display.
fn rewrite_hunks(&self, hunks: Vec<Arc<Hunk>>) -> Vec<Arc<Hunk>> {
if self.display_cwd.is_none() {
return hunks;
@@ -219,7 +208,6 @@ impl HunkTrackerContext {
if new_path == h.path {
return h;
}
// Clone the hunk with the rewritten path
Arc::new(Hunk {
path: new_path,
id: h.id.clone(),
@@ -236,7 +224,6 @@ impl HunkTrackerContext {
}
}
/// Get the hunk tracker context for the given session.
fn get_hunk_tracker(
agent: &MvpAgent,
session_id: Option<&acp::SessionId>,
@@ -256,8 +243,6 @@ fn get_hunk_tracker(
})
}
/// Compute file summaries from hunks.
///
/// `staged_paths` contains the absolute paths of files staged in the git index.
fn compute_file_summaries(
hunks: &[Arc<Hunk>],
@@ -287,7 +272,6 @@ fn compute_file_summaries(
.map(|t| t.lines().count())
.unwrap_or(0);
// Mark as agent file if any hunk is from agent
if hunk.source.is_agent_edit() {
entry.is_agent_file = true;
}
@@ -298,24 +282,16 @@ fn compute_file_summaries(
files
}
// ═══════════════════════════════════════════════════════════════════════
// Main Handler
// ═══════════════════════════════════════════════════════════════════════
pub async fn handle(
agent: &MvpAgent,
ops: &kigi_workspace::WorkspaceOps,
args: &acp::ExtRequest,
) -> ExtResult {
match args.method.as_ref() {
// ───────────────────────────────────────────────────────────────
// Queries
// ───────────────────────────────────────────────────────────────
"kigi/hunk-tracker/get-hunks" => {
let req = parse_params::<GetHunksRequest>(args)?;
let ctx = get_hunk_tracker(agent, req.session_id.as_ref())?;
// If path is specified, use get_file_hunk_data to get hunks + content together
let (hunks, baseline, current, baseline_content, current_content) =
if let Some(path) = req.path {
let data = ctx.handle.get_file_hunk_data(PathBuf::from(path)).await;
@@ -330,7 +306,6 @@ pub async fn handle(
(ctx.handle.get_all_hunks().await, None, None, None, None)
};
// Filter by source if specified
let hunks = match req.source.as_deref() {
Some("agent") => hunks
.into_iter()
@@ -340,10 +315,9 @@ pub async fn handle(
.into_iter()
.filter(|h| h.source.is_external())
.collect(),
_ => hunks, // "all" or unspecified
_ => hunks,
};
// Rewrite worktree paths to display paths for client UI
let hunks = ctx.rewrite_hunks(hunks);
to_ext_response(Ok(GetHunksResponse {
@@ -363,7 +337,6 @@ pub async fn handle(
let staged_paths = ctx.handle.get_staged_files().await;
// Rewrite paths before computing summaries so file paths are stable
let hunks = ctx.rewrite_hunks(hunks);
// Rewrite staged paths for display (worktree → display path)
let staged_paths: HashSet<PathBuf> =
staged_paths.iter().map(|p| ctx.display_path(p)).collect();
let files = compute_file_summaries(&hunks, &staged_paths);
@@ -375,8 +348,6 @@ pub async fn handle(
let req = parse_params::<GetFilesRequest>(args)?;
let sid = req.session_id.as_ref().map(|s| s.0.as_ref());
// The RPC returns the lean wire type; bridge it back to the
// hunk-tracker type so the ACP response shape is unchanged.
let mut files: Vec<FileContentEntry> = ops
.dispatch(&HunkGetAllFileContentsReq {}, sid)
.await
@@ -384,7 +355,6 @@ pub async fn handle(
.into_iter()
.map(file_content_entry_from_wire)
.collect();
// Post-dispatch: rewrite worktree paths to display paths for client UI
if let Some(ctx) = req
.session_id
.as_ref()
@@ -409,9 +379,6 @@ pub async fn handle(
to_ext_response(Ok(result))
}
// ───────────────────────────────────────────────────────────────
// Single Hunk Action
// ───────────────────────────────────────────────────────────────
"kigi/hunk-tracker/hunk-action" => {
let req = parse_params::<HunkActionRequest>(args)?;
@@ -445,9 +412,6 @@ pub async fn handle(
}
}
// ───────────────────────────────────────────────────────────────
// Bulk Actions
// ───────────────────────────────────────────────────────────────
"kigi/hunk-tracker/file-action" => {
let req = parse_params::<FileActionRequest>(args)?;
@@ -701,19 +665,10 @@ mod tests {
let files = compute_file_summaries(&hunks, &staged);
assert_eq!(files.len(), 2);
// a.txt is staged
assert!(files[0].staged);
// b.txt is not staged
assert!(!files[1].staged);
}
// =========================================================================
// GetHunksResponse Serialization Tests
// =========================================================================
// These tests verify that the ACP get-hunks response correctly serializes
// the new explicit status fields (baseline, current) alongside legacy fields.
/// GetHunksResponse serializes Full status with all fields
#[test]
fn get_hunks_response_serializes_full_status() {
let baseline_text = "baseline content\n";
@@ -729,7 +684,6 @@ mod tests {
let json = serde_json::to_value(&response).unwrap();
// Verify baseline view fields
let baseline = json.get("baseline").expect("baseline should be present");
assert_eq!(baseline.get("status").unwrap().as_str().unwrap(), "full");
assert_eq!(
@@ -741,7 +695,6 @@ mod tests {
baseline_text
);
// Verify current view fields
let current = json.get("current").expect("current should be present");
assert_eq!(current.get("status").unwrap().as_str().unwrap(), "full");
assert_eq!(
@@ -749,7 +702,6 @@ mod tests {
current_text
);
// Verify legacy fields
assert_eq!(
json.get("baselineContent").unwrap().as_str().unwrap(),
baseline_text
@@ -760,7 +712,6 @@ mod tests {
);
}
/// GetHunksResponse serializes Missing status
#[test]
fn get_hunks_response_serializes_missing_status() {
let response = GetHunksResponse {
@@ -773,17 +724,14 @@ mod tests {
let json = serde_json::to_value(&response).unwrap();
// Verify baseline is Missing (no content or byteLen)
let baseline = json.get("baseline").expect("baseline should be present");
assert_eq!(baseline.get("status").unwrap().as_str().unwrap(), "missing");
assert!(baseline.get("content").is_none());
assert!(baseline.get("byteLen").is_none());
// Legacy baseline_content should be absent (skipped when None)
assert!(json.get("baselineContent").is_none());
}
/// GetHunksResponse serializes Binary status with byte_len
#[test]
fn get_hunks_response_serializes_binary_status() {
let response = GetHunksResponse {
@@ -796,19 +744,16 @@ mod tests {
let json = serde_json::to_value(&response).unwrap();
// Verify baseline is Binary
let baseline = json.get("baseline").expect("baseline should be present");
assert_eq!(baseline.get("status").unwrap().as_str().unwrap(), "binary");
assert_eq!(baseline.get("byteLen").unwrap().as_u64().unwrap(), 1024);
assert!(baseline.get("content").is_none());
// Verify current is Binary
let current = json.get("current").expect("current should be present");
assert_eq!(current.get("status").unwrap().as_str().unwrap(), "binary");
assert_eq!(current.get("byteLen").unwrap().as_u64().unwrap(), 2048);
}
/// GetHunksResponse serializes TooLarge status with byte_len
#[test]
fn get_hunks_response_serializes_too_large_status() {
let response = GetHunksResponse {
@@ -821,7 +766,6 @@ mod tests {
let json = serde_json::to_value(&response).unwrap();
// Verify baseline is TooLarge
let baseline = json.get("baseline").expect("baseline should be present");
assert_eq!(
baseline.get("status").unwrap().as_str().unwrap(),
@@ -833,7 +777,6 @@ mod tests {
);
assert!(baseline.get("content").is_none());
// Verify current is TooLarge
let current = json.get("current").expect("current should be present");
assert_eq!(current.get("status").unwrap().as_str().unwrap(), "tooLarge");
assert_eq!(
@@ -842,7 +785,6 @@ mod tests {
);
}
/// GetHunksResponse omits baseline/current when None (get-all-hunks case)
#[test]
fn get_hunks_response_omits_none_fields() {
let response = GetHunksResponse {
@@ -861,18 +803,15 @@ mod tests {
let json = serde_json::to_value(&response).unwrap();
// baseline, current, baselineContent, currentContent should all be absent
assert!(json.get("baseline").is_none());
assert!(json.get("current").is_none());
assert!(json.get("baselineContent").is_none());
assert!(json.get("currentContent").is_none());
// hunks should still be present
assert!(json.get("hunks").is_some());
assert_eq!(json.get("hunks").unwrap().as_array().unwrap().len(), 1);
}
/// FileContentView default is Missing status
#[test]
fn file_content_view_default_is_missing() {
let view = FileContentView::default();
@@ -882,11 +821,6 @@ mod tests {
assert!(view.content.is_none());
}
// =========================================================================
// GetAllFileContentsResponse Serialization Tests
// =========================================================================
/// GetAllFileContentsResponse serializes with all fields using camelCase
#[test]
fn get_all_file_contents_response_serializes_correctly() {
use kigi_hunk_tracker::FileContentEntry;
@@ -910,7 +844,6 @@ mod tests {
assert!(f.get("isAgentFile").unwrap().as_bool().unwrap());
assert!(!f.get("staged").unwrap().as_bool().unwrap());
// Baseline
let baseline = f.get("baseline").unwrap();
assert_eq!(baseline.get("status").unwrap().as_str().unwrap(), "full");
assert_eq!(
@@ -918,7 +851,6 @@ mod tests {
"old content\n"
);
// Current
let current = f.get("current").unwrap();
assert_eq!(current.get("status").unwrap().as_str().unwrap(), "full");
assert_eq!(
@@ -927,7 +859,6 @@ mod tests {
);
}
/// GetAllFileContentsResponse handles missing baseline (new file)
#[test]
fn get_all_file_contents_response_missing_baseline() {
use kigi_hunk_tracker::FileContentEntry;
@@ -953,7 +884,6 @@ mod tests {
assert!(baseline.get("byteLen").is_none());
}
/// GetAllFileContentsResponse handles binary files
#[test]
fn get_all_file_contents_response_binary_file() {
use kigi_hunk_tracker::FileContentEntry;
@@ -981,7 +911,6 @@ mod tests {
assert_eq!(current.get("byteLen").unwrap().as_u64().unwrap(), 2048);
}
/// GetAllFileContentsResponse returns empty files array when no tracked files
#[test]
fn get_all_file_contents_response_empty() {
let response = GetAllFileContentsResponse { files: vec![] };
@@ -991,7 +920,6 @@ mod tests {
assert!(files.is_empty());
}
/// GetAllFileContentsResponse with multiple files preserves all entries
#[test]
fn get_all_file_contents_response_multiple_files() {
use kigi_hunk_tracker::FileContentEntry;
@@ -1019,11 +947,9 @@ mod tests {
let files = json.get("files").unwrap().as_array().unwrap();
assert_eq!(files.len(), 2);
// First file: agent, staged
assert!(files[0].get("isAgentFile").unwrap().as_bool().unwrap());
assert!(files[0].get("staged").unwrap().as_bool().unwrap());
// Second file: not agent, not staged
assert!(!files[1].get("isAgentFile").unwrap().as_bool().unwrap());
assert!(!files[1].get("staged").unwrap().as_bool().unwrap());
}
@@ -66,8 +66,6 @@ pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
mod tests {
use super::*;
/// Legacy wire shape (no `content`) parses byte-identically: text-only,
/// zero images, no text override.
#[test]
fn parse_without_content_is_legacy_text_only() {
let req: InterjectRequest = serde_json::from_value(serde_json::json!({
@@ -83,9 +81,6 @@ mod tests {
assert!(images.is_empty());
}
/// `content` with text + image blocks parses; the images are extracted
/// and the Text block (the client's rewritten, path-stripped text)
/// overrides the raw `text` param.
#[test]
fn parse_with_content_extracts_images_and_prefers_block_text() {
let req: InterjectRequest = serde_json::from_value(serde_json::json!({
@@ -108,8 +103,6 @@ mod tests {
assert_eq!(images[0].data, "aGVsbG8=");
}
/// Garbage `content` fails the whole parse (strict, like other params)
/// instead of silently dropping attachments.
#[test]
fn parse_with_garbage_content_is_an_error() {
let result: Result<InterjectRequest, _> = serde_json::from_value(serde_json::json!({
@@ -52,7 +52,6 @@ pub async fn try_handle(
})
}
// Operations that don't apply to jj
"kigi/git/checkout" => Some(Err(acp::Error::invalid_params()
.data("checkout is not supported in jj repos; use `jj new` or `jj edit`"))),
"kigi/git/stash" => Some(Err(acp::Error::invalid_params()
@@ -50,8 +50,6 @@ pub mod mcp_methods {
use crate::agent::MvpAgent;
use crate::session::mcp_servers::{MCP_TOOL_NAME_DELIMITER, McpClient, McpServerName, McpState};
// ── Wire types: mcp/list ────────────────────────────────────────────
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpListRequest {
@@ -155,8 +153,6 @@ pub struct McpToolEntry {
pub enabled: bool,
}
// ── Wire types: mcp/call ────────────────────────────────────────────
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpCallRequest {
@@ -188,8 +184,6 @@ pub struct McpContentBlock {
pub text: String,
}
// ── Internal types (not serialized to wire) ─────────────────────────
#[derive(Debug, Clone, Default)]
pub struct McpStatusSnapshot {
pub configs: Vec<acp::McpServer>,
@@ -204,8 +198,6 @@ pub struct McpClientStatus {
pub tools: Vec<McpToolEntry>,
}
// ── Notification: mcp/servers_updated ────────────────────────────────
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct McpServersUpdated {
@@ -312,8 +304,6 @@ pub async fn notify_servers_updated(
}
}
// ── Dispatch ────────────────────────────────────────────────────────
/// Inbound `kigi/mcp/*` methods this agent services, resolved from the wire string.
///
/// Single source of truth for forward-method routing: [`handle`] maps each variant to
@@ -364,9 +354,6 @@ pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
}
}
// ── Catalog (shared by mcp/list and InitializeResponse._meta) ───────
/// Extract URL from an MCP server (HTTP/SSE only, None for Stdio).
fn mcp_server_url(server: &acp::McpServer) -> Option<&str> {
match server {
acp::McpServer::Http(acp::McpServerHttp { url, .. })
@@ -384,7 +371,6 @@ pub fn build_mcp_catalog(local_servers: &[acp::McpServer]) -> Vec<McpServerEntry
let mut servers: Vec<McpServerEntry> = Vec::new();
let mut seen = std::collections::HashSet::new();
// Local servers (HTTP or Stdio)
for server in local_servers {
let name = crate::session::mcp_servers::mcp_server_name(server).to_string();
if seen.insert(name.clone()) {
@@ -443,8 +429,6 @@ fn disabled_server_placeholder_entry(name: &str) -> McpServerEntry {
}
}
// ── Session-level operations (called via SessionCommand) ────────────
/// Build session MCP status: which servers are enabled, healthy, and what tools they expose.
/// Clones state under lock then releases — does not hold lock across awaits.
pub async fn build_mcp_status(
@@ -530,7 +514,6 @@ pub async fn build_mcp_status(
})
.collect();
// Include disabled tools from stashed registrations.
for (qname, desc) in &disabled_regs {
if qname.starts_with(&prefix) {
let unqualified = qname.strip_prefix(&prefix).unwrap_or(qname).to_string();
@@ -592,7 +575,6 @@ async fn ensure_agent_pool_initialized(mcp_state: &Arc<TokioMutex<McpState>>) {
return;
}
if state.is_initializing() {
// Another call is initializing — wait and retry.
drop(state);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
continue;
@@ -734,8 +716,6 @@ pub async fn call_mcp_tool(
})
}
// ── mcp/list handler ────────────────────────────────────────────────
async fn handle_list(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
let req = parse_params::<McpListRequest>(args)?;
@@ -797,7 +777,6 @@ async fn handle_list(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
let catalog_names: std::collections::HashSet<String> =
servers.iter().map(|s| s.name.clone()).collect();
// Annotate catalog entries with session state.
for entry in &mut servers {
let enabled = snapshot
.configs
@@ -853,8 +832,6 @@ async fn handle_list(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
to_ext_response(Ok(McpListResponse { servers }))
}
// ── mcp/call handler ────────────────────────────────────────────────
async fn handle_call(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
let req = parse_params::<McpCallRequest>(args)?;
@@ -993,12 +970,6 @@ pub async fn read_mcp_resource(
Ok(McpReadResourceResponse { contents })
}
// ── McpResourceProvider bridge ───────────────────────────────────────
//
// Implements the `McpResourceProvider` trait from kigi-tools so that
// `ListMcpResources` / `FetchMcpResource` tools can access MCP
// servers without depending on `kigi-mcp` directly.
/// Bridge from `McpState` to the `McpResourceProvider` trait.
///
/// Injected into the agent's `SharedResources` via `tool_bridge.update_resource()`
@@ -1145,8 +1116,6 @@ impl kigi_tools::types::resources::McpResourceProvider for McpStateResourceProvi
}
}
// ── Auth status / trigger ────────────────────────────────────────────
#[derive(serde::Deserialize)]
struct McpAuthStatusRequest {
session_id: String,
@@ -1209,8 +1178,6 @@ async fn handle_auth_trigger(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtRes
}
}
// ── mcp/toggle handler ───────────────────────────────────────────────
#[derive(serde::Deserialize)]
struct McpToggleRequest {
session_id: String,
@@ -1294,8 +1261,6 @@ async fn handle_toggle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
to_ext_response(Ok(McpToggleResponse { ok: true }))
}
// ── mcp/toggle_tool handler ─────────────────────────────────────────
#[derive(serde::Deserialize)]
struct McpToggleToolRequest {
session_id: String,
@@ -1320,8 +1285,6 @@ async fn handle_toggle_tool(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResu
to_ext_response(Ok(McpToggleResponse { ok: true }))
}
// ── mcp/upsert handler ──────────────────────────────────────────────
#[derive(serde::Deserialize)]
struct McpUpsertRequest {
session_id: String,
@@ -1334,12 +1297,10 @@ async fn handle_upsert(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
let req = parse_params::<McpUpsertRequest>(args)?;
let acp_id = acp::SessionId::new(req.session_id.clone());
// Persist to config.toml first.
crate::util::config::save_mcp_server_config(&req.server_name, &req.config)
.await
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
// Build the ACP server config for live addition.
let server_config = req
.config
.to_acp_mcp_server(&req.server_name)
@@ -1358,8 +1319,6 @@ async fn handle_upsert(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
to_ext_response(Ok(McpToggleResponse { ok: true }))
}
// ── mcp/delete handler ──────────────────────────────────────────────
#[derive(serde::Deserialize)]
struct McpDeleteRequest {
session_id: String,
@@ -1370,7 +1329,6 @@ async fn handle_delete(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
let req = parse_params::<McpDeleteRequest>(args)?;
let acp_id = acp::SessionId::new(req.session_id.clone());
// Verify the server exists in local config (not managed).
let existed = crate::util::config::delete_mcp_server_config(&req.server_name)
.await
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
@@ -1382,7 +1340,6 @@ async fn handle_delete(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
)));
}
// Live teardown: disable the server in the running session.
let handle = agent
.get_session_handle(&acp_id)
.ok_or_else(|| acp::Error::invalid_params().data("session not found"))?;
@@ -1463,12 +1420,10 @@ mod tests {
],
};
let json = serde_json::to_value(&resp).unwrap();
// [0] local HTTP
assert_eq!(json["servers"][0]["source"], "local");
assert_eq!(json["servers"][0]["type"], "http");
assert_eq!(json["servers"][0]["url"], "https://mcp.linear.app");
assert!(json["servers"][0].get("session").is_none());
// [1] local Stdio
assert_eq!(json["servers"][1]["source"], "local");
assert_eq!(json["servers"][1]["type"], "stdio");
assert_eq!(json["servers"][1]["command"], "/usr/bin/mcp-filesystem");
@@ -26,7 +26,6 @@ pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
async fn handle_compact(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
let req: CompactConversationRequest = parse_params(args)?;
// send over the compact query here properly
let session_handle = {
let sessions = agent.sessions.borrow();
sessions.get(&req.session_id.into()).cloned()
@@ -48,7 +48,6 @@ pub fn parse_params_str<T: DeserializeOwned>(raw: &str) -> Result<T, acp::Error>
serde_json::from_str(raw)
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {}", e)))
}
/// Extract the session ID from an extension request's params.
pub fn parse_session_id(args: &acp::ExtRequest) -> Option<acp::SessionId> {
let v: serde_json::Value = serde_json::from_str(args.params.get()).ok()?;
let sid = v.get("sessionId")?.as_str()?;
@@ -65,7 +64,6 @@ pub fn to_raw_response<T: Serialize>(v: &T) -> ExtResult {
.map(|raw| acp::ExtResponse::new(Arc::from(raw)))
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
}
/// Convert a result with optional warning to an ExtResponse.
pub fn to_ext_response_partial<T: Serialize>(
result: anyhow::Result<T>,
warning: Option<String>,
@@ -79,6 +77,5 @@ pub fn to_ext_response_partial<T: Serialize>(
.to_ext_response()
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
}
/// Empty response for operations that return no data.
#[derive(Debug, Serialize)]
pub struct Empty {}
@@ -19,9 +19,7 @@ pub struct GoalDeliverableInfo {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionNotification {
/// The ID of the session this update pertains to.
pub session_id: acp::SessionId,
/// The actual update content.
pub update: SessionUpdate,
/// Extension point for implementations
#[serde(skip_serializing_if = "Option::is_none", rename = "_meta")]
@@ -127,12 +125,16 @@ impl PromptUsage {
let PromptUsageModel {
input_tokens,
output_tokens,
total_tokens: _, // derived from input + output
// derived from input + output
total_tokens: _,
cached_read_tokens,
reasoning_tokens: _, // subset of output_tokens
// subset of output_tokens
reasoning_tokens: _,
model_calls,
api_duration_ms: _, // timing, not tokens
cost_usd_ticks: _, // cost without usage cannot occur
// timing, not tokens
api_duration_ms: _,
// cost without usage cannot occur
cost_usd_ticks: _,
cost_is_partial: _,
cost_missing_calls: _,
} = self.totals;
@@ -261,11 +263,14 @@ pub fn project_result_usage(result: &mut serde_json::Value, usage: &PromptUsage)
total_tokens,
cached_read_tokens,
reasoning_tokens,
model_calls: _, // totals-level; headless carries num_turns instead
api_duration_ms: _, // dropped: not part of the frozen headless shape
// totals-level; headless carries num_turns instead
model_calls: _,
// dropped: not part of the frozen headless shape
api_duration_ms: _,
cost_usd_ticks,
cost_is_partial,
cost_missing_calls: _, // internal partiality count; the flag suffices
// internal partiality count; the flag suffices
cost_missing_calls: _,
} = usage.totals;
result["usage"] = serde_json::json!({
"input_tokens": uncached_input_tokens(input_tokens, cached_read_tokens),
@@ -295,11 +300,14 @@ pub fn project_result_usage(result: &mut serde_json::Value, usage: &PromptUsage)
let PromptUsageModel {
input_tokens,
output_tokens,
total_tokens: _, // derivable per row
// derivable per row
total_tokens: _,
cached_read_tokens,
reasoning_tokens: _, // dropped: reduced per-model schema
// dropped: reduced per-model schema
reasoning_tokens: _,
model_calls,
api_duration_ms: _, // dropped: reduced per-model schema
// dropped: reduced per-model schema
api_duration_ms: _,
cost_usd_ticks,
cost_is_partial,
cost_missing_calls: _,
@@ -368,48 +376,36 @@ pub enum SessionUpdate {
RetryState(RetryState),
/// Auto-compact is starting due to context window threshold
AutoCompactStarted {
/// Current token usage
tokens_used: u64,
/// Total context window size
context_window: u64,
/// Percentage used (e.g., 82)
percentage: u8,
/// Reason for compaction
reason: String,
},
/// Auto-compact completed successfully
AutoCompactCompleted {
/// Tokens used before compaction. `None` on payloads from older shells.
#[serde(default, skip_serializing_if = "Option::is_none")]
tokens_before: Option<u64>,
/// Tokens used after compaction
tokens_after: u64,
/// How long the compaction took (milliseconds)
#[serde(skip_serializing_if = "Option::is_none")]
elapsed_ms: Option<i64>,
/// Summary preview (first ~100 chars of summary)
summary_preview: Option<String>,
},
/// Auto-compact failed
AutoCompactFailed {
/// Error message
error: String,
},
/// Memory flush is starting before compaction
MemoryFlushStarted,
/// Memory flush completed
MemoryFlushCompleted {
/// Outcome description
result: String,
/// Path to the written memory file (if any)
#[serde(default, skip_serializing_if = "Option::is_none")]
path: Option<String>,
},
/// Memory dream consolidation completed
MemoryDreamCompleted {
/// Outcome description
result: String,
/// Path to the written memory file (if any)
#[serde(default, skip_serializing_if = "Option::is_none")]
path: Option<String>,
},
@@ -435,11 +431,8 @@ pub enum SessionUpdate {
AutoRecoveryStarted {
/// Current recovery attempt number (1-indexed)
attempt: u32,
/// Maximum number of recovery attempts allowed
max_retries: u32,
/// The error that triggered recovery
error: String,
/// Delay in milliseconds before the retry
delay_ms: u64,
},
/// Auto-recovery exhausted all retries and the turn is failing
@@ -459,7 +452,6 @@ pub enum SessionUpdate {
HookExecution {
/// The hook event name ("pre_tool_use" or "post_tool_use").
event_name: String,
/// The tool name this hook is associated with.
#[serde(default, skip_serializing_if = "Option::is_none")]
tool_name: Option<String>,
/// The prompt turn this batch belongs to, when known; lets the
@@ -467,7 +459,6 @@ pub enum SessionUpdate {
/// turn's marker.
#[serde(default, skip_serializing_if = "Option::is_none")]
prompt_id: Option<String>,
/// Individual hook run results.
runs: Vec<HookRunEntryDto>,
},
/// Hooks registry changed (after reload or trust/untrust).
@@ -535,10 +526,8 @@ pub enum SessionUpdate {
RewindMarker {
/// The prompt index being rewound to (0-based).
target_prompt_index: usize,
/// When the rewind occurred.
created_at: String,
},
/// Task completed notification
TaskCompleted {
task_snapshot: TaskSnapshot,
/// Whether an auto-wake prompt follows this completion. The pager
@@ -558,16 +547,12 @@ pub enum SessionUpdate {
SubagentSpawned {
/// Unique subagent identifier (same as child session ID).
subagent_id: String,
/// The parent session that spawned this subagent.
parent_session_id: String,
/// The parent prompt/turn that spawned this subagent.
#[serde(default, skip_serializing_if = "Option::is_none")]
parent_prompt_id: Option<String>,
/// The child session's ACP session ID.
child_session_id: String,
/// Agent type used for the subagent ("general-purpose", "explore", "plan", or custom).
subagent_type: String,
/// Short human-readable description of the task.
description: String,
/// Effective context source after bootstrap: "new" or "resumed".
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -578,7 +563,6 @@ pub enum SessionUpdate {
/// Capability mode applied to this subagent (e.g. "read-only").
#[serde(default, skip_serializing_if = "Option::is_none")]
capability_mode: Option<String>,
/// Named persona applied to this subagent.
#[serde(default, skip_serializing_if = "Option::is_none")]
persona: Option<String>,
/// Role that supplied defaults for this subagent (e.g. "researcher").
@@ -598,52 +582,37 @@ pub enum SessionUpdate {
/// when the subagent completes or is cancelled. The TUI merges these
/// into the same state path used by ACP poll responses.
SubagentProgress {
/// Unique subagent identifier.
subagent_id: String,
/// The parent session that owns this subagent.
parent_session_id: String,
/// The child session's ACP session ID.
child_session_id: String,
/// Elapsed wall-clock time in milliseconds.
duration_ms: u64,
/// Number of completed turns so far.
turn_count: u32,
/// Total tool calls executed so far.
tool_call_count: u32,
/// Current tokens used in the context window.
tokens_used: u64,
/// Total context window capacity (tokens).
context_window_tokens: u64,
/// Context window usage as a percentage (0-100).
context_usage_pct: u8,
/// Distinct tool names called so far.
tools_used: Vec<String>,
/// Number of errors encountered so far.
error_count: u32,
},
/// A subagent session has finished (success, failure, or cancellation).
///
/// Sent on the PARENT session's notification channel.
SubagentFinished {
/// Unique subagent identifier.
subagent_id: String,
/// The child session's ACP session ID.
child_session_id: String,
/// Outcome: "completed", "failed", or "cancelled".
status: String,
/// Error message if the subagent failed.
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
/// Number of tool calls made by the subagent.
tool_calls: u32,
/// Number of conversation turns taken by the subagent.
turns: u32,
/// Total wall-clock duration in milliseconds.
duration_ms: u64,
/// Total tokens consumed by the subagent's context window.
#[serde(default)]
tokens_used: u64,
/// Final output text from the subagent (if completed).
#[serde(default, skip_serializing_if = "Option::is_none")]
output: Option<String>,
/// Whether an auto-wake prompt follows this completion. The pager
@@ -656,11 +625,8 @@ pub enum SessionUpdate {
/// Task backgrounded notification — a bash command transitioned to background execution.
/// Sent for both direct `is_background=true` tasks and foreground→background transitions.
TaskBackgrounded {
/// The tool_call_id of the bash tool invocation.
tool_call_id: String,
/// The background task registry ID.
task_id: String,
/// The shell command being executed.
command: String,
/// Absolute path of the working directory.
cwd: String,
@@ -704,9 +670,7 @@ pub enum SessionUpdate {
ModelAutoSwitched {
/// The model ID that was persisted in the session but is no longer available.
previous_model_id: String,
/// The model ID that was selected as a replacement.
new_model_id: String,
/// Human-readable reason for the switch.
reason: String,
},
/// The session's model was switched via `session/setModel`.
@@ -751,7 +715,6 @@ pub enum SessionUpdate {
/// One or more prompt images were resized to fit within API limits.
ImageCompressed {
images: Vec<ImageCompressedEntry>,
/// Human-readable summary for display.
message: String,
},
/// Prompt images dropped before send (integrity / upscale-cap). The
@@ -876,8 +839,8 @@ pub enum SessionUpdate {
tool_call_id: String,
kind: crate::session::pending_interaction::PendingKind,
},
/// A previously-pending reverse-request **resolved** (answered, cancelled,
/// or errored). Fire-and-forget, **never persisted**. Subscribers clear the
/// A pending reverse-request **resolved** (answered, cancelled, or
/// errored). Fire-and-forget, **never persisted**. Subscribers clear the
/// pending ⏳ for this `tool_call_id`.
InteractionResolved { tool_call_id: String },
/// The durable, replayable signal that a turn reached its terminal
@@ -891,7 +854,6 @@ pub enum SessionUpdate {
prompt_id: String,
/// Why the turn ended (the model's stop reason, or e.g. "cancelled").
stop_reason: String,
/// Final agent result text, when the turn produced one.
#[serde(default, skip_serializing_if = "Option::is_none")]
agent_result: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -975,20 +937,14 @@ impl From<&crate::session::image_normalize::ImageCompressionInfo> for ImageCompr
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum RetryState {
/// A retry is in progress
Retrying {
/// Current retry attempt number (1-indexed)
attempt: u32,
/// Maximum number of retries allowed
max_retries: u32,
/// Human-readable reason for the retry
reason: String,
},
/// All retries have been exhausted
Exhausted {
/// Total number of attempts made
attempts: u32,
/// Human-readable reason for the failure
reason: String,
/// True when the exhaustion was caused by an HTTP 429 rate limit.
/// Clients use this to show a user-friendly upgrade message instead
@@ -1000,7 +956,6 @@ pub enum RetryState {
Failed {
/// Category of the error (e.g., "auth", "invalid_params", "server")
error_type: String,
/// Human-readable error message
message: String,
},
}
@@ -1023,7 +978,6 @@ pub fn is_reauthable_failure(error_type: Option<&str>, message: &str) -> bool {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(tag = "type", rename = "diff")]
pub struct DiffContent {
/// The diff details.
#[serde(flatten)]
pub diff: acp::Diff,
}
@@ -1032,13 +986,9 @@ pub struct DiffContent {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct FeedbackRequestNotification {
/// Unique ID for this feedback request
pub request_id: String,
/// The tier that triggered this request
pub tier: String,
/// Human-readable prompt to show the user
pub prompt: String,
/// Whether this is a non-intrusive/dismissible request
pub dismissible: bool,
/// Trigger type identifier (e.g., "tier1_engagement", "tier2_complex_recovery")
pub trigger_type: String,
@@ -1068,8 +1018,6 @@ impl From<FeedbackRequestData> for FeedbackRequestNotification {
}
}
// ── Compaction checkpoint types ────────────────────────────────────────
/// Metadata stored in `updates.jsonl` as a `CompactionCheckpoint` session update.
///
/// This is a lightweight reference; the full compacted conversation lives in a
@@ -1089,7 +1037,6 @@ pub struct CompactionCheckpointInfo {
/// auto-continue prompt that was injected after compaction.
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_continue: Option<AutoContinueInfo>,
/// Schema version for forward compatibility.
pub schema_version: u32,
/// ISO 8601 timestamp of when the checkpoint was created.
pub created_at: String,
@@ -1117,7 +1064,6 @@ pub struct CompactionCheckpointFile {
pub prompt_index_at_compaction: usize,
/// The exact compacted conversation used by the model.
pub compacted_history: Vec<crate::sampling::ConversationItem>,
/// Schema version for forward compatibility.
pub schema_version: u32,
/// ISO 8601 timestamp of when the checkpoint was created.
pub created_at: String,
@@ -1159,7 +1105,6 @@ pub struct CompactionSegmentFile {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct CompactionRequestFile {
/// Schema version for forward compatibility.
pub schema_version: u32,
/// Unique artifact identifier (filename stem).
/// Note: this is a per-artifact ID, not the model API's `x_kigi_req_id`
@@ -1172,7 +1117,6 @@ pub struct CompactionRequestFile {
/// Which prompt template was used: `"short"` (concise self-summarization)
/// or `"detailed"` (10-section structured prompt for kigi and similar agents).
pub prompt_variant: String,
/// The model id that ran the summarization.
pub model: String,
/// User-provided context from `/compact <text>`, if any.
pub user_context: Option<String>,
@@ -1196,7 +1140,6 @@ pub struct CompactionRequestFile {
/// On total failure (all retries exhausted, or a deterministic error)
/// `summary` is `None` and this field carries the final error.
pub error: Option<String>,
/// Number of attempts the retry loop made before settling on the final outcome.
pub attempts: u32,
/// Per-attempt diagnostics (one per retry-loop iteration), in order —
/// records each rejected/degraded attempt so retries aren't bumped
@@ -1216,7 +1159,6 @@ pub struct CompactionRequestFile {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct RecapRequestFile {
/// Schema version for forward compatibility.
pub schema_version: u32,
/// Unique artifact identifier (filename stem). Distinct from the model
/// API's `x_kigi_req_id` (also recorded below for proxy correlation).
@@ -1226,7 +1168,6 @@ pub struct RecapRequestFile {
/// What kicked off the recap: `"manual"` (`/recap`) or `"auto"`
/// (return-from-away).
pub trigger: String,
/// The model id used for the recap side-call.
pub model: String,
/// Sampling request id sent to the proxy (`xai-recap-{uuid}`).
pub x_kigi_req_id: String,
@@ -1459,7 +1400,6 @@ mod tests {
will_wake: false,
})
.unwrap();
// All three should have distinct tags
assert_eq!(spawned["sessionUpdate"], "subagent_spawned");
assert_eq!(progress["sessionUpdate"], "subagent_progress");
assert_eq!(finished["sessionUpdate"], "subagent_finished");
@@ -1701,7 +1641,6 @@ mod tests {
assert_eq!(json["sessionUpdate"], "tool_call_delta_chunk");
assert_eq!(json["tool_index"], 0);
assert_eq!(json["arguments_delta"], "{\"file\":\"src/");
// Optional fields skipped when None.
assert!(json.get("tool_call_id").is_none());
assert!(json.get("name").is_none());
}
@@ -2017,8 +1956,6 @@ mod tests {
}
}
// ── ModelChanged (leader-mode multi-client model switch fan-out) ──
/// Wire format for `ModelChanged` — sanity-check the JSON exactly,
/// since the pager and any third-party clients consume this on the wire.
/// Specifically:
@@ -2093,8 +2030,6 @@ mod tests {
assert_eq!(json["update"]["model_id"], "kigi-4");
}
// ── TurnCompleted (durable, replayable turn-end signal) ──
#[test]
fn turn_completed_serializes_snake_case_tag_and_fields() {
// Mirrors the SubagentProgress convention: `rename_all = "snake_case"`
@@ -18,7 +18,6 @@ struct ListRequest {
session_id: String,
}
/// Convert a `LoadedPlugin` to a `PluginInfo` DTO.
pub fn loaded_plugin_to_info(plugin: &kigi_agent::plugins::LoadedPlugin) -> PluginInfo {
use kigi_agent::plugins::discovery::PluginScope as AgentScope;
@@ -74,7 +73,6 @@ pub fn loaded_plugin_to_info(plugin: &kigi_agent::plugins::LoadedPlugin) -> Plug
}
}
/// Map the agent-side origin to the wire DTO.
fn origin_to_dto(origin: &kigi_agent::plugins::PluginOrigin) -> PluginOrigin {
use kigi_agent::plugins::PluginOrigin as AgentOrigin;
match origin {
@@ -104,7 +102,6 @@ fn marketplace_source_label(origin: &PluginOrigin) -> Option<String> {
PluginOrigin::MarketplaceInstall {
git_url: Some(url), ..
} => {
// Derive short name from URL: "https://github.com/obra/superpowers.git" → "obra/superpowers"
let label = url
.trim_end_matches(".git")
.rsplit("://")
@@ -160,12 +157,12 @@ pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
super::to_ext_response(result)
}
"kigi/plugins/notify-updates" => {
// Broadcast a PluginUpdatesInstalled notification to the session.
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct NotifyUpdatesRequest {
session_id: String,
updates: Vec<(String, String, String)>, // (name, old_ver, new_ver)
// (name, old_ver, new_ver)
updates: Vec<(String, String, String)>,
}
let req: NotifyUpdatesRequest = super::parse_params(args)?;
let sid = acp::SessionId::new(req.session_id);
@@ -53,7 +53,6 @@ pub async fn handle(_agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
async fn handle_prompt_history(args: &acp::ExtRequest) -> ExtResult {
let request: PromptHistoryRequest = parse_params(args)?;
// If session_id is specified, use slow path (needed for rewind feature).
// Use timed!(try: ...) so we still log timing even when returning early on error.
let all_prompts = timed!(try: "prompt_history: load prompts", async {
tracing::debug!(
@@ -64,8 +63,6 @@ async fn handle_prompt_history(args: &acp::ExtRequest) -> ExtResult {
);
if let Some(filter_session_id) = request.filter_session_id.as_deref() {
// Fast path, scoped to a single session: filter the per-CWD history
// file by session id, preserving most-recent-first ordering.
prompt_history::load_prompts_for_session_async(
request.cwd.clone(),
filter_session_id.to_string(),
@@ -76,10 +73,8 @@ async fn handle_prompt_history(args: &acp::ExtRequest) -> ExtResult {
.data(format!("failed to load prompt history: {e}"))
})
} else if request.session_id.is_some() {
// Slow path: load from session storage for per-session queries
load_session_prompts(&request.cwd, request.session_id.as_deref()).await
} else {
// Fast path: use per-CWD prompt history file
prompt_history::load_prompts_async(request.cwd.clone())
.await
.map_err(|e| {
@@ -100,19 +95,14 @@ async fn handle_prompt_history(args: &acp::ExtRequest) -> ExtResult {
})
}
/// Load prompts using the slow path (session-based loading).
/// Used when `session_id` is specified: rebuilds prompts from session storage
/// in chronological order with stable per-session indices.
async fn load_session_prompts(
cwd: &str,
session_id: Option<&str>,
) -> Result<Vec<String>, acp::Error> {
// Load session summaries - either all for the cwd or just the specific session
let mut summaries = list_summaries(Some(cwd)).await.map_err(|e| {
acp::Error::internal_error().data(format!("failed to load session history: {e}"))
})?;
// Filter to specific session if session_id is provided
if let Some(target_session_id) = session_id {
summaries.retain(|s| s.info.id.0.as_ref() == target_session_id);
}
@@ -121,11 +111,9 @@ async fn load_session_prompts(
// so that when we reverse the final list, most recent prompts are first
summaries.sort_by_key(|a| a.updated_at);
// Load only user prompts using the optimized method (avoids loading full session data)
let root_dir = crate::util::kigi_home::kigi_home();
let storage = JsonlStorageAdapter::with_root(root_dir);
// Load prompts from sessions with bounded concurrency using stream
// Using `buffered` (not `buffer_unordered`) to preserve session order
use futures::stream::{self, StreamExt};
@@ -147,7 +135,6 @@ async fn load_session_prompts(
.collect()
.await;
// Deduplicate consecutive identical prompts
all_prompts.dedup();
// DON'T reverse when filtering to a single session - keep chronological
@@ -14,14 +14,12 @@ pub struct PromptBlockMeta {
}
impl PromptBlockMeta {
/// Create meta for a direct bash command.
pub fn bash(command: impl Into<String>) -> Self {
Self {
bash_command: Some(command.into()),
}
}
/// Try to parse from a freeform `_meta` map.
pub fn from_value(value: &agent_client_protocol::Meta) -> Option<Self> {
serde_json::from_value(serde_json::Value::Object(value.clone())).ok()
}
@@ -48,8 +48,6 @@ pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
);
};
// Fire-and-forget: the recap is emitted later as a SessionRecap
// notification. We only ack that the request was accepted.
let _ = session
.cmd_tx
.send(SessionCommand::Recap { auto: req.auto });
@@ -40,8 +40,6 @@ struct RewindPointsRequest {
#[serde(alias = "sessionId")]
session_id: String,
}
/// Look up a `SessionHandle` by id string, or return a `resource_not_found`
/// `acp::Error`. Used by both arms below.
fn lookup_session(agent: &MvpAgent, session_id: String) -> Result<SessionHandle, acp::Error> {
agent
.sessions
@@ -23,8 +23,6 @@ pub async fn handle(_agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
)
.in_scope(|| {});
// Log the survey via telemetry (this will go to Mixpanel and BigQuery)
tracing::info!(
"Rollout survey received for session {}: preferences={:?}, feedback={}",
req.session_id,
@@ -2,7 +2,6 @@ use agent_client_protocol as acp;
use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender;
use serde::{Deserialize, Serialize};
// Re-export from workspace crate (canonical home for fuzzy search).
pub use kigi_workspace::file_system::{ClientId, TargetClientId};
/// Metadata from the request, used for routing notifications back to the
@@ -266,7 +266,6 @@ mod tests {
let json = r#"{"sessionId": "session-123", "cwd": "/path/to/project"}"#;
let req: FuzzyOpenRequest = serde_json::from_str(json).unwrap();
// Both should be present - cwd takes precedence in resolve_cwd
assert!(req.session_id.is_some());
assert_eq!(req.cwd, Some("/path/to/project".to_string()));
}
@@ -321,7 +320,6 @@ mod tests {
#[test]
fn test_fuzzy_open_request_with_meta() {
// Test that FuzzyOpenRequest correctly deserializes _meta.clientId
// This is what the relay injects into the request
let json = r#"{
"cwd": "/path/to/project",
@@ -51,9 +51,6 @@ pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
}
}
// session/rename
/// Handles renaming a session.
async fn handle_session_rename(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -81,7 +78,6 @@ async fn handle_session_rename(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtR
let session_id = acp::SessionId::new(Arc::from(req.session_id.as_str()));
// Find the session info, scoping to cwd if provided
let summaries = list_summaries(req.cwd.as_deref())
.await
.map_err(|e| acp::Error::internal_error().data(format!("failed to list sessions: {e}")))?;
@@ -95,7 +91,6 @@ async fn handle_session_rename(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtR
let info = summary.info.clone();
// Update the session title in local storage
let storage = JsonlStorageAdapter::default();
storage
.update_session_title(&info, req.title.clone())
@@ -104,13 +99,10 @@ async fn handle_session_rename(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtR
acp::Error::internal_error().data(format!("failed to update session title: {e}"))
})?;
// Update session search index with new title
crate::session::storage::search::notify_session_updated(&info.id.to_string(), &info.cwd);
// Send a SessionSummaryGenerated notification so the TUI updates its title
notify_session_title(agent, session_id, &req.title).await;
// Hook 2: update session replica with summary (fire-and-forget)
if let Some(client) = agent.session_registry_client() {
let sid = req.session_id.to_string();
let title = Some(req.title.clone());
@@ -133,8 +125,6 @@ async fn handle_session_rename(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtR
to_raw_response(&serde_json::json!({ "success": true }))
}
/// Notify connected clients of a session's new title via
/// `SessionSummaryGenerated`.
async fn notify_session_title(agent: &MvpAgent, session_id: acp::SessionId, title: &str) {
use crate::extensions::notification::{SessionNotification, SessionUpdate};
@@ -152,9 +142,6 @@ async fn notify_session_title(agent: &MvpAgent, session_id: acp::SessionId, titl
}
}
// session/delete
/// Delete a session from history.
async fn handle_session_delete(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -195,8 +182,6 @@ async fn handle_session_delete(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtR
to_raw_response(&serde_json::json!({ "success": true }))
}
// session/update_mcp_servers
async fn handle_update_mcp_servers(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -252,8 +237,6 @@ async fn handle_update_mcp_servers(agent: &MvpAgent, args: &acp::ExtRequest) ->
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
}
// internal/reload_skills
/// Reload skills for ALL active sessions. Called by the skills file watcher
/// (via ACP injection from `app.rs`) when `SKILL.md` files change.
fn handle_reload_skills(agent: &MvpAgent) -> ExtResult {
@@ -268,8 +251,6 @@ fn handle_reload_skills(agent: &MvpAgent) -> ExtResult {
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
}
// internal/reload_all_mcp_servers
/// Reload MCP servers for ALL active sessions. Called by the config
/// hot-reload watcher when `[mcp_servers]` changes in config.toml.
async fn handle_reload_all_mcp_servers(agent: &MvpAgent) -> ExtResult {
@@ -326,8 +307,6 @@ async fn handle_reload_all_mcp_servers(agent: &MvpAgent) -> ExtResult {
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
}
// internal/reload_project_mcp_servers
/// Reload MCP servers for sessions whose `cwd` matches (or sits beneath)
/// the project root passed in `params.cwd`. Called by the config
/// hot-reload watcher when `<cwd>/.kigi/config.toml`,
@@ -419,8 +398,6 @@ fn cwd_matches(session_cwd: &std::path::Path, target_cwd: &std::path::Path) -> b
session_cwd == target_cwd || session_cwd.starts_with(target_cwd)
}
// internal/reload_models
/// Re-resolve the agent model list from config.toml. Called by the config
/// hot-reload watcher when `[model.*]` or `[models]` changes.
///
@@ -470,8 +447,6 @@ fn handle_reload_models(agent: &MvpAgent) -> ExtResult {
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
}
// internal/reload_models_cache
/// Hot-reload the model catalog from `~/.kigi/models_cache.json` after an
/// external write detected by the config watcher.
///
@@ -489,8 +464,6 @@ fn handle_reload_models_cache(agent: &MvpAgent) -> ExtResult {
.map_err(|e| acp::Error::internal_error().data(e.to_string()))
}
// plugins/reload
async fn handle_plugins_reload(agent: &MvpAgent) -> ExtResult {
// Rebuild the shared registry so future/new sessions clone the latest.
let session_cwd = agent
@@ -522,8 +495,6 @@ async fn handle_plugins_reload(agent: &MvpAgent) -> ExtResult {
super::to_ext_response(Ok(serde_json::json!({"ok": true})))
}
// commands/list
async fn handle_commands_list(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
let req: crate::session::slash_commands::ListCommandsRequest = parse_params(args)?;
@@ -583,8 +554,6 @@ async fn handle_commands_list(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtRe
)))
}
// session/fork
async fn handle_session_fork(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
use crate::session::fork::{ForkSessionRequest, fork_session};
@@ -22,7 +22,6 @@ use super::ExtResult;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchSessionsRequest {
/// The search query string.
pub query: String,
/// Optional workspace directory to scope results to.
#[serde(default)]
@@ -67,7 +66,6 @@ pub struct SearchSessionHit {
pub snippet: Option<String>,
}
/// Route `kigi/session/search` extension method calls.
pub async fn handle(args: &acp::ExtRequest) -> ExtResult {
match args.method.as_ref() {
"kigi/session/search" => {
@@ -92,7 +90,6 @@ pub async fn handle(args: &acp::ExtRequest) -> ExtResult {
}
}
/// Convert the internal response to the ACP-facing response.
fn to_response(resp: SessionSearchResponse) -> SearchSessionsResponse {
SearchSessionsResponse {
results: resp
@@ -130,8 +130,7 @@ fn try_stream_tail_page(request: &Request, updates_path: &Path) -> io::Result<Op
let reader = BufReader::new(file);
if is_turn_index {
// Single-pass scan: read lines, detect rewinds, and compute prompt
// boundaries in one pass to avoid a second traversal.
// Single pass to avoid a second traversal.
let mut has_rewinds = false;
let mut all_lines = Vec::new();
let mut prompt_starts = Vec::new();
@@ -19,7 +19,6 @@ struct CwdParams {
pub struct SkillsAddRequest {
/// Path to add (directory or SKILL.md file). Supports `~` expansion.
pub path: String,
/// Working directory for skill discovery context.
#[serde(default)]
pub cwd: Option<String>,
}
@@ -31,11 +30,9 @@ pub struct SkillsAddResponse {
pub added_count: usize,
/// Total number of skills loaded across all sources.
pub total: usize,
/// The path that was added to config.
pub path: String,
/// Full updated skill list after reload.
pub skills: Vec<SkillInfo>,
/// Human-readable message.
pub message: String,
}
@@ -44,7 +41,6 @@ pub struct SkillsAddResponse {
pub struct SkillsRemoveRequest {
/// Path to remove from config paths.
pub path: String,
/// Working directory for skill discovery context.
#[serde(default)]
pub cwd: Option<String>,
}
@@ -52,11 +48,9 @@ pub struct SkillsRemoveRequest {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillsRemoveResponse {
/// The path that was removed.
pub path: String,
/// Full updated skill list after reload.
pub skills: Vec<SkillInfo>,
/// Human-readable message.
pub message: String,
}
@@ -65,18 +59,15 @@ pub struct SkillsRemoveResponse {
pub struct SkillsResetResponse {
/// Full updated skill list after reload.
pub skills: Vec<SkillInfo>,
/// Human-readable message.
pub message: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillsToggleRequest {
/// Skill name to toggle.
pub name: String,
/// Whether to enable (`true`) or disable (`false`) the skill.
pub enabled: bool,
/// Working directory for skill discovery context.
#[serde(default)]
pub cwd: Option<String>,
}
@@ -84,14 +75,12 @@ pub struct SkillsToggleRequest {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillsListRequest {
/// Working directory for skill discovery context.
pub cwd: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillsListResponse {
/// All discovered skills.
pub skills: Vec<SkillInfo>,
}
@@ -102,11 +91,8 @@ pub struct SkillsConfigResponse {
pub paths: Vec<String>,
/// Ignored paths from `[skills].ignore`.
pub ignore: Vec<String>,
/// Total loaded skill count.
pub total_skills: usize,
/// Human-readable summary.
pub message: String,
/// Full updated skill list.
pub skills: Vec<SkillInfo>,
}
@@ -156,8 +142,6 @@ fn resolve_skill_path(raw: &str, cwd: &str) -> String {
PathBuf::from(raw)
};
// If already absolute, canonicalize to resolve `..` etc.
// If relative, join with cwd first.
let absolute = if expanded.is_absolute() {
expanded
} else {
@@ -127,8 +127,6 @@ fn is_path_like(s: &str) -> bool {
s.contains('/') || s == "~"
}
// ── Directory/prefix split + `~`/`$VAR` expansion (listing only) ────────
struct SplitToken<'a> {
/// Expanded directory to list (absolute, or joined onto the cwd).
list_dir: PathBuf,
@@ -254,8 +252,6 @@ fn expand_vars(s: &str, plain: &[bool], lookup: impl Fn(&str) -> Option<String>)
out
}
// ── Matching + ranking ──────────────────────────────────────────────────
struct ScoredEntry {
name: String,
is_dir: bool,
@@ -398,8 +394,6 @@ fn ci_starts_with(name: &str, prefix_lower: &str) -> bool {
mod tests {
use super::*;
// --- extract_file_context (completion decision) ---
#[test]
fn context_file_cmd_with_arg() {
let tok = extract_file_context("cat foo").unwrap();
@@ -463,8 +457,6 @@ mod tests {
assert!(extract_file_context("> lo").is_some());
}
// --- is_path_like ---
#[test]
fn path_like_matrix() {
assert!(is_path_like("/usr/bin"));
@@ -479,8 +471,6 @@ mod tests {
assert!(!is_path_like("~user"));
}
// --- split_token + expansion ---
fn no_vars(_: &str) -> Option<String> {
None
}
@@ -669,8 +659,6 @@ mod tests {
assert_eq!(expand_vars(s, &plain, lookup), "$HOME//home/me");
}
// --- file_command_boost ---
#[test]
fn boost_for_known_file_commands_only() {
assert_eq!(file_command_boost(Some("cat")), FILE_CMD_BOOST);
@@ -696,8 +684,6 @@ mod tests {
assert!(ci_starts_with("\u{c9}tude", "\u{e9}t"));
}
// --- list_ranked_entries ---
#[tokio::test]
async fn rank_exact_then_ci_prefix_then_fuzzy() {
let tmp = tempfile::TempDir::new().unwrap();
@@ -859,8 +845,6 @@ mod tests {
assert_eq!(entries.len(), MAX_RESULTS);
}
// --- end-to-end via suggest() ---
fn ctx(text: &str, cwd: &Path) -> SuggestContext {
SuggestContext::new(
text.to_owned(),
@@ -89,8 +89,6 @@ fn rank_history_matches(
results
}
// --- Cross-CWD cache ---
struct CrossCwdCache {
prompts: Vec<String>,
updated_at: Instant,
@@ -173,8 +171,6 @@ fn scan_cross_cwd_prompts() -> Vec<String> {
prompts
}
// --- Shell history cache ---
struct ShellHistoryCache {
commands: Vec<String>,
updated_at: Instant,
@@ -286,7 +282,6 @@ fn load_bash_history(path: &std::path::Path) -> Vec<String> {
Err(_) => continue,
};
let trimmed = line.trim();
// Skip empty lines and HISTTIMEFORMAT timestamp markers (`#1700000000`)
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
@@ -357,7 +352,6 @@ fn load_fish_history(path: &std::path::Path) -> Vec<String> {
Ok(l) => l,
Err(_) => continue,
};
// Fish history entries start with "- cmd: "
if let Some(cmd) = line.strip_prefix("- cmd: ") {
let cmd = cmd.trim();
if !cmd.is_empty() {
@@ -496,8 +490,6 @@ mod tests {
assert_eq!(results[1].insert_text, "grep foo");
}
// --- Shell history priority ordering ---
#[test]
fn shell_history_ranked_between_local_and_cross_cwd() {
let local = vec!["git push origin main".into()];
@@ -522,8 +514,6 @@ mod tests {
assert_eq!(texts, &["ls -la", "ls -lh"]);
}
// --- Bash history parsing ---
#[test]
fn parse_bash_history_basic() {
let mut f = NamedTempFile::new().unwrap();
@@ -598,8 +588,6 @@ mod tests {
assert!(commands.is_empty());
}
// --- Zsh history parsing ---
#[test]
fn parse_zsh_history_extended_format() {
let mut f = NamedTempFile::new().unwrap();
@@ -664,8 +652,6 @@ mod tests {
assert_eq!(commands, &["echo foo; echo bar"]);
}
// --- Fish history parsing ---
#[test]
fn parse_fish_history_basic() {
let mut f = NamedTempFile::new().unwrap();
@@ -367,7 +367,6 @@ fn aggregate(
(ghost, completions)
}
/// Determines whether AI suggestions can be skipped based on history quality.
pub(crate) fn should_skip_ai(history_matches: &[RankedSuggestion], prefix: &str) -> bool {
if history_matches.is_empty() {
return false;
@@ -401,8 +400,6 @@ mod tests {
}
}
// --- aggregate ---
#[test]
fn aggregate_sorts_by_descending_priority() {
let history = vec![
@@ -491,8 +488,6 @@ mod tests {
assert_eq!(ghost.suffix, " commit --amend");
}
// --- should_skip_ai ---
#[test]
fn skip_ai_returns_false_for_empty_history() {
assert!(!should_skip_ai(&[], "git"));
@@ -527,7 +522,6 @@ mod tests {
ranked(4, SuggestionSource::History, false, "b"),
ranked(3, SuggestionSource::History, false, "c"),
];
// empty prefix + 3 matches: !prefix.is_empty() is false, len >= 3 is true → false AND true → false
assert!(!should_skip_ai(&m, ""));
}
@@ -540,8 +534,6 @@ mod tests {
assert!(!should_skip_ai(&m, ""));
}
// --- context ---
#[test]
fn context_clamps_cursor_to_len() {
let ctx = SuggestContext::new("abc".into(), 100, "/tmp".into());
@@ -551,9 +543,11 @@ mod tests {
#[test]
fn context_adjusts_to_char_boundary() {
let text = "caf\u{00e9}"; // "cafe" with e-acute (2 bytes for e-acute)
// "cafe" ending in e-acute, which is 2 bytes (so the string is 5 bytes).
let text = "caf\u{00e9}";
assert_eq!(text.len(), 5);
let ctx = SuggestContext::new(text.into(), 4, "/tmp".into()); // middle of 2-byte e-acute
// Cursor 4 is the middle of the 2-byte e-acute.
let ctx = SuggestContext::new(text.into(), 4, "/tmp".into());
assert_eq!(ctx.prefix(), "caf");
}
@@ -87,8 +87,6 @@ fn filter_executables(
results
}
// --- PATH cache ---
struct PathCacheInner {
executables: Vec<String>,
updated_at: Instant,
@@ -185,8 +183,6 @@ fn scan_path_from(path_var: &str) -> Vec<String> {
mod tests {
use super::*;
// --- extract_command_token ---
fn cmd(prefix: &str) -> Option<(usize, String)> {
extract_command_token(prefix).map(|t| (t.start, t.value))
}
@@ -225,8 +221,8 @@ mod tests {
assert_eq!(cmd(" "), None);
}
/// A separator inside quotes is data, not a command position — the old
/// naive segment scan offered executables inside quoted strings.
/// A separator inside quotes is data, not a command position — a naive
/// segment scan would offer executables inside quoted strings.
#[test]
fn none_inside_quoted_data() {
assert_eq!(cmd("echo \"x | gr"), None);
@@ -239,8 +235,6 @@ mod tests {
assert_eq!(cmd("> lo"), None);
}
// --- filter_executables ---
fn tok(prefix: &str) -> CurrentToken {
parse_current_token(prefix)
}
@@ -326,8 +320,6 @@ mod tests {
assert_eq!(results[0].insert_text, "\"grep\"");
}
// --- scan_path_from ---
#[test]
fn scan_nonexistent_dir() {
assert!(scan_path_from("/nonexistent/path/that/doesnt/exist").is_empty());
@@ -236,8 +236,6 @@ pub(super) fn parse_current_token(prefix: &str) -> CurrentToken {
}
}
// ── Insert-token construction (quoting) ─────────────────────────────────
/// Build the replacement for the whole token: the user's verbatim directory
/// prefix plus the completed component escaped for the quote context at the
/// cursor. Files close an open quote; directories keep it open (and get the
@@ -361,8 +359,6 @@ fn escape_single_quoted(name: &str) -> String {
mod tests {
use super::*;
// --- parse_current_token ---
#[test]
fn parse_after_pipe_and_semicolon() {
let tok = parse_current_token("echo hi | cat foo");
@@ -479,8 +475,6 @@ mod tests {
assert_eq!(tok.start, 13);
}
// --- escaping / insert-token construction ---
#[test]
fn escape_unquoted_space_and_specials() {
assert_eq!(escape_unquoted("My File.txt"), "My\\ File.txt");
@@ -107,8 +107,6 @@ pub struct CancelSubagentResponse {
pub outcome: Option<SubagentCancelOutcomeDto>,
}
// ── Subagent list_running DTOs ────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ListRunningSubagentsRequest {
@@ -161,8 +159,6 @@ impl From<ResolvedRunningSubagent> for SubagentLiveSnapshotDto {
}
}
// ── Subagent get DTOs ────────────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GetSubagentRequest {
@@ -194,7 +190,7 @@ struct SubagentSnapshotDto {
started_at_epoch_ms: u64,
duration_ms: u64,
status: String,
// ── Running fields (present only when status == "running") ────
// Running fields, present only when status == "running".
#[serde(skip_serializing_if = "Option::is_none")]
turn_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -209,7 +205,7 @@ struct SubagentSnapshotDto {
tools_used: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
error_count: Option<u32>,
// ── Completed fields ─────────────────────────────────────────
// Completed fields.
#[serde(skip_serializing_if = "Option::is_none")]
output: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -218,12 +214,12 @@ struct SubagentSnapshotDto {
turns: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
worktree_path: Option<String>,
// ── Failed / Cancelled fields ────────────────────────────────
// Failed / Cancelled fields.
#[serde(skip_serializing_if = "Option::is_none")]
failure_error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
cancel_reason: Option<String>,
// ── Fork/resume provenance ─────────────────────────────────
// Fork/resume provenance.
#[serde(skip_serializing_if = "Option::is_none")]
fork_context_source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -313,8 +309,6 @@ impl SubagentSnapshotDto {
}
}
// ── Helpers ──────────────────────────────────────────────────────────────
fn parse<T: serde::de::DeserializeOwned>(args: &acp::ExtRequest) -> Result<T, acp::Error> {
serde_json::from_str(args.params.get())
.map_err(|e| acp::Error::invalid_params().data(format!("invalid params: {e}")))
@@ -352,8 +346,6 @@ pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
}
}
// ── Scheduler DTOs ────────────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DeleteScheduledTaskRequest {
@@ -368,7 +360,6 @@ struct DeleteScheduledTaskResponse {
deleted: bool,
}
/// Handle `kigi/scheduler/*` extension methods.
pub async fn handle_scheduler(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
match args.method.as_ref() {
"kigi/scheduler/delete" => {
@@ -386,7 +377,6 @@ pub async fn handle_scheduler(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtRe
}
}
/// Handle `kigi/subagent/*` extension methods.
pub async fn handle_subagent(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
match args.method.as_ref() {
"kigi/subagent/cancel" => {
@@ -548,8 +538,6 @@ mod tests {
assert_eq!(json["subagents"], serde_json::json!([]));
}
// ── SubagentSnapshotDto serialization tests ────────────────────────
#[test]
fn snapshot_dto_running_serializes_with_progress_fields() {
let snap = SubagentSnapshot {
@@ -782,8 +770,6 @@ mod tests {
assert!(req.timeout_ms.is_none());
}
// ── Polling control-flow tests ──────────────────────────────────────
#[test]
fn block_true_with_completed_snapshot_returns_immediately() {
// When block=true but the snapshot is already completed,
@@ -956,8 +942,6 @@ mod tests {
assert_eq!(json["forkParentPromptId"], "prompt-5");
}
// ── kigi/subagent/cancel outcome wire DTO ──────────────────────────
#[test]
fn subagent_cancel_outcome_dto_maps_from_coordinator_outcome() {
// Cancelled → legacy bool true (a real finish is coming).

Some files were not shown because too many files have changed in this diff Show More