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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user