#![cfg_attr(rustfmt, rustfmt::skip)] use super::*; use crate::test_support::lsp_runtime::{ DummyLspDispatch, ctx_with_toggle, make_request, test_gateway, }; #[test] fn normalize_forked_context_strips_project_layout() { use kigi_sampling_types::conversation::ConversationItem; let big_layout = "\nline1\nline2\nline3\n"; let items = vec![ ConversationItem::system("sys"), ConversationItem::user(big_layout), ConversationItem::assistant("ack"), ]; let (conv, _) = kigi_subagent_resolution::context::normalize_forked_context( items, ); if let ConversationItem::User(u) = &conv[1] { let text = u .content .iter() .filter_map(|p| match p { kigi_sampling_types::conversation::ContentPart::Text { text } => { Some(text.as_ref()) } _ => None, }) .collect::(); assert!( ! text.contains(""), "project_layout tag should be stripped" ); assert!(! text.contains("line1"), "layout content should be removed"); } else { panic!("expected User at position 1"); } } #[test] fn normalize_forked_context_consecutive_users() { use kigi_sampling_types::conversation::ConversationItem; let items = vec![ ConversationItem::system("sys"), ConversationItem::user("prefix"), ConversationItem::user("query"), ConversationItem::assistant("response"), ]; let (conv, prefix_len) = kigi_subagent_resolution::context::normalize_forked_context( items, ); assert_eq!(prefix_len, 2); if let ConversationItem::User(u) = &conv[1] { let text = u .content .iter() .filter_map(|p| match p { kigi_sampling_types::conversation::ContentPart::Text { text } => { Some(text.as_ref()) } _ => None, }) .collect::(); assert!(text.contains("[User]: prefix"), "should include first user msg"); assert!(text.contains("[User]: query"), "should include second user msg"); assert!(text.contains("[Assistant]: response"), "should include assistant"); } else { panic!("expected User at position 1"); } } /// End-to-end test: after normalization + system prompt replacement, /// the conversation shape is [System(child's), BackgroundContext]. /// Then the Prompt command appends the task as [2], giving: /// [System(child's), BackgroundContext, Task]. #[test] fn end_to_end_normalized_conversation_shape() { use kigi_sampling_types::conversation::ConversationItem; let parent_conv = vec![ ConversationItem::system("parent system prompt"), ConversationItem::user("user prefix with project info"), ConversationItem::user("implement quicksort"), ConversationItem::assistant("here is quicksort"), ]; let (mut conv, prefix_len) = kigi_subagent_resolution::context::normalize_forked_context( parent_conv, ); assert_eq!(prefix_len, 2); assert_eq!(conv.len(), 2); if let ConversationItem::System(ref mut sys) = conv[0] { sys.content = "child system prompt with tool guidance".into(); } else { panic!("expected System at position 0"); } if let ConversationItem::System(ref sys) = conv[0] { assert_eq!(sys.content.as_ref(), "child system prompt with tool guidance"); } if let ConversationItem::User(ref u) = conv[1] { let text = u .content .iter() .filter_map(|p| match p { kigi_sampling_types::conversation::ContentPart::Text { text } => { Some(text.as_ref()) } _ => None, }) .collect::(); assert!(text.contains("")); assert!(text.contains("[User]: implement quicksort")); } else { panic!("expected User (background) at position 1"); } let task = "implement bubble sort in Rust"; conv.push(ConversationItem::user(task)); assert_eq!(conv.len(), 3); assert!(matches!(conv[0], ConversationItem::System(_))); assert!(matches!(conv[1], ConversationItem::User(_))); assert!(matches!(conv[2], ConversationItem::User(_))); if let ConversationItem::User(ref u) = conv[2] { let text = u .content .iter() .filter_map(|p| match p { kigi_sampling_types::conversation::ContentPart::Text { text } => { Some(text.as_ref()) } _ => None, }) .collect::(); assert_eq!(text, task, "last user message should be the task"); } assert_eq!(prefix_len, 2); assert!(prefix_len < conv.len(), "prefix should not cover the task"); } /// Verify that the task prompt (not background context) would be the /// cached prompt text in the session pipeline. #[test] fn cached_prompt_text_is_task_not_background() { use kigi_sampling_types::conversation::ConversationItem; let parent_conv = vec![ ConversationItem::system("sys"), ConversationItem::user("parent query"), ConversationItem::assistant("parent answer"), ]; let (conv, _) = kigi_subagent_resolution::context::normalize_forked_context( parent_conv, ); let background_text = if let ConversationItem::User(ref u) = conv[1] { u.content .iter() .filter_map(|p| match p { kigi_sampling_types::conversation::ContentPart::Text { text } => { Some(text.as_ref()) } _ => None, }) .collect::() } else { String::new() }; let task_prompt = "fix the failing test in src/lib.rs"; assert_ne!(task_prompt, background_text.trim()); assert!( ! background_text.contains(task_prompt), "background should not contain the task prompt" ); assert!( background_text.contains(""), "background should be the inherited context" ); } /// Verify extract_last_real_user_query would return the task. #[test] fn last_user_message_is_task_after_normalization() { use kigi_sampling_types::conversation::ConversationItem; let parent_conv = vec![ ConversationItem::system("sys"), ConversationItem::user("parent context"), ConversationItem::assistant("ack"), ]; let (mut conv, _) = kigi_subagent_resolution::context::normalize_forked_context( parent_conv, ); let task = "deploy the service to staging"; conv.push(ConversationItem::user(task)); let last_user = conv .iter() .rev() .find_map(|item| { if let ConversationItem::User(u) = item { let text: String = u .content .iter() .filter_map(|p| match p { kigi_sampling_types::conversation::ContentPart::Text { text, } => Some(text.as_ref()), _ => None, }) .collect(); Some(text) } else { None } }); assert_eq!( last_user.as_deref(), Some(task), "last user message should be the task, not background context" ); } /// Simulate compaction preserving the inherited prefix. /// The compactor produces [System, UserPrefix, Summary, ...]. The prefix /// preservation logic takes [System, BackgroundContext] from the original /// conversation and skips the compacted System, resulting in: /// [System(inherited), BackgroundContext(inherited), UserPrefix(compacted), Summary, ...] #[test] fn compaction_preserves_inherited_prefix() { use kigi_sampling_types::conversation::ConversationItem; let parent_conv = vec![ ConversationItem::system("parent sys"), ConversationItem::user("parent question"), ConversationItem::assistant("parent answer"), ]; let (conv, prefix_len) = kigi_subagent_resolution::context::normalize_forked_context( parent_conv, ); assert_eq!(prefix_len, 2); let mut full_conv = conv; if let ConversationItem::System(ref mut sys) = full_conv[0] { sys.content = "child system prompt".into(); } full_conv.push(ConversationItem::user("do the thing")); full_conv.push(ConversationItem::assistant("done")); let compacted_history = vec![ ConversationItem::system("fresh system prompt after compaction"), ConversationItem::user("user prefix"), ConversationItem::user("summary of work"), ]; let inherited: Vec<_> = full_conv[..prefix_len].to_vec(); let child_items: Vec<_> = compacted_history .into_iter() .skip_while(|i| matches!(i, ConversationItem::System(_))) .collect(); let mut preserved = inherited; preserved.extend(child_items); assert_eq!(preserved.len(), 4); if let ConversationItem::System(ref sys) = preserved[0] { assert_eq!(sys.content.as_ref(), "child system prompt"); } else { panic!("expected System at [0]"); } if let ConversationItem::User(ref u) = preserved[1] { let text: String = u .content .iter() .filter_map(|p| match p { kigi_sampling_types::conversation::ContentPart::Text { text } => { Some(text.as_ref()) } _ => None, }) .collect::>() .join(""); assert!( text.contains(""), "background context should be preserved across compaction" ); } else { panic!("expected BackgroundContext User at [1]"); } let system_count = preserved .iter() .filter(|i| matches!(i, ConversationItem::System(_))) .count(); assert_eq!(system_count, 1, "should have exactly one System after compaction"); let bg_count = preserved .iter() .filter(|i| { if let ConversationItem::User(u) = i { u.content .iter() .any(|p| { matches!( p, kigi_sampling_types::conversation::ContentPart::Text { text } if text.contains("") ) }) } else { false } }) .count(); assert_eq!( bg_count, 1, "should have exactly one background_context after compaction" ); } /// Verify that compaction with prefix_len=0 (non-forked) passes through unchanged. #[test] fn compaction_no_prefix_passes_through() { use kigi_sampling_types::conversation::ConversationItem; let compacted = vec![ ConversationItem::system("sys"), ConversationItem::user("summary"), ]; let prefix_len: usize = 0; let result = if prefix_len > 0 { unreachable!() } else { compacted.clone() }; assert_eq!(result.len(), 2); assert!(matches!(result[0], ConversationItem::System(_))); } #[test] fn resumable_source_returns_none_for_unknown_id() { let coordinator = SubagentCoordinator::new(); assert!( coordinator.resumable_source_for("unknown", "parent", Path::new("/tmp")) .is_none() ); } #[test] fn resumable_source_returns_none_for_active_subagent() { let coordinator = SubagentCoordinator::new(); assert!(! coordinator.is_active("active-id")); assert!( coordinator.resumable_source_for("active-id", "parent", Path::new("/tmp")) .is_none() ); } #[test] fn resumable_source_returns_info_for_completed_subagent() { let mut coordinator = SubagentCoordinator::new(); coordinator .completed .insert( "sub-resume".to_string(), CompletedSubagent { subagent_id: "sub-resume".into(), parent_session_id: "parent-1".into(), parent_prompt_id: Some("prompt-1".into()), child_session_id: "child-resume".into(), description: "resumable task".into(), subagent_type: "general-purpose".into(), persona: Some("implementer".into()), started_at: std::time::Instant::now(), completed_at: std::time::Instant::now(), result: SubagentResult { success: true, output: "done".into(), subagent_id: "sub-resume".into(), child_session_id: "child-resume".into(), ..Default::default() }, resumed_from: None, child_cwd: "/workspace".into(), worktree_path: Some(PathBuf::from("/tmp/worktree-1")), snapshot_ref: None, effective_model_id: "kigi-3".into(), block_waited: false, explicitly_killed: false, }, ); let info = coordinator .resumable_source_for("sub-resume", "parent-1", Path::new("/tmp")) .expect("should find completed subagent"); assert_eq!(info.subagent_id, "sub-resume"); assert_eq!(info.child_session_id, "child-resume"); assert_eq!(info.child_cwd, "/workspace"); assert_eq!(info.worktree_path.as_deref(), Some(Path::new("/tmp/worktree-1"))); assert_eq!(info.subagent_type, "general-purpose"); assert_eq!(info.persona.as_deref(), Some("implementer")); } #[test] fn resumable_source_survives_move_to_completed_with_metadata() { let mut coordinator = SubagentCoordinator::new(); coordinator .move_to_completed( "sub-moved", "moved task".to_string(), "explore".to_string(), SubagentResult { success: true, output: "found files".into(), subagent_id: "sub-moved".into(), child_session_id: "sub-moved".into(), ..Default::default() }, ); let info = coordinator .resumable_source_for("sub-moved", "", Path::new("/tmp")) .expect("should find moved subagent"); assert_eq!(info.subagent_id, "sub-moved"); assert_eq!(info.child_cwd, ""); assert!(info.worktree_path.is_none()); } #[test] fn resumed_from_field_in_meta_roundtrips() { let meta = SubagentMeta { subagent_id: "sa-resumed".into(), parent_session_id: "parent".into(), child_session_id: "child".into(), subagent_type: "general-purpose".into(), description: "resumed task".into(), prompt: "continue".into(), status: "running".into(), started_at: chrono::Utc::now(), completed_at: None, duration_ms: None, tool_calls: None, turns: None, error: None, effective_context_source: None, context_normalized: false, fork_copy_error: None, persona: None, resumed_from: Some("prev-subagent-id".into()), child_cwd: None, worktree_path: None, snapshot_ref: None, effective_model_id: None, }; let json = serde_json::to_string(&meta).unwrap(); assert!(json.contains("resumed_from")); assert!(json.contains("prev-subagent-id")); let parsed: SubagentMeta = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.resumed_from.as_deref(), Some("prev-subagent-id")); } #[test] fn resumed_from_none_not_serialized_in_meta() { let meta = SubagentMeta { subagent_id: "sa-fresh".into(), parent_session_id: "p".into(), child_session_id: "c".into(), subagent_type: "explore".into(), description: "d".into(), prompt: "p".into(), status: "running".into(), started_at: chrono::Utc::now(), completed_at: None, duration_ms: None, tool_calls: None, turns: None, error: None, effective_context_source: None, context_normalized: false, fork_copy_error: None, persona: None, resumed_from: None, child_cwd: None, worktree_path: None, snapshot_ref: None, effective_model_id: None, }; let json = serde_json::to_string(&meta).unwrap(); assert!(! json.contains("resumed_from"), "None resumed_from should be omitted"); } #[test] fn backward_compat_meta_without_resumed_from() { let json = r#"{ "subagent_id": "sa1", "parent_session_id": "p1", "child_session_id": "c1", "subagent_type": "explore", "description": "d", "prompt": "p", "status": "completed", "started_at": "2026-01-01T00:00:00Z" }"#; let meta: SubagentMeta = serde_json::from_str(json).unwrap(); assert!(meta.resumed_from.is_none()); } #[test] fn snapshot_ref_field_in_meta_roundtrips() { let meta = SubagentMeta { subagent_id: "sa-snap".into(), parent_session_id: "parent".into(), child_session_id: "child".into(), subagent_type: "general-purpose".into(), description: "snapshot task".into(), prompt: "do work".into(), status: "completed".into(), started_at: chrono::Utc::now(), completed_at: Some(chrono::Utc::now()), duration_ms: Some(10), tool_calls: Some(1), turns: Some(1), error: None, effective_context_source: None, context_normalized: false, fork_copy_error: None, persona: None, resumed_from: None, child_cwd: None, worktree_path: Some("/tmp/kigi-wt/sa-snap".into()), snapshot_ref: Some("refs/kigi/subagent-snapshots/sa-snap".into()), effective_model_id: None, }; let json = serde_json::to_string(&meta).unwrap(); assert!(json.contains("snapshot_ref")); assert!(json.contains("refs/kigi/subagent-snapshots/sa-snap")); let parsed: SubagentMeta = serde_json::from_str(&json).unwrap(); assert_eq!( parsed.snapshot_ref.as_deref(), Some("refs/kigi/subagent-snapshots/sa-snap") ); } #[test] fn backward_compat_meta_without_snapshot_ref() { let json = r#"{ "subagent_id": "sa1", "parent_session_id": "p1", "child_session_id": "c1", "subagent_type": "explore", "description": "d", "prompt": "p", "status": "completed", "started_at": "2026-01-01T00:00:00Z" }"#; let meta: SubagentMeta = serde_json::from_str(json).unwrap(); assert!(meta.snapshot_ref.is_none()); } /// Minimal completed-status meta for the snapshot-ref persistence tests. fn snapshot_test_meta(id: &str) -> SubagentMeta { SubagentMeta { subagent_id: id.into(), parent_session_id: "session-A".into(), child_session_id: format!("child-{id}"), subagent_type: "general-purpose".into(), description: "task".into(), prompt: "do work".into(), status: "completed".into(), started_at: chrono::Utc::now(), completed_at: Some(chrono::Utc::now()), duration_ms: Some(1), tool_calls: Some(0), turns: Some(1), error: None, effective_context_source: None, context_normalized: false, fork_copy_error: None, persona: None, resumed_from: None, child_cwd: None, worktree_path: Some("/tmp/kigi-wt/subagent-x".into()), snapshot_ref: None, effective_model_id: None, } } /// The follow-up writer persists `snapshot_ref` into an already-finalized /// meta.json so `resumable_source_for` rehydrates the disposed worktree. #[test] fn update_subagent_meta_snapshot_ref_persists_to_disk() { let dir = tempfile::TempDir::new().unwrap(); assert!(write_subagent_meta(dir.path(), & snapshot_test_meta("sa-write"))); assert!( update_subagent_meta_snapshot_ref(dir.path(), "refs/kigi/subagents/sa-write", "completed"), "persisting the ref into an existing meta.json must report success" ); let data = std::fs::read_to_string(dir.path().join("meta.json")).unwrap(); let reread: SubagentMeta = serde_json::from_str(&data).unwrap(); assert_eq!(reread.snapshot_ref.as_deref(), Some("refs/kigi/subagents/sa-write")); assert_eq!(reread.status, "completed"); assert_eq!(reread.worktree_path.as_deref(), Some("/tmp/kigi-wt/subagent-x")); } /// Missing meta.json → the writer reports failure (it `warn!`s), so the /// completion path keeps the worktree instead of removing it ref-less. #[test] fn update_subagent_meta_snapshot_ref_reports_failure_when_meta_missing() { let dir = tempfile::TempDir::new().unwrap(); assert!( ! update_subagent_meta_snapshot_ref(dir.path(), "refs/kigi/subagents/sa-missing", "completed") ); } /// A stale non-terminal record (e.g. completed-status write failed) is /// promoted to terminal alongside the snapshot_ref, so the durable resume /// fallback accepts it after the worktree is removed. #[test] fn snapshot_ref_write_promotes_nonterminal_status_to_terminal() { let dir = tempfile::TempDir::new().unwrap(); let mut meta = snapshot_test_meta("sa-promote"); meta.status = "running".into(); assert!(write_subagent_meta(dir.path(), & meta)); assert!( update_subagent_meta_snapshot_ref(dir.path(), "refs/kigi/subagents/x", "completed") ); let data = std::fs::read_to_string(dir.path().join("meta.json")).unwrap(); let reread: SubagentMeta = serde_json::from_str(&data).unwrap(); assert_eq!(Some("refs/kigi/subagents/x"), reread.snapshot_ref.as_deref()); assert_eq!("completed", reread.status); } /// The coordinator setter stamps the snapshot ref onto the in-memory /// completed entry so `resume_from` can rehydrate before TTL eviction. #[tokio::test] async fn set_completed_snapshot_ref_updates_in_memory_entry() { let mut coordinator = SubagentCoordinator::new(); coordinator.insert(dummy_tracker("sa-mem", "session-A", "explore", "task")); coordinator .move_to_completed( "sa-mem", "task".into(), "explore".into(), SubagentResult { success: true, subagent_id: "sa-mem".into(), child_session_id: "sa-mem".into(), ..Default::default() }, ); let before = coordinator .resumable_source_for("sa-mem", "session-A", Path::new("/tmp")) .unwrap(); assert!(before.snapshot_ref.is_none()); coordinator .set_completed_snapshot_ref("sa-mem", "refs/kigi/subagents/sa-mem".into()); let after = coordinator .resumable_source_for("sa-mem", "session-A", Path::new("/tmp")) .unwrap(); assert_eq!(after.snapshot_ref.as_deref(), Some("refs/kigi/subagents/sa-mem")); } /// Unknown id is a no-op (entry already TTL-evicted; meta.json still holds it). #[test] fn set_completed_snapshot_ref_unknown_id_is_noop() { let mut coordinator = SubagentCoordinator::new(); coordinator.set_completed_snapshot_ref("ghost", "refs/kigi/subagents/ghost".into()); assert!( coordinator.resumable_source_for("ghost", "session-A", Path::new("/tmp")) .is_none() ); } /// Gate defaults OFF: no config, no remote → snapshotting disabled, so the /// completion path keeps the worktree preserved (no production change). #[test] fn subagent_worktree_snapshot_gate_defaults_off() { let ctx = ctx_with_toggle(std::collections::HashMap::new()); assert!(! ctx.resolve_subagent_worktree_snapshot_enabled()); } /// Remote remote settings value enables the gate when no local override exists. #[test] fn subagent_worktree_snapshot_gate_remote_enables() { let mut ctx = ctx_with_toggle(std::collections::HashMap::new()); ctx.remote_settings = Some(crate::util::config::RemoteSettings { subagent_worktree_snapshot_enabled: Some(true), ..Default::default() }); assert!(ctx.resolve_subagent_worktree_snapshot_enabled()); } /// Local config wins over remote (kill-switch parity with the other gates). #[test] fn subagent_worktree_snapshot_gate_local_overrides_remote() { let mut config = crate::agent::config::Config::default(); config.features.subagent_worktree_snapshot = Some(false); let mut ctx = ctx_with_toggle(std::collections::HashMap::new()); ctx.agent_config = Some(config); ctx.remote_settings = Some(crate::util::config::RemoteSettings { subagent_worktree_snapshot_enabled: Some(true), ..Default::default() }); assert!( ! ctx.resolve_subagent_worktree_snapshot_enabled(), "local [features] subagent_worktree_snapshot=false must override remote enable" ); } /// Local config alone enables the gate (the per-deployment rollout lever). #[test] fn subagent_worktree_snapshot_gate_local_enables() { let mut config = crate::agent::config::Config::default(); config.features.subagent_worktree_snapshot = Some(true); let mut ctx = ctx_with_toggle(std::collections::HashMap::new()); ctx.agent_config = Some(config); assert!(ctx.resolve_subagent_worktree_snapshot_enabled()); } /// Subagent spawns carry concrete ask_user_question timeout params (the /// session-level config follows the child) while bash stays on tool /// defaults. Tier precedence itself is pinned by the resolver's own /// tests; asserting concrete values here would read the host's disk /// layers and flake on configured dev machines. #[test] fn subagent_tool_params_carry_ask_user_question_timeouts() { let ctx = ctx_with_toggle(std::collections::HashMap::new()); let params = ctx.resolve_tool_params_json(); assert!(params.bash.is_none(), "bash must stay on tool defaults"); let ask = params .ask_user_question .expect("subagents must receive resolved ask_user_question params"); assert!(ask.get("timeout_enabled").is_some_and(| v | v.is_boolean())); assert!(ask.get("timeout_secs").is_some_and(| v | v.is_u64())); } /// Seed a coordinator with one completed subagent owned by `session-A`. fn coordinator_with_completed(id: &str) -> SubagentCoordinator { let mut coordinator = SubagentCoordinator::new(); coordinator.insert(dummy_tracker(id, "session-A", "explore", "task")); coordinator .move_to_completed( id, "task".into(), "explore".into(), SubagentResult { success: true, subagent_id: id.into(), child_session_id: id.into(), ..Default::default() }, ); coordinator } /// End-to-end glue: gate ON + a worktree present runs the completion /// sequence (snapshot → persist ref to meta.json AND in-memory → remove) /// and asserts all three post-conditions hold together. #[tokio::test] async fn completion_snapshot_sequence_persists_ref_then_removes_worktree() { kigi_test_utils::require_git!(); use kigi_test_utils::git::{git_commit_all, init_git_repo}; let temp = tempfile::TempDir::new().unwrap(); let repo = temp.path().join("repo"); std::fs::create_dir(&repo).unwrap(); init_git_repo(&repo); std::fs::write(repo.join("tracked.txt"), "original").unwrap(); git_commit_all(&repo, "initial"); let wt = temp.path().join("subagent-glue-1"); kigi_fast_worktree::WorktreeBuilder::new(&repo, &wt) .standalone(true) .create() .unwrap(); std::fs::write(wt.join("tracked.txt"), "edited").unwrap(); let mut config = crate::agent::config::Config::default(); config.features.subagent_worktree_snapshot = Some(true); let mut ctx = ctx_with_toggle(std::collections::HashMap::new()); ctx.agent_config = Some(config); assert!(ctx.resolve_subagent_worktree_snapshot_enabled()); let meta_dir = temp.path().join("meta"); write_subagent_meta(&meta_dir, &snapshot_test_meta("glue-1")); let mut coordinator = coordinator_with_completed("glue-1"); let ref_name = "refs/kigi/subagents/glue-1"; let snapshot_ref = crate::session::worktree::snapshot_subagent_worktree( &wt, &repo, ref_name, ) .await .unwrap(); assert!(update_subagent_meta_snapshot_ref(& meta_dir, & snapshot_ref, "completed")); coordinator.set_completed_snapshot_ref("glue-1", snapshot_ref); crate::session::worktree::remove_subagent_worktree(&wt).await.unwrap(); let data = std::fs::read_to_string(meta_dir.join("meta.json")).unwrap(); let reread: SubagentMeta = serde_json::from_str(&data).unwrap(); assert_eq!(reread.snapshot_ref.as_deref(), Some(ref_name)); let src = coordinator .resumable_source_for("glue-1", "session-A", Path::new("/tmp")) .unwrap(); assert_eq!(src.snapshot_ref.as_deref(), Some(ref_name)); assert!(! wt.exists(), "worktree dir should be removed after the sequence"); } /// With snapshot-dispose on, completion clears the model-facing /// `result.worktree_path` (the dir is removed) while resume still recovers /// the tracker-retained direct `worktree_path` plus the snapshot_ref. #[tokio::test] async fn gate_on_completion_clears_model_facing_worktree_path_but_resume_retains_it() { let wt = PathBuf::from("/tmp/kigi-wt/subagent-disp-1"); let mut coordinator = SubagentCoordinator::new(); let mut tracker = dummy_tracker("disp-1", "session-A", "explore", "task"); tracker.worktree_path = Some(wt.clone()); coordinator.insert(tracker); let mut result = SubagentResult { success: true, subagent_id: "disp-1".into(), child_session_id: "disp-1".into(), worktree_path: Some(wt.to_string_lossy().into_owned()), ..Default::default() }; let worktree_removed = true; if worktree_removed { result.worktree_path = None; } coordinator.move_to_completed("disp-1", "task".into(), "explore".into(), result); coordinator .set_completed_snapshot_ref("disp-1", "refs/kigi/subagents/disp-1".into()); let listed = coordinator.completed.get("disp-1").expect("completed entry"); assert_eq!(None, listed.result.worktree_path); let src = coordinator .resumable_source_for("disp-1", "session-A", Path::new("/tmp")) .unwrap(); assert_eq!(Some(wt), src.worktree_path); assert_eq!(Some("refs/kigi/subagents/disp-1"), src.snapshot_ref.as_deref()); } /// Gate on but the worktree was NOT removed (snapshot/persist/remove failed): /// the model-facing `result.worktree_path` is RETAINED so the parent can still /// locate the preserved dir. #[tokio::test] async fn gate_on_completion_retains_worktree_path_when_not_removed() { let wt = PathBuf::from("/tmp/kigi-wt/subagent-keep-1"); let mut coordinator = SubagentCoordinator::new(); coordinator.insert(dummy_tracker("keep-1", "session-A", "explore", "task")); let mut result = SubagentResult { success: true, subagent_id: "keep-1".into(), child_session_id: "keep-1".into(), worktree_path: Some(wt.to_string_lossy().into_owned()), ..Default::default() }; let worktree_removed = false; if worktree_removed { result.worktree_path = None; } coordinator.move_to_completed("keep-1", "task".into(), "explore".into(), result); let entry = coordinator.completed.get("keep-1").expect("completed entry"); assert_eq!(Some(wt.to_string_lossy().into_owned()), entry.result.worktree_path); } /// Teardown ordering invariant: disposal (snapshot -> persist -> remove) runs /// BEFORE the subagent is made observable, so the first completed-map entry /// already reflects a removed worktree plus a recorded snapshot_ref. #[tokio::test] async fn disposal_completes_before_subagent_is_observable() { kigi_test_utils::require_git!(); use kigi_test_utils::git::{git_commit_all, init_git_repo}; let temp = tempfile::TempDir::new().unwrap(); let repo = temp.path().join("repo"); std::fs::create_dir(&repo).unwrap(); init_git_repo(&repo); std::fs::write(repo.join("tracked.txt"), "original").unwrap(); git_commit_all(&repo, "initial"); let wt = temp.path().join("subagent-order-1"); kigi_fast_worktree::WorktreeBuilder::new(&repo, &wt) .standalone(true) .create() .unwrap(); std::fs::write(wt.join("tracked.txt"), "edited").unwrap(); let meta_dir = temp.path().join("meta"); write_subagent_meta(&meta_dir, &snapshot_test_meta("order-1")); let mut coordinator = SubagentCoordinator::new(); coordinator.insert(dummy_tracker("order-1", "session-A", "explore", "task")); let ref_name = "refs/kigi/subagents/order-1"; let snapshot_ref = crate::session::worktree::snapshot_subagent_worktree( &wt, &repo, ref_name, ) .await .unwrap(); assert!(update_subagent_meta_snapshot_ref(& meta_dir, & snapshot_ref, "completed")); let disposed_snapshot_ref = Some(snapshot_ref); crate::session::worktree::remove_subagent_worktree(&wt).await.unwrap(); assert!(! coordinator.completed.contains_key("order-1")); assert!(! wt.exists(), "worktree must be removed before observability"); coordinator .move_to_completed( "order-1", "task".into(), "explore".into(), SubagentResult { success: true, subagent_id: "order-1".into(), child_session_id: "order-1".into(), ..Default::default() }, ); if let Some(r) = disposed_snapshot_ref { coordinator.set_completed_snapshot_ref("order-1", r); } let entry = coordinator.completed.get("order-1").expect("completed entry"); assert_eq!(Some(ref_name), entry.snapshot_ref.as_deref()); assert!(! wt.exists()); } /// Gate OFF: the completion path snapshots/removes nothing and records no /// ref, so the worktree is preserved for review (no production change). #[tokio::test] async fn completion_gate_off_preserves_and_records_no_ref() { let ctx = ctx_with_toggle(std::collections::HashMap::new()); assert!( ! ctx.resolve_subagent_worktree_snapshot_enabled(), "default gate must be off" ); let coordinator = coordinator_with_completed("glue-off"); let src = coordinator .resumable_source_for("glue-off", "session-A", Path::new("/tmp")) .unwrap(); assert!(src.snapshot_ref.is_none(), "gate off must not record a snapshot ref"); } #[test] fn initial_context_source_resumed_variant() { let source = InitialContextSource::Resumed; assert!(matches!(source, InitialContextSource::Resumed)); assert_ne!(source, InitialContextSource::New); } /// Resume must preserve only the System head (`Some(1)`) while passing the full /// transcript through intact — a whole-transcript prefix is what pinned compaction. #[test] fn resume_initial_context_preserves_head_only() { use kigi_sampling_types::conversation::ConversationItem; let mut conversation = vec![ConversationItem::system("sys")]; for i in 0..8 { conversation.push(ConversationItem::user(format!("u{i}"))); conversation.push(ConversationItem::assistant(format!("a{i}"))); } let original_len = conversation.len(); let ctx = resume_initial_context(conversation); assert_eq!(ctx.source, InitialContextSource::Resumed); assert!(ctx.copy_error.is_none()); assert_eq!( ctx.prefix_len, Some(1), "resume preserves only the System head, not the full transcript" ); assert_eq!(ctx.conversation.len(), original_len, "transcript preserved intact"); } #[test] fn resume_prefix_len_is_system_head_only() { use kigi_sampling_types::conversation::ConversationItem; let mut conversation = vec![ConversationItem::system("sys")]; for i in 0..6 { conversation.push(ConversationItem::user(format!("u{i}"))); conversation.push(ConversationItem::assistant(format!("a{i}"))); } assert_eq!(resume_inherited_prefix_len(& conversation), 1); } #[test] fn resume_prefix_len_is_zero_without_system_head() { use kigi_sampling_types::conversation::ConversationItem; let conversation = vec![ ConversationItem::user("task"), ConversationItem::assistant("done"), ]; assert_eq!(resume_inherited_prefix_len(& conversation), 0); } #[test] fn resume_prefix_len_counts_consecutive_system_head() { use kigi_sampling_types::conversation::ConversationItem; let conversation = vec![ ConversationItem::system("sys a"), ConversationItem::system("sys b"), ConversationItem::user("work"), ]; assert_eq!(resume_inherited_prefix_len(& conversation), 2); } #[test] fn resume_source_worktree_reuse() { let source_with_worktree = ResumeSourceData { subagent_id: "sub-wt".into(), child_session_id: "child-wt".into(), child_cwd: "/tmp/worktree".into(), worktree_path: Some( PathBuf::from("/home/user/.kigi/worktrees/myrepo/subagent-sub-wt"), ), snapshot_ref: None, subagent_type: "general-purpose".into(), persona: None, model_id: None, }; let worktree = source_with_worktree.worktree_path.clone(); assert_eq!( worktree.as_deref(), Some(Path::new("/home/user/.kigi/worktrees/myrepo/subagent-sub-wt",)), "should reuse source worktree" ); let source_without_worktree = ResumeSourceData { subagent_id: "sub-no-wt".into(), child_session_id: "child-no-wt".into(), child_cwd: "/workspace".into(), worktree_path: None, snapshot_ref: None, subagent_type: "general-purpose".into(), persona: None, model_id: None, }; assert!(source_without_worktree.worktree_path.is_none(), "no worktree to reuse"); } #[test] fn resolve_child_cwd_uses_override_when_no_worktree() { let parent = PathBuf::from("/parent/workspace"); let result = resolve_child_cwd(None, Some("/target/dir"), &parent); assert_eq!(result, PathBuf::from("/target/dir")); } #[test] fn resolve_child_cwd_worktree_takes_precedence_over_override() { let parent = PathBuf::from("/parent/workspace"); let worktree = Path::new("/worktree/path"); let result = resolve_child_cwd(Some(worktree), Some("/target/dir"), &parent); assert_eq!(result, PathBuf::from(worktree)); } #[test] fn resolve_child_cwd_falls_back_to_parent_when_no_overrides() { let parent = PathBuf::from("/parent/workspace"); let result = resolve_child_cwd(None, None, &parent); assert_eq!(result, parent); } #[test] fn resolve_child_cwd_empty_override_falls_back_to_parent() { let parent = PathBuf::from("/parent/workspace"); let result = resolve_child_cwd(None, Some(""), &parent); assert_eq!(result, parent); } #[test] fn resume_inherited_cwd_requires_existing_non_worktree_dir() { let dir = tempfile::TempDir::new().unwrap(); let existing = dir.path().to_string_lossy().into_owned(); let present = ResumeSourceData { subagent_id: "sub-present".into(), child_session_id: "child-present".into(), child_cwd: existing.clone(), worktree_path: None, snapshot_ref: None, subagent_type: "general-purpose".into(), persona: None, model_id: None, }; assert_eq!(resume_inherited_cwd(Some(& present)), Some(existing.as_str())); let missing = ResumeSourceData { child_cwd: "/no/such/dir/kigi-missing".into(), ..present.clone() }; assert_eq!(resume_inherited_cwd(Some(& missing)), None); let worktree_source = ResumeSourceData { child_cwd: existing.clone(), worktree_path: Some(dir.path().to_path_buf()), ..present.clone() }; assert_eq!(resume_inherited_cwd(Some(& worktree_source)), None); assert_eq!(resume_inherited_cwd(None), None); } #[test] fn select_override_cwd_resume_never_falls_through_to_request_cwd() { let source = ResumeSourceData { subagent_id: "sub-wt".into(), child_session_id: "child-wt".into(), child_cwd: "/tmp/whatever".into(), worktree_path: Some( PathBuf::from("/home/user/.kigi/worktrees/repo/subagent-sub-wt"), ), snapshot_ref: None, subagent_type: "general-purpose".into(), persona: None, model_id: None, }; assert_eq!(select_override_cwd(Some(& source), Some("/x")), None); } #[test] fn select_override_cwd_fresh_spawn_uses_request_cwd() { assert_eq!(select_override_cwd(None, Some("/x")), Some("/x")); } #[test] fn resumable_source_rejects_cross_session_lookup() { let mut coordinator = SubagentCoordinator::new(); coordinator .completed .insert( "sub-other".to_string(), CompletedSubagent { subagent_id: "sub-other".into(), parent_session_id: "session-A".into(), parent_prompt_id: None, child_session_id: "child-other".into(), description: "other task".into(), subagent_type: "explore".into(), persona: None, started_at: std::time::Instant::now(), completed_at: std::time::Instant::now(), result: SubagentResult { success: true, ..Default::default() }, resumed_from: None, child_cwd: "/workspace".into(), worktree_path: None, snapshot_ref: None, effective_model_id: String::new(), block_waited: false, explicitly_killed: false, }, ); assert!( coordinator.resumable_source_for("sub-other", "session-A", Path::new("/tmp")) .is_some() ); assert!( coordinator.resumable_source_for("sub-other", "session-B", Path::new("/tmp")) .is_none(), "should reject resume from a different parent session" ); } #[test] fn resumed_session_uses_current_runtime_contract() { use kigi_sampling_types::conversation::ConversationItem; let mut conversation = [ ConversationItem::system("old source system prompt"), ConversationItem::user("task 1"), ConversationItem::assistant("done"), ]; let current_prompt = "freshly rendered current system prompt"; if let Some(ConversationItem::System(sys)) = conversation.first_mut() { sys.content = current_prompt.into(); } match &conversation[0] { ConversationItem::System(sys) => { assert_eq!(sys.content.as_ref(), current_prompt); assert!(! sys.content.contains("old source")); } _ => panic!("first item should be System"), } assert_eq!(conversation.len(), 3); } #[test] fn token_estimation_for_window_safety() { use kigi_sampling_types::conversation::ConversationItem; let conversation = vec![ ConversationItem::system("You are a helpful assistant."), ConversationItem::user("Hello, how are you?"), ConversationItem::assistant("I'm doing well, thank you!"), ]; let estimated = kigi_chat_state::estimate_conversation_tokens(&conversation); assert!(estimated > 0, "should produce non-zero estimate"); assert!(estimated < 100, "short conversation should have small token estimate"); assert_eq!(kigi_chat_state::estimate_conversation_tokens(& []), 0); } #[test] fn token_estimation_accounts_for_images() { use kigi_sampling_types::conversation::{ContentPart, ConversationItem, UserItem}; let text_only = vec![ ConversationItem::User(UserItem { content : vec![ContentPart::Text { text : "describe this".into(), }], synthetic_reason : None, ..Default::default() }) ]; let text_tokens = kigi_chat_state::estimate_conversation_tokens(&text_only); let with_image = vec![ ConversationItem::User(UserItem { content : vec![ContentPart::Text { text : "describe this".into(), }, ContentPart::Image { url : "data:image/png;base64,abc" .into(), },], synthetic_reason : None, ..Default::default() }) ]; let image_tokens = kigi_chat_state::estimate_conversation_tokens(&with_image); assert_eq!(image_tokens, text_tokens + 765, "one image should add 765 tokens"); let multi_image = vec![ ConversationItem::User(UserItem { content : vec![ContentPart::Image { url : "img1".into() }, ContentPart::Image { url : "img2".into() }, ContentPart::Image { url : "img3".into() },], synthetic_reason : None, ..Default::default() }) ]; let multi_tokens = kigi_chat_state::estimate_conversation_tokens(&multi_image); assert_eq!(multi_tokens, 765 * 3, "three images = 3 * 765 tokens"); } #[test] fn durable_fallback_roundtrips_child_cwd_and_worktree() { let dir = std::env::temp_dir() .join("kigi-test-durable-resume") .join(uuid::Uuid::now_v7().to_string()); let _ = std::fs::create_dir_all(&dir); let meta = SubagentMeta { subagent_id: "sa-dur".into(), parent_session_id: "parent-dur".into(), child_session_id: "child-dur".into(), subagent_type: "general-purpose".into(), description: "d".into(), prompt: "p".into(), status: "completed".into(), started_at: chrono::Utc::now(), completed_at: Some(chrono::Utc::now()), duration_ms: Some(100), tool_calls: Some(1), turns: Some(1), error: None, effective_context_source: None, context_normalized: false, fork_copy_error: None, persona: Some("implementer".into()), resumed_from: None, child_cwd: Some("/workspace/project".into()), worktree_path: Some("/tmp/kigi-wt/sa-dur".into()), snapshot_ref: None, effective_model_id: Some("kigi-3".into()), }; write_subagent_meta(&dir, &meta); let data = std::fs::read_to_string(dir.join("meta.json")).unwrap(); let loaded: SubagentMeta = serde_json::from_str(&data).unwrap(); assert_eq!(loaded.child_cwd.as_deref(), Some("/workspace/project")); assert_eq!(loaded.worktree_path.as_deref(), Some("/tmp/kigi-wt/sa-dur")); assert_eq!(loaded.status, "completed"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn durable_fallback_rejects_running_status() { let dir = std::env::temp_dir() .join("kigi-test-durable-status") .join(uuid::Uuid::now_v7().to_string()); let parent_dir = dir.join("subagents").join("sa-running"); let _ = std::fs::create_dir_all(&parent_dir); let meta = SubagentMeta { subagent_id: "sa-running".into(), parent_session_id: "parent-x".into(), child_session_id: "child-running".into(), subagent_type: "explore".into(), description: "d".into(), prompt: "p".into(), status: "running".into(), started_at: chrono::Utc::now(), completed_at: None, duration_ms: None, tool_calls: None, turns: None, error: None, effective_context_source: None, context_normalized: false, fork_copy_error: None, persona: None, resumed_from: None, child_cwd: Some("/workspace".into()), worktree_path: None, snapshot_ref: None, effective_model_id: None, }; write_subagent_meta(&parent_dir, &meta); let data = std::fs::read_to_string(parent_dir.join("meta.json")).unwrap(); let loaded: SubagentMeta = serde_json::from_str(&data).unwrap(); let is_terminal = matches!( loaded.status.as_str(), "completed" | "failed" | "cancelled" ); assert!(! is_terminal, "status=running should NOT be considered terminal/resumable"); let _ = std::fs::remove_dir_all(&dir); } /// Count persisted `SubagentFinished{status:"cancelled"}` for `id` on a /// session cmd channel, asserting field consistency. fn drain_cancelled_finish_cmds( cmd_rx: &mut mpsc::UnboundedReceiver, id: &str, ) -> usize { let mut count = 0; while let Ok(cmd) = cmd_rx.try_recv() { if let SessionCommand::XaiSessionNotification { notification } = cmd && let SessionUpdate::SubagentFinished { subagent_id, status, error, .. } = ¬ification .update && subagent_id == id { assert_eq!(status, "cancelled"); assert_eq!(error.as_deref(), Some("interrupted by process restart")); count += 1; } } count } /// Count live `SubagentFinished{status:"cancelled"}` for `id` broadcast to /// the gateway, asserting method + typed payload (not substring matching). fn drain_cancelled_finish_broadcasts( gateway_rx: &mut mpsc::UnboundedReceiver< crate::test_support::lsp_runtime::GatewayOut, >, id: &str, ) -> usize { let mut count = 0; while let Ok(msg) = gateway_rx.try_recv() { let kigi_acp_lib::AcpClientMessage::ExtNotification(args) = msg else { continue; }; assert_eq!(args.request.method.as_ref(), "kigi/session_notification"); let notification: SessionNotification = serde_json::from_str( args.request.params.get(), ) .expect("params must deserialize as SessionNotification"); if let SessionUpdate::SubagentFinished { subagent_id, status, .. } = ¬ification .update && subagent_id == id { assert_eq!(status, "cancelled"); count += 1; } } count } /// A `running` meta with no terminal counterpart, as left by a dead process. fn running_test_meta(id: &str, parent_session_id: &str) -> SubagentMeta { SubagentMeta { subagent_id: id.into(), parent_session_id: parent_session_id.into(), child_session_id: format!("child-{id}"), subagent_type: "explore".into(), description: "task".into(), prompt: "do work".into(), status: "running".into(), started_at: chrono::Utc::now(), completed_at: None, duration_ms: None, tool_calls: None, turns: None, error: None, effective_context_source: None, context_normalized: false, fork_copy_error: None, persona: None, resumed_from: None, child_cwd: Some("/workspace".into()), worktree_path: None, snapshot_ref: None, effective_model_id: None, } } #[test] fn reconcile_orphan_flips_running_meta_to_cancelled() { let session_dir = tempfile::TempDir::new().unwrap(); let id = "sa-orphan"; let sub_dir = session_dir.path().join("subagents").join(id); write_subagent_meta(&sub_dir, &running_test_meta(id, "parent-x")); let coordinator = SubagentCoordinator::new(); reconcile_orphaned_subagents( &[], &coordinator, session_dir.path(), "parent-x", &test_gateway(), None, ); let data = std::fs::read_to_string(sub_dir.join("meta.json")).unwrap(); let reread: SubagentMeta = serde_json::from_str(&data).unwrap(); assert_eq!(reread.status, "cancelled"); assert!(reread.completed_at.is_some(), "must stamp completed_at"); assert!(reread.duration_ms.is_some(), "must stamp duration_ms"); assert_eq!(reread.tool_calls, Some(0)); assert_eq!(reread.turns, Some(0)); assert_eq!(reread.error.as_deref(), Some("interrupted by process restart"),); } #[tokio::test] async fn reconcile_orphan_skips_ids_in_live_registry() { let session_dir = tempfile::TempDir::new().unwrap(); let id = "sa-live"; let sub_dir = session_dir.path().join("subagents").join(id); write_subagent_meta(&sub_dir, &running_test_meta(id, "parent-x")); let mut coordinator = SubagentCoordinator::new(); coordinator.insert(dummy_tracker(id, "parent-x", "explore", "task")); reconcile_orphaned_subagents( &[], &coordinator, session_dir.path(), "parent-x", &test_gateway(), None, ); let data = std::fs::read_to_string(sub_dir.join("meta.json")).unwrap(); let reread: SubagentMeta = serde_json::from_str(&data).unwrap(); assert_eq!(reread.status, "running", "a live subagent must not be reconciled"); } #[test] fn reconcile_orphan_skips_pending_ids_in_live_registry() { let session_dir = tempfile::TempDir::new().unwrap(); let id = "sa-pending"; let sub_dir = session_dir.path().join("subagents").join(id); write_subagent_meta(&sub_dir, &running_test_meta(id, "parent-x")); let mut coordinator = SubagentCoordinator::new(); coordinator .insert_pending(PendingSubagent { subagent_id: id.to_string(), subagent_type: "explore".to_string(), description: "task".to_string(), persona: None, parent_prompt_id: None, parent_session_id: "parent-x".to_string(), started_at: std::time::Instant::now(), run_in_background: false, surface_completion: true, color: None, cancel_token: CancellationToken::new(), }); reconcile_orphaned_subagents( &[], &coordinator, session_dir.path(), "parent-x", &test_gateway(), None, ); let data = std::fs::read_to_string(sub_dir.join("meta.json")).unwrap(); let reread: SubagentMeta = serde_json::from_str(&data).unwrap(); assert_eq!( reread.status, "running", "a pending (initializing) subagent must not be reconciled" ); } #[test] fn reconcile_orphan_idempotent_on_terminal_meta() { use crate::test_support::lsp_runtime::test_gateway_with_receiver; let session_dir = tempfile::TempDir::new().unwrap(); let id = "sa-done"; let sub_dir = session_dir.path().join("subagents").join(id); let mut meta = running_test_meta(id, "parent-x"); meta.status = "cancelled".into(); meta.completed_at = Some(chrono::Utc::now()); meta.error = Some("interrupted by process restart".into()); write_subagent_meta(&sub_dir, &meta); let coordinator = SubagentCoordinator::new(); let (gateway, mut gateway_rx) = test_gateway_with_receiver(); let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); reconcile_orphaned_subagents( &[], &coordinator, session_dir.path(), "parent-x", &gateway, Some(&cmd_tx), ); assert!( cmd_rx.try_recv().is_err(), "terminal meta must not persist a fresh SubagentFinished" ); assert!(gateway_rx.try_recv().is_err(), "terminal meta must not broadcast"); } #[test] fn reconcile_orphan_ignores_other_parent_session() { let session_dir = tempfile::TempDir::new().unwrap(); let id = "sa-other"; let sub_dir = session_dir.path().join("subagents").join(id); write_subagent_meta(&sub_dir, &running_test_meta(id, "other-parent")); let coordinator = SubagentCoordinator::new(); reconcile_orphaned_subagents( &[], &coordinator, session_dir.path(), "parent-x", &test_gateway(), None, ); let data = std::fs::read_to_string(sub_dir.join("meta.json")).unwrap(); let reread: SubagentMeta = serde_json::from_str(&data).unwrap(); assert_eq!(reread.status, "running", "cross-parent meta must be left alone"); } #[test] fn reconcile_orphan_skips_malformed_meta() { let session_dir = tempfile::TempDir::new().unwrap(); let sub_dir = session_dir.path().join("subagents").join("sa-bad"); std::fs::create_dir_all(&sub_dir).unwrap(); std::fs::write(sub_dir.join("meta.json"), "{not valid json").unwrap(); let coordinator = SubagentCoordinator::new(); let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); reconcile_orphaned_subagents( &[], &coordinator, session_dir.path(), "parent-x", &test_gateway(), Some(&cmd_tx), ); assert!(cmd_rx.try_recv().is_err(), "malformed meta must not emit a finish"); assert_eq!( std::fs::read_to_string(sub_dir.join("meta.json")).unwrap(), "{not valid json" ); } #[test] fn reconcile_orphan_noop_on_missing_subagents_dir() { let session_dir = tempfile::TempDir::new().unwrap(); let coordinator = SubagentCoordinator::new(); let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); reconcile_orphaned_subagents( &[], &coordinator, session_dir.path(), "parent-x", &test_gateway(), Some(&cmd_tx), ); assert!(cmd_rx.try_recv().is_err(), "no subagents dir → no emit"); } #[test] fn reconcile_replayed_orphan_emits_finish_for_inherited_orphan() { let session_dir = tempfile::TempDir::new().unwrap(); let coordinator = SubagentCoordinator::new(); let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); let unfinished = vec![("sa-inherited".to_string(), "child-inherited".to_string())]; reconcile_orphaned_subagents( &unfinished, &coordinator, session_dir.path(), "parent-x", &test_gateway(), Some(&cmd_tx), ); assert_eq!(drain_cancelled_finish_cmds(& mut cmd_rx, "sa-inherited"), 1); } #[test] fn reconcile_replayed_orphan_uses_real_terminal_status_from_meta() { let session_dir = tempfile::TempDir::new().unwrap(); let sub_dir = session_dir.path().join("subagents").join("sa-done"); let mut meta = running_test_meta("sa-done", "parent-x"); meta.status = "completed".into(); meta.tool_calls = Some(7); write_subagent_meta(&sub_dir, &meta); let coordinator = SubagentCoordinator::new(); let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); let unfinished = vec![("sa-done".to_string(), "child-sa-done".to_string())]; reconcile_orphaned_subagents( &unfinished, &coordinator, session_dir.path(), "parent-x", &test_gateway(), Some(&cmd_tx), ); let mut found = None; while let Ok(cmd) = cmd_rx.try_recv() { if let SessionCommand::XaiSessionNotification { notification } = cmd && let SessionUpdate::SubagentFinished { subagent_id, status, tool_calls, .. } = ¬ification.update && subagent_id == "sa-done" { found = Some((status.clone(), *tool_calls)); } } assert_eq!(found, Some(("completed".to_string(), 7))); } #[tokio::test] async fn reconcile_reemits_rewound_finish_even_when_id_still_in_completed_registry() { let session_dir = tempfile::TempDir::new().unwrap(); let id = "sa-done"; let sub_dir = session_dir.path().join("subagents").join(id); let mut meta = running_test_meta(id, "parent-x"); meta.status = "completed".into(); meta.tool_calls = Some(7); write_subagent_meta(&sub_dir, &meta); let mut coordinator = SubagentCoordinator::new(); coordinator.insert(dummy_tracker(id, "parent-x", "explore", "task")); coordinator .move_to_completed( id, "task".into(), "explore".into(), SubagentResult { success: true, ..Default::default() }, ); let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); let unfinished = vec![(id.to_string(), format!("child-{id}"))]; reconcile_orphaned_subagents( &unfinished, &coordinator, session_dir.path(), "parent-x", &test_gateway(), Some(&cmd_tx), ); let mut found = None; while let Ok(cmd) = cmd_rx.try_recv() { if let SessionCommand::XaiSessionNotification { notification } = cmd && let SessionUpdate::SubagentFinished { subagent_id, status, .. } = ¬ification .update && subagent_id == id { found = Some(status.clone()); } } assert_eq!( found, Some("completed".to_string()), "a completed-then-rewound subagent must re-emit its real finish, not be skipped" ); } #[tokio::test] async fn reconcile_reemits_real_outcome_for_completed_with_running_meta() { let session_dir = tempfile::TempDir::new().unwrap(); let id = "sa-raced"; let sub_dir = session_dir.path().join("subagents").join(id); write_subagent_meta(&sub_dir, &running_test_meta(id, "parent-x")); let mut coordinator = SubagentCoordinator::new(); coordinator.insert(dummy_tracker(id, "parent-x", "explore", "task")); coordinator .move_to_completed( id, "task".into(), "explore".into(), SubagentResult { success: true, ..Default::default() }, ); let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); let unfinished = vec![(id.to_string(), format!("child-{id}"))]; reconcile_orphaned_subagents( &unfinished, &coordinator, session_dir.path(), "parent-x", &test_gateway(), Some(&cmd_tx), ); let mut found = None; while let Ok(cmd) = cmd_rx.try_recv() { if let SessionCommand::XaiSessionNotification { notification } = cmd && let SessionUpdate::SubagentFinished { subagent_id, status, .. } = ¬ification .update && subagent_id == id { found = Some(status.clone()); } } assert_eq!( found, Some("completed".to_string()), "must re-emit the real terminal outcome, not cancel" ); let reread: SubagentMeta = serde_json::from_str( &std::fs::read_to_string(sub_dir.join("meta.json")).unwrap(), ) .unwrap(); assert_eq!( reread.status, "running", "must not finalize a completed subagent as cancelled" ); } #[test] fn reconcile_dedups_orphan_present_in_both_sources() { let session_dir = tempfile::TempDir::new().unwrap(); let sub_dir = session_dir.path().join("subagents").join("sa-crash"); write_subagent_meta(&sub_dir, &running_test_meta("sa-crash", "parent-x")); let coordinator = SubagentCoordinator::new(); let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); let unfinished = vec![("sa-crash".to_string(), "child-sa-crash".to_string())]; reconcile_orphaned_subagents( &unfinished, &coordinator, session_dir.path(), "parent-x", &test_gateway(), Some(&cmd_tx), ); assert_eq!( drain_cancelled_finish_cmds(& mut cmd_rx, "sa-crash"), 1, "an orphan in both sources is healed exactly once" ); } #[test] fn reconcile_orphan_persists_subagent_finished_via_cmd_tx() { use crate::test_support::lsp_runtime::test_gateway_with_receiver; let session_dir = tempfile::TempDir::new().unwrap(); let id = "sa-emit"; let sub_dir = session_dir.path().join("subagents").join(id); write_subagent_meta(&sub_dir, &running_test_meta(id, "parent-x")); let coordinator = SubagentCoordinator::new(); let (gateway, mut gateway_rx) = test_gateway_with_receiver(); let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel(); reconcile_orphaned_subagents( &[], &coordinator, session_dir.path(), "parent-x", &gateway, Some(&cmd_tx), ); assert_eq!( drain_cancelled_finish_cmds(& mut cmd_rx, id), 1, "must persist exactly one SubagentFinished via parent_cmd_tx" ); assert_eq!( drain_cancelled_finish_broadcasts(& mut gateway_rx, id), 1, "must broadcast exactly one SubagentFinished via gateway" ); } #[test] fn resume_rejects_conflicting_subagent_type() { let source = ResumeSourceData { subagent_id: "sub-gp".into(), child_session_id: "child-gp".into(), child_cwd: "/workspace".into(), worktree_path: None, snapshot_ref: None, subagent_type: "general-purpose".into(), persona: None, model_id: None, }; let request_type = "explore"; assert_ne!( request_type, source.subagent_type, "conflicting types should be detected" ); } #[test] fn resume_rejects_conflicting_persona() { let source = ResumeSourceData { subagent_id: "sub-impl".into(), child_session_id: "child-impl".into(), child_cwd: "/workspace".into(), worktree_path: None, snapshot_ref: None, subagent_type: "general-purpose".into(), persona: Some("implementer".into()), model_id: None, }; let request_persona = Some("reviewer".to_string()); let conflict = request_persona.as_deref() != source.persona.as_deref(); assert!(conflict, "different persona should be detected as conflict"); } #[test] fn resume_allows_matching_identity() { let source = ResumeSourceData { subagent_id: "sub-ok".into(), child_session_id: "child-ok".into(), child_cwd: "/workspace".into(), worktree_path: None, snapshot_ref: None, subagent_type: "general-purpose".into(), persona: Some("implementer".into()), model_id: Some("kigi-3".into()), }; assert_eq!("general-purpose", source.subagent_type); assert_eq!(Some("implementer"), source.persona.as_deref()); assert_eq!(Some("kigi-3"), source.model_id.as_deref()); } #[test] fn resume_identity_does_not_gate_on_model() { let source = ResumeSourceData { subagent_id: "sub-model".into(), child_session_id: "child-model".into(), child_cwd: "/workspace".into(), worktree_path: None, snapshot_ref: None, subagent_type: "general-purpose".into(), persona: None, model_id: Some("kigi-3".into()), }; assert!( kigi_subagent_resolution::validate_resume_identity("general-purpose", None, & source,).is_ok() ); assert_eq!( source.model_id.as_deref(), Some("kigi-3"), "source model remains available for pinning" ); } #[test] fn durable_meta_roundtrips_effective_model_id() { let dir = std::env::temp_dir() .join("kigi-test-model-roundtrip") .join(uuid::Uuid::now_v7().to_string()); let _ = std::fs::create_dir_all(&dir); let meta = SubagentMeta { subagent_id: "sa-model".into(), parent_session_id: "parent".into(), child_session_id: "child".into(), subagent_type: "general-purpose".into(), description: "d".into(), prompt: "p".into(), status: "completed".into(), started_at: chrono::Utc::now(), completed_at: Some(chrono::Utc::now()), duration_ms: Some(100), tool_calls: Some(1), turns: Some(1), error: None, effective_context_source: None, context_normalized: false, fork_copy_error: None, persona: None, resumed_from: None, child_cwd: Some("/workspace".into()), worktree_path: None, snapshot_ref: None, effective_model_id: Some("kigi-3".into()), }; write_subagent_meta(&dir, &meta); let data = std::fs::read_to_string(dir.join("meta.json")).unwrap(); let loaded: SubagentMeta = serde_json::from_str(&data).unwrap(); assert_eq!( loaded.effective_model_id.as_deref(), Some("kigi-3"), "model ID should round-trip through meta.json" ); let _ = std::fs::remove_dir_all(&dir); } #[test] fn resume_model_pinning_overrides_default_resolution() { let source_model = Some("kigi-3".to_string()); let resolved_model = "kigi-light"; let needs_pin = source_model.as_deref() != Some(resolved_model); assert!(needs_pin, "resolved model differs from source — pinning should trigger"); let resolved_same = "kigi-3"; let no_pin = source_model.as_deref() == Some(resolved_same); assert!(no_pin, "same model — no pinning needed"); } #[test] fn resume_window_safety_rejects_instead_of_swapping() { let estimated_tokens: u64 = 100_000; let child_window: u64 = 256_000; const SAFE_RESUME_PERCENT: u64 = 80; let threshold = child_window * SAFE_RESUME_PERCENT / 100; assert!( estimated_tokens <= threshold, "100k tokens should be within 80% of 256k window" ); let large_transcript: u64 = 210_000; assert!( large_transcript > threshold, "210k tokens exceeds 80% of 256k window — resume should be rejected" ); } #[test] fn provenance_carries_resumed_from() { let prov = SubagentProvenance { fork_parent_prompt_id: Some("prompt-1".into()), resumed_from: Some("prev-agent-id".into()), }; assert_eq!(prov.resumed_from.as_deref(), Some("prev-agent-id")); let fresh = SubagentProvenance::default(); assert!(fresh.resumed_from.is_none()); } #[test] fn notification_subagent_spawned_includes_resumed_from() { let notification = SessionUpdate::SubagentSpawned { subagent_id: "sa-resumed".into(), parent_session_id: "parent".into(), parent_prompt_id: Some("prompt-1".into()), child_session_id: "child-resumed".into(), subagent_type: "general-purpose".into(), description: "fix review feedback".into(), effective_context_source: Some("resumed".into()), context_normalized: false, capability_mode: None, persona: Some("implementer".into()), role: None, model: None, resumed_from: Some("prev-agent-id".into()), }; let json = serde_json::to_value(¬ification).unwrap(); assert_eq!(json["resumed_from"], "prev-agent-id"); assert_eq!(json["effective_context_source"], "resumed"); assert_eq!(json["role"], serde_json::Value::Null); assert_eq!(json["model"], serde_json::Value::Null); let fresh = SessionUpdate::SubagentSpawned { subagent_id: "sa-fresh".into(), parent_session_id: "p".into(), parent_prompt_id: None, child_session_id: "c".into(), subagent_type: "explore".into(), description: "d".into(), effective_context_source: Some("new".into()), context_normalized: false, capability_mode: None, persona: None, role: None, model: None, resumed_from: None, }; let json = serde_json::to_value(&fresh).unwrap(); assert!(json.get("resumed_from").is_none()); assert!(json.get("role").is_none()); assert!(json.get("model").is_none()); } #[tokio::test] async fn completion_notify_fires_on_move_to_completed() { let mut coordinator = SubagentCoordinator::new(); let notify = coordinator.completion_notify(); let notified = notify.notified(); coordinator .move_to_completed( "sub-n1", "notify test".to_string(), "explore".to_string(), SubagentResult { success: true, output: std::sync::Arc::from("ok"), subagent_id: "sub-n1".to_string(), child_session_id: "sub-n1".to_string(), tool_calls: 1, turns: 1, duration_ms: 100, ..Default::default() }, ); tokio::time::timeout(std::time::Duration::from_millis(50), notified) .await .expect("completion_notify should have fired after move_to_completed"); } #[test] fn drain_pending_completions_returns_and_clears() { let mut coordinator = SubagentCoordinator::new(); coordinator .move_to_completed( "sub-d1", "task 1".to_string(), "explore".to_string(), SubagentResult { success: true, output: std::sync::Arc::from("done"), subagent_id: "sub-d1".to_string(), child_session_id: "sub-d1".to_string(), tool_calls: 3, turns: 2, duration_ms: 500, ..Default::default() }, ); coordinator .move_to_completed( "sub-d2", "task 2".to_string(), "plan".to_string(), SubagentResult { success: false, output: std::sync::Arc::from(""), error: Some("crashed".to_string()), subagent_id: "sub-d2".to_string(), child_session_id: "sub-d2".to_string(), duration_ms: 200, ..Default::default() }, ); let summaries = coordinator.drain_pending_completions(); assert_eq!(summaries.len(), 2); assert_eq!(summaries[0].subagent_id, "sub-d1"); assert!(summaries[0].success); assert_eq!(summaries[0].description, "task 1"); assert_eq!(summaries[0].subagent_type, "explore"); assert_eq!(summaries[0].tool_calls, 3); assert_eq!(summaries[0].turns, 2); assert_eq!(summaries[0].duration_ms, 500); assert_eq!(summaries[1].subagent_id, "sub-d2"); assert!(! summaries[1].success); let again = coordinator.drain_pending_completions(); assert!(again.is_empty(), "buffer should be empty after drain"); } #[test] fn drain_pending_completions_cancelled_is_not_success() { let mut coordinator = SubagentCoordinator::new(); coordinator .move_to_completed( "sub-c1", "cancelled task".to_string(), "explore".to_string(), SubagentResult { success: true, cancelled: true, output: std::sync::Arc::from(""), subagent_id: "sub-c1".to_string(), child_session_id: "sub-c1".to_string(), ..Default::default() }, ); let summaries = coordinator.drain_pending_completions(); assert_eq!(summaries.len(), 1); assert!( ! summaries[0].success, "cancelled subagent should not be marked as success" ); } #[tokio::test] async fn outstanding_for_prompt_includes_pending_and_active() { let mut coordinator = SubagentCoordinator::new(); coordinator .insert_pending(PendingSubagent { subagent_id: "sub-p1".to_string(), subagent_type: "explore".to_string(), description: "pending for X".to_string(), persona: None, parent_prompt_id: Some("prompt-X".to_string()), parent_session_id: String::new(), started_at: std::time::Instant::now(), run_in_background: false, surface_completion: true, color: None, cancel_token: CancellationToken::new(), }); let mut tracker = dummy_tracker("sub-a1", "session-1", "plan", "active for X"); tracker.parent_prompt_id = Some("prompt-X".to_string()); coordinator.insert(tracker); let mut tracker2 = dummy_tracker("sub-a2", "session-1", "explore", "active for Y"); tracker2.parent_prompt_id = Some("prompt-Y".to_string()); coordinator.insert(tracker2); let outstanding = coordinator.outstanding_for_prompt("prompt-X"); assert_eq!(outstanding.len(), 2); assert!(outstanding.contains(& "sub-p1".to_string())); assert!(outstanding.contains(& "sub-a1".to_string())); } #[tokio::test] async fn outstanding_for_prompt_excludes_completed() { let mut coordinator = SubagentCoordinator::new(); let mut tracker = dummy_tracker("sub-done", "session-1", "explore", "done for X"); tracker.parent_prompt_id = Some("prompt-X".to_string()); coordinator.insert(tracker); coordinator .move_to_completed( "sub-done", "done for X".to_string(), "explore".to_string(), SubagentResult { success: true, output: std::sync::Arc::from("done"), subagent_id: "sub-done".to_string(), child_session_id: "sub-done".to_string(), ..Default::default() }, ); let outstanding = coordinator.outstanding_for_prompt("prompt-X"); assert!( outstanding.is_empty(), "completed subagents should not appear in outstanding" ); } #[test] fn outstanding_for_prompt_returns_empty_for_unknown_prompt() { let coordinator = SubagentCoordinator::new(); let outstanding = coordinator.outstanding_for_prompt("nonexistent"); assert!(outstanding.is_empty()); } /// Background children never gate the turn-end drain: they are excluded /// from `outstanding_for_prompt` and reported via `background_live` /// instead, including a foreground child auto-backgrounded mid-turn. #[tokio::test] async fn background_children_do_not_gate_the_drain() { let mut coordinator = SubagentCoordinator::new(); let mut bg = dummy_tracker("sub-bg", "session-1", "explore", "background"); bg.parent_prompt_id = Some("prompt-X".to_string()); bg.run_in_background = true; coordinator.insert(bg); let mut fg = dummy_tracker("sub-fg", "session-1", "plan", "foreground"); fg.parent_prompt_id = Some("prompt-X".to_string()); coordinator.insert(fg); assert_eq!( coordinator.outstanding_for_prompt("prompt-X"), vec!["sub-fg".to_string()], "only the foreground child gates the drain" ); assert!(coordinator.background_live_for_prompt("prompt-X")); assert!(! coordinator.background_live_for_prompt("prompt-Y")); coordinator.mark_backgrounded("sub-fg"); assert!(coordinator.outstanding_for_prompt("prompt-X").is_empty()); assert!(coordinator.background_live_for_prompt("prompt-X")); } #[tokio::test] async fn subagent_usage_not_applied_sticky_after_completion_and_is_prompt_scoped() { let mut coordinator = SubagentCoordinator::new(); let mut tracker = dummy_tracker("sub-1", "session-1", "explore", "task"); tracker.parent_prompt_id = Some("p-1".to_string()); coordinator.insert(tracker); coordinator.mark_subagent_usage_not_applied("p-1"); coordinator .move_to_completed( "sub-1", "task".into(), "explore".into(), SubagentResult { success: true, output: std::sync::Arc::from("ok"), subagent_id: "sub-1".to_string(), child_session_id: "sub-1".to_string(), ..Default::default() }, ); assert!(coordinator.outstanding_for_prompt("p-1").is_empty()); assert!(coordinator.subagent_usage_not_applied("p-1")); assert!(! coordinator.subagent_usage_not_applied("p-2")); let reply = coordinator.outstanding_reply_for_prompt("p-1"); assert!(reply.live_ids.is_empty()); assert!(reply.subagent_usage_not_applied); coordinator.clear_subagent_usage_not_applied("p-1"); assert!(! coordinator.subagent_usage_not_applied("p-1")); } #[test] fn outstanding_for_prompt_returns_sorted_ids() { let mut coordinator = SubagentCoordinator::new(); coordinator .insert_pending(PendingSubagent { subagent_id: "zzz".to_string(), subagent_type: "explore".to_string(), description: "z".to_string(), persona: None, parent_prompt_id: Some("p".to_string()), parent_session_id: String::new(), started_at: std::time::Instant::now(), run_in_background: false, surface_completion: true, color: None, cancel_token: CancellationToken::new(), }); coordinator .insert_pending(PendingSubagent { subagent_id: "aaa".to_string(), subagent_type: "explore".to_string(), description: "a".to_string(), persona: None, parent_prompt_id: Some("p".to_string()), parent_session_id: String::new(), started_at: std::time::Instant::now(), run_in_background: false, surface_completion: true, color: None, cancel_token: CancellationToken::new(), }); let ids = coordinator.outstanding_for_prompt("p"); assert_eq!(ids, vec!["aaa", "zzz"]); } #[test] fn turn_active_flag_defaults_to_false() { let coordinator = SubagentCoordinator::new(); assert!(! coordinator.is_turn_active()); } #[test] fn turn_active_flag_shared_via_arc() { let coordinator = SubagentCoordinator::new(); let flag = coordinator.turn_active_flag(); assert!(! flag.load(std::sync::atomic::Ordering::Relaxed)); flag.store(true, std::sync::atomic::Ordering::Relaxed); assert!(coordinator.is_turn_active()); flag.store(false, std::sync::atomic::Ordering::Relaxed); assert!(! coordinator.is_turn_active()); } #[test] fn completions_buffered_while_turn_inactive_drained_later() { let mut coordinator = SubagentCoordinator::new(); assert!(! coordinator.is_turn_active()); coordinator .move_to_completed( "sub-idle", "idle task".to_string(), "explore".to_string(), SubagentResult { success: true, output: std::sync::Arc::from("result"), subagent_id: "sub-idle".to_string(), child_session_id: "sub-idle".to_string(), ..Default::default() }, ); let drained = coordinator.drain_pending_completions(); assert_eq!(drained.len(), 1); assert_eq!(drained[0].subagent_id, "sub-idle"); assert!(coordinator.drain_pending_completions().is_empty()); } fn ctx_with_parent_chat_state( session_model_id: &str, inference_slug: &str, global_model_id: &str, available_models: indexmap::IndexMap, ) -> SubagentSpawnContext { let mut ctx = ctx_with_toggle(HashMap::new()); ctx.model_id = acp::ModelId::new(session_model_id); ctx.parent_chat_state = Some(spawn_test_parent_chat_state(inference_slug)); ctx.models_manager = crate::agent::models::ModelsManager::new( None, available_models.clone(), acp::ModelId::new(global_model_id), ctx.auth_manager.clone(), crate::agent::config::Config::default(), ); ctx.available_models = available_models; ctx } #[tokio::test] async fn read_parent_sampling_config_keeps_auto_catalog_id_with_routing_slug() { let mut models = indexmap::IndexMap::new(); models.insert("auto".to_string(), test_model_entry("kigi-4.5")); let ctx = ctx_with_parent_chat_state("auto", "kigi-4.5", "composer-2-fast", models); let (config, model_id) = read_parent_sampling_config(&ctx).await; assert_eq!(config.model, "kigi-4.5"); assert_eq!(model_id.0.as_ref(), "auto"); } #[tokio::test] async fn read_parent_sampling_config_keeps_auto_when_catalog_has_slug_key_only() { let mut models = indexmap::IndexMap::new(); models.insert("kigi-4.5".to_string(), test_model_entry("kigi-4.5")); let ctx = ctx_with_parent_chat_state("auto", "kigi-4.5", "auto", models); let (config, model_id) = read_parent_sampling_config(&ctx).await; assert_eq!(config.model, "kigi-4.5"); assert_eq!(model_id.0.as_ref(), "auto"); } #[tokio::test] async fn read_parent_sampling_config_fallback_uses_session_model_id() { let mut models = indexmap::IndexMap::new(); models.insert("composer-2-fast".to_string(), test_model_entry("composer-2-fast")); let mut ctx = ctx_with_toggle(HashMap::new()); ctx.model_id = acp::ModelId::new("composer-2-fast"); ctx.parent_chat_state = None; ctx.sampling_config.model = "composer-2-fast".to_string(); ctx.available_models = models; ctx.models_manager = crate::agent::models::ModelsManager::new( None, indexmap::IndexMap::new(), acp::ModelId::new("auto"), ctx.auth_manager.clone(), crate::agent::config::Config::default(), ); let (config, model_id) = read_parent_sampling_config(&ctx).await; assert_eq!(config.model, "composer-2-fast"); assert_eq!(model_id.0.as_ref(), "composer-2-fast"); assert_ne!(model_id.0.as_ref(), "auto"); } #[tokio::test] async fn read_parent_sampling_config_ignores_global_default() { let mut models = indexmap::IndexMap::new(); models.insert("composer-2-fast".to_string(), test_model_entry("composer-2-fast")); let ctx = ctx_with_parent_chat_state( "composer-2-fast", "composer-2-fast", "auto", models, ); let (config, model_id) = read_parent_sampling_config(&ctx).await; assert_eq!(config.model, "composer-2-fast"); assert_eq!(model_id.0.as_ref(), "composer-2-fast"); assert_ne!(model_id.0.as_ref(), ctx.models_manager.current_model_id().0.as_ref(),); } #[tokio::test] async fn read_parent_sampling_config_resolves_backend_search_from_catalog() { let mut entry = test_model_entry("kigi-4.5"); entry.info.supports_backend_search = true; let mut models = indexmap::IndexMap::new(); models.insert("auto".to_string(), entry); let mut ctx = ctx_with_parent_chat_state("auto", "kigi-4.5", "auto", models); ctx.sampling_config.supports_backend_search = false; let (config, _model_id) = read_parent_sampling_config(&ctx).await; assert!( config.supports_backend_search, "subagent should inherit backend-tools capability from the live model catalog" ); } #[tokio::test] async fn read_parent_sampling_config_fallback_resolves_backend_search_from_catalog() { let mut entry = test_model_entry("composer-2-fast"); entry.info.supports_backend_search = true; let mut models = indexmap::IndexMap::new(); models.insert("composer-2-fast".to_string(), entry); let mut ctx = ctx_with_toggle(HashMap::new()); ctx.model_id = acp::ModelId::new("composer-2-fast"); ctx.parent_chat_state = None; ctx.sampling_config.model = "composer-2-fast".to_string(); ctx.sampling_config.supports_backend_search = false; ctx.models_manager = crate::agent::models::ModelsManager::new( None, models, acp::ModelId::new("composer-2-fast"), ctx.auth_manager.clone(), crate::agent::config::Config::default(), ); let (config, model_id) = read_parent_sampling_config(&ctx).await; assert_eq!(model_id.0.as_ref(), "composer-2-fast"); assert!( config.supports_backend_search, "fallback path should also resolve backend-tools capability from the catalog" ); } #[tokio::test] async fn read_parent_sampling_config_resolves_compactions_remaining_from_catalog() { use kigi_sampling_types::CompactionsRemaining; let mut entry = test_model_entry("kigi-4.5"); entry.info.compactions_remaining = Some(CompactionsRemaining::Dynamic(true)); let mut models = indexmap::IndexMap::new(); models.insert("auto".to_string(), entry); let mut ctx = ctx_with_parent_chat_state("auto", "kigi-4.5", "auto", models); ctx.sampling_config.compactions_remaining = None; let (config, _model_id) = read_parent_sampling_config(&ctx).await; assert_eq!( config.compactions_remaining, Some(CompactionsRemaining::Dynamic(true)), "subagent should inherit compactions-remaining capability from the live model catalog" ); } #[tokio::test] async fn read_parent_sampling_config_fallback_resolves_compactions_remaining_from_catalog() { use kigi_sampling_types::CompactionsRemaining; let mut entry = test_model_entry("composer-2-fast"); entry.info.compactions_remaining = Some(CompactionsRemaining::Dynamic(true)); let mut models = indexmap::IndexMap::new(); models.insert("composer-2-fast".to_string(), entry); let mut ctx = ctx_with_toggle(HashMap::new()); ctx.model_id = acp::ModelId::new("composer-2-fast"); ctx.parent_chat_state = None; ctx.sampling_config.model = "composer-2-fast".to_string(); ctx.sampling_config.compactions_remaining = None; ctx.models_manager = crate::agent::models::ModelsManager::new( None, models, acp::ModelId::new("composer-2-fast"), ctx.auth_manager.clone(), crate::agent::config::Config::default(), ); let (config, model_id) = read_parent_sampling_config(&ctx).await; assert_eq!(model_id.0.as_ref(), "composer-2-fast"); assert_eq!( config.compactions_remaining, Some(CompactionsRemaining::Dynamic(true)), "fallback path should also resolve compactions-remaining capability from the catalog" ); } /// Drive the REAL precedence path /// (`resolve_effective_model_config`, which `handle_subagent_request` /// calls) with BOTH an explicit `runtime_override_model` AND a /// `[subagents.models]` pin for the same agent present, asserting the /// runtime override wins; with `None` (inherit) the pin wins (precedence /// handed back); and an unknown override falls through to the pin. #[tokio::test] async fn runtime_override_wins_over_subagents_models_pin_in_precedence_path() { use kigi_agent::config::ModelOverride; let build_ctx = || { let mut models = indexmap::IndexMap::new(); models.insert("goal-model".to_string(), test_model_entry("goal-model")); models.insert("pinned-model".to_string(), test_model_entry("pinned-model")); let mut ctx = ctx_with_toggle(HashMap::new()); ctx.available_models = models; ctx.subagent_model_overrides = HashMap::from([ ("explore".to_string(), "pinned-model".to_string()), ]); ctx }; let ctx = build_ctx(); let (config, model_id) = resolve_effective_model_config( Some("goal-model"), "explore", &ModelOverride::Inherit, &ctx, ) .await; assert_eq!( config.model, "goal-model", "the goal runtime override must win over the `[subagents.models]` pin", ); assert_eq!(model_id.0.as_ref(), "goal-model"); let ctx = build_ctx(); let (config, model_id) = resolve_effective_model_config( None, "explore", &ModelOverride::Inherit, &ctx, ) .await; assert_eq!( config.model, "pinned-model", "with no runtime override, the `[subagents.models]` pin wins", ); assert_eq!(model_id.0.as_ref(), "pinned-model"); let ctx = build_ctx(); let (config, _) = resolve_effective_model_config( Some("does-not-exist"), "explore", &ModelOverride::Inherit, &ctx, ) .await; assert_eq!( config.model, "pinned-model", "an unknown override falls through to the pin", ); } /// A `fork_context = true` spawn must infer on the parent session model /// (`ctx.model_id`) for per-model radix reuse, even when a /// `[subagents.models]` pin and an `AgentDefinition.model` override are /// both present. `handle_subagent_request` forces /// `effective_runtime.model = Some(ctx.model_id)` on the fork path after /// other override sources; the runtime override wins in /// `resolve_effective_model_config`. #[tokio::test] async fn fork_context_pins_parent_model_over_overrides() { use kigi_agent::config::ModelOverride; let build_ctx = || { let mut ctx = ctx_with_toggle(HashMap::new()); ctx.sampling_config.model = "parent-model".to_string(); ctx.model_id = acp::ModelId::new("parent-model"); ctx.available_models .insert("parent-model".to_string(), test_model_entry("parent-model")); ctx.available_models .insert("pinned-model".to_string(), test_model_entry("pinned-model")); ctx.available_models .insert("agentdef-model".to_string(), test_model_entry("agentdef-model")); ctx.subagent_model_overrides .insert("general-purpose".to_string(), "pinned-model".to_string()); ctx }; let agent_def = ModelOverride::Override("agentdef-model".to_string()); let ctx = build_ctx(); let fork_context = true; let mut runtime_override: Option = None; if fork_context { runtime_override = Some(ctx.model_id.0.to_string()); } let (config, model_id) = resolve_effective_model_config( runtime_override.as_deref(), "general-purpose", &agent_def, &ctx, ) .await; assert_eq!( config.model, "parent-model", "fork_context must pin the parent model over the [subagents.models] pin and agent-def override", ); assert_eq!(model_id.0.as_ref(), "parent-model"); let ctx = build_ctx(); let (config, model_id) = resolve_effective_model_config( None, "general-purpose", &agent_def, &ctx, ) .await; assert_eq!( config.model, "pinned-model", "without the fork pin the [subagents.models] override wins", ); assert_eq!(model_id.0.as_ref(), "pinned-model"); } /// With no explicit pin, the subagent inherits the parent model for any /// parent model, with no special-casing (a "heavy"/custom parent /// is treated identically to any other). #[tokio::test] async fn resolve_subagent_inherits_parent_model_without_pins() { use kigi_agent::config::ModelOverride; for parent_model in ["kigi-4.5", "composer-2-fast", "my-custom-byok-model"] { let mut ctx = ctx_with_toggle(HashMap::new()); ctx.sampling_config.model = parent_model.to_string(); ctx.model_id = acp::ModelId::new(parent_model); let (config, model_id) = resolve_subagent_sampling_config( "explore", &ModelOverride::Inherit, &ctx, ) .await; assert_eq!( config.model, parent_model, "subagent must inherit parent model {parent_model:?} when no pin is set", ); assert_eq!(model_id.0.as_ref(), parent_model); } } /// An explicit `[subagents.models]` pin routes the subagent to that /// model regardless of the parent model — both a light parent /// (`kigi-4.5`) and a custom parent (`composer-2-fast`) /// honor the pin identically now that the heavy-model gate is gone. #[tokio::test] async fn resolve_subagent_config_override_pin_applies_for_any_parent() { use kigi_agent::config::ModelOverride; for parent_model in ["kigi-4.5", "composer-2-fast"] { let mut ctx = ctx_with_toggle(HashMap::new()); ctx.sampling_config.model = parent_model.to_string(); ctx.model_id = acp::ModelId::new(parent_model); ctx.available_models .insert("pinned-model".to_string(), test_model_entry("pinned-model")); ctx.subagent_model_overrides .insert("explore".to_string(), "pinned-model".to_string()); let (config, model_id) = resolve_subagent_sampling_config( "explore", &ModelOverride::Inherit, &ctx, ) .await; assert_eq!( config.model, "pinned-model", "config pin must win for parent {parent_model:?}", ); assert_eq!(model_id.0.as_ref(), "pinned-model"); } } /// An explicit `AgentDefinition.model = Override(id)` pin routes the /// subagent to that model even when the parent runs a light model. #[tokio::test] async fn resolve_subagent_agent_definition_pin_applies_for_light_parent() { use kigi_agent::config::ModelOverride; let mut ctx = ctx_with_toggle(HashMap::new()); ctx.sampling_config.model = "kigi-4.5".to_string(); ctx.model_id = acp::ModelId::new("kigi-4.5"); ctx.available_models .insert("pinned-model".to_string(), test_model_entry("pinned-model")); let agent_model = ModelOverride::Override("pinned-model".to_string()); let (config, model_id) = resolve_subagent_sampling_config( "explore", &agent_model, &ctx, ) .await; assert_eq!(config.model, "pinned-model"); assert_eq!(model_id.0.as_ref(), "pinned-model"); } /// Priority 1 (`[subagents.models]`) wins over Priority 2 /// (`AgentDefinition.model`) when both pins are set and both resolve. #[tokio::test] async fn resolve_subagent_config_override_wins_over_agent_definition() { use kigi_agent::config::ModelOverride; let mut ctx = ctx_with_toggle(HashMap::new()); ctx.sampling_config.model = "kigi-4.5".to_string(); ctx.model_id = acp::ModelId::new("kigi-4.5"); ctx.available_models .insert("config-pin".to_string(), test_model_entry("config-pin")); ctx.available_models .insert("agentdef-pin".to_string(), test_model_entry("agentdef-pin")); ctx.subagent_model_overrides.insert("explore".to_string(), "config-pin".to_string()); let agent_model = ModelOverride::Override("agentdef-pin".to_string()); let (config, model_id) = resolve_subagent_sampling_config( "explore", &agent_model, &ctx, ) .await; assert_eq!(config.model, "config-pin"); assert_eq!(model_id.0.as_ref(), "config-pin"); } /// An unresolvable `[subagents.models]` pin (model absent from /// `available_models`) falls through to inherit the parent model. #[tokio::test] async fn resolve_subagent_config_override_unknown_model_falls_through_to_inherit() { use kigi_agent::config::ModelOverride; let mut ctx = ctx_with_toggle(HashMap::new()); ctx.sampling_config.model = "kigi-4.5".to_string(); ctx.model_id = acp::ModelId::new("kigi-4.5"); ctx.subagent_model_overrides .insert("explore".to_string(), "does-not-exist".to_string()); let (config, model_id) = resolve_subagent_sampling_config( "explore", &ModelOverride::Inherit, &ctx, ) .await; assert_eq!(config.model, "kigi-4.5"); assert_eq!(model_id.0.as_ref(), "kigi-4.5"); } /// Build an `Arc` (primary Kimi) holding `key` as its live bearer. /// The `TempDir` is returned so the caller keeps it alive. fn kimi_primary_with_token(key: &str) -> (tempfile::TempDir, std::sync::Arc) { let dir = tempfile::tempdir().unwrap(); let manager = std::sync::Arc::new(crate::auth::AuthManager::new( dir.path(), crate::auth::KimiCodeConfig::default(), )); manager.hot_swap(crate::auth::KimiAuth { key: key.to_string(), auth_mode: crate::auth::AuthMode::OAuth, ..crate::auth::KimiAuth::test_default() }); (dir, manager) } /// LEAK 2 (subagent model-override): a grok (oauth-platform) override with a /// Kimi primary must NEVER receive the primary Kimi session token as its /// `api_key` — it draws grok's own pooled token (or `None`). Revert-to-red: the /// pre-fix code passed `ctx.auth` (Kimi) straight to `resolve_credentials`, so /// `config.api_key == "kimi-secret"` and this assertion fails. #[tokio::test] async fn subagent_override_grok_model_never_leaks_kimi_session_token() { let (_kd, manager) = kimi_primary_with_token("kimi-secret"); let mut grok = test_model_entry("grok-4-latest"); grok.info.id = Some("xai-grok/grok-4-latest".to_string()); grok.info.base_url = "https://api.x.ai/v1".to_string(); let mut models = indexmap::IndexMap::new(); models.insert("grok".to_string(), grok); let mut ctx = ctx_with_toggle(HashMap::new()); ctx.available_models = models; ctx.auth = Some(crate::auth::KimiAuth { key: "kimi-secret".to_string(), auth_mode: crate::auth::AuthMode::OAuth, ..crate::auth::KimiAuth::test_default() }); ctx.auth_manager = manager; let (config, _model_id) = resolve_model_override_to_config("grok", &ctx).expect("grok override resolves to a config"); assert_ne!( config.api_key.as_deref(), Some("kimi-secret"), "a grok override must never receive the primary Kimi session token", ); } /// Byte-identical guard: an override on the SESSION's own first-party endpoint /// (the kimi-code subscription channel) still resolves to the primary session /// token — the primary path is unchanged. #[tokio::test] async fn subagent_override_first_party_model_still_gets_primary_token() { let (_kd, manager) = kimi_primary_with_token("kimi-secret"); let mut entry = test_model_entry("kimi-for-coding"); entry.info.id = Some("kimi-code/kimi-for-coding".to_string()); entry.info.base_url = kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url.to_string(); let mut models = indexmap::IndexMap::new(); models.insert("kfc".to_string(), entry); let mut ctx = ctx_with_toggle(HashMap::new()); ctx.available_models = models; ctx.auth = Some(crate::auth::KimiAuth { key: "kimi-secret".to_string(), auth_mode: crate::auth::AuthMode::OAuth, ..crate::auth::KimiAuth::test_default() }); ctx.auth_manager = manager; let (config, _model_id) = resolve_model_override_to_config("kfc", &ctx) .expect("first-party override resolves to a config"); assert_eq!( config.api_key.as_deref(), Some("kimi-secret"), "a first-party override must still receive the primary session token", ); } /// LEAK guard (C1, subagent-override `api_key` channel): an API-key registry /// platform override must NOT receive the parent's primary Kimi session token — /// `resolve_credentials` would stamp it as the child's `api_key` on /// `api.moonshot.cn`. This test previously asserted the opposite /// (`subagent_override_non_oauth_model_still_gets_primary_token`), which encoded /// the defect. /// /// Revert-to-red: make `CredentialAuthority::governing_manager`'s /// `Some(platform) => None` arm return `self.primary.clone()` and `api_key` /// becomes `Some("kimi-secret")`. #[tokio::test] async fn subagent_override_api_key_platform_never_gets_the_primary_token() { let (_kd, manager) = kimi_primary_with_token("kimi-secret"); let mut entry = test_model_entry("kimi-k2-0905-preview"); entry.info.id = Some("moonshot-cn/kimi-k2".to_string()); entry.info.base_url = "https://api.moonshot.cn/v1".to_string(); let mut models = indexmap::IndexMap::new(); models.insert("k2".to_string(), entry); let mut ctx = ctx_with_toggle(HashMap::new()); ctx.available_models = models; ctx.auth = Some(crate::auth::KimiAuth { key: "kimi-secret".to_string(), auth_mode: crate::auth::AuthMode::OAuth, ..crate::auth::KimiAuth::test_default() }); ctx.auth_manager = manager; let (config, _model_id) = resolve_model_override_to_config("k2", &ctx) .expect("an API-key-platform override still resolves to a config"); assert_ne!( config.api_key.as_deref(), Some("kimi-secret"), "LEAK: an API-key-platform override must never receive the primary Kimi session token", ); } /// An unresolvable `AgentDefinition.model` pin (model absent from /// `available_models`) falls through to inherit the parent model. #[tokio::test] async fn resolve_subagent_agent_definition_unknown_model_falls_through_to_inherit() { use kigi_agent::config::ModelOverride; let mut ctx = ctx_with_toggle(HashMap::new()); ctx.sampling_config.model = "kigi-4.5".to_string(); ctx.model_id = acp::ModelId::new("kigi-4.5"); let agent_model = ModelOverride::Override("does-not-exist".to_string()); let (config, model_id) = resolve_subagent_sampling_config( "explore", &agent_model, &ctx, ) .await; assert_eq!(config.model, "kigi-4.5"); assert_eq!(model_id.0.as_ref(), "kigi-4.5"); } #[test] fn key_prefix_truncates_to_8_chars() { let key = Some("eyJ0eXAiOiJhbGciOiJSUzI1NiJ9".to_string()); assert_eq!(key_prefix(& key), "eyJ0eXAi"); } #[test] fn key_prefix_short_key_not_truncated() { let key = Some("abc".to_string()); assert_eq!(key_prefix(& key), "abc"); } #[test] fn key_prefix_none_returns_placeholder() { assert_eq!(key_prefix(& None), ""); } #[test] fn key_prefix_empty_string() { let key = Some(String::new()); assert_eq!(key_prefix(& key), ""); } #[test] fn non_cursor_persona_injected_as_system_reminder() { use kigi_sampling_types::conversation::{ConversationItem, SyntheticReason}; let persona = "You are a pragmatic implementer."; let mut conv = vec![ ConversationItem::system("sys"), ConversationItem::user("task"), ]; let mut prefix_len: usize = 2; let reminder = ConversationItem::system_reminder( format!("\n{persona}\n"), ); let insert_at = prefix_len.min(conv.len()); conv.insert(insert_at, reminder); prefix_len += 1; assert_eq!(conv.len(), 3, "conversation should have 3 items"); assert_eq!(prefix_len, 3, "prefix_len should be incremented"); if let ConversationItem::User(ref u) = conv[2] { assert_eq!(u.synthetic_reason, Some(SyntheticReason::SystemReminder)); let text = u .content .first() .map(|c| match c { kigi_sampling_types::conversation::ContentPart::Text { text } => { text.as_ref() } _ => "", }); assert!( text.unwrap_or("").contains(""), "should use hyphen tag format" ); assert!( text.unwrap_or("").contains(persona), "should contain the persona instructions" ); } else { panic!("expected User variant for system_reminder"); } } #[test] fn persona_injection_skipped_for_resumed() { use kigi_sampling_types::conversation::ConversationItem; let persona_instructions = Some("Be thorough.".to_string()); let context_source = InitialContextSource::Resumed; let mut conv = vec![ ConversationItem::system("sys"), ConversationItem::user("old turn"), ]; let original_len = conv.len(); let mut prefix_len = original_len; if context_source != InitialContextSource::Resumed && let Some(ref pi) = persona_instructions { let reminder = ConversationItem::system_reminder( format!("\n{pi}\n"), ); let insert_at = prefix_len.min(conv.len()); conv.insert(insert_at, reminder); prefix_len += 1; } assert_eq!( conv.len(), original_len, "resumed session should not get persona injected" ); assert_eq!(prefix_len, original_len, "prefix_len should be unchanged"); } #[test] fn persona_injection_into_empty_conversation() { use kigi_sampling_types::conversation::ConversationItem; let mut conv: Vec = vec![]; let mut prefix_len: usize = 0; let reminder = ConversationItem::system_reminder( "\nDo X.\n".to_string(), ); let insert_at = prefix_len.min(conv.len()); conv.insert(insert_at, reminder); prefix_len += 1; assert_eq!(conv.len(), 1); assert_eq!(prefix_len, 1); assert!(matches!(& conv[0], ConversationItem::User(_))); } mod cancellation_error_message_tests { use super::super::cancellation_error_message; use crate::session::commands::CancellationContext; use kigi_file_utils::events::types::CancellationCategory; #[test] fn permission_rejected_with_context() { let ctx = CancellationContext { tool_name: Some("run_terminal_cmd".into()), reason: Some("User rejected the execution".into()), ..Default::default() }; let msg = cancellation_error_message( Some(CancellationCategory::PermissionRejected), Some(&ctx), ); assert!(msg.contains("user rejected permission")); assert!(msg.contains("run_terminal_cmd")); assert!(msg.contains("User rejected the execution")); } #[test] fn permission_rejected_without_context() { let msg = cancellation_error_message( Some(CancellationCategory::PermissionRejected), None, ); assert!(msg.contains("user rejected a permission prompt")); } #[test] fn permission_cancelled() { let msg = cancellation_error_message( Some(CancellationCategory::PermissionCancelled), None, ); assert!(msg.contains("user cancelled a permission prompt")); } #[test] fn hook_denied_with_context() { let ctx = CancellationContext { tool_name: Some("run_terminal_cmd".into()), reason: Some("blocked by policy".into()), hook_name: Some("safe-shell-guard".into()), ..Default::default() }; let msg = cancellation_error_message( Some(CancellationCategory::HookDenied), Some(&ctx), ); assert!(msg.contains("hook denied")); assert!(msg.contains("safe-shell-guard")); assert!(msg.contains("run_terminal_cmd")); } #[test] fn hook_denied_without_context() { let msg = cancellation_error_message( Some(CancellationCategory::HookDenied), None, ); assert!(msg.contains("blocked by a hook")); } #[test] fn mid_turn_abort() { let msg = cancellation_error_message( Some(CancellationCategory::MidTurnAbort), None, ); assert!(msg.contains("aborted mid-turn")); } #[test] fn no_category_no_context() { let msg = cancellation_error_message(None, None); assert_eq!(msg, "Subagent turn was cancelled"); } #[test] fn partial_context_only_tool_name() { let ctx = CancellationContext { tool_name: Some("search_replace".into()), ..Default::default() }; let msg = cancellation_error_message( Some(CancellationCategory::PermissionRejected), Some(&ctx), ); assert!(msg.contains("search_replace")); } #[test] fn empty_context_falls_back() { let ctx = CancellationContext::default(); let msg = cancellation_error_message( Some(CancellationCategory::PermissionRejected), Some(&ctx), ); assert!(msg.contains("user rejected a permission prompt")); } } fn make_pool(names: &[&str]) -> crate::session::mcp_servers::SharedMcpPool { use crate::session::mcp_servers::{McpClient, McpState, SharedMcpPool}; let mut state = McpState::new(vec![]); for &name in names { state.owned_clients.insert(name.to_string(), Arc::new(McpClient::stub(name))); } SharedMcpPool::from_state(&state) } fn pool_names(pool: &crate::session::mcp_servers::SharedMcpPool) -> Vec { let mut names: Vec = pool.server_names().map(str::to_string).collect(); names.sort(); names } #[test] fn filter_inheritance_all_passes_everything_through() { let pool = make_pool(&["github", "linear", "slack"]); let result = super::filter_pool_by_inheritance( pool, &kigi_agent::config::McpInheritance::All, ); let result = result.expect("All should return Some"); assert_eq!(pool_names(& result), vec!["github", "linear", "slack"]); } #[test] fn filter_inheritance_none_returns_none() { let pool = make_pool(&["github", "linear"]); let result = super::filter_pool_by_inheritance( pool, &kigi_agent::config::McpInheritance::None, ); assert!(result.is_none()); } #[test] fn filter_inheritance_named_selects_specific_servers() { let pool = make_pool(&["github", "linear", "slack", "jira"]); let result = super::filter_pool_by_inheritance( pool, &kigi_agent::config::McpInheritance::Named( vec!["github".into(), "slack".into()], ), ); let result = result.expect("Named should return Some"); assert_eq!(pool_names(& result), vec!["github", "slack"]); } #[test] fn filter_inheritance_except_excludes_specific_servers() { let pool = make_pool(&["github", "linear", "slack", "jira"]); let result = super::filter_pool_by_inheritance( pool, &kigi_agent::config::McpInheritance::Except( vec!["linear".into(), "jira".into()], ), ); let result = result.expect("Except should return Some"); assert_eq!(pool_names(& result), vec!["github", "slack"]); } #[test] fn filter_inheritance_named_empty_list_gives_empty_pool() { let pool = make_pool(&["github", "linear"]); let result = super::filter_pool_by_inheritance( pool, &kigi_agent::config::McpInheritance::Named(vec![]), ); let result = result.expect("Named([]) should return Some (empty pool)"); assert_eq!(result.server_names().count(), 0); } #[test] fn filter_inheritance_except_empty_list_keeps_all() { let pool = make_pool(&["github", "linear"]); let result = super::filter_pool_by_inheritance( pool, &kigi_agent::config::McpInheritance::Except(vec![]), ); let result = result.expect("Except([]) should return Some"); assert_eq!(pool_names(& result), vec!["github", "linear"]); } #[test] fn filter_inheritance_named_nonexistent_servers_ignored() { let pool = make_pool(&["github", "linear"]); let result = super::filter_pool_by_inheritance( pool, &kigi_agent::config::McpInheritance::Named( vec!["nonexistent".into(), "github".into(),], ), ); let result = result.expect("Named should return Some"); assert_eq!(pool_names(& result), vec!["github"]); } #[test] fn filter_inheritance_except_nonexistent_servers_ignored() { let pool = make_pool(&["github", "linear"]); let result = super::filter_pool_by_inheritance( pool, &kigi_agent::config::McpInheritance::Except(vec!["nonexistent".into()]), ); let result = result.expect("Except should return Some"); assert_eq!(pool_names(& result), vec!["github", "linear"]); } #[test] fn filter_inheritance_named_all_nonexistent_gives_empty() { let pool = make_pool(&["github", "linear"]); let result = super::filter_pool_by_inheritance( pool, &kigi_agent::config::McpInheritance::Named(vec!["foo".into(), "bar".into()]), ); let result = result.expect("Named should return Some"); assert_eq!(result.server_names().count(), 0); } #[test] fn filter_inheritance_except_all_servers_gives_empty() { let pool = make_pool(&["github", "linear"]); let result = super::filter_pool_by_inheritance( pool, &kigi_agent::config::McpInheritance::Except( vec!["github".into(), "linear".into()], ), ); let result = result.expect("Except should return Some"); assert_eq!(result.server_names().count(), 0); } fn make_test_skill( name: &str, plugin: Option<&str>, ) -> kigi_tools::implementations::skills::types::SkillInfo { kigi_tools::implementations::skills::types::SkillInfo { name: name.into(), display_name: None, description: format!("{name} skill"), path: format!("/skills/{name}/SKILL.md"), scope: kigi_tools::implementations::skills::types::SkillScope::Local, enabled: true, user_invocable: true, plugin_name: plugin.map(Into::into), when_to_use: None, short_description: None, author: None, argument_hint: None, license: None, compatibility: None, metadata: None, config_source: None, plugin_version: None, plugin_root: None, plugin_data: None, allowed_tools: None, model: None, effort: None, disable_model_invocation: false, has_user_specified_description: false, paths: None, body: None, } } #[test] fn skills_inherited_count_zero_when_inherit_disabled() { let inherit_skills = false; let parent_skills = Some(vec![make_test_skill("skill-a", None)]); let count = if inherit_skills { parent_skills.as_ref().map(|s| s.len() as u32).unwrap_or(0) } else { 0 }; assert_eq!(count, 0, "should be 0 when inherit_skills is false"); } #[test] fn skills_inherited_count_matches_parent_skills_len() { let inherit_skills = true; let parent_skills = Some( vec![ make_test_skill("codegen-conventions", None), make_test_skill("tui-release", Some("my-plugin")), ], ); let count = if inherit_skills { parent_skills.as_ref().map(|s| s.len() as u32).unwrap_or(0) } else { 0 }; assert_eq!(count, 2); } /// Both directions of the publisher→parent goal gate: flipping it /// would silently kill live-token wiring end-to-end. #[test] fn goal_tick_cmd_tx_gates_on_goal_enabled() { let (tx, _rx) = mpsc::unbounded_channel::(); assert!( goal_tick_cmd_tx(true, Some(& tx)).is_some(), "goal on + channel present must wire ticks", ); assert!( goal_tick_cmd_tx(false, Some(& tx)).is_none(), "goal off must not pay the per-tick send", ); assert!(goal_tick_cmd_tx(true, None).is_none()); assert!(goal_tick_cmd_tx(false, None).is_none()); } /// Producer side of the goal live-token wiring: a publisher tick must /// land on the parent command channel as a `SubagentProgress` /// notification carrying the child's signal values. #[tokio::test(flavor = "current_thread", start_paused = true)] async fn progress_publisher_delivers_ticks_to_parent_cmd_channel() { use crate::session::signals::SessionSignalsHandle; use crate::test_support::lsp_runtime::test_gateway; let local = tokio::task::LocalSet::new(); local .run_until(async { let signals = SessionSignalsHandle::new(); signals.increment_turn(); signals.record_tool_call("bash"); tokio::task::yield_now().await; let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::(); let cancel = tokio_util::sync::CancellationToken::new(); spawn_progress_publisher( signals, test_gateway(), "parent-1".to_string(), "sub-1".to_string(), "child-1".to_string(), std::time::Instant::now(), cancel.clone(), Some(cmd_tx), ); let cmd = tokio::time::timeout( std::time::Duration::from_secs(30), cmd_rx.recv(), ) .await .expect("a tick must arrive within the publish interval") .expect("channel open"); cancel.cancel(); let SessionCommand::XaiSessionNotification { notification } = cmd else { panic!("expected XaiSessionNotification"); }; let SessionUpdate::SubagentProgress { subagent_id, parent_session_id, turn_count, tool_call_count, .. } = notification.update else { panic!("expected SubagentProgress, got {:?}", notification.update); }; assert_eq!(subagent_id, "sub-1"); assert_eq!(parent_session_id, "parent-1"); assert_eq!(turn_count, 1); assert_eq!(tool_call_count, 1); }) .await; }