Files
Kigi-CLI/crates/codegen/kigi-shell/src/agent/mvp_agent/tests.rs
T
ZacharyZhang-NY 48d89c7830 refactor(auth): centralize inference-credential routing in CredentialAuthority
One authority answers 'which credential may ride this request':
credential_class / manager_for / credential_for / bearer_resolver_for,
keyed by (platform, base_url). SessionCredential is an opaque type with
no production constructor, so a new call site cannot re-introduce the
session-bearer leak. Platform-scoped tests extended across all bearer
channels (session, aux, summary, subagent override).

Verified: cargo check --workspace --all-targets clean; kigi-shell and
kigi-tui suites green (6611+ tests).
2026-07-22 15:12:00 -04:00

3308 lines
134 KiB
Rust

use super::*;
mod hunk_tracking_mode {
use super::super::{plan_hunk_tracking, resolve_hunk_tracking_mode};
use kigi_hunk_tracker::TrackingMode;
#[test]
fn off_and_disabled_disable_tracking() {
assert_eq!(resolve_hunk_tracking_mode(Some("off")), None);
assert_eq!(resolve_hunk_tracking_mode(Some("disabled")), None);
}
#[test]
fn matching_is_case_insensitive_and_trimmed() {
assert_eq!(resolve_hunk_tracking_mode(Some("OFF")), None);
assert_eq!(resolve_hunk_tracking_mode(Some(" Off ")), None);
assert_eq!(resolve_hunk_tracking_mode(Some("DISABLED")), None);
assert_eq!(
resolve_hunk_tracking_mode(Some("Agent_Only")),
Some(TrackingMode::AgentOnly)
);
assert_eq!(
resolve_hunk_tracking_mode(Some(" ALL_DIRTY ")),
Some(TrackingMode::AllDirty)
);
}
#[test]
fn recognized_modes_parse() {
assert_eq!(
resolve_hunk_tracking_mode(Some("agent_only")),
Some(TrackingMode::AgentOnly)
);
assert_eq!(
resolve_hunk_tracking_mode(Some("all_dirty")),
Some(TrackingMode::AllDirty)
);
}
#[test]
fn parser_absent_returns_none_policy_defaults_in_plan() {
assert_eq!(resolve_hunk_tracking_mode(None), None);
assert_eq!(resolve_hunk_tracking_mode(Some("")), None);
assert_eq!(
resolve_hunk_tracking_mode(Some("bogus")),
Some(TrackingMode::AllDirty)
);
}
#[test]
fn plan_disables_actor_forward_and_loc_together() {
for off in ["off", "disabled", "OFF"] {
let plan = plan_hunk_tracking(Some(off));
assert_eq!(plan.actor_mode, None, "{off} must not spawn the actor");
assert!(!plan.enabled(), "{off} must disable the forward + LOC sink");
}
}
#[test]
fn plan_enables_actor_and_forward_for_active_modes() {
for (mode, expected) in [
("agent_only", TrackingMode::AgentOnly),
("all_dirty", TrackingMode::AllDirty),
("bogus", TrackingMode::AllDirty),
] {
let plan = plan_hunk_tracking(Some(mode));
assert_eq!(plan.actor_mode, Some(expected));
assert!(plan.enabled());
}
let plan = plan_hunk_tracking(None);
assert_eq!(plan.actor_mode, None);
assert!(!plan.enabled());
}
}
mod capture {
use tokio::sync::mpsc;
use tracing::Subscriber;
use tracing_subscriber::layer::{Context, Layer, SubscriberExt};
use tracing_subscriber::registry::LookupSpan;
pub(crate) struct CapturedEvent {
pub level: tracing::Level,
pub fields: String,
}
pub(crate) struct Captured {
pub events_rx: mpsc::UnboundedReceiver<CapturedEvent>,
_guard: tracing::subscriber::DefaultGuard,
}
pub(crate) fn capture() -> Captured {
let (tx, rx) = mpsc::unbounded_channel();
let subscriber = tracing_subscriber::registry().with(CaptureLayer { tx });
let guard = tracing::subscriber::set_default(subscriber);
Captured {
events_rx: rx,
_guard: guard,
}
}
struct CaptureLayer {
tx: mpsc::UnboundedSender<CapturedEvent>,
}
impl<S> Layer<S> for CaptureLayer
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
let mut v = Visitor::default();
event.record(&mut v);
let _ = self.tx.send(CapturedEvent {
level: *event.metadata().level(),
fields: v.out,
});
}
}
#[derive(Default)]
struct Visitor {
out: String,
}
impl tracing::field::Visit for Visitor {
fn record_debug(&mut self, f: &tracing::field::Field, v: &dyn std::fmt::Debug) {
if !self.out.is_empty() {
self.out.push(' ');
}
self.out.push_str(f.name());
self.out.push('=');
self.out.push_str(&format!("{v:?}"));
}
fn record_str(&mut self, f: &tracing::field::Field, v: &str) {
if !self.out.is_empty() {
self.out.push(' ');
}
self.out.push_str(f.name());
self.out.push('=');
self.out.push_str(v);
}
}
}
#[test]
fn warn_on_missing_parent_session_emits_when_session_absent() {
let captured = capture::capture();
warn_on_missing_parent_session_for_validate_type("ghost-session", false);
let mut rx = captured.events_rx;
let mut saw = false;
while let Ok(event) = rx.try_recv() {
if event.level == tracing::Level::WARN
&& event
.fields
.contains("ValidateType received for unknown parent session")
&& event.fields.contains("parent_session_id=ghost-session")
{
saw = true;
break;
}
}
assert!(saw, "warn must fire");
}
#[test]
fn warn_on_missing_parent_session_silent_when_session_present() {
let captured = capture::capture();
warn_on_missing_parent_session_for_validate_type("real-session", true);
let mut rx = captured.events_rx;
assert!(rx.try_recv().is_err());
}
#[tokio::test(flavor = "current_thread")]
async fn broadcast_refresh_skill_baseline_sends_one_message_per_sender() {
let mut receivers = Vec::new();
let mut senders = Vec::new();
for _ in 0..3 {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
senders.push(tx);
receivers.push(rx);
}
MvpAgent::broadcast_refresh_skill_baseline(senders);
for mut rx in receivers {
assert!(matches!(
rx.try_recv(),
Ok(crate::session::SessionCommand::RefreshSkillBaseline)
));
assert!(
rx.try_recv().is_err(),
"broadcast must send exactly one message per sender",
);
}
}
#[tokio::test(flavor = "current_thread")]
async fn broadcast_refresh_skill_baseline_tolerates_dropped_receiver() {
let (tx_alive, mut rx_alive) = tokio::sync::mpsc::unbounded_channel();
let (tx_dead, rx_dead) = tokio::sync::mpsc::unbounded_channel();
drop(rx_dead);
MvpAgent::broadcast_refresh_skill_baseline(vec![tx_alive, tx_dead]);
assert!(matches!(
rx_alive.try_recv(),
Ok(crate::session::SessionCommand::RefreshSkillBaseline)
));
}
/// 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;
let result = i32::try_from(small).unwrap_or(i32::MAX);
assert_eq!(result, 42);
let huge: u64 = (i32::MAX as u64) + 100;
let result = i32::try_from(huge).unwrap_or(i32::MAX);
assert_eq!(result, i32::MAX);
let boundary: u64 = i32::MAX as u64;
let result = i32::try_from(boundary).unwrap_or(i32::MAX);
assert_eq!(result, i32::MAX);
}
/// After allocating a turn number, `session_turn_numbers` holds the next
/// value (current + 1). This is the value that must be persisted via
/// `SetNextTraceTurn` so the counter survives restarts.
#[test]
fn allocate_turn_number_advances_counter() {
use std::cell::RefCell;
use std::collections::HashMap;
let counters: RefCell<HashMap<acp::SessionId, u64>> = RefCell::new(HashMap::new());
let sid = acp::SessionId::new("test-session");
let allocate = |id: &acp::SessionId| -> u64 {
let mut m = counters.borrow_mut();
let turn = m.get(id).copied().unwrap_or(0u64);
m.insert(id.clone(), turn.saturating_add(1));
turn
};
assert_eq!(allocate(&sid), 0);
assert_eq!(*counters.borrow().get(&sid).unwrap(), 1);
assert_eq!(allocate(&sid), 1);
assert_eq!(*counters.borrow().get(&sid).unwrap(), 2);
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() {
let prev = std::env::var("KIGI_AGENT").ok();
unsafe {
std::env::remove_var("KIGI_AGENT");
}
let tmp = tempfile::tempdir().unwrap();
let def = MvpAgent::resolve_agent_definition(
tmp.path(),
None,
&config::AgentSelectionConfig::default(),
None,
None,
);
assert_eq!(def.name, config::DEFAULT_AGENT_TYPE);
if let Some(v) = prev {
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() {
let prev = std::env::var("KIGI_AGENT").ok();
unsafe {
std::env::remove_var("KIGI_AGENT");
}
let tmp = tempfile::tempdir().unwrap();
let def = MvpAgent::resolve_agent_definition(
tmp.path(),
None,
&config::AgentSelectionConfig::default(),
None,
Some("codex"),
);
assert_eq!(def.name, "codex");
if let Some(v) = prev {
unsafe { std::env::set_var("KIGI_AGENT", v) }
}
}
/// When model_agent_type is None, the chain-resolved default agent is
/// NOT overridden. This is the crux of the leader-mode fix: a session whose
/// model has no agent_type must get the default agent, not a stale value
/// from a different client's model.
#[test]
#[serial_test::serial]
fn resolve_agent_definition_none_agent_type_does_not_override() {
let prev = std::env::var("KIGI_AGENT").ok();
unsafe {
std::env::remove_var("KIGI_AGENT");
}
let tmp = tempfile::tempdir().unwrap();
let def = MvpAgent::resolve_agent_definition(
tmp.path(),
None,
&config::AgentSelectionConfig::default(),
None,
None,
);
assert_eq!(def.name, config::DEFAULT_AGENT_TYPE);
if let Some(v) = prev {
unsafe { std::env::set_var("KIGI_AGENT", v) }
}
}
/// Regression for the web-client devbox bug: an ACP profile must
/// win when the model's `agent_type` is the default value.
#[test]
#[serial_test::serial]
fn resolve_agent_definition_acp_profile_wins_when_model_agent_type_is_default() {
let prev = std::env::var("KIGI_AGENT").ok();
unsafe {
std::env::remove_var("KIGI_AGENT");
}
let tmp = tempfile::tempdir().unwrap();
let acp_profile = kigi_agent::AgentDefinition::from_json(&serde_json::json!(
{ "name" : "custom-devbox-profile", "description" :
"Custom devbox profile", "systemPrompt" :
"You are a custom-configured devbox agent.", }
))
.expect("agent definition must parse");
let def = MvpAgent::resolve_agent_definition(
tmp.path(),
None,
&config::AgentSelectionConfig::default(),
Some(acp_profile),
Some(config::DEFAULT_AGENT_TYPE),
);
assert_eq!(
def.name, "custom-devbox-profile",
"ACP _meta.agentProfile must win when model_agent_type is the default value"
);
if let Some(v) = prev {
unsafe { std::env::set_var("KIGI_AGENT", v) }
}
}
/// Regression: after `DEFAULT_AGENT_TYPE` flipped to
/// `kigi-plan`, 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.
#[test]
#[serial_test::serial]
fn resolve_agent_definition_acp_profile_wins_for_explicit_kigi_family() {
let prev = std::env::var("KIGI_AGENT").ok();
unsafe {
std::env::remove_var("KIGI_AGENT");
}
let tmp = tempfile::tempdir().unwrap();
let acp_profile = kigi_agent::AgentDefinition::from_json(&serde_json::json!(
{ "name" : "custom-devbox-profile", "description" :
"Custom devbox profile", }
))
.expect("agent definition must parse");
for family_variant in ["kigi", "kigi-plan", "kigi-concise"] {
let def = MvpAgent::resolve_agent_definition(
tmp.path(),
None,
&config::AgentSelectionConfig::default(),
Some(acp_profile.clone()),
Some(family_variant),
);
assert_eq!(
def.name, "custom-devbox-profile",
"ACP profile must win for kigi family variant `{family_variant}`"
);
}
if let Some(v) = prev {
unsafe { std::env::set_var("KIGI_AGENT", v) }
}
}
/// A non-strict (stock / vision-capable) model leaves the template alone, so
/// such models keep native image input.
#[test]
fn inherited_harness_template_skips_nonstrict_model() {
use kigi_agent::prompt::user_message::UserMessageTemplate;
let tmp = tempfile::tempdir().unwrap();
assert!(
inherited_harness_template(
&UserMessageTemplate::Default,
Some(config::DEFAULT_AGENT_TYPE),
tmp.path(),
)
.is_none()
);
}
/// An explicit (non-default) template is never overridden — inheritance only
/// fills in the default.
#[test]
fn inherited_harness_template_respects_explicit_template() {
use kigi_agent::prompt::user_message::UserMessageTemplate;
let tmp = tempfile::tempdir().unwrap();
let explicit = UserMessageTemplate::Custom("MY CUSTOM TEMPLATE".to_owned());
assert!(inherited_harness_template(&explicit, Some("cursor"), tmp.path()).is_none());
}
/// CLI `--agent-profile` wins when model_agent_type is the default
/// (also shadowed by the same regression).
#[test]
#[serial_test::serial]
fn resolve_agent_definition_cli_agent_profile_wins_when_model_agent_type_is_default() {
let prev = std::env::var("KIGI_AGENT").ok();
unsafe {
std::env::remove_var("KIGI_AGENT");
}
let tmp = tempfile::tempdir().unwrap();
let profile_path = tmp.path().join("cli-profile.md");
std::fs::write(
&profile_path,
"---\nname: cli-profile\ndescription: cli test\n---\nYou are a CLI profile.\n",
)
.unwrap();
let def = MvpAgent::resolve_agent_definition(
tmp.path(),
Some(&profile_path),
&config::AgentSelectionConfig::default(),
None,
Some(config::DEFAULT_AGENT_TYPE),
);
assert_eq!(def.name, "cli-profile");
if let Some(v) = prev {
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() {
let prev = std::env::var("KIGI_AGENT").ok();
unsafe {
std::env::remove_var("KIGI_AGENT");
}
let tmp = tempfile::tempdir().unwrap();
let agents_dir = tmp.path().join(".kigi").join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(
agents_dir.join("test-architect.md"),
"---\nname: test-architect\ndescription: test\nmodel: test-model-123\n---\nYou are a test.\n",
)
.unwrap();
let agent_config = config::AgentSelectionConfig {
name: Some("test-architect".to_string()),
definition: None,
system_prompt_label: None,
};
let def = MvpAgent::resolve_agent_definition(tmp.path(), None, &agent_config, None, None);
assert_eq!(def.name, "test-architect");
assert_eq!(
def.model,
kigi_agent::config::ModelOverride::Override("test-model-123".to_string()),
"agent profile model override must be preserved through resolution"
);
match prev {
Some(v) => unsafe { std::env::set_var("KIGI_AGENT", v) },
None => unsafe { std::env::remove_var("KIGI_AGENT") },
}
}
#[test]
fn read_session_or_init_meta_str_prefers_session_meta() {
let session = serde_json::json!({ "rules" : "from-session" });
let init = serde_json::json!({ "rules" : "from-init" });
assert_eq!(
read_session_or_init_meta_str(session.as_object(), init.as_object(), "rules"),
Some("from-session"),
);
}
#[test]
fn read_session_or_init_meta_str_falls_back_to_init_meta() {
let session = serde_json::json!({ "other" : "x" });
let init = serde_json::json!({ "rules" : "from-init" });
assert_eq!(
read_session_or_init_meta_str(session.as_object(), init.as_object(), "rules"),
Some("from-init"),
);
assert_eq!(
read_session_or_init_meta_str(None, init.as_object(), "rules"),
Some("from-init"),
);
}
#[test]
fn parse_session_plugin_dirs_filters_and_dedupes() {
let tmp = tempfile::tempdir().unwrap();
let dir = dunce::canonicalize(tmp.path()).unwrap().join("plugin");
std::fs::create_dir(&dir).unwrap();
let file = tmp.path().join("file.txt");
std::fs::write(&file, "x").unwrap();
let meta = serde_json::json!(
{ "pluginDirs" : [dir.to_string_lossy(), dir.to_string_lossy(), file
.to_string_lossy(), "relative/path", 42,] }
);
assert_eq!(parse_session_plugin_dirs(meta.as_object()), vec![dir]);
assert!(parse_session_plugin_dirs(None).is_empty());
assert!(parse_session_plugin_dirs(serde_json::json!({}).as_object()).is_empty());
}
#[test]
fn read_session_or_init_meta_str_returns_none_when_absent() {
assert_eq!(read_session_or_init_meta_str(None, None, "rules"), None,);
let session = serde_json::json!({ "other" : "x" });
assert_eq!(
read_session_or_init_meta_str(session.as_object(), None, "rules"),
None,
);
}
#[test]
fn read_session_or_init_meta_str_ignores_non_string_values() {
let session = serde_json::json!({ "rules" : 42 });
let init = serde_json::json!({ "rules" : "from-init" });
assert_eq!(
read_session_or_init_meta_str(session.as_object(), init.as_object(), "rules"),
Some("from-init"),
);
}
#[test]
fn system_prompt_override_from_meta_prefers_session_and_rejects_empty() {
let session = serde_json::json!({ "systemPromptOverride" : "from session" });
let init = serde_json::json!({ "systemPromptOverride" : "from init" });
assert_eq!(
system_prompt_override_from_meta(session.as_object(), init.as_object()),
Some("from session")
);
assert_eq!(
system_prompt_override_from_meta(None, init.as_object()),
Some("from init")
);
let empty = serde_json::json!({ "systemPromptOverride" : "" });
assert_eq!(
system_prompt_override_from_meta(empty.as_object(), None),
None
);
assert_eq!(system_prompt_override_from_meta(None, None), None);
}
#[test]
fn enqueue_replace_system_prompt_override_sends_when_present() {
use crate::session::SessionCommand;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let session = serde_json::json!({ "systemPromptOverride" : "from session" });
let init = serde_json::json!({ "systemPromptOverride" : "from init" });
enqueue_replace_system_prompt_override(&tx, session.as_object(), init.as_object());
match rx.try_recv() {
Ok(SessionCommand::ReplaceSystemPrompt { system_prompt }) => {
assert_eq!(system_prompt, "from session", "session meta wins over init");
}
_ => panic!("expected a ReplaceSystemPrompt command"),
}
}
#[test]
fn enqueue_replace_system_prompt_override_noop_when_absent_or_empty() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
enqueue_replace_system_prompt_override(
&tx,
serde_json::json!({ "systemPromptOverride" : "" }).as_object(),
None,
);
enqueue_replace_system_prompt_override(&tx, serde_json::json!({}).as_object(), None);
enqueue_replace_system_prompt_override(&tx, None, None);
assert!(
rx.try_recv().is_err(),
"no command should be enqueued without a non-empty override"
);
}
/// Regression for the web-client `_meta.agentProfile` -> `set_session_model`
/// flow: a zero-turn switch from `kigi` (a client profile name) to
/// `kigi-plan` (the default model agent_type) must be
/// treated as compatible so the harness rebuild is skipped and the
/// custom prompt body is preserved.
#[test]
fn harnesses_are_compatible_for_stock_family_pairs() {
assert!(harnesses_are_compatible("kigi", "kigi-plan"));
assert!(harnesses_are_compatible("kigi-plan", "kigi"));
assert!(harnesses_are_compatible("kigi", "kigi"));
assert!(harnesses_are_compatible(
"kigi-concise",
"kigi-plan"
));
assert!(harnesses_are_compatible(
"remote-sidebar",
"kigi-plan"
));
}
#[test]
fn harnesses_are_compatible_rejects_strict_mismatches() {
assert!(harnesses_are_compatible("codex", "codex"));
assert!(!harnesses_are_compatible("kigi-plan", "codex"));
}
#[test]
fn explicit_agent_type_wins_over_session_default() {
assert_eq!(
resolve_required_agent_type(Some("cursor"), "kigi-plan"),
"cursor"
);
}
#[test]
fn null_agent_type_falls_back_to_session_default_kigi_plan() {
assert_eq!(
resolve_required_agent_type(None, "kigi-plan"),
"kigi-plan"
);
}
#[test]
fn null_agent_type_falls_back_to_session_default_kigi() {
assert_eq!(
resolve_required_agent_type(None, "kigi"),
"kigi"
);
}
#[test]
fn null_agent_type_returns_to_session_default_after_cursor_switch() {
let session_default = "kigi-plan";
let required_after_null = resolve_required_agent_type(None, session_default);
assert_eq!(required_after_null, "kigi-plan");
assert_ne!(required_after_null, "cursor");
}
/// Compatible stock switches (no rebuild) must NOT mutate `agent_name`,
/// preserving the session's original ACP `agentProfile`.
#[test]
fn agent_name_unchanged_without_harness_rebuild() {
let unchanged = agent_name_after_model_switch(false, "kigi-plan", "remote-sidebar");
assert_eq!(
unchanged, "remote-sidebar",
"a compatible stock switch must preserve the original agent profile name"
);
}
/// End-to-end test: config -> resolve -> override -> finalize -> tool_definitions.
///
/// Exercises the full live path through to the finalized toolset, proving
/// that the hashline tools appear in the actual tool definitions that
/// would be sent to the model.
#[tokio::test]
async fn file_toolset_override_e2e_to_finalized_toolset() {
use crate::tools::{FileToolset, ShellToolsetConfig};
use kigi_tools::computer::local::{LocalFs, LocalTerminalBackend};
use kigi_tools::notification::ToolNotificationHandle;
use kigi_tools::registry::types::SessionContext;
let tmp = tempfile::tempdir().unwrap();
let mut def = MvpAgent::resolve_agent_definition(
tmp.path(),
None,
&config::AgentSelectionConfig::default(),
None,
None,
);
let toolset_config = ShellToolsetConfig {
file_toolset: FileToolset::Hashline,
..ShellToolsetConfig::default()
};
let effective = toolset_config.resolve_file_toolset(None);
let file_tools = effective
.tool_configs(&toolset_config.hashline)
.expect("default hashline config should validate");
def.override_file_tools(file_tools);
let builder = kigi_tools::registry::types::ToolRegistryBuilder::new();
let ctx = SessionContext {
backend: std::sync::Arc::new(LocalTerminalBackend::new()),
fs: std::sync::Arc::new(LocalFs),
cwd: tmp.path().to_path_buf(),
session_folder: tmp.path().join("session"),
session_env: std::sync::Arc::new(std::collections::HashMap::new()),
notification_handle: ToolNotificationHandle::noop(),
owner_session_id: None,
parent_scheduler_handle: None,
skills: vec![],
state_path: tmp.path().join("state.json"),
memory_backend: None,
web_search_config: kigi_tools::implementations::web_search::WebSearchConfig::default(),
web_fetch_config: Default::default(),
lsp: None,
app_builder_deployer_config: kigi_tools::implementations::kigi::deploy_app::AppBuilderDeployerConfig::default(),
api_key_provider: None,
attribution_callback: None,
system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG,
};
let toolset = builder
.finalize(def.tool_config, ctx)
.expect("hashline toolset should finalize");
let defs = toolset.tool_definitions();
let names: Vec<&str> = defs.iter().map(|d| d.function.name.as_str()).collect();
assert!(names.contains(&"hashline_read"), "defs: {names:?}");
assert!(names.contains(&"hashline_edit"), "defs: {names:?}");
assert!(names.contains(&"hashline_grep"), "defs: {names:?}");
assert!(!names.contains(&"read_file"), "defs: {names:?}");
assert!(!names.contains(&"search_replace"), "defs: {names:?}");
assert!(names.contains(&"list_dir"), "defs: {names:?}");
}
/// Invalid hashline config returns a clean error, not a panic.
#[test]
fn file_toolset_override_invalid_config_returns_error() {
use crate::tools::FileToolset;
use crate::tools::config::HashlineSchemeConfig;
let bad = HashlineSchemeConfig {
scheme: "bogus".to_owned(),
hash_len: 0,
chunk_size: 0,
};
let err = FileToolset::Hashline.tool_configs(&bad);
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,
yolo: bool,
client_id: Option<&str>,
) -> crate::session::SessionHandle {
let (cmd_tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let (persistence_tx, _persistence_rx) = tokio::sync::mpsc::unbounded_channel();
let (hunk_event_tx, _hunk_event_rx) = tokio::sync::mpsc::unbounded_channel();
let hunk_cancel = tokio_util::sync::CancellationToken::new();
let hunk_tracker_handle = kigi_hunk_tracker::HunkTrackerActor::spawn(
"test".to_string(),
std::path::PathBuf::from("/tmp"),
hunk_event_tx,
kigi_hunk_tracker::TrackingMode::AllDirty,
hunk_cancel,
);
crate::session::SessionHandle {
cmd_tx,
persistence_tx,
current_prompt_id: std::sync::Arc::new(std::sync::Mutex::new(None)),
pending_interactions: std::sync::Arc::new(std::sync::Mutex::new(
std::collections::HashMap::new(),
)),
info: crate::session::info::Info {
id: acp::SessionId::new("test"),
cwd: "/tmp".to_string(),
},
max_turns: None,
hunk_tracker_handle,
chat_state_handle: kigi_chat_state::ChatStateHandle::noop(),
signals_handle: crate::session::signals::SessionSignalsHandle::new(),
gateway_enabled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)),
mcp_servers: vec![],
initial_client_mcp_servers: vec![],
display_cwd: None,
feedback_manager: std::sync::Arc::new(
crate::session::feedback_manager::FeedbackManager::local_only("test"),
),
tool_context: crate::tools::ToolContext::new_local_context(
kigi_paths::AbsPathBuf::new(std::path::PathBuf::from("/tmp")).unwrap(),
std::sync::Arc::new(kigi_workspace::file_system::LocalFs::new(
std::path::PathBuf::from("/tmp"),
)),
std::sync::Arc::new(crate::terminal::LocalTerminalRunner),
),
model_id: acp::ModelId::new(model),
reasoning_effort: None,
yolo_mode: yolo,
origin_client: client_id.map(|s| crate::http::OriginClientInfo {
product: s.to_string(),
version: None,
}),
code_nav_enabled: false,
ask_user_question_enabled: true,
plan_mode: std::sync::Arc::new(parking_lot::Mutex::new(
crate::session::plan_mode::PlanModeTracker::new(std::path::PathBuf::from("/tmp")),
)),
force_compact: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
permission_handle: kigi_workspace::permission::PermissionHandle::allow_all(),
attribution_callback: None,
agent_name: "kigi".to_string(),
session_default_agent_profile: None,
allowed_subagent_types: None,
hook_registry: None,
workspace_ops: kigi_workspace::WorkspaceOps::for_test(),
terminal_backend: None,
tools_notification_handle: None,
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");
let sid_b = acp::SessionId::new("sess-b");
let default_model = acp::ModelId::new("default-model");
let sessions: HashMap<acp::SessionId, crate::session::SessionHandle> = [
(sid_a.clone(), make_test_handle("kigi-3-fast", false, None)),
(sid_b.clone(), make_test_handle("codex-mini", false, None)),
]
.into();
assert_eq!(
lookup_session_model(&sessions, Some(&sid_a), &default_model)
.0
.as_ref(),
"kigi-3-fast"
);
assert_eq!(
lookup_session_model(&sessions, Some(&sid_b), &default_model)
.0
.as_ref(),
"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");
let sessions: HashMap<acp::SessionId, crate::session::SessionHandle> = HashMap::new();
assert_eq!(
lookup_session_model(&sessions, None, &default_model)
.0
.as_ref(),
"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");
let sid_b = acp::SessionId::new("sess-b");
let default_model = acp::ModelId::new("default");
let mut sessions: HashMap<acp::SessionId, crate::session::SessionHandle> = [
(sid_a.clone(), make_test_handle("kigi-3", false, None)),
(sid_b.clone(), make_test_handle("kigi-3", false, None)),
]
.into();
sessions.get_mut(&sid_a).unwrap().model_id = acp::ModelId::new("codex-mini");
assert_eq!(
lookup_session_model(&sessions, Some(&sid_a), &default_model)
.0
.as_ref(),
"codex-mini"
);
assert_eq!(
lookup_session_model(&sessions, Some(&sid_b), &default_model)
.0
.as_ref(),
"kigi-3",
"Session B's model must not be affected by session A's model change"
);
}
#[tokio::test]
async fn model_state_prefers_session_reasoning_effort_over_global() {
use crate::agent::config::{EndpointsConfig, ModelEntry};
use kigi_sampling_types::{REASONING_EFFORT_META_KEY, ReasoningEffort};
let agent = build_minimal_agent_for_tests();
let mut entry = ModelEntry::fallback("effort-model", &EndpointsConfig::default());
entry.info.supports_reasoning_effort = true;
agent
.models_manager
.insert_test_entry("effort-model", entry);
agent
.models_manager
.set_current_reasoning_effort(Some(ReasoningEffort::Low));
let read_effort = |state: &acp::SessionModelState| -> Option<String> {
state
.available_models
.iter()
.find(|m| m.model_id.0.as_ref() == "effort-model")
.and_then(|m| m.meta.as_ref())
.and_then(|m| m.get(REASONING_EFFORT_META_KEY))
.and_then(|v| v.as_str())
.map(str::to_owned)
};
let pinned = acp::SessionId::new("sess-pinned");
let mut handle = make_test_handle("effort-model", false, None);
handle.reasoning_effort = Some(ReasoningEffort::Xhigh);
agent.sessions.borrow_mut().insert(pinned.clone(), handle);
assert_eq!(
read_effort(&agent.model_state(Some(&pinned))).as_deref(),
Some("xhigh"),
"model_state must report the session's own restored effort",
);
let unset = acp::SessionId::new("sess-unset");
agent
.sessions
.borrow_mut()
.insert(unset.clone(), make_test_handle("effort-model", false, None));
assert_eq!(
read_effort(&agent.model_state(Some(&unset))).as_deref(),
Some("low"),
"absent session effort falls back to the global default",
);
}
/// A session persisted under a routing *slug* (not the catalog map key) must
/// still get reasoning modes and a selected model from
/// `session_config_options` — the id is resolved to the catalog key before
/// the catalog effort lookups and the selected-model match.
#[tokio::test]
async fn session_config_options_resolves_routing_slug_to_catalog_model() {
use crate::agent::config::{EndpointsConfig, ModelEntry};
use kigi_sampling_types::ReasoningEffort;
let agent = build_minimal_agent_for_tests();
let mut entry = ModelEntry::fallback("catalog-key-model", &EndpointsConfig::default());
entry.info.model = "routing-slug".to_string();
entry.info.supports_reasoning_effort = true;
entry.info.reasoning_effort = Some(ReasoningEffort::High);
agent
.models_manager
.insert_test_entry("catalog-key-model", entry);
let sid = acp::SessionId::new("sess-slug");
agent
.sessions
.borrow_mut()
.insert(sid.clone(), make_test_handle("routing-slug", false, None));
let state = agent.model_state(Some(&sid));
assert_eq!(state.current_model_id.0.as_ref(), "routing-slug");
let opts = agent.session_config_options(Some(&sid), &state);
let modes: Vec<_> = opts.iter().filter(|o| o.category == "mode").collect();
assert!(
!modes.is_empty(),
"reasoning modes must surface for a slug-identified session"
);
assert!(
modes.iter().any(|o| o.id == "high" && o.selected),
"catalog default effort should be selected"
);
assert!(
opts.iter()
.any(|o| o.category == "model" && o.id == "catalog-key-model" && o.selected),
"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");
let sid_vscode = acp::SessionId::new("sess-vscode");
let mut sessions: HashMap<acp::SessionId, crate::session::SessionHandle> = [
(
sid_tui.clone(),
make_test_handle("kigi-3", false, Some("kigi-tui")),
),
(
sid_vscode.clone(),
make_test_handle("kigi-3", false, Some("kigi-code-extension")),
),
]
.into();
let updated = apply_yolo_mode_to_matching_sessions(&mut sessions, Some("kigi-tui"), true);
assert_eq!(updated, 1, "exactly one matching session should be updated");
assert!(
sessions[&sid_tui].yolo_mode,
"TUI session should have yolo=true after TUI toggle"
);
assert!(
!sessions[&sid_vscode].yolo_mode,
"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");
let sid_other = acp::SessionId::new("sess-other");
let mut sessions: HashMap<acp::SessionId, crate::session::SessionHandle> = [
(
sid_tui.clone(),
make_test_handle("kigi-3", true, Some("kigi-tui")),
),
(
sid_other.clone(),
make_test_handle("kigi-3", true, Some("kigi-code-extension")),
),
]
.into();
let updated = apply_yolo_mode_to_matching_sessions(&mut sessions, Some("kigi-tui"), false);
assert_eq!(updated, 1, "only the sender's session should be updated");
assert!(
!sessions[&sid_tui].yolo_mode,
"sender session should be switched to yolo=false"
);
assert!(
sessions[&sid_other].yolo_mode,
"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>> =
RefCell::new(HashMap::new());
let sid = acp::SessionId::new("drain-test");
let handle = std::thread::spawn(|| {});
std::thread::sleep(std::time::Duration::from_millis(10));
session_threads.borrow_mut().insert(
sid.clone(),
crate::session::SessionThread::from_handle(handle),
);
let thread = session_threads.borrow_mut().remove(&sid).unwrap();
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>> =
RefCell::new(HashMap::new());
let sid = acp::SessionId::new("slow-drain");
let handle = std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_millis(100));
});
session_threads.borrow_mut().insert(
sid.clone(),
crate::session::SessionThread::from_handle(handle),
);
let thread = session_threads.borrow_mut().remove(&sid).unwrap();
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if thread.is_finished() {
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"thread should finish within 5s"
);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
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>> =
RefCell::new(HashMap::new());
let sid = acp::SessionId::new("hung-drain");
let handle = std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(30));
});
session_threads.borrow_mut().insert(
sid.clone(),
crate::session::SessionThread::from_handle(handle),
);
let thread = session_threads.borrow_mut().remove(&sid).unwrap();
let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(200);
let mut timed_out = false;
loop {
if thread.is_finished() {
break;
}
if tokio::time::Instant::now() >= deadline {
timed_out = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert!(
timed_out,
"should have timed out waiting for the hung thread"
);
assert!(!thread.is_finished(), "thread should still be running");
}
#[test]
fn parse_code_nav_capability_present_and_true() {
let mut meta = serde_json::Map::new();
meta.insert(
"kigi/codeNavigation".to_string(),
serde_json::json!({ "enabled" : true }),
);
let init = acp::InitializeRequest::new(acp::ProtocolVersion::V1).client_capabilities(
acp::ClientCapabilities::new()
.fs(acp::FileSystemCapabilities::new())
.terminal(false)
.meta(meta),
);
assert!(MvpAgent::parse_code_nav_capability(&init));
}
#[test]
fn parse_code_nav_capability_absent_returns_false() {
let init = acp::InitializeRequest::new(acp::ProtocolVersion::V1).client_capabilities(
acp::ClientCapabilities::new()
.fs(acp::FileSystemCapabilities::new())
.terminal(false),
);
assert!(!MvpAgent::parse_code_nav_capability(&init));
}
#[test]
fn parse_code_nav_capability_false_returns_false() {
let mut meta = serde_json::Map::new();
meta.insert(
"kigi/codeNavigation".to_string(),
serde_json::json!({ "enabled" : false }),
);
let init = acp::InitializeRequest::new(acp::ProtocolVersion::V1).client_capabilities(
acp::ClientCapabilities::new()
.fs(acp::FileSystemCapabilities::new())
.terminal(false)
.meta(meta),
);
assert!(!MvpAgent::parse_code_nav_capability(&init));
}
/// Verify that two session handles with different code-nav state produce
/// independent eligibility outcomes — the key leader-mode isolation test.
///
/// This tests the `code_nav_eligibility_for_request` lookup path directly
/// by inspecting the per-handle fields rather than building a full agent,
/// which mirrors what the method actually reads at runtime.
#[tokio::test]
async fn test_per_session_code_nav_isolation() {
let web_handle = {
let mut h = make_test_handle("model", false, Some("kigi-web"));
h.code_nav_enabled = true;
h
};
let tui_handle = {
let mut h = make_test_handle("model", false, Some("kigi-tui"));
h.code_nav_enabled = false;
h
};
let check = |handle: &crate::session::SessionHandle| {
let ct = crate::http::client_type_from_origin(handle.origin_client.as_ref());
if !matches!(ct, ClientType::KigiWeb) {
return Err(CodeNavEligibility::ClientNotWeb);
}
if !handle.code_nav_enabled {
return Err(CodeNavEligibility::CapabilityNotAdvertised);
}
Ok(())
};
assert!(
check(&web_handle).is_ok(),
"web session with capability should pass client-type and capability gates"
);
assert_eq!(
check(&tui_handle),
Err(CodeNavEligibility::ClientNotWeb),
"tui session should be rejected at gate 1"
);
let mut web_no_cap = web_handle.clone();
web_no_cap.code_nav_enabled = false;
assert_eq!(
check(&web_no_cap),
Err(CodeNavEligibility::CapabilityNotAdvertised),
"web session without capability should be rejected at gate 2"
);
assert!(
check(&web_handle).is_ok(),
"original web handle must be unaffected"
);
}
/// Verify that code-nav requests without a sessionId are rejected.
///
/// `sessionId` is required so per-client capability gating is unambiguous
/// in both simple and leader modes. Falling back to shared global state
/// (last-client-wins in leader mode) is not safe.
#[test]
fn test_sessionless_request_requires_session_id() {
let session_id: Option<&acp::SessionId> = None;
let result: Result<(), CodeNavEligibility> = if session_id.is_none() {
Err(CodeNavEligibility::SessionRequired)
} else {
Ok(())
};
assert_eq!(
result,
Err(CodeNavEligibility::SessionRequired),
"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};
let temp_dir = tempfile::tempdir().unwrap();
let auth_manager =
std::sync::Arc::new(AuthManager::new(temp_dir.path(), KimiCodeConfig::default()));
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let gateway = GatewaySender::new(tx);
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};
let temp_dir = tempfile::tempdir().unwrap();
let auth_manager =
std::sync::Arc::new(AuthManager::new(temp_dir.path(), KimiCodeConfig::default()));
auth_manager.hot_swap(auth);
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let gateway = GatewaySender::new(tx);
let cfg = AgentConfig::default();
MvpAgent::new(gateway, &cfg, auth_manager, None).expect("valid test config")
}
/// Regression (token-leak, Facet B via the api_key channel): under a
/// session-based (Kimi) primary auth method, `prepare_sampling_config_for_model`
/// must resolve the session token by the MODEL's platform — so a grok
/// (oauth-platform) model NEVER carries the primary Kimi session key as its
/// `api_key`, while a first-party Kimi model still does. Relies on the
/// process-global OAuth pool (there is no per-session snapshot).
///
/// Reverting the fix (resolving the session token from the primary regardless of
/// the model's platform) fails the grok assertion below — it would stamp the
/// live Kimi key on a request bound for api.x.ai.
#[tokio::test]
#[serial_test::serial]
async fn prepare_sampling_config_never_stamps_kimi_key_on_grok_model() {
use crate::agent::auth_method::{
CACHED_TOKEN_AUTH_METHOD_ID, HOUSE_API_KEY_ENV_VAR, LEGACY_XAI_API_KEY_ENV_VAR,
XAI_API_KEY_ENV_VAR,
};
use crate::agent::config::{EndpointsConfig, ModelEntry};
use kigi_test_support::EnvGuard;
const KIMI_KEY: &str = "kimi-session-secret-DO-NOT-LEAK";
// No ambient BYOK env key: a grok model with no stored oauth session then
// resolves to no api_key at all, rather than a global-key fallback that could
// mask the leak under test.
let _house = EnvGuard::unset(HOUSE_API_KEY_ENV_VAR);
let _xai = EnvGuard::unset(XAI_API_KEY_ENV_VAR);
let _legacy = EnvGuard::unset(LEGACY_XAI_API_KEY_ENV_VAR);
// Primary: a live Kimi session token under a session-based auth method.
let agent = build_agent_with_auth(crate::auth::KimiAuth {
key: KIMI_KEY.to_string(),
auth_mode: crate::auth::AuthMode::OAuth,
..crate::auth::KimiAuth::test_default()
});
agent.set_auth_method(acp::AuthMethodId::new(CACHED_TOKEN_AUTH_METHOD_ID));
let endpoints = EndpointsConfig::default();
// First-party SUBSCRIPTION model (kimi-code): the primary session key IS its
// 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();
assert!(!kimi_model.has_own_credentials());
let kimi_cfg = agent.prepare_sampling_config_for_model(&kimi_model, None);
assert_eq!(
kimi_cfg.api_key.as_deref(),
Some(KIMI_KEY),
"the first-party subscription model must carry the primary session key \
(primary path unchanged)"
);
// xai-grok model (oauth platform): the session token resolves from its OWN
// pool-backed manager, INDEPENDENT of the Kimi primary — so its api_key can
// NEVER be the Kimi session key.
let mut grok_model = ModelEntry::fallback("grok-4-latest", &endpoints);
grok_model.info.id = Some("xai-grok/grok-4-latest".to_string());
assert!(!grok_model.has_own_credentials());
let grok_cfg = agent.prepare_sampling_config_for_model(&grok_model, None);
assert_ne!(
grok_cfg.api_key.as_deref(),
Some(KIMI_KEY),
"LEAK: a grok model must never carry the primary Kimi session key as api_key"
);
}
/// Regression: boot-time plugin discovery is deferred past ACP
/// `initialize`, so the shared plugin registry starts empty.
/// `resolve_mcp_servers` reads that snapshot to merge plugin-contributed
/// MCP servers into a new session, so without lazy population the servers
/// silently vanished until an explicit `/plugins reload`.
/// `ensure_plugin_registry` must build the snapshot on first use.
#[tokio::test]
#[serial_test::serial]
async fn ensure_plugin_registry_lazily_populates_snapshot() {
use crate::agent::config::Config as AgentConfig;
use crate::auth::{AuthManager, KimiCodeConfig};
use kigi_test_support::EnvGuard;
let kigi_home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set("KIGI_SHARE_DIR", kigi_home.path());
let plugin_dir = tempfile::tempdir().unwrap();
std::fs::write(
plugin_dir.path().join("plugin.json"),
r#"{"name": "regr-lazy-mcp-plugin"}"#,
)
.unwrap();
std::fs::write(
plugin_dir.path().join(".mcp.json"),
r#"{"mcpServers":{"regr-srv":{"command":"echo","args":["hi"]}}}"#,
)
.unwrap();
let auth_home = tempfile::tempdir().unwrap();
let auth_manager =
std::sync::Arc::new(AuthManager::new(auth_home.path(), KimiCodeConfig::default()));
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let gateway = GatewaySender::new(tx);
let mut cfg = AgentConfig::default();
cfg.plugins.cli_plugin_dirs = vec![plugin_dir.path().to_path_buf()];
let agent = MvpAgent::new(gateway, &cfg, auth_manager, None).expect("valid test config");
assert!(
agent.plugin_registry_handle.snapshot().is_none(),
"snapshot must start empty (boot discovery deferred past initialize)"
);
agent.ensure_plugin_registry();
let snapshot = agent
.plugin_registry_handle
.snapshot()
.expect("snapshot must be populated on first use");
assert!(
snapshot.get("regr-lazy-mcp-plugin").is_some(),
"lazy discovery must surface the plugin so its MCP server merges into the session"
);
agent.ensure_plugin_registry();
assert!(
agent
.plugin_registry_handle
.snapshot()
.is_some_and(|s| s.get("regr-lazy-mcp-plugin").is_some()),
"repeat call must keep the populated snapshot"
);
}
mod subagent_spawn_context_tests;
/// LEAK guard for the `api_key` channel (C1/C2), through the real
/// `prepare_sampling_config_for_model` resolution path.
mod api_key_channel_leak_tests;
mod chokepoint_leak_tests;
/// No load in flight and no session → the wait returns immediately
/// (the caller then surfaces "unknown session id" exactly as before).
#[tokio::test]
async fn wait_for_in_flight_load_returns_immediately_when_idle() {
let agent = build_minimal_agent_for_tests();
let sid = acp::SessionId::new("sess-none");
tokio::time::timeout(
std::time::Duration::from_millis(200),
agent.wait_for_in_flight_session_load(&sid),
)
.await
.expect("wait must not block when no load is in flight");
}
/// A waiter racing an in-flight `session/load` blocks until the load
/// finishes and then observes the registered session. This is the
/// agent-side guarantee that closes the post-leader-crash
/// "unknown session id" race: the reconnect replay's `session/load` and
/// the client's next `session/prompt` can arrive back-to-back.
#[tokio::test]
async fn wait_for_in_flight_load_blocks_until_load_completes() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let agent = std::rc::Rc::new(build_minimal_agent_for_tests());
let sid = acp::SessionId::new("sess-loading");
let guard = agent.begin_session_load(&sid);
let waiter_agent = agent.clone();
let waiter_sid = sid.clone();
let waiter = tokio::task::spawn_local(async move {
waiter_agent
.wait_for_in_flight_session_load(&waiter_sid)
.await;
waiter_agent.sessions.borrow().contains_key(&waiter_sid)
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(!waiter.is_finished(), "waiter must block while loading");
let handle = make_test_handle("test-model", false, None);
agent.sessions.borrow_mut().insert(sid.clone(), handle);
drop(guard);
let found_session = tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
.await
.expect("waiter must wake when the load guard drops")
.expect("waiter task must not panic");
assert!(
found_session,
"after the wait, the session must be visible to the racing request"
);
})
.await;
}
/// A failed load (guard dropped WITHOUT registering the session) also
/// wakes waiters — they re-check, find nothing, and the caller surfaces
/// the regular "unknown session id" error rather than hanging.
#[tokio::test]
async fn wait_for_in_flight_load_wakes_on_failed_load() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let agent = std::rc::Rc::new(build_minimal_agent_for_tests());
let sid = acp::SessionId::new("sess-load-fails");
let guard = agent.begin_session_load(&sid);
let waiter_agent = agent.clone();
let waiter_sid = sid.clone();
let waiter = tokio::task::spawn_local(async move {
waiter_agent
.wait_for_in_flight_session_load(&waiter_sid)
.await;
waiter_agent.sessions.borrow().contains_key(&waiter_sid)
});
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
drop(guard);
let found_session = tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
.await
.expect("waiter must wake when the failed load's guard drops")
.expect("waiter task must not panic");
assert!(!found_session, "failed load leaves no session behind");
})
.await;
}
/// Two concurrent loads of the same session: the first guard's drop must
/// not remove the second load's marker (waiters keep waiting on the
/// newer in-flight load).
#[tokio::test]
async fn concurrent_load_guards_do_not_clobber_each_other() {
let agent = build_minimal_agent_for_tests();
let sid = acp::SessionId::new("sess-concurrent");
let guard_one = agent.begin_session_load(&sid);
let guard_two = agent.begin_session_load(&sid);
drop(guard_one);
assert!(
agent.loading_sessions.borrow().contains_key(&sid),
"second load's marker must survive the first guard's drop"
);
drop(guard_two);
assert!(
agent.loading_sessions.borrow().is_empty(),
"all markers removed once every load finished"
);
}
/// `resident_activity` returns `NeedsInput` whenever the session's
/// pending-interaction map is non-empty — and that wins even over a
/// running turn (a session blocked on a permission mid-turn "needs
/// input"). Clearing the map falls back to Working / Idle.
#[tokio::test]
async fn resident_activity_reports_needs_input_when_pending() {
use crate::agent::roster::RosterActivity;
let agent = build_minimal_agent_for_tests();
let sid = acp::SessionId::new("sess-pending");
let handle = make_test_handle("kigi-3", false, None);
let pending = handle.pending_interactions.clone();
let prompt_id = handle.current_prompt_id.clone();
agent.sessions.borrow_mut().insert(sid.clone(), handle);
assert_eq!(agent.resident_activity(&sid), RosterActivity::Idle);
*prompt_id.lock().unwrap() = Some("turn-1".to_string());
assert_eq!(agent.resident_activity(&sid), RosterActivity::Working);
pending.lock().unwrap().insert(
"call-1".to_string(),
crate::session::pending_interaction::PendingKind::Permission,
);
assert_eq!(agent.resident_activity(&sid), RosterActivity::NeedsInput);
let entry = agent.resident_roster_entry(&sid).expect("resident entry");
assert_eq!(entry.activity, RosterActivity::NeedsInput);
pending.lock().unwrap().clear();
assert_eq!(agent.resident_activity(&sid), RosterActivity::Working);
}
/// Drain the agent gateway, returning the first `kigi/sessions/changed`
/// payload that carries an upserted entry (ignoring any unrelated
/// notifications, which parse into an empty `RosterChanged`).
fn drain_roster_changed(
rx: &mut tokio::sync::mpsc::UnboundedReceiver<kigi_acp_lib::AcpClientMessage>,
) -> Option<crate::agent::roster::RosterChanged> {
let mut found = None;
while let Ok(msg) = rx.try_recv() {
if let kigi_acp_lib::AcpClientMessage::ExtNotification(args) = msg {
if found.is_none()
&& let Ok(changed) = serde_json::from_str::<crate::agent::roster::RosterChanged>(
args.request.params.get(),
)
&& !changed.upserted.is_empty()
{
found = Some(changed);
}
let _ = args.response_tx.send(Ok(()));
}
}
found
}
/// A turn-boundary activity delta (`push_roster_activity_delta`) broadcasts
/// an `kigi/sessions/changed` upsert carrying the *overridden* activity, so
/// every attached dashboard reflects Working/Idle immediately instead of
/// waiting for the ≤1s roster poll (turn-start/turn-end). The
/// override matters because at turn-start the actor has not yet published
/// `current_prompt_id`, so a natural `resident_activity` read would emit
/// `Idle` for a session that is in fact starting a turn.
#[tokio::test]
async fn push_roster_activity_delta_broadcasts_overridden_activity() {
use crate::agent::config::Config as AgentConfig;
use crate::agent::roster::RosterActivity;
use crate::auth::{AuthManager, KimiCodeConfig};
let temp_dir = tempfile::tempdir().unwrap();
let auth_manager =
std::sync::Arc::new(AuthManager::new(temp_dir.path(), KimiCodeConfig::default()));
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let gateway = GatewaySender::new(tx);
let cfg = AgentConfig::default();
let agent = MvpAgent::new(gateway, &cfg, auth_manager, None).expect("valid test config");
let sid = acp::SessionId::new("sess-activity");
agent
.sessions
.borrow_mut()
.insert(sid.clone(), make_test_handle("kigi-3", false, None));
agent.push_roster_activity_delta(&sid, RosterActivity::Working);
let changed = drain_roster_changed(&mut rx).expect("turn-start delta emitted");
assert_eq!(changed.upserted.len(), 1);
assert_eq!(changed.upserted[0].session_id, sid.0.to_string());
assert!(changed.upserted[0].resident);
assert_eq!(
changed.upserted[0].activity,
RosterActivity::Working,
"forced activity must override the Idle that resident_activity would read"
);
assert!(changed.removed.is_empty());
agent.push_roster_activity_delta(&sid, RosterActivity::Idle);
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"
)]
fn parse_ext_body(resp: &acp::ExtResponse) -> serde_json::Value {
let outer: serde_json::Value =
serde_json::from_str(resp.0.get()).expect("ExtResponse must be valid JSON");
outer
.get("result")
.cloned()
.unwrap_or_else(|| panic!("ExtResponse has no 'result' key; full JSON: {outer}"))
}
/// Replicate the lookup logic of code_nav_eligibility_for_request so we
/// can test it with a plain sessions HashMap.
fn check_nav_eligibility_from_sessions(
sessions: &HashMap<acp::SessionId, crate::session::SessionHandle>,
session_id: Option<&acp::SessionId>,
) -> Result<(), CodeNavEligibility> {
let session_id = match session_id {
Some(sid) => sid,
None => return Err(CodeNavEligibility::SessionRequired),
};
let Some(handle) = sessions.get(session_id) else {
return Err(CodeNavEligibility::SessionRequired);
};
let ct = crate::http::client_type_from_origin(handle.origin_client.as_ref());
if !matches!(ct, ClientType::KigiWeb) {
return Err(CodeNavEligibility::ClientNotWeb);
}
if !handle.code_nav_enabled {
return Err(CodeNavEligibility::CapabilityNotAdvertised);
}
Ok(())
}
/// Web session with code-nav capability is eligible.
///
/// This is the "happy path" that allows lazy index startup on the first
/// code-nav request.
#[tokio::test]
async fn test_web_session_with_capability_is_eligible() {
let sid = acp::SessionId::new("sess-web");
let mut handle = make_test_handle("model", false, Some("kigi-web"));
handle.code_nav_enabled = true;
let sessions = [(sid.clone(), handle)].into();
assert!(
check_nav_eligibility_from_sessions(&sessions, Some(&sid)).is_ok(),
"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");
let mut handle = make_test_handle("model", false, Some("kigi-tui"));
handle.code_nav_enabled = true;
let sessions = [(sid.clone(), handle)].into();
assert_eq!(
check_nav_eligibility_from_sessions(&sessions, Some(&sid)),
Err(CodeNavEligibility::ClientNotWeb),
"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");
let mut handle = make_test_handle("model", false, Some("kigi-web"));
handle.code_nav_enabled = false;
let sessions = [(sid.clone(), handle)].into();
assert_eq!(
check_nav_eligibility_from_sessions(&sessions, Some(&sid)),
Err(CodeNavEligibility::CapabilityNotAdvertised),
"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");
let tui_sid = acp::SessionId::new("tui");
let mut web_handle = make_test_handle("model", false, Some("kigi-web"));
web_handle.code_nav_enabled = true;
let mut tui_handle = make_test_handle("model", false, Some("kigi-tui"));
tui_handle.code_nav_enabled = false;
let sessions = [(web_sid.clone(), web_handle), (tui_sid.clone(), tui_handle)].into();
assert!(
check_nav_eligibility_from_sessions(&sessions, Some(&web_sid)).is_ok(),
"web session must be eligible"
);
assert_eq!(
check_nav_eligibility_from_sessions(&sessions, Some(&tui_sid)),
Err(CodeNavEligibility::ClientNotWeb),
"tui session must remain ineligible even when web session is eligible"
);
}
/// Unknown session ID returns SessionRequired, not a global fallback.
///
/// This is the stale/evicted session path: a caller with a session ID that
/// no longer exists in the sessions map must get SessionRequired, not
/// accidentally inherit the last-initialized client's eligibility.
#[tokio::test]
async fn test_unknown_session_id_returns_session_required() {
let known_sid = acp::SessionId::new("known");
let mut known_handle = make_test_handle("model", false, Some("kigi-web"));
known_handle.code_nav_enabled = true;
let sessions = [(known_sid.clone(), known_handle)].into();
let stale_sid = acp::SessionId::new("stale-or-evicted");
assert_eq!(
check_nav_eligibility_from_sessions(&sessions, Some(&stale_sid)),
Err(CodeNavEligibility::SessionRequired),
"stale/evicted sessionId must not fall back to global state"
);
assert!(check_nav_eligibility_from_sessions(&sessions, Some(&known_sid)).is_ok());
}
mod parse_json_object_env_tests {
use super::parse_json_object_env;
unsafe fn set(k: &str, v: &str) {
unsafe { std::env::set_var(k, v) };
}
unsafe fn unset(k: &str) {
unsafe { std::env::remove_var(k) };
}
#[test]
#[serial_test::serial]
fn valid_json_object_returns_some() {
unsafe { set("TEST_JSON_OBJ", r#"{"team":"platform","org":"acme"}"#) };
let result = parse_json_object_env("TEST_JSON_OBJ");
unsafe { unset("TEST_JSON_OBJ") };
let val = result.expect("should parse valid JSON object");
assert_eq!(val["team"], "platform");
assert_eq!(val["org"], "acme");
}
#[test]
#[serial_test::serial]
fn non_object_json_returns_none() {
unsafe { set("TEST_JSON_ARR", r#"["not","an","object"]"#) };
let result = parse_json_object_env("TEST_JSON_ARR");
unsafe { unset("TEST_JSON_ARR") };
assert!(result.is_none());
}
#[test]
#[serial_test::serial]
fn invalid_json_returns_none() {
unsafe { set("TEST_JSON_BAD", "not json at all") };
let result = parse_json_object_env("TEST_JSON_BAD");
unsafe { unset("TEST_JSON_BAD") };
assert!(result.is_none());
}
#[test]
#[serial_test::serial]
fn unset_var_returns_none() {
unsafe { unset("TEST_JSON_UNSET") };
assert!(parse_json_object_env("TEST_JSON_UNSET").is_none());
}
}
mod eligibility_gates {
use super::*;
/// Standalone replica of the first three eligibility gates.
/// Gate 4 (git root) requires a real filesystem and is covered by
/// integration tests.
fn check_gates(
client_type: ClientType,
code_nav_enabled: bool,
indexing_enabled: bool,
) -> Result<(), CodeNavEligibility> {
if !matches!(client_type, ClientType::KigiWeb) {
return Err(CodeNavEligibility::ClientNotWeb);
}
if !code_nav_enabled {
return Err(CodeNavEligibility::CapabilityNotAdvertised);
}
if !indexing_enabled {
return Err(CodeNavEligibility::DisabledByConfig);
}
Ok(())
}
#[test]
fn non_web_client_rejected() {
assert_eq!(
check_gates(ClientType::Generic, true, true),
Err(CodeNavEligibility::ClientNotWeb)
);
}
#[test]
fn tui_client_rejected() {
assert_eq!(
check_gates(ClientType::KigiTUI, true, true),
Err(CodeNavEligibility::ClientNotWeb)
);
}
#[test]
fn web_client_no_capability_rejected() {
assert_eq!(
check_gates(ClientType::KigiWeb, false, true),
Err(CodeNavEligibility::CapabilityNotAdvertised)
);
}
#[test]
fn web_client_with_capability_config_disabled_rejected() {
assert_eq!(
check_gates(ClientType::KigiWeb, true, false),
Err(CodeNavEligibility::DisabledByConfig)
);
}
#[test]
fn web_client_with_capability_and_config_passes_first_three_gates() {
assert!(check_gates(ClientType::KigiWeb, true, true).is_ok());
}
}
#[test]
fn find_model_by_id_prefers_key_then_falls_back_to_slug() {
let entry = |model: &str| ModelEntry {
info: config::ModelInfo {
user_selectable: true,
id: None,
model: model.to_string(),
base_url: String::new(),
name: None,
description: None,
max_completion_tokens: None,
temperature: None,
top_p: None,
api_backend: crate::sampling::ApiBackend::default(),
auth_scheme: Default::default(),
extra_headers: IndexMap::new(),
context_window: std::num::NonZeroU64::new(200_000).unwrap(),
auto_compact_threshold_percent: None,
system_prompt_label: None,
use_concise: false,
agent_type: config::default_agent_type(),
inference_idle_timeout_secs: None,
max_retries: None,
hidden: false,
supported_in_api: true,
reasoning_effort: None,
supports_reasoning_effort: false,
reasoning_efforts: Vec::new(),
capabilities: Vec::new(),
supports_backend_search: false,
compactions_remaining: None,
compaction_at_tokens: None,
show_model_fingerprint: false,
stream_tool_calls: None,
laziness_detector: crate::agent::config::LazinessDetectorPerModelConfig::default(),
},
api_key: None,
env_key: None,
api_base_url: None,
};
let mut models = indexmap::IndexMap::new();
models.insert("a".to_string(), entry("target"));
models.insert("target".to_string(), entry("other"));
assert_eq!(
config::find_model_by_id(&models, "target").unwrap().model,
"other",
"key match should win over slug scan"
);
assert_eq!(
config::find_model_by_id(&models, "a").unwrap().model,
"target",
"exact key match for 'a'"
);
}
fn write_updates(dir: &std::path::Path, lines: &[&str]) -> PathBuf {
let path = dir.join("updates.jsonl");
std::fs::write(&path, lines.join("\n")).unwrap();
path
}
fn bg_line(task_id: &str) -> String {
format!(
r#"{{"timestamp":1,"method":"_kigi/session/update","params":{{"sessionId":"s","update":{{"sessionUpdate":"task_backgrounded","task_id":"{task_id}","command":"sleep 99","cwd":"/tmp"}}}}}}"#
)
}
fn completed_line(task_id: &str) -> String {
format!(
r#"{{"timestamp":2,"method":"_kigi/session/update","params":{{"sessionId":"s","update":{{"sessionUpdate":"task_completed","task_snapshot":{{"task_id":"{task_id}","completed":true}}}}}}}}"#
)
}
fn orphaned_ids(tasks: &[OrphanedTask]) -> std::collections::HashSet<&str> {
tasks.iter().map(|t| t.task_id.as_str()).collect()
}
#[test]
fn orphaned_tasks_returns_empty_for_no_file() {
let result = MvpAgent::find_orphaned_background_tasks(&None);
assert!(result.is_empty());
}
#[test]
fn orphaned_tasks_returns_empty_for_missing_file() {
let path = PathBuf::from("/nonexistent/updates.jsonl");
let result = MvpAgent::find_orphaned_background_tasks(&Some(path));
assert!(result.is_empty());
}
#[test]
fn orphaned_tasks_returns_empty_when_all_completed() {
let tmp = tempfile::tempdir().unwrap();
let bg = bg_line("t1");
let done = completed_line("t1");
let path = write_updates(tmp.path(), &[&bg, &done]);
let result = MvpAgent::find_orphaned_background_tasks(&Some(path));
assert!(result.is_empty());
}
#[test]
fn orphaned_tasks_returns_uncompleted() {
let tmp = tempfile::tempdir().unwrap();
let bg1 = bg_line("t1");
let bg2 = bg_line("t2");
let done1 = completed_line("t1");
let path = write_updates(tmp.path(), &[&bg1, &bg2, &done1]);
let result = MvpAgent::find_orphaned_background_tasks(&Some(path));
let ids = orphaned_ids(&result);
assert_eq!(ids.len(), 1);
assert!(ids.contains("t2"));
}
#[test]
fn orphaned_tasks_returns_multiple_uncompleted() {
let tmp = tempfile::tempdir().unwrap();
let bg1 = bg_line("t1");
let bg2 = bg_line("t2");
let bg3 = bg_line("t3");
let done2 = completed_line("t2");
let path = write_updates(tmp.path(), &[&bg1, &bg2, &bg3, &done2]);
let result = MvpAgent::find_orphaned_background_tasks(&Some(path));
let ids = orphaned_ids(&result);
assert_eq!(ids.len(), 2);
assert!(ids.contains("t1"));
assert!(ids.contains("t3"));
}
#[test]
fn orphaned_tasks_captures_command_and_cwd() {
let tmp = tempfile::tempdir().unwrap();
let bg = bg_line("t1");
let path = write_updates(tmp.path(), &[&bg]);
let result = MvpAgent::find_orphaned_background_tasks(&Some(path));
assert_eq!(result.len(), 1);
assert_eq!(result[0].command, "sleep 99");
assert_eq!(result[0].cwd, "/tmp");
}
#[test]
fn orphaned_tasks_skips_malformed_lines() {
let tmp = tempfile::tempdir().unwrap();
let bg = bg_line("t1");
let path = write_updates(tmp.path(), &["not json", &bg, "{}"]);
let result = MvpAgent::find_orphaned_background_tasks(&Some(path));
assert_eq!(result.len(), 1);
}
#[test]
fn orphaned_tasks_ignores_unrelated_updates() {
let tmp = tempfile::tempdir().unwrap();
let bg = bg_line("t1");
let unrelated = r#"{"timestamp":1,"method":"_kigi/session/update","params":{"sessionId":"s","update":{"sessionUpdate":"auto_compact_started","percentage":80}}}"#;
let path = write_updates(tmp.path(), &[&bg, unrelated]);
let result = MvpAgent::find_orphaned_background_tasks(&Some(path));
assert_eq!(result.len(), 1);
}
#[test]
fn orphaned_tasks_filters_rewind_dead_branches() {
let tmp = tempfile::tempdir().unwrap();
let user_msg = r#"{"timestamp":0,"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"hello"}}}}"#;
let bg_before_rewind = bg_line("t-dead");
let rewind = r#"{"timestamp":3,"method":"_kigi/session/update","params":{"sessionId":"s","update":{"sessionUpdate":"rewind_marker","target_prompt_index":0,"created_at":"2025-01-01T00:00:00Z"}}}"#;
let user_msg2 = r#"{"timestamp":4,"method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"retry"}}}}"#;
let bg_after_rewind = bg_line("t-alive");
let path = write_updates(
tmp.path(),
&[
user_msg,
&bg_before_rewind,
rewind,
user_msg2,
&bg_after_rewind,
],
);
let result = MvpAgent::find_orphaned_background_tasks(&Some(path));
let ids = orphaned_ids(&result);
assert!(
ids.contains("t-alive"),
"task after rewind should be present"
);
assert!(
!ids.contains("t-dead"),
"task in dead branch should be filtered"
);
}
#[test]
fn on_demand_enabled_from_remote_settings() {
let json = serde_json::json!({ "on_demand_enabled" : false });
let rs: crate::util::config::RemoteSettings = serde_json::from_value(json).unwrap();
assert_eq!(rs.on_demand_enabled, Some(false));
let json = serde_json::json!({});
let rs: crate::util::config::RemoteSettings = serde_json::from_value(json).unwrap();
assert_eq!(rs.on_demand_enabled, None);
}
/// Regression for a 401 sequence seen in production. After a long idle
/// window, the auth manager may have no
/// live token by the time `session/new` runs. For session-based auth methods
/// we MUST still report `SessionToken` so chat_state credentials retain the
/// session-token shape and `try_refresh_session_token` will run on the next
/// prompt instead of early-returning.
#[tokio::test(flavor = "current_thread")]
async fn auth_type_session_based_no_current_returns_session_token() {
for method_id in [
crate::agent::auth_method::CACHED_TOKEN_AUTH_METHOD_ID,
crate::agent::auth_method::KIMI_CODE_METHOD_ID,
] {
let agent = build_minimal_agent_for_tests();
agent.set_auth_method(acp::AuthMethodId::new(method_id));
assert!(
agent.auth_manager.current().is_none(),
"{method_id}: precondition: AuthManager has no current token",
);
assert_eq!(
agent.auth_type(),
kigi_chat_state::AuthType::SessionToken,
"{method_id}: session-based auth must report SessionToken even \
without a live token -- otherwise chat_state gets locked into \
auth_type = ApiKey and try_refresh_session_token will skip \
every subsequent refresh attempt.",
);
}
}
/// BYOK guard. Users with `xai.api_key` must continue to report `ApiKey`
/// regardless of live-token state -- BYOK sessions have nothing to refresh,
/// and reporting `SessionToken` would route through cli-chat-proxy paths
/// that don't apply to BYOK keys.
#[tokio::test(flavor = "current_thread")]
async fn auth_type_xai_api_key_no_current_returns_api_key() {
let agent = build_minimal_agent_for_tests();
agent.set_auth_method(acp::AuthMethodId::new(
crate::agent::auth_method::XAI_API_KEY_METHOD_ID,
));
assert!(agent.auth_manager.current().is_none());
assert_eq!(
agent.auth_type(),
kigi_chat_state::AuthType::ApiKey,
"xai.api_key auth must report ApiKey -- BYOK has no session-token \
behavior to fall back to."
);
}
/// Positive baseline: when both signals agree (session-based method AND
/// a live in-memory token), `SessionToken` is returned. This is the
/// common case during a healthy session.
#[tokio::test(flavor = "current_thread")]
async fn auth_type_session_based_with_current_returns_session_token() {
use crate::auth::KimiAuth;
let agent = build_minimal_agent_for_tests();
agent.set_auth_method(acp::AuthMethodId::new(
crate::agent::auth_method::KIMI_CODE_METHOD_ID,
));
agent.auth_manager.hot_swap(KimiAuth::test_default());
assert!(agent.auth_manager.current().is_some());
assert_eq!(agent.auth_type(), kigi_chat_state::AuthType::SessionToken,);
}
/// Defensive case: no `auth_method_id` selected yet (pre-`authenticate`
/// state) and no live credential. We default to `ApiKey` so callers
/// that key off this value (e.g. `resolve_chat_state_auth_type` for chat
/// routing) don't accidentally route session-token-shaped traffic
/// through cli-chat-proxy before a method has been chosen.
#[tokio::test(flavor = "current_thread")]
async fn auth_type_no_method_id_no_current_returns_api_key() {
let agent = build_minimal_agent_for_tests();
assert!(agent.auth_method_id.load().is_none());
assert!(agent.auth_manager.current().is_none());
assert_eq!(agent.auth_type(), kigi_chat_state::AuthType::ApiKey,);
}
/// Live credential present but `auth_method_id` is still `None`. The
/// 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.
#[tokio::test(flavor = "current_thread")]
async fn auth_type_no_method_id_with_current_returns_session_token() {
use crate::auth::KimiAuth;
let agent = build_minimal_agent_for_tests();
agent.auth_manager.hot_swap(KimiAuth::test_default());
assert!(agent.auth_method_id.load().is_none());
assert!(agent.auth_manager.current().is_some());
assert_eq!(agent.auth_type(), kigi_chat_state::AuthType::SessionToken,);
}
/// Deployment-key / managed-config user: `XAI_API_KEY` resolves and the kill
/// switch is off, so a dead `cached_token` MUST fall through to `xai.api_key`
/// (no browser). This is the exact regression the fallthrough fixes.
#[tokio::test(flavor = "current_thread")]
#[serial_test::serial]
async fn cached_token_fallthrough_prefers_api_key_for_deployment_key() {
use crate::agent::auth_method::{XAI_API_KEY_ENV_VAR, XAI_API_KEY_METHOD_ID};
use kigi_test_support::EnvGuard;
let _lockdown = EnvGuard::unset("KIGI_DISABLE_API_KEY_AUTH");
let _key = EnvGuard::set(XAI_API_KEY_ENV_VAR, "test-deployment-key");
let agent = build_minimal_agent_for_tests();
assert_eq!(
agent.cached_token_fallthrough_method_id().0.as_ref(),
XAI_API_KEY_METHOD_ID,
"deployment-key user (XAI_API_KEY set, no kill switch) must fall \
through to xai.api_key on a dead cached_token -- not interactive login",
);
}
/// No advertiseable credentials at all (no env key, no kill switch): the user
/// genuinely needs to log in, so the fallthrough is interactive `kimi-code`.
#[tokio::test(flavor = "current_thread")]
#[serial_test::serial]
async fn cached_token_fallthrough_falls_to_kigi_com_without_credentials() {
use crate::agent::auth_method::{
HOUSE_API_KEY_ENV_VAR, KIMI_CODE_METHOD_ID, LEGACY_XAI_API_KEY_ENV_VAR,
XAI_API_KEY_ENV_VAR,
};
use kigi_test_support::EnvGuard;
let _lockdown = EnvGuard::unset("KIGI_DISABLE_API_KEY_AUTH");
let _house = EnvGuard::unset(HOUSE_API_KEY_ENV_VAR);
let _new = EnvGuard::unset(XAI_API_KEY_ENV_VAR);
let _legacy = EnvGuard::unset(LEGACY_XAI_API_KEY_ENV_VAR);
let agent = build_minimal_agent_for_tests();
assert_eq!(
agent.cached_token_fallthrough_method_id().0.as_ref(),
KIMI_CODE_METHOD_ID,
"no API-key creds and no kill switch -> interactive kimi.com login",
);
}
/// `parse_session_kind` routes `session/load` to the gateway Chat path vs. the
/// disk-backed Build path. Anything but an explicit `kind: "chat"` is Build.
#[test]
fn parse_session_kind_matrix() {
use crate::session::unified_list::SessionKind;
use serde_json::json;
let cases: &[(&str, serde_json::Value, SessionKind)] = &[
(
"chat",
json!({ "kigi/session" : { "kind" : "chat" } }),
SessionKind::Chat,
),
(
"build",
json!({ "kigi/session" : { "kind" : "build" } }),
SessionKind::Build,
),
(
"chat_malformed_sibling",
json!({ "kigi/session" : { "kind" : "chat", "facets" : "not-a-map" } }),
SessionKind::Chat,
),
(
"unknown_kind",
json!({ "kigi/session" : { "kind" : "frob" } }),
SessionKind::Build,
),
("absent", json!({}), SessionKind::Build),
];
for (label, meta, expected) in cases {
assert_eq!(parse_session_kind(meta.as_object()), *expected, "[{label}]");
}
assert_eq!(parse_session_kind(None), SessionKind::Build, "[none]");
}
#[test]
fn chat_initial_model_matrix() {
let cases: &[(&str, bool, Option<&str>, Option<&str>)] = &[
("chat_with_model", true, Some("kigi-4.5"), Some("kigi-4.5")),
("chat_without_model", true, None, None),
("build_with_model", false, Some("kigi-4.5"), None),
("build_without_model", false, None, None),
];
for (label, is_chat_kind, custom_model_id, expected) in cases {
assert_eq!(
chat_initial_model(*is_chat_kind, *custom_model_id).as_deref(),
*expected,
"[{label}]"
);
}
}
#[test]
fn chat_new_session_model_state_matrix() {
fn state_with(current: &str, available: &[&str]) -> acp::SessionModelState {
acp::SessionModelState::new(
acp::ModelId::new(current.to_owned()),
available
.iter()
.map(|id| {
acp::ModelInfo::new(acp::ModelId::new((*id).to_owned()), (*id).to_owned())
})
.collect(),
)
}
let cases: &[(&str, acp::SessionModelState, Option<&str>, &str)] = &[
(
"requested_in_catalog",
state_with("auto", &["auto", "kigi-4"]),
Some("kigi-4"),
"kigi-4",
),
(
"no_request_keeps_catalog_default",
state_with("auto", &["auto", "kigi-4"]),
None,
"auto",
),
(
"requested_not_in_catalog",
state_with("auto", &["auto"]),
Some("kigi-4.5"),
"kigi-4.5",
),
(
"requested_with_empty_catalog",
state_with("", &[]),
Some("kigi-4"),
"kigi-4",
),
];
for (label, state, requested, expected) in cases {
let out = chat_new_session_model_state(state.clone(), requested.map(str::to_owned));
assert_eq!(out.current_model_id.0.as_ref(), *expected, "[{label}]");
assert_eq!(
out.available_models.len(),
state.available_models.len(),
"[{label}] override must not mutate the catalog"
);
}
}
/// Session-actor tests use `tokio::task::spawn_local`, so run the body
/// inside a current-thread `LocalSet`.
fn run_local_for_bridge_test<F, Fut, T>(body: F) -> T
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = T>,
{
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime must build");
let local = tokio::task::LocalSet::new();
local.block_on(&rt, body())
}
#[test]
fn chat_session_spawn_options_matches_thin_profile() {
let sid = acp::SessionId::new(std::sync::Arc::from("00000000-0000-0000-0000-000000000099"));
let cwd = kigi_paths::AbsPathBuf::new(std::env::temp_dir()).expect("temp cwd");
let opts = chat_session_spawn_options(
SessionInfo {
id: sid,
cwd: cwd.as_str().to_owned(),
},
cwd,
None,
None,
acp::ModelId::new(std::sync::Arc::from("test-model")),
false,
);
assert!(opts.mcp_servers.is_empty());
assert!(opts.initial_client_mcp_servers.is_empty());
assert!(!opts.client_code_nav_enabled);
assert!(!opts.client_terminal);
assert!(!opts.client_fs_read);
assert!(!opts.client_fs_write);
assert!(opts.chat_history.is_empty());
assert!(!opts.session_auto_mode);
assert!(
opts.persistence.is_noop(),
"K10 thin profile must use PersistenceHandle::noop()"
);
}
/// Without a bridge, `ext_method` falls through to the unchanged local
/// dispatch (`rewind::handle`), which reports the missing session — proving
/// the routing hook is skipped in local mode.
#[test]
fn ext_method_rewind_uses_local_dispatch_without_bridge() {
use acp::Agent as _;
run_local_for_bridge_test(|| async {
let agent = build_minimal_agent_for_tests();
let params = serde_json::json!({ "sessionId" : "sess-local" });
let err = agent
.ext_method(acp::ExtRequest::new(
"kigi/rewind/points",
std::sync::Arc::from(serde_json::value::to_raw_value(&params).unwrap()),
))
.await
.expect_err("local rewind with no session must error");
assert_eq!(err.code, acp::Error::resource_not_found(None).code);
});
}
#[test]
fn cancel_does_not_forward_to_bridge_in_local_mode() {
use crate::session::SessionCommand;
use acp::Agent as _;
run_local_for_bridge_test(|| async {
let agent = build_minimal_agent_for_tests();
let sid = acp::SessionId::new("sess-cancel-local");
let (handle, _tx, mut cmd_rx) = make_live_session_handle(&sid, None);
agent.sessions.borrow_mut().insert(sid.clone(), handle);
agent
.cancel(acp::CancelNotification::new(sid.clone()))
.await
.expect("cancel must succeed");
let mut saw_local_cancel = false;
while let Ok(cmd) = cmd_rx.try_recv() {
if let SessionCommand::Cancel { .. } = cmd {
saw_local_cancel = true;
}
}
assert!(
saw_local_cancel,
"local-mode cancel dispatches the local SessionCommand::Cancel with no bridge attached"
);
});
}
use crate::session::SessionCommand as TestSessionCommand;
/// Build a session handle wired to a *live* command channel. Returns the
/// handle (move into `sessions`) plus a probe `cmd_tx`/`cmd_rx` so a test
/// can observe what the agent sends to the actor and prove the channel is
/// live.
fn make_live_session_handle(
sid: &acp::SessionId,
running_prompt: Option<&str>,
) -> (
crate::session::SessionHandle,
tokio::sync::mpsc::UnboundedSender<TestSessionCommand>,
tokio::sync::mpsc::UnboundedReceiver<TestSessionCommand>,
) {
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
let mut handle = make_test_handle("test-model", false, Some("kigi-tui"));
handle.cmd_tx = cmd_tx.clone();
handle.info = crate::session::info::Info {
id: sid.clone(),
cwd: "/tmp".to_string(),
};
if let Some(pid) = running_prompt {
*handle.current_prompt_id.lock().unwrap() = Some(pid.to_string());
}
(handle, cmd_tx, cmd_rx)
}
/// Spawn a minimal fake session actor on the `LocalSet` that answers
/// `SessionCommand::IsBusy` with `busy` and forwards every other command to
/// the returned receiver so a test can assert on them (e.g. `Shutdown`).
fn spawn_fake_actor(
mut cmd_rx: tokio::sync::mpsc::UnboundedReceiver<TestSessionCommand>,
busy: bool,
) -> tokio::sync::mpsc::UnboundedReceiver<TestSessionCommand> {
let (observed_tx, observed_rx) = tokio::sync::mpsc::unbounded_channel();
tokio::task::spawn_local(async move {
while let Some(cmd) = cmd_rx.recv().await {
match cmd {
TestSessionCommand::IsBusy { respond_to } => {
let _ = respond_to.send(busy);
}
other => {
let _ = observed_tx.send(other);
}
}
}
});
observed_rx
}
/// Drive `kigi/internal/evict_sessions` through the real `ext_notification`
/// handler path (not the internal helper) — matches how the leader server
/// signals a client disconnect.
async fn drive_disconnect(agent: &MvpAgent, sid: &acp::SessionId) {
drive_disconnect_many(agent, &[sid]).await;
}
/// Like `drive_disconnect`, but evicts several sessions in a single
/// `kigi/internal/evict_sessions` notification — the realistic shape of a
/// real client disconnect, and the path that exercises `handle_evict_sessions`'
/// concurrent `join_all` check pass followed by the sequential act pass.
async fn drive_disconnect_many(agent: &MvpAgent, sids: &[&acp::SessionId]) {
use acp::Agent as _;
let ids: Vec<&str> = sids.iter().map(|s| s.0.as_ref()).collect();
let params = serde_json::json!({ "sessionIds" : ids });
let raw = serde_json::value::to_raw_value(&params).unwrap();
agent
.ext_notification(acp::ExtNotification::new(
"kigi/internal/evict_sessions",
raw.into(),
))
.await
.expect("evict_sessions notification must be handled");
}
/// Drive `kigi/session/close` through the real `ext_method` dispatch
/// (`ext_method` → `handlers::session::handle` → `handle_session_close`),
/// exercising the exact production path that finalizes the replica.
async fn drive_close(agent: &MvpAgent, session_id: &str) -> Result<acp::ExtResponse, acp::Error> {
use acp::Agent as _;
let params = serde_json::json!({ "sessionId" : session_id });
let raw = serde_json::value::to_raw_value(&params).unwrap();
agent
.ext_method(acp::ExtRequest::new(
"kigi/session/close",
std::sync::Arc::from(raw),
))
.await
}
/// No-evict keystone: a client disconnecting mid-turn must NOT destroy the
/// session. The actor stays resident, no `Shutdown` is sent, the resident
/// session's command channel still **delivers** commands (so a reconnecting
/// `session/load` can keep driving the turn), and `finalize()` is NOT called
/// on a mere disconnect.
#[test]
fn disconnect_keeps_live_session_resident_without_finalize() {
run_local_for_bridge_test(|| async {
let agent = build_minimal_agent_for_tests();
let sid = acp::SessionId::new("sess-live");
let (_cmd_tx, mut cmd_rx) = {
let (handle, tx, rx) = make_live_session_handle(&sid, Some("turn-1"));
agent.sessions.borrow_mut().insert(sid.clone(), handle);
(tx, rx)
};
drive_disconnect(&agent, &sid).await;
assert!(
agent.sessions.borrow().contains_key(&sid),
"live session must stay resident across client disconnect"
);
assert!(
matches!(
cmd_rx.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
),
"no command may be sent to a session kept resident with live work"
);
let resident = agent
.sessions
.borrow()
.get(&sid)
.cloned()
.expect("session must still be resident");
resident
.cmd_tx
.send(TestSessionCommand::ResetPermissionState)
.expect("resident session channel must accept commands post-disconnect");
assert!(
matches!(
cmd_rx.try_recv(),
Ok(TestSessionCommand::ResetPermissionState)
),
"the resident session's receiver must observe the delivered command"
);
assert!(
agent.finalize_spy.borrow().is_empty(),
"finalize() must NOT fire on client disconnect"
);
assert_eq!(
agent.session_live_state_for(&sid),
Some(SessionLiveState::Working),
"a kept-resident session with live work is Working"
);
});
}
/// Keep-resident must hold even if the `current_prompt_id` lock is poisoned:
/// an unknown state is treated as "busy" (never unload). Guards against a
/// regression flipping the `unwrap_or(true)` fallback to `false`.
#[test]
fn disconnect_keeps_resident_on_poisoned_lock() {
run_local_for_bridge_test(|| async {
let agent = build_minimal_agent_for_tests();
let sid = acp::SessionId::new("sess-poison");
let (handle, _tx, _rx) = make_live_session_handle(&sid, None);
let poison_target = handle.current_prompt_id.clone();
agent.sessions.borrow_mut().insert(sid.clone(), handle);
let _ = std::thread::spawn(move || {
let _g = poison_target.lock().unwrap();
panic!("poison current_prompt_id");
})
.join();
assert!(
agent
.sessions
.borrow()
.get(&sid)
.unwrap()
.current_prompt_id
.lock()
.is_err(),
"precondition: the lock must be poisoned"
);
drive_disconnect(&agent, &sid).await;
assert!(
agent.sessions.borrow().contains_key(&sid),
"a session with an unknown (poisoned) state must be kept resident"
);
assert_eq!(
agent.session_live_state_for(&sid),
Some(SessionLiveState::Working),
);
});
}
/// Idle-unload stub (memory bound) + supervisor interaction: a *fully idle*
/// session is unloaded to disk on disconnect (actor `Shutdown`, handle
/// dropped) while the `SessionThread` is **retained** for
/// `drain_old_session_thread`. It is not finalized, and once the kept thread
/// finishes the supervisor reaps it as a *clean* exit — never `DeadFailed`.
#[test]
fn disconnect_unloads_idle_session_without_finalize() {
run_local_for_bridge_test(|| async {
let agent = build_minimal_agent_for_tests();
let sid = acp::SessionId::new("sess-idle");
let (handle, _cmd_tx, cmd_rx) = make_live_session_handle(&sid, None);
agent.sessions.borrow_mut().insert(sid.clone(), handle);
let mut observed = spawn_fake_actor(cmd_rx, false);
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
agent.session_threads.borrow_mut().insert(
sid.clone(),
crate::session::SessionThread::from_handle(std::thread::spawn(move || {
let _ = release_rx.recv();
})),
);
agent.ensure_session_supervisor();
drive_disconnect(&agent, &sid).await;
assert!(
!agent.sessions.borrow().contains_key(&sid),
"idle session must be unloaded from the resident map on disconnect"
);
assert!(
agent.session_threads.borrow().contains_key(&sid),
"idle-unload must keep the SessionThread for reconnect drain"
);
let shutdown = tokio::time::timeout(std::time::Duration::from_secs(1), observed.recv())
.await
.expect("idle-unload must send a command within 1s")
.expect("fake actor channel must stay open");
assert!(
matches!(shutdown, TestSessionCommand::Shutdown),
"idle-unload must send SessionCommand::Shutdown"
);
assert!(
agent.finalize_spy.borrow().is_empty(),
"idle-unload on disconnect must NOT finalize the cloud replica"
);
assert_eq!(
agent.session_live_state_for(&sid),
Some(SessionLiveState::Dormant),
"an idle-unloaded session demotes to Dormant"
);
drop(release_tx);
let deadline = tokio::time::Instant::now() + (SESSION_SUPERVISOR_TICK * 6);
while tokio::time::Instant::now() < deadline {
if !agent.session_threads.borrow().contains_key(&sid) {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
assert!(
!agent.session_threads.borrow().contains_key(&sid),
"supervisor must drop the finished kept thread"
);
assert!(
!agent
.roster_delta_spy
.borrow()
.iter()
.any(|(id, st)| id == sid.0.as_ref() && *st == SessionLiveState::DeadFailed),
"a cleanly idle-unloaded session must not be reaped as DeadFailed"
);
assert_eq!(
agent.session_live_state_for(&sid),
None,
"clean-exit sweep must drop the Dormant live-state entry"
);
});
}
/// The `IsBusy` keep-resident path. A between-turns session
/// (`current_prompt_id = None`) whose actor answers `IsBusy = true` (queued
/// inputs at the turn boundary) must be kept resident — NOT unloaded — and
/// must receive no `Shutdown`. This exercises the async round-trip that the
/// sync fast-path tests skip.
#[test]
fn disconnect_keeps_resident_when_actor_reports_busy() {
run_local_for_bridge_test(|| async {
let agent = build_minimal_agent_for_tests();
let sid = acp::SessionId::new("sess-busy");
let (handle, _cmd_tx, cmd_rx) = make_live_session_handle(&sid, None);
agent.sessions.borrow_mut().insert(sid.clone(), handle);
let mut observed = spawn_fake_actor(cmd_rx, true);
drive_disconnect(&agent, &sid).await;
assert!(
agent.sessions.borrow().contains_key(&sid),
"a between-turns session with queued work (IsBusy=true) must stay resident"
);
assert_eq!(
agent.session_live_state_for(&sid),
Some(SessionLiveState::Working),
"an actor-reported-busy session is kept Working"
);
tokio::task::yield_now().await;
assert!(
matches!(
observed.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
),
"a busy session must not be sent Shutdown"
);
});
}
/// A between-turns session whose ONLY outstanding work is a parked
/// `PlanApproval` reverse-request (the resume re-park) must be kept resident on
/// disconnect. The actor answers `IsBusy = false`, so the keep-resident outcome
/// can come ONLY from the parked-approval sync fast path in `session_has_live_work`
/// — deleting that check would let this session unload (mutation-killing).
#[test]
fn disconnect_keeps_resident_when_plan_approval_parked() {
run_local_for_bridge_test(|| async {
let agent = build_minimal_agent_for_tests();
let sid = acp::SessionId::new("sess-plan-parked");
let (handle, _cmd_tx, cmd_rx) = make_live_session_handle(&sid, None);
handle.pending_interactions.lock().unwrap().insert(
"exit-plan-mode-resume".to_string(),
crate::session::pending_interaction::PendingKind::PlanApproval,
);
agent.sessions.borrow_mut().insert(sid.clone(), handle);
let mut observed = spawn_fake_actor(cmd_rx, false);
drive_disconnect(&agent, &sid).await;
assert!(
agent.sessions.borrow().contains_key(&sid),
"a session with a parked plan-approval must stay resident"
);
assert_eq!(
agent.session_live_state_for(&sid),
Some(SessionLiveState::Working),
"a parked-approval session is kept Working"
);
tokio::task::yield_now().await;
assert!(
matches!(
observed.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
),
"a parked-approval session must not be sent Shutdown"
);
});
}
/// Mixed batch in a *single* `kigi/internal/evict_sessions` notification —
/// the realistic disconnect shape and the path that exercises
/// `handle_evict_sessions`' `join_all` two-pass (concurrent `IsBusy` checks,
/// then sequential act). One session's actor reports busy (→ kept resident,
/// `Working`, no `Shutdown`); the other is idle (→ unloaded, `Dormant`,
/// `Shutdown` sent). Each must get its own outcome with no cross-contamination
/// between the concurrent check pass and the sequential act pass.
#[test]
fn disconnect_mixed_batch_keeps_busy_unloads_idle() {
run_local_for_bridge_test(|| async {
let agent = build_minimal_agent_for_tests();
let sid_busy = acp::SessionId::new("sess-batch-busy");
let sid_idle = acp::SessionId::new("sess-batch-idle");
let (busy_handle, _busy_tx, busy_rx) = make_live_session_handle(&sid_busy, None);
let (idle_handle, _idle_tx, idle_rx) = make_live_session_handle(&sid_idle, None);
agent
.sessions
.borrow_mut()
.insert(sid_busy.clone(), busy_handle);
agent
.sessions
.borrow_mut()
.insert(sid_idle.clone(), idle_handle);
let mut busy_observed = spawn_fake_actor(busy_rx, true);
let mut idle_observed = spawn_fake_actor(idle_rx, false);
drive_disconnect_many(&agent, &[&sid_busy, &sid_idle]).await;
assert!(
agent.sessions.borrow().contains_key(&sid_busy),
"the busy session in the batch must stay resident"
);
assert_eq!(
agent.session_live_state_for(&sid_busy),
Some(SessionLiveState::Working),
"the busy session must be Working"
);
assert!(
!agent.sessions.borrow().contains_key(&sid_idle),
"the idle session in the batch must be unloaded"
);
assert_eq!(
agent.session_live_state_for(&sid_idle),
Some(SessionLiveState::Dormant),
"the idle session must be Dormant"
);
let idle_shutdown =
tokio::time::timeout(std::time::Duration::from_secs(1), idle_observed.recv())
.await
.expect("idle session must receive a command within 1s")
.expect("fake actor channel must stay open");
assert!(
matches!(idle_shutdown, TestSessionCommand::Shutdown),
"the idle session must be sent Shutdown"
);
tokio::task::yield_now().await;
assert!(
matches!(
busy_observed.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
),
"the busy session must not be sent Shutdown in a mixed batch"
);
assert!(
agent.finalize_spy.borrow().is_empty(),
"neither batch outcome may finalize on a mere disconnect"
);
});
}
/// The bounded `session_live_state` map does not grow without bound
/// across repeated create/close cycles — every terminal close drops its
/// entry, so the map size stays at the live count, not the cumulative count.
#[test]
fn session_live_state_map_is_bounded_across_cycles() {
run_local_for_bridge_test(|| async {
let agent = build_minimal_agent_for_tests();
for i in 0..50 {
let sid = acp::SessionId::new(format!("sess-cycle-{i}"));
let (handle, _tx, _rx) = make_live_session_handle(&sid, Some("turn"));
agent.sessions.borrow_mut().insert(sid.clone(), handle);
agent.set_session_live_state(&sid, SessionLiveState::IdleResident);
agent.close_session_explicit(&sid);
}
assert_eq!(
agent.session_live_state.borrow().len(),
0,
"terminal closes must leave no residual live-state entries (bounded map)"
);
});
}
/// 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`.)
#[test]
fn explicit_close_finalizes_the_replica() {
run_local_for_bridge_test(|| async {
let agent = build_minimal_agent_for_tests();
let sid = acp::SessionId::new("sess-close");
let (handle, _tx, mut cmd_rx) = make_live_session_handle(&sid, Some("turn-1"));
agent.sessions.borrow_mut().insert(sid.clone(), handle);
drive_close(&agent, "no-such-session")
.await
.expect("close of a missing session must succeed as a no-op");
assert!(
agent.finalize_spy.borrow().is_empty(),
"closing a missing session must NOT finalize"
);
drive_close(&agent, sid.0.as_ref())
.await
.expect("session close must be handled");
assert!(
matches!(cmd_rx.try_recv(), Ok(TestSessionCommand::Shutdown)),
"handle_session_close must send Shutdown to the actor"
);
assert_eq!(
agent.finalize_spy.borrow().as_slice(),
&[sid.0.to_string()],
"explicit close must finalize the cloud replica exactly once"
);
assert!(
!agent.sessions.borrow().contains_key(&sid),
"explicit close removes the session"
);
assert_eq!(
agent.session_live_state_for(&sid),
None,
"terminal removal must drop the live-state entry (bounded map)"
);
assert!(
agent
.roster_delta_spy
.borrow()
.iter()
.any(|(id, st)| id == sid.0.as_ref() && *st == SessionLiveState::Completed),
"explicit close must emit a Completed roster delta"
);
});
}
/// Join-handle supervisor: a *resident* actor that panics is reaped
/// promptly — removed from `sessions`/`session_threads`, demoted to
/// `DeadFailed` (observed via the roster delta, since the live-state entry
/// is dropped on removal), and NOT finalized (the conversation persists).
///
/// Polls in real time (the panic unwinds on a real OS thread, independent of
/// the tokio clock); the reap lands within a small number of supervisor
/// ticks. The injected-panic backtrace on stderr is expected and harmless.
#[test]
fn supervisor_reaps_panicked_resident_actor() {
run_local_for_bridge_test(|| async {
let agent = build_minimal_agent_for_tests();
let sid = acp::SessionId::new("sess-panic");
let (handle, _tx, _rx) = make_live_session_handle(&sid, Some("turn-1"));
agent.sessions.borrow_mut().insert(sid.clone(), handle);
let panic_thread = std::thread::spawn(|| panic!("injected actor panic"));
agent.session_threads.borrow_mut().insert(
sid.clone(),
crate::session::SessionThread::from_handle(panic_thread),
);
agent.set_session_live_state(&sid, SessionLiveState::Working);
agent.ensure_session_supervisor();
let deadline = tokio::time::Instant::now() + (SESSION_SUPERVISOR_TICK * 6);
while tokio::time::Instant::now() < deadline {
if !agent.session_threads.borrow().contains_key(&sid) {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
assert!(
!agent.session_threads.borrow().contains_key(&sid),
"supervisor must reap the dead thread"
);
assert!(
!agent.sessions.borrow().contains_key(&sid),
"reaped session must be removed from the resident map"
);
assert_eq!(
agent.session_live_state_for(&sid),
None,
"terminal removal drops the live-state entry (bounded map)"
);
assert!(
agent
.roster_delta_spy
.borrow()
.iter()
.any(|(id, st)| id == sid.0.as_ref() && *st == SessionLiveState::DeadFailed),
"a reaped resident actor must emit a DeadFailed roster delta"
);
assert!(
agent.finalize_spy.borrow().is_empty(),
"reaping a dead actor must NOT finalize (conversation persists)"
);
});
}
/// `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 {
let agent = build_minimal_agent_for_tests();
assert_eq!(agent.supervisor_spawn_count.get(), 0);
agent.ensure_session_supervisor();
agent.ensure_session_supervisor();
agent.ensure_session_supervisor();
assert_eq!(
agent.supervisor_spawn_count.get(),
1,
"the supervisor task must be spawned at most once"
);
assert!(agent.supervisor_started.get());
});
}
/// After a terminal removal (reap/close drops the live-state entry), a later
/// reload of the same SessionId starts clean at `IdleResident` with no stale
/// terminal state leaking in (ties to the bounded-map fix).
#[test]
fn reload_after_terminal_removal_starts_clean() {
run_local_for_bridge_test(|| async {
let agent = build_minimal_agent_for_tests();
let sid = acp::SessionId::new("sess-reload");
let (handle, _tx, _rx) = make_live_session_handle(&sid, Some("turn-1"));
agent.sessions.borrow_mut().insert(sid.clone(), handle);
agent.close_session_explicit(&sid);
assert_eq!(
agent.session_live_state_for(&sid),
None,
"terminal removal must leave no stale state"
);
let (handle2, _tx2, _rx2) = make_live_session_handle(&sid, None);
agent.sessions.borrow_mut().insert(sid.clone(), handle2);
agent.set_session_live_state(&sid, SessionLiveState::IdleResident);
assert_eq!(
agent.session_live_state_for(&sid),
Some(SessionLiveState::IdleResident),
"a reloaded session must start at IdleResident, not a stale terminal state"
);
});
}
/// Build an agent whose gateway is wired to a live receiver, so a test can
/// observe (and answer) agent→client reverse-requests like the dormant
/// `kigi/folder_trust/request` round-trip.
fn build_agent_with_gateway_rx() -> (
MvpAgent,
tokio::sync::mpsc::UnboundedReceiver<kigi_acp_lib::AcpClientMessage>,
) {
use crate::agent::config::Config as AgentConfig;
use crate::auth::{AuthManager, KimiCodeConfig};
let temp_dir = tempfile::tempdir().unwrap();
let auth_manager =
std::sync::Arc::new(AuthManager::new(temp_dir.path(), KimiCodeConfig::default()));
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let gateway = GatewaySender::new(tx);
let cfg = AgentConfig::default();
let agent = MvpAgent::new(gateway, &cfg, auth_manager, None).expect("valid test config");
(agent, rx)
}
/// A git repo whose only repo-local config is a project `.mcp.json` declaring
/// `projsrv` — so it is untrusted-with-configs, and the project server should
/// reappear after a trust grant.
fn repo_with_project_mcp_server() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
git2::Repository::init(tmp.path()).unwrap();
std::fs::write(
tmp.path().join(".mcp.json"),
r#"{"mcpServers":{"projsrv":{"command":"echo","args":["hi"]}}}"#,
)
.unwrap();
tmp
}
fn folder_trust_on() -> crate::util::config::RemoteSettings {
crate::util::config::RemoteSettings {
folder_trust_enabled: Some(true),
..Default::default()
}
}
/// Pull the next `kigi/folder_trust/request` reverse-request off the gateway and
/// answer it with `outcome`. Returns the request's decoded params.
async fn answer_folder_trust_request(
gw_rx: &mut tokio::sync::mpsc::UnboundedReceiver<kigi_acp_lib::AcpClientMessage>,
outcome: &str,
) -> serde_json::Value {
let msg = tokio::time::timeout(std::time::Duration::from_secs(2), gw_rx.recv())
.await
.expect("trust request must be sent")
.expect("gateway channel open");
let kigi_acp_lib::AcpClientMessage::ExtMethod(args) = msg else {
panic!("expected an ext_method reverse-request, got a different message");
};
assert_eq!(args.request.method.as_ref(), "kigi/folder_trust/request");
let params: serde_json::Value = serde_json::from_str(args.request.params.get()).unwrap();
let resp: acp::ExtResponse = acp::ExtResponse::new(std::sync::Arc::from(
serde_json::value::to_raw_value(&serde_json::json!({ "outcome" : outcome })).unwrap(),
));
let _ = args.response_tx.send(Ok(resp));
params
}
#[test]
#[serial_test::serial]
fn interactive_trust_prompt_grant_reloads_project_mcp() {
use kigi_test_support::EnvGuard;
use kigi_workspace::trust::{TrustStore, workspace_key};
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path());
let _sim = EnvGuard::set(kigi_version::TEST_VERSION_ENV, "0.0-sim");
let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST");
let repo = repo_with_project_mcp_server();
let repo_path = repo.path().to_path_buf();
let remote = folder_trust_on();
run_local_for_bridge_test(|| async {
let (agent, mut gw_rx) = build_agent_with_gateway_rx();
agent.interactive_trust_client.set(true);
crate::agent::folder_trust::resolve_and_record(&repo_path, Some(&remote), false);
assert!(
!crate::agent::folder_trust::project_scope_allowed(&repo_path),
"untrusted-with-configs workspace must gate project scope before the grant"
);
let sid = acp::SessionId::new("sess-trust");
let (mut handle, _tx, mut cmd_rx) = make_live_session_handle(&sid, None);
handle.info.cwd = repo_path.to_string_lossy().to_string();
agent.sessions.borrow_mut().insert(sid.clone(), handle);
agent.maybe_spawn_interactive_trust_prompt(&sid, &repo_path, Some(&remote));
let params = answer_folder_trust_request(&mut gw_rx, "trust").await;
assert!(
params["configKinds"]
.as_array()
.is_some_and(|k| k.iter().any(|v| v == "mcp")),
"request must summarize detected config kinds; got {params}"
);
assert_eq!(
params["sessionId"], "sess-trust",
"trust request must carry the session id for leader routing; got {params}"
);
let mut saw_project_mcp = false;
let mut saw_reload_plugins = false;
let mut saw_reload_hooks = false;
for _ in 0..8 {
match tokio::time::timeout(std::time::Duration::from_secs(2), cmd_rx.recv()).await {
Ok(Some(TestSessionCommand::UpdateMcpServers { mcp_servers, .. })) => {
saw_project_mcp |= mcp_servers
.iter()
.any(|s| crate::session::managed_mcp::mcp_server_name(s) == "projsrv");
}
Ok(Some(TestSessionCommand::ReloadPlugins { .. })) => {
saw_reload_plugins = true;
}
Ok(Some(TestSessionCommand::ReloadHooks)) => saw_reload_hooks = true,
Ok(Some(_other)) => continue,
_ => break,
}
if saw_project_mcp && saw_reload_plugins && saw_reload_hooks {
break;
}
}
assert!(
saw_project_mcp,
"trust grant must reload the session's now-trusted project MCP server"
);
assert!(
saw_reload_plugins,
"trust grant must reload plugins (plugin-contributed hooks/MCP)"
);
assert!(
saw_reload_hooks,
"trust grant must reload the session's own project hooks"
);
assert!(
TrustStore::load().is_trusted(&workspace_key(&repo_path)),
"accepting the prompt must persist the trust grant"
);
assert!(
crate::agent::folder_trust::project_scope_allowed(&repo_path),
"the in-process gate must flip to trusted after the grant"
);
});
}
#[test]
#[serial_test::serial]
fn interactive_trust_prompt_reject_keeps_gated() {
use kigi_test_support::EnvGuard;
use kigi_workspace::trust::{TrustStore, workspace_key};
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path());
let _sim = EnvGuard::set(kigi_version::TEST_VERSION_ENV, "0.0-sim");
let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST");
let repo = repo_with_project_mcp_server();
let repo_path = repo.path().to_path_buf();
let remote = folder_trust_on();
run_local_for_bridge_test(|| async {
let (agent, mut gw_rx) = build_agent_with_gateway_rx();
agent.interactive_trust_client.set(true);
crate::agent::folder_trust::resolve_and_record(&repo_path, Some(&remote), false);
let sid = acp::SessionId::new("sess-reject");
let (mut handle, _tx, mut cmd_rx) = make_live_session_handle(&sid, None);
handle.info.cwd = repo_path.to_string_lossy().to_string();
agent.sessions.borrow_mut().insert(sid.clone(), handle);
agent.maybe_spawn_interactive_trust_prompt(&sid, &repo_path, Some(&remote));
let _ = answer_folder_trust_request(&mut gw_rx, "reject").await;
assert!(
tokio::time::timeout(std::time::Duration::from_millis(300), cmd_rx.recv())
.await
.is_err(),
"rejecting trust must leave the session's project servers gated (no reload)"
);
assert!(
!TrustStore::load().is_trusted(&workspace_key(&repo_path)),
"rejecting trust must leave the store unchanged"
);
assert!(
!crate::agent::folder_trust::project_scope_allowed(&repo_path),
"rejecting trust must keep the workspace gated"
);
});
}
#[test]
#[serial_test::serial]
fn interactive_trust_prompt_dormant_when_feature_off() {
use kigi_test_support::EnvGuard;
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path());
let _sim = EnvGuard::set(kigi_version::TEST_VERSION_ENV, "0.0-sim");
let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST");
let repo = repo_with_project_mcp_server();
let repo_path = repo.path().to_path_buf();
let remote = crate::util::config::RemoteSettings {
folder_trust_enabled: Some(false),
..Default::default()
};
run_local_for_bridge_test(|| async {
let (agent, mut gw_rx) = build_agent_with_gateway_rx();
agent.interactive_trust_client.set(true);
let sid = acp::SessionId::new("sess-dormant");
let (mut handle, _tx, _cmd_rx) = make_live_session_handle(&sid, None);
handle.info.cwd = repo_path.to_string_lossy().to_string();
agent.sessions.borrow_mut().insert(sid.clone(), handle);
agent.maybe_spawn_interactive_trust_prompt(&sid, &repo_path, Some(&remote));
assert!(
tokio::time::timeout(std::time::Duration::from_millis(300), gw_rx.recv())
.await
.is_err(),
"feature off must emit no trust request (dormant)"
);
});
}
#[test]
#[serial_test::serial]
fn interactive_trust_prompt_no_request_without_capability() {
use kigi_test_support::EnvGuard;
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path());
let _sim = EnvGuard::set(kigi_version::TEST_VERSION_ENV, "0.0-sim");
let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST");
let repo = repo_with_project_mcp_server();
let repo_path = repo.path().to_path_buf();
let remote = folder_trust_on();
run_local_for_bridge_test(|| async {
let (agent, mut gw_rx) = build_agent_with_gateway_rx();
assert!(!agent.interactive_trust_client.get());
let sid = acp::SessionId::new("sess-nocap");
let (mut handle, _tx, _cmd_rx) = make_live_session_handle(&sid, None);
handle.info.cwd = repo_path.to_string_lossy().to_string();
agent.sessions.borrow_mut().insert(sid.clone(), handle);
agent.maybe_spawn_interactive_trust_prompt(&sid, &repo_path, Some(&remote));
assert!(
tokio::time::timeout(std::time::Duration::from_millis(300), gw_rx.recv())
.await
.is_err(),
"a client without the capability must get no trust request"
);
});
}
#[test]
#[serial_test::serial]
fn interactive_trust_prompt_client_error_fails_closed() {
use kigi_test_support::EnvGuard;
use kigi_workspace::trust::{TrustStore, workspace_key};
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path());
let _sim = EnvGuard::set(kigi_version::TEST_VERSION_ENV, "0.0-sim");
let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST");
let repo = repo_with_project_mcp_server();
let repo_path = repo.path().to_path_buf();
let remote = folder_trust_on();
run_local_for_bridge_test(|| async {
let (agent, mut gw_rx) = build_agent_with_gateway_rx();
agent.interactive_trust_client.set(true);
crate::agent::folder_trust::resolve_and_record(&repo_path, Some(&remote), false);
let sid = acp::SessionId::new("sess-err");
let (mut handle, _tx, mut cmd_rx) = make_live_session_handle(&sid, None);
handle.info.cwd = repo_path.to_string_lossy().to_string();
agent.sessions.borrow_mut().insert(sid.clone(), handle);
agent.maybe_spawn_interactive_trust_prompt(&sid, &repo_path, Some(&remote));
let msg = tokio::time::timeout(std::time::Duration::from_secs(2), gw_rx.recv())
.await
.expect("trust request must be sent")
.expect("gateway channel open");
assert!(matches!(msg, kigi_acp_lib::AcpClientMessage::ExtMethod(_)));
drop(msg);
assert!(
tokio::time::timeout(std::time::Duration::from_millis(300), cmd_rx.recv())
.await
.is_err(),
"a failed client round-trip must not reload the session"
);
assert!(
!TrustStore::load().is_trusted(&workspace_key(&repo_path)),
"a failed client round-trip must not grant trust"
);
assert!(!crate::agent::folder_trust::project_scope_allowed(
&repo_path
));
});
}
#[test]
#[serial_test::serial]
fn interactive_trust_prompt_dedups_same_workspace() {
use kigi_test_support::EnvGuard;
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path());
let _sim = EnvGuard::set(kigi_version::TEST_VERSION_ENV, "0.0-sim");
let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST");
let repo = repo_with_project_mcp_server();
let repo_path = repo.path().to_path_buf();
let remote = folder_trust_on();
run_local_for_bridge_test(|| async {
let (agent, mut gw_rx) = build_agent_with_gateway_rx();
agent.interactive_trust_client.set(true);
crate::agent::folder_trust::resolve_and_record(&repo_path, Some(&remote), false);
let sid = acp::SessionId::new("sess-dedup");
let (mut handle, _tx, _cmd_rx) = make_live_session_handle(&sid, None);
handle.info.cwd = repo_path.to_string_lossy().to_string();
agent.sessions.borrow_mut().insert(sid.clone(), handle);
agent.maybe_spawn_interactive_trust_prompt(&sid, &repo_path, Some(&remote));
let first = tokio::time::timeout(std::time::Duration::from_secs(2), gw_rx.recv()).await;
assert!(
matches!(first, Ok(Some(kigi_acp_lib::AcpClientMessage::ExtMethod(_)))),
"first prompt for an untrusted workspace must emit a request"
);
agent.maybe_spawn_interactive_trust_prompt(&sid, &repo_path, Some(&remote));
assert!(
tokio::time::timeout(std::time::Duration::from_millis(300), gw_rx.recv())
.await
.is_err(),
"a workspace already prompted this process must not be re-prompted"
);
});
}
/// Which reload commands a session received after a grant.
struct ReloadCmds {
update_mcp: bool,
reload_plugins: bool,
reload_hooks: bool,
mcp_names: Vec<String>,
}
/// Drain a session's command channel for the post-grant reload trio
/// (`UpdateMcpServers` + `ReloadPlugins` + `ReloadHooks`), capturing the merged
/// MCP server names so a test can assert per-cwd reload.
async fn drain_reload_commands(
cmd_rx: &mut tokio::sync::mpsc::UnboundedReceiver<TestSessionCommand>,
) -> ReloadCmds {
let mut out = ReloadCmds {
update_mcp: false,
reload_plugins: false,
reload_hooks: false,
mcp_names: Vec::new(),
};
for _ in 0..8 {
match tokio::time::timeout(std::time::Duration::from_secs(2), cmd_rx.recv()).await {
Ok(Some(TestSessionCommand::UpdateMcpServers { mcp_servers, .. })) => {
out.update_mcp = true;
out.mcp_names = mcp_servers
.iter()
.map(|s| crate::session::managed_mcp::mcp_server_name(s).to_string())
.collect();
}
Ok(Some(TestSessionCommand::ReloadPlugins { .. })) => {
out.reload_plugins = true;
}
Ok(Some(TestSessionCommand::ReloadHooks)) => out.reload_hooks = true,
Ok(Some(_other)) => continue,
_ => break,
}
if out.update_mcp && out.reload_plugins && out.reload_hooks {
break;
}
}
out
}
#[test]
#[serial_test::serial]
fn interactive_trust_prompt_reloads_all_same_workspace_sessions() {
use kigi_test_support::EnvGuard;
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path());
let _sim = EnvGuard::set(kigi_version::TEST_VERSION_ENV, "0.0-sim");
let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST");
let repo = repo_with_project_mcp_server();
let root = repo.path().to_path_buf();
let subdir = root.join("sub");
std::fs::create_dir_all(&subdir).unwrap();
std::fs::write(
subdir.join(".mcp.json"),
r#"{"mcpServers":{"subsrv":{"command":"echo","args":["hi"]}}}"#,
)
.unwrap();
let other = repo_with_project_mcp_server();
let other_path = other.path().to_path_buf();
let remote = folder_trust_on();
run_local_for_bridge_test(|| async {
let (agent, mut gw_rx) = build_agent_with_gateway_rx();
agent.interactive_trust_client.set(true);
crate::agent::folder_trust::resolve_and_record(&root, Some(&remote), false);
let sid_root = acp::SessionId::new("sess-root");
let (mut h_root, _t1, mut rx_root) = make_live_session_handle(&sid_root, None);
h_root.info.cwd = root.to_string_lossy().to_string();
agent.sessions.borrow_mut().insert(sid_root.clone(), h_root);
let sid_sub = acp::SessionId::new("sess-sub");
let (mut h_sub, _t2, mut rx_sub) = make_live_session_handle(&sid_sub, None);
h_sub.info.cwd = subdir.to_string_lossy().to_string();
agent.sessions.borrow_mut().insert(sid_sub.clone(), h_sub);
let sid_other = acp::SessionId::new("sess-other");
let (mut h_other, _t3, mut rx_other) = make_live_session_handle(&sid_other, None);
h_other.info.cwd = other_path.to_string_lossy().to_string();
agent
.sessions
.borrow_mut()
.insert(sid_other.clone(), h_other);
agent.maybe_spawn_interactive_trust_prompt(&sid_root, &root, Some(&remote));
let _ = answer_folder_trust_request(&mut gw_rx, "trust").await;
let root_cmds = drain_reload_commands(&mut rx_root).await;
assert!(
root_cmds.update_mcp && root_cmds.reload_plugins && root_cmds.reload_hooks,
"root session must get UpdateMcpServers + ReloadPlugins + ReloadHooks"
);
let sub_cmds = drain_reload_commands(&mut rx_sub).await;
assert!(
sub_cmds.update_mcp && sub_cmds.reload_plugins && sub_cmds.reload_hooks,
"subdir session must get UpdateMcpServers + ReloadPlugins + ReloadHooks"
);
assert!(
sub_cmds.mcp_names.iter().any(|n| n == "subsrv"),
"subdir session must reload against its own cwd (expect subsrv); got {:?}",
sub_cmds.mcp_names
);
assert!(
tokio::time::timeout(std::time::Duration::from_millis(300), rx_other.recv())
.await
.is_err(),
"a session under a different workspace_key must not be reloaded"
);
});
}
#[test]
#[serial_test::serial]
fn interactive_trust_prompt_reprompts_after_untrust() {
use kigi_test_support::EnvGuard;
use kigi_hooks_plugins_types::HooksAction;
let home = tempfile::tempdir().unwrap();
let _env = EnvGuard::set("KIGI_SHARE_DIR", home.path());
let _sim = EnvGuard::set(kigi_version::TEST_VERSION_ENV, "0.0-sim");
let _flag = EnvGuard::unset("KIGI_FOLDER_TRUST");
let repo = repo_with_project_mcp_server();
let repo_path = repo.path().to_path_buf();
let remote = folder_trust_on();
run_local_for_bridge_test(|| async {
let (agent, mut gw_rx) = build_agent_with_gateway_rx();
agent.interactive_trust_client.set(true);
crate::agent::folder_trust::resolve_and_record(&repo_path, Some(&remote), false);
let sid = acp::SessionId::new("sess-reprompt");
let (mut handle, _tx, _cmd_rx) = make_live_session_handle(&sid, None);
handle.info.cwd = repo_path.to_string_lossy().to_string();
agent.sessions.borrow_mut().insert(sid.clone(), handle);
agent.maybe_spawn_interactive_trust_prompt(&sid, &repo_path, Some(&remote));
assert!(
matches!(
tokio::time::timeout(std::time::Duration::from_secs(2), gw_rx.recv()).await,
Ok(Some(kigi_acp_lib::AcpClientMessage::ExtMethod(_)))
),
"first prompt must emit a request"
);
agent.maybe_spawn_interactive_trust_prompt(&sid, &repo_path, Some(&remote));
assert!(
tokio::time::timeout(std::time::Duration::from_millis(200), gw_rx.recv())
.await
.is_err(),
"a prompted workspace must be suppressed before untrust"
);
let _ = tokio::time::timeout(
std::time::Duration::from_millis(200),
agent.execute_hooks_action(&sid, HooksAction::Untrust),
)
.await;
agent.maybe_spawn_interactive_trust_prompt(&sid, &repo_path, Some(&remote));
assert!(
matches!(
tokio::time::timeout(std::time::Duration::from_secs(2), gw_rx.recv()).await,
Ok(Some(kigi_acp_lib::AcpClientMessage::ExtMethod(_)))
),
"after untrust clears the dedup, the workspace must be promptable again"
);
});
}
mod direct_hub_cloud_removed {
use super::super::{DIRECT_HUB_CLOUD_REMOVED_MSG, reject_direct_hub_cloud_meta};
fn assert_direct_hub_error(err: agent_client_protocol::Error) {
assert_eq!(
err.data.as_ref(),
Some(&serde_json::Value::String(
DIRECT_HUB_CLOUD_REMOVED_MSG.to_string()
)),
"error data must be the exact D8 message, got: {err:?}"
);
assert_eq!(
err.code,
agent_client_protocol::ErrorCode::InvalidParams,
"must be invalid_params, got: {err:?}"
);
}
#[test]
fn cloud_server_id_meta_is_hard_error() {
let meta = serde_json::json!({ "kigi/cloud_server_id" : "srv-123" });
let err = reject_direct_hub_cloud_meta(meta.as_object()).expect_err("must reject");
assert_direct_hub_error(err);
}
#[test]
fn cloud_server_id_null_still_present_is_hard_error() {
let meta = serde_json::json!({ "kigi/cloud_server_id" : null });
let err = reject_direct_hub_cloud_meta(meta.as_object()).expect_err("must reject");
assert_direct_hub_error(err);
}
#[test]
fn cloud_server_id_with_gateway_meta_still_hard_error() {
let meta = serde_json::json!(
{ "kigi/cloud_server_id" : "srv-legacy", "envId" : "env-1",
"kigi/cloud_existing_workspace" : { "server_id" : "ws-1", "cwd" :
"/workspace" } }
);
let err = reject_direct_hub_cloud_meta(meta.as_object()).expect_err("Direct stamp wins");
assert_direct_hub_error(err);
}
#[test]
fn absent_or_empty_meta_ok() {
assert!(reject_direct_hub_cloud_meta(None).is_ok());
assert!(reject_direct_hub_cloud_meta(serde_json::json!({}).as_object()).is_ok());
assert!(
reject_direct_hub_cloud_meta(serde_json::json!({ "envId" : "env-1" }).as_object())
.is_ok()
);
assert!(
reject_direct_hub_cloud_meta(
serde_json::json!({
"kigi/cloud_existing_workspace" : { "server_id" : "ws-1", "cwd" :
"/workspace" } })
.as_object()
)
.is_ok()
);
}
}
mod soft_default_settings_emit {
use super::*;
#[tokio::test]
async fn emit_settings_update_carries_permission_mode_from_cfg() {
use crate::agent::config::Config as AgentConfig;
use crate::auth::{AuthManager, KimiCodeConfig};
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let temp_dir = tempfile::tempdir().unwrap();
let auth_manager = std::sync::Arc::new(AuthManager::new(
temp_dir.path(),
KimiCodeConfig::default(),
));
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let gateway = GatewaySender::new(tx);
let cfg = AgentConfig {
remote_settings: Some(crate::util::config::RemoteSettings {
permission_mode: Some("always-approve".into()),
..Default::default()
}),
..Default::default()
};
let agent =
MvpAgent::new(gateway, &cfg, auth_manager, None).expect("valid test config");
agent.cfg.borrow_mut().remote_settings = cfg.remote_settings.clone();
agent.emit_settings_update_notification();
let msg = rx.try_recv().expect("settings/update must be emitted");
let kigi_acp_lib::AcpClientMessage::ExtNotification(args) = msg else {
panic!("expected ExtNotification, got {msg:?}");
};
assert_eq!(args.request.method.as_ref(), "kigi/settings/update");
let params: serde_json::Value =
serde_json::from_str(args.request.params.get()).expect("parse params");
assert_eq!(
params.get("permission_mode").and_then(|v| v.as_str()),
Some("always-approve"),
"post-auth emit must carry remote permission_mode for first session"
);
let _ = args.response_tx.send(Ok(()));
})
.await;
}
}