Files
Kigi-CLI/crates/codegen/kigi-shell/src/agent/subagent/tests/mod.rs
T
ZacharyZhang-NY d6c20fc13f M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

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

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

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

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

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

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

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
2026-07-17 05:31:01 -04:00

3304 lines
125 KiB
Rust

#![cfg_attr(rustfmt, rustfmt::skip)]
use super::*;
use crate::test_support::lsp_runtime::{
DummyLspDispatch, ctx_with_toggle, make_request, test_gateway,
};
/// Invariant: resolving a subagent applies the parent session's
/// `--tools`/`--disallowed-tools`/`--permission-mode` — driven through
/// `resolve_agent_definition` so the spawn path can't skip them.
#[tokio::test]
async fn subagent_inherits_session_cli_overrides() {
use kigi_agent::config::{AgentDefinition, PermissionMode};
let mut probe = AgentDefinition::general_purpose();
probe.name = "session-override-probe".into();
probe.permission_mode = PermissionMode::Plan;
probe.disallowed_tools = vec!["write".into()];
let mut config = crate::agent::config::Config::default();
config.cli_agents = vec![probe];
config.cli_agent_overrides = crate::agent::config::CliAgentOverrides {
tools: Some(vec!["read_file".into(), "grep".into()]),
disallowed_tools: Some(vec!["web_search".into(), "write".into()]),
permission_mode: Some(PermissionMode::AcceptEdits),
..Default::default()
};
let mut ctx = ctx_with_toggle(std::collections::HashMap::new());
ctx.agent_config = Some(config);
let def = resolve_agent_definition("session-override-probe", &ctx)
.expect("cli agent resolves");
assert_eq!(
def.session_tools_allowlist.as_deref(), Some(& ["read_file".into(), "grep"
.into()] [..])
);
assert_eq!(
def.session_tools_denylist.as_deref(), Some(& ["web_search".into(), "write"
.into()] [..])
);
assert_eq!(def.disallowed_tools, vec!["write"]);
assert_eq!(def.permission_mode, PermissionMode::AcceptEdits);
}
/// `permissionMode: bypassPermissions` is downgraded to `Default` under the
/// pin and honored without it; other modes and plugin stripping unaffected.
#[test]
fn subagent_bypass_permission_mode_gated_by_policy_pin() {
use kigi_agent::config::PermissionMode;
const PIN: &str = kigi_workspace::permission::resolution::YOLO_PIN_REASON_REQUIREMENTS;
assert_eq!(
resolve_subagent_permission_mode(PermissionMode::BypassPermissions, false, None),
PermissionMode::BypassPermissions,
);
assert_eq!(
resolve_subagent_permission_mode(PermissionMode::BypassPermissions, false,
Some(PIN)), PermissionMode::Default,
);
assert_eq!(
resolve_subagent_permission_mode(PermissionMode::Plan, false, Some(PIN)),
PermissionMode::Plan,
);
assert_eq!(
resolve_subagent_permission_mode(PermissionMode::BypassPermissions, true, None),
PermissionMode::Default,
);
}
/// Persisted⇒stamped chokepoint for the subagent emitter: the
/// `SessionCommand` persist hop and the live broadcast must carry the
/// SAME `eventId`, minted before the fork (divergent or missing ids
/// degrade cursor reconnects to full replays or re-applied lines).
#[tokio::test]
async fn emit_subagent_notification_stamps_one_event_id_on_both_paths() {
use crate::test_support::lsp_runtime::test_gateway_with_receiver;
let (gateway, mut gateway_rx) = test_gateway_with_receiver();
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel();
emit_subagent_notification(
&gateway,
"parent-sess",
SessionUpdate::SubagentFinished {
subagent_id: "sa-1".into(),
child_session_id: "child-1".into(),
status: "completed".into(),
error: None,
tool_calls: 0,
turns: 0,
duration_ms: 5,
tokens_used: 0,
output: None,
will_wake: false,
},
Some(&cmd_tx),
);
let persisted_id = match cmd_rx.try_recv().expect("persist hop must fire") {
SessionCommand::XaiSessionNotification { notification } => {
notification
.meta
.as_ref()
.and_then(|m| m.get("eventId"))
.and_then(|v| v.as_str())
.expect("persisted subagent lines must carry an eventId")
.to_string()
}
_ => panic!("expected XaiSessionNotification"),
};
assert!(persisted_id.starts_with("parent-sess-"));
let broadcast_id = match gateway_rx.try_recv().expect("broadcast must fire") {
kigi_acp_lib::AcpClientMessage::ExtNotification(args) => {
let params: serde_json::Value = serde_json::from_str(
args.request.params.get(),
)
.unwrap();
params["_meta"]["eventId"].as_str().unwrap().to_string()
}
_ => panic!("expected ExtNotification"),
};
assert_eq!(persisted_id, broadcast_id);
}
#[test]
fn subagent_max_turns_definition_wins_else_inherits_parent() {
assert_eq!(super::resolve_subagent_max_turns(Some(2), Some(5)), Some(2));
assert_eq!(super::resolve_subagent_max_turns(None, Some(5)), Some(5));
}
#[test]
fn resume_worktree_action_covers_three_outcomes() {
use super::{ResumeWorktreeAction, resume_worktree_action};
assert_eq!(
resume_worktree_action(true, Some("refs/grok/subagents/x")),
ResumeWorktreeAction::Rehydrate
);
assert_eq!(
resume_worktree_action(false, Some("refs/grok/subagents/x")),
ResumeWorktreeAction::Rehydrate
);
assert_eq!(resume_worktree_action(true, None), ResumeWorktreeAction::Reuse);
assert_eq!(resume_worktree_action(false, None), ResumeWorktreeAction::Shared);
}
#[test]
fn subagent_inherits_parent_lsp_via_context() {
let parent: std::sync::Arc<dyn kigi_tools::implementations::lsp::LspBackend> = Arc::new(
DummyLspDispatch,
);
let mut ctx = ctx_with_toggle(HashMap::new());
ctx.lsp = Some(parent.clone());
assert!(ctx.lsp.is_some());
assert_eq!(
Arc::as_ptr(& parent), Arc::as_ptr(ctx.lsp.as_ref().unwrap()),
"child should inherit parent LSP via context"
);
}
#[test]
fn subagent_inherits_managed_mcp_state_via_context() {
let handle = crate::session::managed_mcp::ManagedMcpStateHandle::default();
let mut ctx = ctx_with_toggle(HashMap::new());
ctx.managed_mcp_state = handle.clone();
assert!(
Arc::ptr_eq(& handle, & ctx.managed_mcp_state),
"child should share parent's managed MCP state (Arc identity)"
);
}
#[test]
fn no_parent_lsp_means_child_gets_none() {
let ctx = ctx_with_toggle(HashMap::new());
assert!(ctx.lsp.is_none());
}
#[test]
fn is_subagent_enabled_returns_true_for_absent_names() {
let ctx = ctx_with_toggle(HashMap::from([("plan".to_string(), false)]));
assert!(ctx.is_subagent_enabled("explore"), "absent key should default to enabled");
assert!(
ctx.is_subagent_enabled("general-purpose"),
"absent key should default to enabled"
);
assert!(
ctx.is_subagent_enabled("custom-agent"), "absent key should default to enabled"
);
}
#[test]
fn is_subagent_enabled_returns_false_for_disabled_names() {
let ctx = ctx_with_toggle(
HashMap::from([
("plan".to_string(), false),
("code-reviewer".to_string(), false),
("explore".to_string(), true),
]),
);
assert!(! ctx.is_subagent_enabled("plan"), "plan = false should be disabled");
assert!(
! ctx.is_subagent_enabled("code-reviewer"),
"code-reviewer = false should be disabled"
);
assert!(ctx.is_subagent_enabled("explore"), "explore = true should be enabled");
}
#[test]
fn lookup_returns_none_for_unknown_id() {
let coordinator = SubagentCoordinator::new();
assert!(coordinator.lookup("nonexistent").is_none());
}
#[test]
fn lookup_returns_ready_for_completed_subagent() {
let mut coordinator = SubagentCoordinator::new();
coordinator
.move_to_completed(
"sub-1",
"test task".to_string(),
"explore".to_string(),
SubagentResult {
success: true,
output: std::sync::Arc::from("found 3 files"),
subagent_id: "sub-1".to_string(),
child_session_id: "sub-1".to_string(),
tool_calls: 5,
turns: 2,
duration_ms: 1234,
..Default::default()
},
);
let lookup = coordinator.lookup("sub-1");
assert!(lookup.is_some());
assert!(
matches!(lookup, Some(SnapshotLookup::Ready(ref snap)) if snap.subagent_id ==
"sub-1"), "completed subagent should return Ready variant"
);
}
#[tokio::test]
async fn resolve_snapshot_returns_none_for_none_input() {
let result = resolve_snapshot(None).await;
assert!(result.is_none());
}
#[tokio::test]
async fn resolve_snapshot_returns_ready_unchanged() {
let snap = SubagentSnapshot {
subagent_id: "sub-1".to_string(),
description: "test".to_string(),
subagent_type: "explore".to_string(),
status: SubagentSnapshotStatus::Completed {
output: "done".to_string(),
tool_calls: 3,
turns: 1,
worktree_path: None,
},
started_at_epoch_ms: 0,
duration_ms: 100,
persona: None,
};
let result = resolve_snapshot(Some(SnapshotLookup::Ready(snap))).await;
let result = result.expect("Ready should resolve to Some");
assert_eq!(result.subagent_id, "sub-1");
assert!(matches!(result.status, SubagentSnapshotStatus::Completed { .. }));
}
#[tokio::test]
async fn resolve_snapshot_populates_running_from_signals() {
use crate::session::signals::SessionSignalsHandle;
let signals_handle = SessionSignalsHandle::new();
signals_handle.increment_turn();
signals_handle.record_tool_call("bash");
signals_handle.record_tool_call("read_file");
signals_handle.record_tool_call("bash");
tokio::task::yield_now().await;
let seed = RunningSnapshotSeed {
subagent_id: "sub-running".to_string(),
description: "running task".to_string(),
subagent_type: "general-purpose".to_string(),
started_at_epoch_ms: 1000,
duration_ms: 5000,
persona: None,
signals_handle,
};
let result = resolve_snapshot(Some(SnapshotLookup::NeedsSignals(seed))).await;
let snap = result.expect("NeedsSignals should resolve to Some");
assert_eq!(snap.subagent_id, "sub-running");
assert_eq!(snap.duration_ms, 5000);
match &snap.status {
SubagentSnapshotStatus::Running {
turn_count,
tool_call_count,
tools_used,
..
} => {
assert_eq!(* turn_count, 1, "should have 1 turn");
assert_eq!(* tool_call_count, 3, "should have 3 tool calls");
assert!(
tools_used.contains(& "bash".to_string()),
"tools_used should contain bash"
);
assert!(
tools_used.contains(& "read_file".to_string()),
"tools_used should contain read_file"
);
}
other => panic!("expected Running, got {other:?}"),
}
}
#[test]
fn is_running_returns_true_for_running_variant() {
let snap = SubagentSnapshot {
subagent_id: "s".to_string(),
description: "d".to_string(),
subagent_type: "t".to_string(),
status: SubagentSnapshotStatus::Running {
turn_count: 0,
tool_call_count: 0,
tokens_used: 0,
context_window_tokens: 0,
context_usage_pct: 0,
tools_used: vec![],
error_count: 0,
},
started_at_epoch_ms: 0,
duration_ms: 0,
persona: None,
};
assert!(is_running(& snap));
}
#[test]
fn is_running_returns_false_for_completed_variant() {
let snap = SubagentSnapshot {
subagent_id: "s".to_string(),
description: "d".to_string(),
subagent_type: "t".to_string(),
status: SubagentSnapshotStatus::Completed {
output: "done".to_string(),
tool_calls: 0,
turns: 0,
worktree_path: None,
},
started_at_epoch_ms: 0,
duration_ms: 0,
persona: None,
};
assert!(! is_running(& snap));
}
#[test]
fn lookup_returns_initializing_for_pending_subagent() {
let mut coordinator = SubagentCoordinator::new();
coordinator
.insert_pending(PendingSubagent {
subagent_id: "sub-pending".to_string(),
subagent_type: "general-purpose".to_string(),
description: "pending task".to_string(),
persona: None,
parent_prompt_id: None,
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 lookup = coordinator.lookup("sub-pending");
assert!(
matches!(lookup, Some(SnapshotLookup::Ready(ref snap)) if snap.subagent_id ==
"sub-pending" && matches!(snap.status, SubagentSnapshotStatus::Initializing)),
"pending subagent should return Ready(Initializing)"
);
}
/// The running gauge must track `pending.len() + active.len()` through the
/// full lifecycle: it feeds `AgentActivity::is_busy`, which gates the
/// leader auto-update shutdown.
#[tokio::test]
async fn running_gauge_tracks_pending_and_active() {
use std::sync::atomic::Ordering;
let mut coordinator = SubagentCoordinator::new();
let gauge = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
coordinator.set_running_gauge(gauge.clone());
assert_eq!(gauge.load(Ordering::Relaxed), 0);
coordinator
.insert_pending(PendingSubagent {
subagent_id: "sub-gauge".to_string(),
subagent_type: "general-purpose".to_string(),
description: "gauge task".to_string(),
persona: None,
parent_prompt_id: None,
parent_session_id: String::new(),
started_at: std::time::Instant::now(),
run_in_background: true,
surface_completion: true,
color: None,
cancel_token: CancellationToken::new(),
});
assert_eq!(gauge.load(Ordering::Relaxed), 1, "pending counts as running");
coordinator
.insert(
dummy_tracker("sub-gauge", "parent-session", "general-purpose", "gauge task"),
);
assert_eq!(gauge.load(Ordering::Relaxed), 1, "active counts as running");
coordinator
.move_to_completed(
"sub-gauge",
"gauge task".into(),
"general-purpose".into(),
SubagentResult::default(),
);
assert_eq!(gauge.load(Ordering::Relaxed), 0, "completed does not count");
coordinator
.insert_pending(PendingSubagent {
subagent_id: "sub-gauge-2".to_string(),
subagent_type: "general-purpose".to_string(),
description: "gauge task 2".to_string(),
persona: None,
parent_prompt_id: None,
parent_session_id: String::new(),
started_at: std::time::Instant::now(),
run_in_background: true,
surface_completion: true,
color: None,
cancel_token: CancellationToken::new(),
});
assert_eq!(gauge.load(Ordering::Relaxed), 1);
coordinator.move_pending_to_failed("sub-gauge-2", "worktree setup failed");
assert_eq!(gauge.load(Ordering::Relaxed), 0);
let late_gauge = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
coordinator
.insert_pending(PendingSubagent {
subagent_id: "sub-gauge-3".to_string(),
subagent_type: "general-purpose".to_string(),
description: "gauge task 3".to_string(),
persona: None,
parent_prompt_id: None,
parent_session_id: String::new(),
started_at: std::time::Instant::now(),
run_in_background: true,
surface_completion: true,
color: None,
cancel_token: CancellationToken::new(),
});
coordinator.set_running_gauge(late_gauge.clone());
assert_eq!(late_gauge.load(Ordering::Relaxed), 1);
}
#[test]
fn mark_block_waited_sets_flag_on_completed() {
let mut coordinator = SubagentCoordinator::new();
coordinator
.move_to_completed(
"sub-bw",
"test".into(),
"explore".into(),
SubagentResult {
success: true,
subagent_id: "sub-bw".into(),
child_session_id: "sub-bw".into(),
..Default::default()
},
);
assert!(! coordinator.is_block_waited("sub-bw"));
coordinator.mark_block_waited("sub-bw");
assert!(coordinator.is_block_waited("sub-bw"));
}
#[test]
fn is_block_waited_returns_false_for_unknown_id() {
let coordinator = SubagentCoordinator::new();
assert!(! coordinator.is_block_waited("nonexistent"));
}
/// Race condition: caller cancels the blocking wait
/// (receiver dropped) and the subagent completes before the query poll
/// loop's next 200ms tick clears the flag. The completion handler's
/// decision-time check must see the dead slot, clear `block_waited`,
/// and let the auto-wake fire.
#[tokio::test]
async fn block_wait_decision_wakes_when_waiter_cancelled_before_poll_tick() {
let mut coordinator = SubagentCoordinator::new();
let tracker = dummy_tracker("sub-race", "session-A", "explore", "bg task");
coordinator.insert(tracker);
let (tx, rx) = oneshot::channel::<Option<SubagentSnapshot>>();
let slot: BlockWaitSlot = std::rc::Rc::new(std::cell::RefCell::new(Some(tx)));
coordinator.register_block_wait("sub-race", slot.clone());
assert!(coordinator.is_block_waited("sub-race"));
drop(rx);
assert!(coordinator.is_block_waited("sub-race"));
assert!(
! coordinator.block_wait_delivered_or_live("sub-race"),
"cancelled waiter must not suppress the completion auto-wake"
);
assert!(
! coordinator.is_block_waited("sub-race"),
"decision must clear the stale block_waited flag"
);
}
/// A live waiter (receiver still open) keeps the wake suppressed — the
/// poll loop will deliver the result within one tick.
#[tokio::test]
async fn block_wait_decision_suppresses_for_live_waiter() {
let mut coordinator = SubagentCoordinator::new();
let tracker = dummy_tracker("sub-live", "session-A", "explore", "bg task");
coordinator.insert(tracker);
let (tx, _rx) = oneshot::channel::<Option<SubagentSnapshot>>();
let slot: BlockWaitSlot = std::rc::Rc::new(std::cell::RefCell::new(Some(tx)));
coordinator.register_block_wait("sub-live", slot.clone());
assert!(
coordinator.block_wait_delivered_or_live("sub-live"),
"live waiter will receive the result — wake would be redundant"
);
assert!(coordinator.is_block_waited("sub-live"), "flag stays set for a live waiter");
}
/// A consumed sender (result already delivered) keeps the wake
/// suppressed even though the registration is gone.
#[tokio::test]
async fn block_wait_decision_suppresses_after_delivery() {
let mut coordinator = SubagentCoordinator::new();
let tracker = dummy_tracker("sub-dlv", "session-A", "explore", "bg task");
coordinator.insert(tracker);
let (tx, mut rx) = oneshot::channel::<Option<SubagentSnapshot>>();
let slot: BlockWaitSlot = std::rc::Rc::new(std::cell::RefCell::new(Some(tx)));
coordinator.register_block_wait("sub-dlv", slot.clone());
let tx = slot.borrow_mut().take().expect("sender parked");
let _ = tx.send(None);
assert!(rx.try_recv().is_ok(), "receiver got the result");
coordinator.unregister_block_wait("sub-dlv", &slot);
assert!(
coordinator.block_wait_delivered_or_live("sub-dlv"),
"already-delivered result must keep the wake suppressed"
);
}
#[tokio::test]
async fn mark_explicitly_killed_active_then_propagates_to_completed() {
let mut coordinator = SubagentCoordinator::new();
let tracker = dummy_tracker("sub-ek", "session-A", "explore", "bg task");
coordinator.insert(tracker);
assert!(! coordinator.is_explicitly_killed("sub-ek"));
coordinator.mark_explicitly_killed("sub-ek");
assert!(coordinator.is_explicitly_killed("sub-ek"));
coordinator
.move_to_completed(
"sub-ek",
"bg task".into(),
"explore".into(),
SubagentResult {
success: false,
cancelled: true,
subagent_id: "sub-ek".into(),
child_session_id: "sub-ek".into(),
..Default::default()
},
);
assert!(
coordinator.is_explicitly_killed("sub-ek"),
"flag must propagate from active tracker to completed entry"
);
}
#[test]
fn should_auto_wake_subagent_requires_background_and_enabled() {
assert!(! should_auto_wake_subagent(false, false, true, false, false, false, true));
assert!(! should_auto_wake_subagent(true, false, false, false, false, false, true));
assert!(should_auto_wake_subagent(true, false, true, false, false, false, true));
}
/// A cancelled child never wakes the parent — most acutely the Ctrl+C
/// race where `ParentGone` backgrounds a foreground child moments before
/// the teardown cancel lands its token.
#[test]
fn should_auto_wake_subagent_refuses_cancelled_results() {
assert!(! should_auto_wake_subagent(true, true, true, false, false, false, true));
}
#[test]
fn should_auto_wake_subagent_suppressed_by_block_waited_or_killed() {
assert!(! should_auto_wake_subagent(true, false, true, true, false, false, true));
assert!(! should_auto_wake_subagent(true, false, true, false, true, false, true));
assert!(! should_auto_wake_subagent(true, false, true, true, true, false, true));
}
/// A goal loop active in the parent suppresses the subagent
/// auto-wake synthetic prompt — the structural sibling of the bash gate.
/// Skipping the inject here also skips `auto_wake_delivered.insert`, so the
/// per-tool-call / between-turn surfaces stay free to drain the completion.
#[test]
fn should_auto_wake_subagent_suppressed_by_goal_loop() {
assert!(! should_auto_wake_subagent(true, false, true, false, false, true, true));
assert!(should_auto_wake_subagent(true, false, true, false, false, false, true));
}
#[test]
fn should_auto_wake_subagent_requires_open_parent_channel() {
assert!(! should_auto_wake_subagent(true, false, true, false, false, false, false));
}
fn auto_wake_test_request(id: &str) -> SubagentRequest {
let (result_tx, _result_rx) = oneshot::channel();
SubagentRequest {
id: id.into(),
prompt: String::new(),
description: "explore".into(),
subagent_type: "general-purpose".into(),
parent_session_id: "parent".into(),
parent_prompt_id: None,
resume_from: None,
cwd: None,
runtime_overrides: Default::default(),
run_in_background: true,
surface_completion: true,
fork_context: false,
result_tx,
}
}
/// Behavior-level: the action half of the subagent auto-wake.
/// When the gate lets it run, `inject_subagent_completed_prompt` sends the
/// synthetic `Prompt` to the parent AND marks the id auto-wake-delivered.
/// Paired with `should_auto_wake_subagent_suppressed_by_goal_loop`, this
/// proves the full Gap-1 contract on the subagent surface: goal active →
/// gate false → this never runs (no prompt, not marked, so surfaces 2/3
/// drain it); goal inactive → gate true → it runs (today's behavior).
#[test]
fn inject_subagent_completed_prompt_sends_prompt_and_marks_delivered() {
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<SessionCommand>();
let auto_wake = kigi_tools::reminders::task_completion::AutoWakeDeliveredIds::default();
let request = auto_wake_test_request("sa-1");
let result = SubagentResult {
success: true,
subagent_id: "sa-1".into(),
child_session_id: "sa-1".into(),
..Default::default()
};
inject_subagent_completed_prompt(
"sa-1",
&result,
&request,
&Some(auto_wake.clone()),
Some(&cmd_tx),
"get_command_or_subagent_output",
);
match cmd_rx.try_recv().expect("expected synthetic Prompt") {
SessionCommand::Prompt { prompt_id, verbatim, .. } => {
assert!(prompt_id.starts_with("subagent-completed-"));
assert!(verbatim);
}
_ => panic!("expected SessionCommand::Prompt"),
}
assert_eq!(auto_wake.snapshot(), vec!["sa-1".to_string()]);
}
#[test]
fn mark_explicitly_killed_sets_flag_on_completed() {
let mut coordinator = SubagentCoordinator::new();
coordinator
.move_to_completed(
"sub-ek-c",
"test".into(),
"explore".into(),
SubagentResult {
success: true,
subagent_id: "sub-ek-c".into(),
child_session_id: "sub-ek-c".into(),
..Default::default()
},
);
assert!(! coordinator.is_explicitly_killed("sub-ek-c"));
coordinator.mark_explicitly_killed("sub-ek-c");
assert!(coordinator.is_explicitly_killed("sub-ek-c"));
}
#[test]
fn is_explicitly_killed_returns_false_for_unknown_id() {
let coordinator = SubagentCoordinator::new();
assert!(! coordinator.is_explicitly_killed("nonexistent"));
}
#[tokio::test]
async fn block_waited_propagates_through_move_to_completed() {
let mut coordinator = SubagentCoordinator::new();
let mut tracker = dummy_tracker("sub-prop", "session-A", "explore", "bg task");
tracker.block_waited = true;
coordinator.insert(tracker);
coordinator
.move_to_completed(
"sub-prop",
"bg task".into(),
"explore".into(),
SubagentResult {
success: true,
subagent_id: "sub-prop".into(),
child_session_id: "sub-prop".into(),
..Default::default()
},
);
assert!(coordinator.is_block_waited("sub-prop"));
}
fn complete_dummy(coordinator: &mut SubagentCoordinator, id: &str, surface: bool) {
let mut tracker = dummy_tracker(id, "session-A", "explore", "task");
tracker.surface_completion = surface;
coordinator.insert(tracker);
coordinator
.move_to_completed(
id,
"task".into(),
"explore".into(),
SubagentResult {
success: true,
subagent_id: id.into(),
child_session_id: id.into(),
..Default::default()
},
);
}
#[tokio::test]
async fn move_to_completed_surfaces_when_flag_true() {
let mut coordinator = SubagentCoordinator::new();
complete_dummy(&mut coordinator, "sub-surface", true);
let drained = coordinator.drain_pending_completions();
assert_eq!(drained.len(), 1);
assert_eq!(drained[0].subagent_id, "sub-surface");
}
#[tokio::test]
async fn move_to_completed_skips_buffer_when_flag_false() {
let mut coordinator = SubagentCoordinator::new();
complete_dummy(&mut coordinator, "sub-hidden", false);
assert!(coordinator.drain_pending_completions().is_empty());
assert!(coordinator.lookup("sub-hidden").is_some());
}
fn fail_pending(coordinator: &mut SubagentCoordinator, id: &str, surface: bool) {
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: String::new(),
started_at: std::time::Instant::now(),
run_in_background: false,
surface_completion: surface,
color: None,
cancel_token: CancellationToken::new(),
});
coordinator.move_pending_to_failed(id, "boom");
}
#[test]
fn failure_completion_surfaces_when_flag_true() {
let mut coordinator = SubagentCoordinator::new();
fail_pending(&mut coordinator, "fail-surface", true);
let drained = coordinator.drain_pending_completions();
assert_eq!(drained.len(), 1);
assert_eq!(drained[0].subagent_id, "fail-surface");
assert!(! drained[0].success);
}
#[test]
fn failure_completion_skips_buffer_when_flag_false() {
let mut coordinator = SubagentCoordinator::new();
fail_pending(&mut coordinator, "fail-hidden", false);
assert!(coordinator.drain_pending_completions().is_empty());
assert!(coordinator.lookup("fail-hidden").is_some());
}
#[test]
fn is_running_returns_true_for_initializing_variant() {
let snap = SubagentSnapshot {
subagent_id: "s".to_string(),
description: "d".to_string(),
subagent_type: "t".to_string(),
status: SubagentSnapshotStatus::Initializing,
started_at_epoch_ms: 0,
duration_ms: 0,
persona: None,
};
assert!(is_running(& snap));
}
#[test]
fn remove_pending_clears_entry() {
let mut coordinator = SubagentCoordinator::new();
coordinator
.insert_pending(PendingSubagent {
subagent_id: "sub-1".to_string(),
subagent_type: "explore".to_string(),
description: "test".to_string(),
persona: None,
parent_prompt_id: None,
parent_session_id: String::new(),
started_at: std::time::Instant::now(),
run_in_background: false,
surface_completion: true,
color: None,
cancel_token: CancellationToken::new(),
});
assert!(coordinator.lookup("sub-1").is_some());
coordinator.remove_pending("sub-1");
assert!(
coordinator.lookup("sub-1").is_none(),
"pending entry should be gone after remove_pending"
);
}
#[test]
fn move_pending_to_failed_creates_completed_entry() {
let mut coordinator = SubagentCoordinator::new();
coordinator
.insert_pending(PendingSubagent {
subagent_id: "sub-fail".to_string(),
subagent_type: "explore".to_string(),
description: "will fail during init".to_string(),
persona: None,
parent_prompt_id: None,
parent_session_id: String::new(),
started_at: std::time::Instant::now(),
run_in_background: true,
surface_completion: true,
color: None,
cancel_token: CancellationToken::new(),
});
coordinator.move_pending_to_failed("sub-fail", "Sampling client error: bad config");
assert!(! coordinator.pending.contains_key("sub-fail"));
let lookup = coordinator.lookup("sub-fail");
assert!(lookup.is_some(), "failed subagent should be queryable");
match lookup.unwrap() {
SnapshotLookup::Ready(snap) => {
assert_eq!(snap.subagent_id, "sub-fail");
assert!(
matches!(snap.status, SubagentSnapshotStatus::Failed { .. }),
"status should be Failed"
);
if let SubagentSnapshotStatus::Failed { error } = &snap.status {
assert!(
error.contains("Sampling client error"),
"error should contain specific message, got: {error}"
);
}
}
_ => panic!("expected Ready snapshot for completed-as-failed subagent"),
}
}
#[test]
fn move_pending_to_failed_fires_completion_notify() {
let mut coordinator = SubagentCoordinator::new();
coordinator
.insert_pending(PendingSubagent {
subagent_id: "sub-notify".to_string(),
subagent_type: "explore".to_string(),
description: "notify test".to_string(),
persona: None,
parent_prompt_id: None,
parent_session_id: String::new(),
started_at: std::time::Instant::now(),
run_in_background: true,
surface_completion: true,
color: None,
cancel_token: CancellationToken::new(),
});
coordinator.move_pending_to_failed("sub-notify", "test error");
let summaries = coordinator.drain_pending_completions();
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].subagent_id, "sub-notify");
assert!(! summaries[0].success);
}
#[test]
fn move_pending_to_failed_noop_for_unknown_id() {
let mut coordinator = SubagentCoordinator::new();
coordinator.move_pending_to_failed("nonexistent", "error");
assert!(coordinator.completed.is_empty());
}
#[test]
fn move_pending_to_cancelled_creates_cancelled_entry() {
let mut coordinator = SubagentCoordinator::new();
coordinator
.insert_pending(PendingSubagent {
subagent_id: "sub-killed".to_string(),
subagent_type: "explore".to_string(),
description: "killed while initializing".to_string(),
persona: None,
parent_prompt_id: None,
parent_session_id: String::new(),
started_at: std::time::Instant::now(),
run_in_background: true,
surface_completion: true,
color: None,
cancel_token: CancellationToken::new(),
});
coordinator.move_pending_to_cancelled("sub-killed", "Subagent was cancelled");
assert!(! coordinator.pending.contains_key("sub-killed"));
match coordinator.lookup("sub-killed") {
Some(SnapshotLookup::Ready(snap)) => {
assert!(
matches!(snap.status, SubagentSnapshotStatus::Cancelled { .. }),
"killed-while-pending should be Cancelled, got {:?}", snap.status
)
}
_ => {
panic!("expected Ready cancelled snapshot for killed-while-pending subagent")
}
}
}
#[test]
fn evict_stale_completed_uses_completion_time() {
let mut coordinator = SubagentCoordinator::new();
coordinator
.completed
.insert(
"sub-recent".to_string(),
CompletedSubagent {
subagent_id: "sub-recent".into(),
parent_session_id: String::new(),
parent_prompt_id: None,
child_session_id: String::new(),
description: "long-running".into(),
subagent_type: "explore".into(),
persona: None,
started_at: std::time::Instant::now()
- std::time::Duration::from_secs(31 * 60),
completed_at: std::time::Instant::now(),
result: SubagentResult {
success: true,
..Default::default()
},
resumed_from: None,
child_cwd: String::new(),
worktree_path: None,
snapshot_ref: None,
effective_model_id: String::new(),
block_waited: false,
explicitly_killed: false,
},
);
coordinator.evict_stale_completed();
assert!(
coordinator.completed.contains_key("sub-recent"),
"recently completed subagent should not be evicted"
);
}
#[test]
fn cancel_with_outcome_fires_pending_token() {
let mut coordinator = SubagentCoordinator::new();
let token = CancellationToken::new();
coordinator
.insert_pending(PendingSubagent {
subagent_id: "sub-cancel".to_string(),
subagent_type: "explore".to_string(),
description: "will be cancelled".to_string(),
persona: None,
parent_prompt_id: None,
parent_session_id: String::new(),
started_at: std::time::Instant::now(),
run_in_background: false,
surface_completion: true,
color: None,
cancel_token: token.clone(),
});
let outcome = coordinator.cancel_with_outcome("sub-cancel");
assert!(
matches!(outcome, SubagentCancelOutcome::Cancelled),
"cancelling pending should return Cancelled"
);
assert!(token.is_cancelled(), "pending cancel must fire the spawn token");
assert!(
coordinator.lookup("sub-cancel").is_some(),
"pending entry stays queryable until the spawn future tears it down"
);
}
#[tokio::test]
async fn cancel_with_outcome_returns_variant_for_active_finished_unknown() {
let mut coordinator = SubagentCoordinator::new();
coordinator.insert(dummy_tracker("sub-active", "session-A", "explore", "task"));
assert!(
matches!(coordinator.cancel_with_outcome("sub-active"),
SubagentCancelOutcome::Cancelled)
);
coordinator
.move_to_completed(
"sub-done",
"done".to_string(),
"explore".to_string(),
SubagentResult {
success: true,
subagent_id: "sub-done".to_string(),
..Default::default()
},
);
assert!(
matches!(coordinator.cancel_with_outcome("sub-done"),
SubagentCancelOutcome::AlreadyFinished { status } if status == "completed")
);
assert!(
matches!(coordinator.cancel_with_outcome("nonexistent"),
SubagentCancelOutcome::NotFound)
);
}
#[test]
fn cancel_by_parent_prompt_id_fires_matching_pending_token() {
let mut coordinator = SubagentCoordinator::new();
let token_a = CancellationToken::new();
let token_b = CancellationToken::new();
coordinator
.insert_pending(PendingSubagent {
subagent_id: "sub-p1".to_string(),
subagent_type: "explore".to_string(),
description: "child of prompt-A".to_string(),
persona: None,
parent_prompt_id: Some("prompt-A".to_string()),
parent_session_id: String::new(),
started_at: std::time::Instant::now(),
run_in_background: false,
surface_completion: true,
color: None,
cancel_token: token_a.clone(),
});
coordinator
.insert_pending(PendingSubagent {
subagent_id: "sub-p2".to_string(),
subagent_type: "explore".to_string(),
description: "child of prompt-B".to_string(),
persona: None,
parent_prompt_id: Some("prompt-B".to_string()),
parent_session_id: String::new(),
started_at: std::time::Instant::now(),
run_in_background: false,
surface_completion: true,
color: None,
cancel_token: token_b.clone(),
});
coordinator.cancel_by_parent_prompt_id("prompt-A");
assert!(token_a.is_cancelled(), "prompt-A token must fire");
assert!(
coordinator.lookup("sub-p1").is_some(),
"prompt-A entry stays queryable until spawn teardown"
);
assert!(! token_b.is_cancelled(), "prompt-B token must not fire");
assert!(coordinator.lookup("sub-p2").is_some());
}
#[test]
fn completed_takes_precedence_over_pending_in_lookup() {
let mut coordinator = SubagentCoordinator::new();
coordinator
.insert_pending(PendingSubagent {
subagent_id: "sub-dup".to_string(),
subagent_type: "explore".to_string(),
description: "duplicate".to_string(),
persona: None,
parent_prompt_id: None,
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
.move_to_completed(
"sub-dup",
"duplicate".to_string(),
"explore".to_string(),
SubagentResult {
success: true,
output: std::sync::Arc::from("done"),
subagent_id: "sub-dup".to_string(),
child_session_id: "child-dup".to_string(),
..Default::default()
},
);
let lookup = coordinator.lookup("sub-dup");
assert!(
matches!(lookup, Some(SnapshotLookup::Ready(ref snap)) if matches!(snap.status,
SubagentSnapshotStatus::Completed { .. })),
"completed should take precedence over pending"
);
}
#[test]
fn list_running_for_parent_returns_empty_when_no_active() {
let coordinator = SubagentCoordinator::new();
let seeds = coordinator.list_running_for_parent("parent-1");
assert!(seeds.is_empty());
}
fn dummy_tracker(
subagent_id: &str,
parent_session_id: &str,
subagent_type: &str,
description: &str,
) -> SubagentTracker {
use crate::session::feedback_manager::{FeedbackManager, FeedbackManagerConfig};
use crate::session::handle::SessionHandle;
use crate::session::info::Info;
use crate::session::plan_mode::PlanModeTracker;
use crate::session::signals::SessionSignalsHandle;
use std::sync::atomic::AtomicBool;
let gateway = test_gateway();
let cwd = kigi_paths::AbsPathBuf::new(PathBuf::from("/tmp")).unwrap();
let fs: Arc<dyn kigi_workspace::file_system::AsyncFileSystem> = Arc::new(
kigi_workspace::file_system::LocalFs::new(PathBuf::from("/tmp")),
);
let terminal: Arc<dyn crate::terminal::AsyncTerminalRunner> = Arc::new(
crate::terminal::TerminalRunner::new(
Arc::new(test_gateway()),
acp::SessionId::new("test"),
),
);
let tool_context = crate::tools::ToolContext::new(
cwd,
Some(gateway),
Some(acp::SessionId::new("test")),
fs,
terminal,
kigi_hunk_tracker::HunkTrackerHandle::noop(),
);
let signals_handle = SessionSignalsHandle::new();
let feedback_manager = FeedbackManager::new(
"test",
None,
FeedbackManagerConfig::default(),
);
let handle = SessionHandle {
cmd_tx: tokio::sync::mpsc::unbounded_channel().0,
persistence_tx: tokio::sync::mpsc::unbounded_channel().0,
current_prompt_id: Arc::new(std::sync::Mutex::new(None)),
pending_interactions: Arc::new(
std::sync::Mutex::new(std::collections::HashMap::new()),
),
info: Info {
id: acp::SessionId::new(subagent_id),
cwd: "/tmp".into(),
},
max_turns: None,
hunk_tracker_handle: kigi_hunk_tracker::HunkTrackerHandle::noop(),
chat_state_handle: kigi_chat_state::ChatStateHandle::noop(),
signals_handle,
gateway_enabled: Arc::new(AtomicBool::new(false)),
mcp_servers: vec![],
initial_client_mcp_servers: vec![],
display_cwd: None,
feedback_manager: Arc::new(feedback_manager),
tool_context,
model_id: acp::ModelId::new("test"),
reasoning_effort: None,
yolo_mode: false,
origin_client: None,
code_nav_enabled: false,
ask_user_question_enabled: true,
plan_mode: Arc::new(
parking_lot::Mutex::new(PlanModeTracker::new(PathBuf::from("/tmp"))),
),
force_compact: Arc::new(AtomicBool::new(false)),
permission_handle: kigi_workspace::permission::PermissionHandle::allow_all(),
attribution_callback: None,
agent_name: "grok-build".to_string(),
managed_mcp_proxy_base_url: String::new(),
session_default_agent_profile: None,
allowed_subagent_types: None,
hook_registry: None,
workspace_ops: kigi_workspace::WorkspaceOps::for_test(),
terminal_backend: None,
tools_notification_handle: None,
scheduler_handle: None,
};
SubagentTracker {
subagent_id: subagent_id.into(),
parent_session_id: parent_session_id.into(),
parent_prompt_id: None,
child_session_id: acp::SessionId::new(subagent_id),
subagent_type: subagent_type.into(),
persona: None,
description: description.into(),
started_at: std::time::Instant::now(),
child_handle: handle,
child_thread: crate::session::SessionThread::from_handle(
std::thread::spawn(|| {}),
),
cancel_token: tokio_util::sync::CancellationToken::new(),
resumed_from: None,
child_cwd: String::new(),
worktree_path: None,
effective_model_id: String::new(),
run_in_background: false,
surface_completion: true,
color: None,
block_waited: false,
explicitly_killed: false,
}
}
#[tokio::test]
async fn active_summaries_for_filters_by_parent_session_id() {
let mut coordinator = SubagentCoordinator::new();
coordinator.insert(dummy_tracker("sub-1", "session-A", "explore", "task 1"));
coordinator.insert(dummy_tracker("sub-2", "session-B", "plan", "task 2"));
coordinator.insert(dummy_tracker("sub-3", "session-A", "general-purpose", "task 3"));
let summaries_a = coordinator.active_summaries_for("session-A");
assert_eq!(summaries_a.len(), 2);
let ids_a: Vec<&str> = summaries_a.iter().map(|s| s.subagent_id.as_str()).collect();
assert!(ids_a.contains(& "sub-1"));
assert!(ids_a.contains(& "sub-3"));
let summaries_b = coordinator.active_summaries_for("session-B");
assert_eq!(summaries_b.len(), 1);
assert_eq!(summaries_b[0].subagent_id, "sub-2");
assert_eq!(summaries_b[0].subagent_type, "plan");
assert_eq!(summaries_b[0].description, "task 2");
let summaries_none = coordinator.active_summaries_for("session-C");
assert!(summaries_none.is_empty());
}
#[tokio::test]
async fn active_summaries_returns_all_regardless_of_parent() {
let mut coordinator = SubagentCoordinator::new();
coordinator.insert(dummy_tracker("sub-1", "session-A", "explore", "task 1"));
coordinator.insert(dummy_tracker("sub-2", "session-B", "plan", "task 2"));
let all = coordinator.active_summaries();
assert_eq!(all.len(), 2);
}
#[tokio::test]
async fn resolve_running_list_returns_empty_for_empty_seeds() {
let resolved = resolve_running_list(vec![]).await;
assert!(resolved.is_empty());
}
#[tokio::test]
async fn resolve_running_list_populates_fields_from_signals() {
use crate::session::signals::SessionSignalsHandle;
let signals = SessionSignalsHandle::new();
signals.increment_turn();
signals.record_tool_call("grep");
tokio::task::yield_now().await;
let seed = RunningSubagentListSeed {
subagent_id: "sub-1".to_string(),
parent_session_id: "parent-1".to_string(),
child_session_id: "child-1".to_string(),
subagent_type: "explore".to_string(),
description: "find endpoints".to_string(),
started_at_epoch_ms: 1000,
duration_ms: 2000,
signals_handle: signals,
};
let resolved = resolve_running_list(vec![seed]).await;
assert_eq!(resolved.len(), 1);
let r = &resolved[0];
assert_eq!(r.subagent_id, "sub-1");
assert_eq!(r.parent_session_id, "parent-1");
assert_eq!(r.child_session_id, "child-1");
assert_eq!(r.subagent_type, "explore");
assert_eq!(r.turn_count, 1);
assert_eq!(r.tool_call_count, 1);
assert!(r.tools_used.contains(& "grep".to_string()));
}
#[test]
fn explicit_override_takes_precedence_over_role() {
let overrides = SubagentRuntimeOverrides {
model: Some("explicit-model".into()),
capability_mode: Some(kigi_tool_types::SubagentCapabilityMode::All),
..Default::default()
};
let role = kigi_subagent_resolution::config::SubagentRole {
description: "test role".into(),
model: Some("role-model".into()),
default_capability_mode: Some("read-only".into()),
..Default::default()
};
let resolved = resolve_effective_overrides(
&overrides,
Some(&role),
&HashMap::new(),
None,
None,
);
assert_eq!(resolved.model.as_deref(), Some("explicit-model"));
assert_eq!(
resolved.capability_mode, Some(kigi_tool_types::SubagentCapabilityMode::All)
);
}
#[test]
fn role_default_used_when_no_explicit_override() {
let overrides = SubagentRuntimeOverrides::default();
let role = kigi_subagent_resolution::config::SubagentRole {
description: "test role".into(),
model: Some("role-model".into()),
default_capability_mode: Some("read-only".into()),
..Default::default()
};
let resolved = resolve_effective_overrides(
&overrides,
Some(&role),
&HashMap::new(),
None,
None,
);
assert_eq!(resolved.model.as_deref(), Some("role-model"));
assert_eq!(
resolved.capability_mode, Some(kigi_tool_types::SubagentCapabilityMode::ReadOnly)
);
}
#[test]
fn no_role_no_override_returns_none() {
let overrides = SubagentRuntimeOverrides::default();
let resolved = resolve_effective_overrides(
&overrides,
None,
&HashMap::new(),
None,
None,
);
assert!(resolved.model.is_none());
assert!(resolved.capability_mode.is_none());
assert!(resolved.reasoning_effort.is_none());
}
#[test]
fn partial_override_fills_from_role() {
let overrides = SubagentRuntimeOverrides {
model: Some("explicit-model".into()),
..Default::default()
};
let role = kigi_subagent_resolution::config::SubagentRole {
description: "test".into(),
default_capability_mode: Some("execute".into()),
..Default::default()
};
let resolved = resolve_effective_overrides(
&overrides,
Some(&role),
&HashMap::new(),
None,
None,
);
assert_eq!(resolved.model.as_deref(), Some("explicit-model"));
assert_eq!(
resolved.capability_mode, Some(kigi_tool_types::SubagentCapabilityMode::Execute)
);
}
#[test]
fn reasoning_effort_explicit_overrides_role() {
let overrides = SubagentRuntimeOverrides {
reasoning_effort: Some("high".into()),
..Default::default()
};
let role = kigi_subagent_resolution::config::SubagentRole {
description: "test".into(),
reasoning_effort: Some("low".into()),
..Default::default()
};
let resolved = resolve_effective_overrides(
&overrides,
Some(&role),
&HashMap::new(),
None,
None,
);
assert_eq!(resolved.reasoning_effort.as_deref(), Some("high"));
}
#[test]
fn reasoning_effort_falls_back_to_role() {
let overrides = SubagentRuntimeOverrides::default();
let role = kigi_subagent_resolution::config::SubagentRole {
description: "test".into(),
reasoning_effort: Some("medium".into()),
..Default::default()
};
let resolved = resolve_effective_overrides(
&overrides,
Some(&role),
&HashMap::new(),
None,
None,
);
assert_eq!(resolved.reasoning_effort.as_deref(), Some("medium"));
}
#[test]
fn invalid_role_capability_mode_ignored() {
let overrides = SubagentRuntimeOverrides::default();
let role = kigi_subagent_resolution::config::SubagentRole {
description: "test".into(),
default_capability_mode: Some("invalid-mode".into()),
..Default::default()
};
let resolved = resolve_effective_overrides(
&overrides,
Some(&role),
&HashMap::new(),
None,
None,
);
assert!(
resolved.capability_mode.is_none(),
"invalid role mode should not produce a capability_mode"
);
}
#[test]
fn persona_resolved_from_config() {
let overrides = SubagentRuntimeOverrides {
persona: Some("researcher".into()),
..Default::default()
};
let mut personas = HashMap::new();
personas
.insert(
"researcher".to_string(),
kigi_subagent_resolution::config::SubagentPersona {
instructions: Some("Be thorough.".into()),
..Default::default()
},
);
let resolved = resolve_effective_overrides(&overrides, None, &personas, None, None);
assert_eq!(resolved.persona.as_deref(), Some("researcher"));
assert_eq!(resolved.persona_instructions.as_deref(), Some("Be thorough."));
}
#[test]
fn unknown_persona_produces_no_instructions() {
let overrides = SubagentRuntimeOverrides {
persona: Some("nonexistent".into()),
..Default::default()
};
let resolved = resolve_effective_overrides(
&overrides,
None,
&HashMap::new(),
None,
None,
);
assert_eq!(resolved.persona.as_deref(), Some("nonexistent"));
assert!(resolved.persona_instructions.is_none());
}
#[test]
fn persona_inline_plus_file_merged_in_order() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("extra.md"), "File-based content.").unwrap();
let overrides = SubagentRuntimeOverrides {
persona: Some("combo".into()),
..Default::default()
};
let mut personas = HashMap::new();
personas
.insert(
"combo".to_string(),
kigi_subagent_resolution::config::SubagentPersona {
instructions: Some("Inline first.".into()),
instructions_file: Some("extra.md".into()),
..Default::default()
},
);
let resolved = resolve_effective_overrides(
&overrides,
None,
&personas,
Some(tmp.path()),
None,
);
let pi = resolved.persona_instructions.as_deref().unwrap();
assert!(pi.starts_with("Inline first."), "inline should come first: {pi}");
assert!(pi.contains("File-based content."), "file content should be included: {pi}");
}
#[test]
fn model_precedence_explicit_over_role_over_persona() {
let mut personas = HashMap::new();
personas
.insert(
"dev".to_string(),
kigi_subagent_resolution::config::SubagentPersona {
model: Some("persona-model".into()),
..Default::default()
},
);
let role = kigi_subagent_resolution::config::SubagentRole {
description: "test".into(),
model: Some("role-model".into()),
..Default::default()
};
let overrides = SubagentRuntimeOverrides {
persona: Some("dev".into()),
model: Some("explicit-model".into()),
..Default::default()
};
let r = resolve_effective_overrides(&overrides, Some(&role), &personas, None, None);
assert_eq!(r.model.as_deref(), Some("explicit-model"));
let overrides = SubagentRuntimeOverrides {
persona: Some("dev".into()),
..Default::default()
};
let r = resolve_effective_overrides(&overrides, Some(&role), &personas, None, None);
assert_eq!(r.model.as_deref(), Some("role-model"));
let role_no_model = kigi_subagent_resolution::config::SubagentRole {
description: "test".into(),
..Default::default()
};
let r = resolve_effective_overrides(
&overrides,
Some(&role_no_model),
&personas,
None,
None,
);
assert_eq!(r.model.as_deref(), Some("persona-model"));
let overrides = SubagentRuntimeOverrides::default();
let r = resolve_effective_overrides(&overrides, None, &HashMap::new(), None, None);
assert!(r.model.is_none());
}
#[test]
fn reasoning_effort_precedence_explicit_over_role_over_persona() {
let mut personas = HashMap::new();
personas
.insert(
"dev".to_string(),
kigi_subagent_resolution::config::SubagentPersona {
reasoning_effort: Some("low".into()),
..Default::default()
},
);
let role = kigi_subagent_resolution::config::SubagentRole {
description: "test".into(),
reasoning_effort: Some("medium".into()),
..Default::default()
};
let overrides = SubagentRuntimeOverrides {
persona: Some("dev".into()),
reasoning_effort: Some("high".into()),
..Default::default()
};
let r = resolve_effective_overrides(&overrides, Some(&role), &personas, None, None);
assert_eq!(r.reasoning_effort.as_deref(), Some("high"));
let overrides = SubagentRuntimeOverrides {
persona: Some("dev".into()),
..Default::default()
};
let r = resolve_effective_overrides(&overrides, Some(&role), &personas, None, None);
assert_eq!(r.reasoning_effort.as_deref(), Some("medium"));
let role_no_re = kigi_subagent_resolution::config::SubagentRole {
description: "test".into(),
..Default::default()
};
let r = resolve_effective_overrides(
&overrides,
Some(&role_no_re),
&personas,
None,
None,
);
assert_eq!(r.reasoning_effort.as_deref(), Some("low"));
let overrides = SubagentRuntimeOverrides::default();
let r = resolve_effective_overrides(&overrides, None, &HashMap::new(), None, None);
assert!(r.reasoning_effort.is_none());
}
#[test]
fn persona_not_found_produces_error() {
let overrides = SubagentRuntimeOverrides {
persona: Some("missing".into()),
..Default::default()
};
let resolved = resolve_effective_overrides(
&overrides,
None,
&HashMap::new(),
None,
None,
);
assert!(resolved.persona_error.is_some());
assert!(resolved.persona_error.as_deref().unwrap().contains("not found"),);
}
#[test]
fn prompt_assembly_ordering() {
let role_prompt = Some(
"<role-instructions>\nRole content\n</role-instructions>".to_string(),
);
let persona_instructions = Some(
"<persona>\nPersona content\n</persona>".to_string(),
);
let task = "Do the task";
let mut sections = Vec::new();
sections.push("<fork-context>...</fork-context>".to_string());
if let Some(ref rp) = role_prompt {
sections.push(rp.clone());
}
if let Some(ref pi) = persona_instructions {
sections.push(pi.clone());
}
sections.push(task.to_string());
let assembled = sections.join("\n\n");
let fork_pos = assembled.find("<fork-context>").unwrap();
let role_pos = assembled.find("<role-instructions>").unwrap();
let persona_pos = assembled.find("<persona>").unwrap();
let task_pos = assembled.find("Do the task").unwrap();
assert!(fork_pos < role_pos, "fork before role");
assert!(role_pos < persona_pos, "role before persona");
assert!(persona_pos < task_pos, "persona before task");
}
#[test]
fn no_persona_produces_none() {
let overrides = SubagentRuntimeOverrides::default();
let resolved = resolve_effective_overrides(
&overrides,
None,
&HashMap::new(),
None,
None,
);
assert!(resolved.persona.is_none());
assert!(resolved.persona_instructions.is_none());
}
#[test]
fn initial_context_source_new_is_default() {
let source = InitialContextSource::New;
assert!(matches!(source, InitialContextSource::New));
}
#[test]
fn initial_context_source_forked_distinct_from_new_and_resumed() {
let source = InitialContextSource::Forked;
assert!(matches!(source, InitialContextSource::Forked));
assert_ne!(source, InitialContextSource::New);
assert_ne!(source, InitialContextSource::Resumed);
}
#[test]
fn forked_initial_context_normalizes_parent_history() {
use kigi_sampling_types::conversation::ConversationItem;
let items = vec![
ConversationItem::system("parent system"),
ConversationItem::user("UNIQUE_FORK_MARKER_abc123 implement multi-repo fix"),
ConversationItem::assistant("noted"),
];
let ctx = forked_initial_context(items);
assert_eq!(ctx.source, InitialContextSource::Forked);
assert!(ctx.copy_error.is_none());
assert_eq!(ctx.prefix_len, Some(2));
assert_eq!(ctx.conversation.len(), 2);
if let ConversationItem::User(ref u) = ctx.conversation[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();
assert!(text.contains("<background_context>"));
assert!(
text.contains("UNIQUE_FORK_MARKER_abc123"),
"distinctive parent token must appear in background: {text}"
);
} else {
panic!("expected User background at [1]");
}
}
#[test]
fn forked_initial_context_inherits_parent_across_reasoning() {
use kigi_sampling_types::conversation::ConversationItem;
let items = vec![
ConversationItem::system("parent system"),
ConversationItem::user("remember UNIQUE_FORK_MARKER_TEST"),
ConversationItem::Reasoning(kigi_sampling_types::synthesized_reasoning_item("deliberating",)),
ConversationItem::assistant("ack"),
];
let ctx = forked_initial_context(items);
assert_eq!(ctx.source, InitialContextSource::Forked);
assert_eq!(ctx.prefix_len, Some(2));
assert_eq!(ctx.conversation.len(), 2);
if let ConversationItem::User(ref u) = ctx.conversation[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();
assert!(
text.contains("<background_context>"),
"background wrapper must be present: {text}"
);
assert!(
text.contains("UNIQUE_FORK_MARKER_TEST"),
"parent context must be inherited across the reasoning sibling: {text}"
);
} else {
panic!("expected User background at [1]");
}
}
#[test]
fn forked_initial_context_empty_fails_open_to_new() {
let ctx = forked_initial_context(vec![]);
assert_eq!(ctx.source, InitialContextSource::New);
assert!(ctx.conversation.is_empty());
assert!(ctx.copy_error.is_some());
}
#[test]
fn resume_vs_fork_helper_shapes_differ() {
use kigi_sampling_types::conversation::ConversationItem;
let resume_items = vec![
ConversationItem::system("child system"),
ConversationItem::user("prior subagent work"),
ConversationItem::assistant("done"),
];
let resumed = resume_initial_context(resume_items.clone());
let forked = forked_initial_context(resume_items);
assert_eq!(resumed.source, InitialContextSource::Resumed);
assert_eq!(forked.source, InitialContextSource::Forked);
assert!(resumed.conversation.len() > forked.conversation.len());
assert!(
! matches!(resumed.conversation.get(1), Some(ConversationItem::User(u)) if u
.content.iter().any(| p | matches!(p,
kigi_sampling_types::conversation::ContentPart::Text { text } if text
.contains("<background_context>"))))
);
}
#[test]
fn forked_initial_context_applies_fork_filter_before_normalize() {
use kigi_sampling_types::conversation::ConversationItem;
let items = vec![
ConversationItem::system("sys"), ConversationItem::user("complete user"),
ConversationItem::assistant("complete asst"),
ConversationItem::user("INCOMPLETE_TRAILING"),
];
let ctx = forked_initial_context(items);
assert_eq!(ctx.source, InitialContextSource::Forked);
if let ConversationItem::User(ref u) = ctx.conversation[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();
assert!(text.contains("complete user"));
assert!(
! text.contains("INCOMPLETE_TRAILING"),
"fork_filter must truncate incomplete trailing turn: {text}"
);
} else {
panic!("expected background user");
}
}
#[test]
fn verbatim_fork_keeps_items_byte_for_byte_when_small() {
use kigi_sampling_types::conversation::{
ContentPart, ConversationItem, SyntheticReason, UserItem,
};
let items = vec![
ConversationItem::system("parent system"),
ConversationItem::user("remember UNIQUE_FORK_MARKER_TEST"),
ConversationItem::User(UserItem { content : vec![ContentPart::Text { text :
"SYNTHETIC_KEEP_ME".into(), }], synthetic_reason :
Some(SyntheticReason::SystemReminder), ..Default::default() }),
ConversationItem::Reasoning(kigi_sampling_types::synthesized_reasoning_item("thinking",)),
ConversationItem::assistant("ack"),
];
let ctx = verbatim_or_normalize_fork(items, 256_000);
assert_eq!(ctx.source, InitialContextSource::Forked);
assert!(ctx.verbatim_fork, "a small, complete-tail parent must mirror verbatim");
assert_eq!(ctx.prefix_len, Some(5));
assert_eq!(ctx.conversation.len(), 5);
assert!(matches!(ctx.conversation[0], ConversationItem::System(_)));
assert!(matches!(ctx.conversation.last(), Some(ConversationItem::Assistant(_))));
let text_present = |needle: &str| {
ctx
.conversation
.iter()
.any(|i| {
matches!(
i, ConversationItem::User(u) if u.content.iter().any(| p |
matches!(p, ContentPart::Text { text } if text.contains(needle)))
)
})
};
assert!(text_present("UNIQUE_FORK_MARKER_TEST"), "marker must survive verbatim");
assert!(
text_present("SYNTHETIC_KEEP_ME"),
"synthetic-reason item must be preserved verbatim, NOT stripped"
);
assert!(
ctx.conversation.iter().any(| i | matches!(i, ConversationItem::User(u) if u
.synthetic_reason.is_some())),
"the synthetic_reason marker itself must remain in the verbatim mirror"
);
assert!(
! text_present("<background_context>"),
"verbatim fork must NOT summarize into a background blob"
);
}
#[test]
fn verbatim_fork_falls_back_to_summary_on_incomplete_tail() {
use kigi_sampling_types::conversation::{
AssistantItem, ContentPart, ConversationItem, ToolCall,
};
let items = vec![
ConversationItem::system("parent system"),
ConversationItem::user("q1 UNIQUE_FORK_MARKER_TEST"),
ConversationItem::assistant("a1"), ConversationItem::user("q2"),
ConversationItem::Assistant(AssistantItem { content : String::new().into(),
tool_calls : vec![ToolCall { id : "tc1".into(), name : "bash".into(), arguments :
"{}".into(), }], model_id : None, model_fingerprint : None, reasoning_effort :
None, }),
];
let ctx = verbatim_or_normalize_fork(items, 256_000);
assert_eq!(ctx.source, InitialContextSource::Forked);
assert!(
! ctx.verbatim_fork,
"an incomplete (dangling tool call) tail must fall back to summary"
);
assert_eq!(ctx.prefix_len, Some(2));
assert!(
ctx.conversation.iter().any(| i | { matches!(i, ConversationItem::User(u) if u
.content.iter().any(| p | matches!(p, ContentPart::Text { text } if text
.contains("<background_context>")))) }),
"summarized fallback must produce a background_context blob"
);
}
#[test]
fn summarized_fork_is_not_a_verbatim_mirror() {
use kigi_sampling_types::conversation::ConversationItem;
let items = vec![
ConversationItem::system("parent system prompt"),
ConversationItem::user("turn one UNIQUE_FORK_MARKER_TEST"),
ConversationItem::assistant("ack"),
];
let ctx = verbatim_or_normalize_fork(items, 1);
assert_eq!(ctx.source, InitialContextSource::Forked);
assert!(! ctx.verbatim_fork);
let verbatim_mirror_fork = ctx.source == InitialContextSource::Forked
&& ctx.verbatim_fork;
assert!(
! verbatim_mirror_fork,
"a summarized fork must NOT be treated as a verbatim mirror"
);
}
#[test]
fn verbatim_fork_falls_back_to_summary_when_oversize() {
use kigi_sampling_types::conversation::{ContentPart, ConversationItem};
let items = vec![
ConversationItem::system("parent system"),
ConversationItem::user("turn one UNIQUE_FORK_MARKER_TEST with some text"),
ConversationItem::assistant("ack one"),
];
let ctx = verbatim_or_normalize_fork(items, 1);
assert_eq!(ctx.source, InitialContextSource::Forked);
assert!(! ctx.verbatim_fork, "oversize parent must fall back to summary");
assert_eq!(ctx.prefix_len, Some(2));
let has_blob = ctx
.conversation
.iter()
.any(|i| {
matches!(
i, ConversationItem::User(u) if u.content.iter().any(| p | matches!(p,
ContentPart::Text { text } if text.contains("<background_context>")))
)
});
assert!(has_blob, "oversize fallback must produce a background_context blob");
}
#[test]
fn verbatim_fork_empty_after_filter_fails_open_to_new() {
use kigi_sampling_types::conversation::ConversationItem;
let items = vec![ConversationItem::user("/goal do the thing")];
let ctx = verbatim_or_normalize_fork(items, 256_000);
assert_eq!(ctx.source, InitialContextSource::New);
assert!(! ctx.verbatim_fork);
assert!(ctx.conversation.is_empty());
}
#[test]
fn verbatim_or_normalize_fork_system_only_fails_open_to_new() {
use kigi_sampling_types::conversation::ConversationItem;
for items in [
vec![ConversationItem::system("sys")],
vec![ConversationItem::system("a"), ConversationItem::system("b")],
] {
let ctx = verbatim_or_normalize_fork(items, 256_000);
assert_eq!(
ctx.source, InitialContextSource::New,
"System-only fork must fail open to New"
);
assert!(! ctx.verbatim_fork);
assert!(ctx.conversation.is_empty());
}
}
#[test]
fn forked_initial_context_system_only_fails_open_to_new() {
use kigi_sampling_types::conversation::ConversationItem;
let ctx = forked_initial_context(vec![ConversationItem::system("sys")]);
assert_eq!(ctx.source, InitialContextSource::New);
assert!(! ctx.verbatim_fork);
assert!(ctx.conversation.is_empty());
assert!(ctx.copy_error.is_some());
}
#[test]
fn fork_context_normalized_only_for_summarized() {
assert!(! fork_context_normalized(& InitialContextSource::Forked, true));
assert!(fork_context_normalized(& InitialContextSource::Forked, false));
assert!(! fork_context_normalized(& InitialContextSource::New, false));
assert!(! fork_context_normalized(& InitialContextSource::Resumed, false));
use kigi_sampling_types::conversation::ConversationItem;
let verbatim = verbatim_or_normalize_fork(
vec![
ConversationItem::system("sys"), ConversationItem::user("q"),
ConversationItem::assistant("a"),
],
256_000,
);
assert!(verbatim.verbatim_fork);
assert!(! fork_context_normalized(& verbatim.source, verbatim.verbatim_fork));
let summarized = verbatim_or_normalize_fork(
vec![
ConversationItem::system("sys"), ConversationItem::user("q with text"),
ConversationItem::assistant("a"),
],
1,
);
assert!(! summarized.verbatim_fork);
assert!(fork_context_normalized(& summarized.source, summarized.verbatim_fork));
}
fn bootstrap_test_request(fork_context: bool) -> SubagentRequest {
let (result_tx, _) = oneshot::channel();
SubagentRequest {
id: "bootstrap-test".into(),
prompt: "plan".into(),
description: "d".into(),
subagent_type: "general-purpose".into(),
parent_session_id: "parent".into(),
parent_prompt_id: None,
resume_from: None,
cwd: None,
runtime_overrides: Default::default(),
run_in_background: false,
surface_completion: false,
fork_context,
result_tx,
}
}
#[tokio::test]
async fn bootstrap_no_fork_is_new() {
let req = bootstrap_test_request(false);
let ctx = ctx_with_toggle(HashMap::new());
let child = SessionInfo {
id: acp::SessionId::new("child-boot"),
cwd: "/tmp".into(),
};
let out = bootstrap_initial_context(
&req,
None,
&ctx,
&child,
Path::new("/tmp"),
"m",
128_000,
)
.await;
match out {
BootstrapInitialContext::Ready(ic) => {
assert_eq!(ic.source, InitialContextSource::New);
assert!(ic.conversation.is_empty());
assert!(ic.copy_error.is_none());
}
BootstrapInitialContext::ResumeAbort(m) => panic!("unexpected abort: {m}"),
}
}
#[tokio::test]
async fn bootstrap_fork_without_parent_fails_open() {
let req = bootstrap_test_request(true);
let mut ctx = ctx_with_toggle(HashMap::new());
ctx.parent_chat_state = None;
ctx.parent_session_info = None;
let child = SessionInfo {
id: acp::SessionId::new("child-boot2"),
cwd: "/tmp".into(),
};
let out = bootstrap_initial_context(
&req,
None,
&ctx,
&child,
Path::new("/tmp"),
"m",
128_000,
)
.await;
match out {
BootstrapInitialContext::Ready(ic) => {
assert_eq!(ic.source, InitialContextSource::New);
assert!(ic.copy_error.is_some());
}
BootstrapInitialContext::ResumeAbort(m) => {
panic!("fork must fail open, not abort: {m}")
}
}
}
#[tokio::test]
async fn bootstrap_fork_live_parent_chat_state_is_forked_with_marker() {
use kigi_sampling_types::conversation::ConversationItem;
const MARKER: &str = "UNIQUE_LIVE_FORK_MARKER_xyz789";
let req = bootstrap_test_request(true);
let mut ctx = ctx_with_toggle(HashMap::new());
let chat = spawn_test_parent_chat_state("grok-4.5");
chat.replace_conversation(
vec![
ConversationItem::system("parent system"),
ConversationItem::user(format!("{MARKER} implement multi-repo fix")),
ConversationItem::assistant("noted the multi-repo work"),
],
);
ctx.parent_chat_state = Some(chat);
ctx.parent_session_info = None;
let child = SessionInfo {
id: acp::SessionId::new("child-boot-live"),
cwd: "/tmp".into(),
};
let out = bootstrap_initial_context(
&req,
None,
&ctx,
&child,
Path::new("/tmp"),
"m",
128_000,
)
.await;
match out {
BootstrapInitialContext::Ready(ic) => {
assert_eq!(ic.source, InitialContextSource::Forked);
assert!(ic.copy_error.is_none());
assert!(ic.verbatim_fork, "small complete-tail parent must mirror verbatim");
assert_eq!(ic.conversation.len(), 3);
assert_eq!(ic.prefix_len, Some(3));
assert!(matches!(ic.conversation[0], ConversationItem::System(_)));
assert!(matches!(ic.conversation[1], ConversationItem::User(_)));
assert!(matches!(ic.conversation[2], ConversationItem::Assistant(_)));
let text: String = ic
.conversation
.iter()
.filter_map(|item| match item {
ConversationItem::User(u) => {
Some(
u
.content
.iter()
.filter_map(|p| match p {
kigi_sampling_types::conversation::ContentPart::Text {
text,
} => Some(text.as_ref()),
_ => None,
})
.collect::<String>(),
)
}
_ => None,
})
.collect();
assert!(
text.contains(MARKER), "live parent marker must appear verbatim: {text}"
);
assert!(
! text.contains("<background_context>"),
"verbatim mirror must NOT wrap items in a background_context blob: {text}"
);
}
BootstrapInitialContext::ResumeAbort(m) => panic!("unexpected abort: {m}"),
}
}
#[tokio::test]
async fn copy_session_data_preserves_parent_chat_history() {
use crate::sampling::ConversationItem;
use crate::session::storage::StorageAdapter;
use crate::session::storage::jsonl::JsonlStorageAdapter;
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let adapter = JsonlStorageAdapter::with_root(root.to_path_buf());
let parent_info = SessionInfo {
id: acp::SessionId::new("parent-fork-test"),
cwd: "/workspace".to_string(),
};
adapter.init_session(&parent_info, acp::ModelId::new("test-model")).await.unwrap();
adapter
.append_chat_message(&parent_info, &ConversationItem::user("What files?"))
.await
.unwrap();
adapter
.append_chat_message(&parent_info, &ConversationItem::assistant("listed"))
.await
.unwrap();
let child_info = SessionInfo {
id: acp::SessionId::new("child-fork-test"),
cwd: "/workspace".to_string(),
};
let result = adapter
.copy_session_data_sync(
&parent_info,
&child_info,
crate::session::storage::CopySessionOptions {
parent_session_id: Some("parent-fork-test".to_string()),
new_model_id: Some("test-model".to_string()),
session_kind: Some("subagent_fork".to_string()),
fork_context_source: Some("forked".to_string()),
copy_plan_state: false,
copy_plan_mode_state: false,
copy_signals: false,
copy_tool_state: false,
fork_filter: true,
..Default::default()
},
)
.unwrap();
assert!(result.chat_messages_copied > 0, "should copy chat history");
let child_data = adapter.load_session(&child_info).await.unwrap();
assert_eq!(child_data.summary.session_kind.as_deref(), Some("subagent_fork"));
assert_eq!(child_data.summary.fork_context_source.as_deref(), Some("forked"));
assert_eq!(
child_data.summary.parent_session_id.as_deref(), Some("parent-fork-test")
);
assert!(
! child_data.chat_history.is_empty(),
"child should have inherited parent chat history"
);
}
#[tokio::test]
async fn handle_subagent_request_rejects_disabled_agent() {
let toggle = HashMap::from([("explore".to_string(), false)]);
let ctx = ctx_with_toggle(toggle);
let coordinator = std::cell::RefCell::new(SubagentCoordinator::new());
let gateway = test_gateway();
let (request, result_rx) = make_request("explore");
let local = tokio::task::LocalSet::new();
local
.run_until(async {
Box::pin(handle_subagent_request(request, ctx, &coordinator, &gateway))
.await;
})
.await;
let result = result_rx.await.expect("should receive result");
assert!(! result.success, "disabled subagent should fail");
assert!(
result.error.as_deref().unwrap_or("").contains("[subagents.toggle]"),
"error should mention [subagents.toggle], got: {:?}", result.error
);
}
#[tokio::test]
async fn handle_subagent_request_allows_when_absent_from_toggle() {
let ctx = ctx_with_toggle(HashMap::new());
let coordinator = std::cell::RefCell::new(SubagentCoordinator::new());
let gateway = test_gateway();
let (request, result_rx) = make_request("explore");
let local = tokio::task::LocalSet::new();
local
.run_until(async {
Box::pin(handle_subagent_request(request, ctx, &coordinator, &gateway))
.await;
})
.await;
let result = result_rx.await.expect("should receive result");
if !result.success {
assert!(
! result.error.as_deref().unwrap_or("").contains("[subagents.toggle]"),
"should not be rejected by toggle gate when absent from toggle, \
but got: {:?}",
result.error
);
}
}
#[tokio::test]
async fn handle_subagent_request_rejects_nonexistent_cwd() {
let ctx = ctx_with_toggle(HashMap::new());
let coordinator = std::cell::RefCell::new(SubagentCoordinator::new());
let gateway = test_gateway();
let (mut request, result_rx) = make_request("explore");
request.cwd = Some("/nonexistent/path/that/does/not/exist".into());
let local = tokio::task::LocalSet::new();
local
.run_until(async {
Box::pin(handle_subagent_request(request, ctx, &coordinator, &gateway))
.await;
})
.await;
let result = result_rx.await.expect("should receive result");
assert!(! result.success, "nonexistent cwd should fail");
assert!(
result.error.as_deref().unwrap_or("").contains("does not exist"),
"error should mention path does not exist, got: {:?}", result.error
);
}
#[tokio::test]
async fn handle_subagent_request_rejects_file_as_cwd() {
let tmp_dir = tempfile::TempDir::new().unwrap();
let tmp_file = tmp_dir.path().join("grok-test-cwd-file");
std::fs::write(&tmp_file, b"not a directory").unwrap();
let ctx = ctx_with_toggle(HashMap::new());
let coordinator = std::cell::RefCell::new(SubagentCoordinator::new());
let gateway = test_gateway();
let (mut request, result_rx) = make_request("explore");
request.cwd = Some(tmp_file.to_string_lossy().to_string());
let local = tokio::task::LocalSet::new();
local
.run_until(async {
Box::pin(handle_subagent_request(request, ctx, &coordinator, &gateway))
.await;
})
.await;
let result = result_rx.await.expect("should receive result");
assert!(! result.success, "file-as-cwd should fail");
assert!(
result.error.as_deref().unwrap_or("").contains("not a directory"),
"error should mention not a directory, got: {:?}", result.error
);
}
#[tokio::test]
async fn handle_subagent_request_valid_cwd_passes_validation() {
let ctx = ctx_with_toggle(HashMap::new());
let coordinator = std::cell::RefCell::new(SubagentCoordinator::new());
let gateway = test_gateway();
let (mut request, result_rx) = make_request("explore");
request.cwd = Some("/tmp".into());
let local = tokio::task::LocalSet::new();
local
.run_until(async {
Box::pin(handle_subagent_request(request, ctx, &coordinator, &gateway))
.await;
})
.await;
let result = result_rx.await.expect("should receive result");
if !result.success {
let err = result.error.as_deref().unwrap_or("");
assert!(
! err.contains("does not exist") && ! err.contains("not a directory"),
"valid cwd should pass validation, but got cwd error: {err}"
);
}
}
#[tokio::test]
async fn handle_subagent_request_quoted_cwd_passes_validation() {
let ctx = ctx_with_toggle(HashMap::new());
let coordinator = std::cell::RefCell::new(SubagentCoordinator::new());
let gateway = test_gateway();
let (mut request, result_rx) = make_request("explore");
request.cwd = Some("\"/tmp".into());
let local = tokio::task::LocalSet::new();
local
.run_until(async {
Box::pin(handle_subagent_request(request, ctx, &coordinator, &gateway))
.await;
})
.await;
let result = result_rx.await.expect("should receive result");
if !result.success {
let err = result.error.as_deref().unwrap_or("");
assert!(
! err.contains("does not exist") && ! err.contains("not a directory"),
"quoted cwd should be sanitized before validation, but got cwd error: {err}"
);
}
}
fn make_validation_ctx(toggle: HashMap<String, bool>) -> SubagentValidationContext {
SubagentValidationContext {
parent_cwd: PathBuf::from("/tmp"),
subagent_toggle: toggle,
..Default::default()
}
}
#[test]
fn validate_subagent_type_returns_ok_for_known_enabled_agent() {
let ctx = make_validation_ctx(HashMap::new());
let outcome = validate_subagent_type("explore", &ctx);
assert!(
matches!(outcome, SubagentValidateTypeOutcome::Ok),
"expected Ok, got {outcome:?}",
);
}
#[test]
fn validate_subagent_type_returns_unknown_for_invented_type() {
let ctx = make_validation_ctx(HashMap::new());
let outcome = validate_subagent_type("totally-invented-agent-name", &ctx);
match outcome {
SubagentValidateTypeOutcome::Unknown { available } => {
for expected in ["general-purpose", "explore", "plan"] {
assert!(
available.iter().any(| n | n == expected),
"available list must include built-in {expected:?}: {available:?}",
);
}
let mut sorted = available.clone();
sorted.sort();
assert_eq!(available, sorted, "available must be sorted");
}
other => panic!("expected Unknown, got {other:?}"),
}
}
#[test]
fn validate_subagent_type_returns_disabled_when_toggled_off() {
let toggle = HashMap::from([("explore".to_string(), false)]);
let ctx = make_validation_ctx(toggle);
let outcome = validate_subagent_type("explore", &ctx);
assert!(
matches!(outcome, SubagentValidateTypeOutcome::Disabled),
"expected Disabled, got {outcome:?}",
);
}
#[test]
fn validate_subagent_type_returns_not_allowed_when_outside_allow_list() {
let mut ctx = make_validation_ctx(HashMap::new());
ctx.allowed_subagent_types = Some(vec!["plan".to_string()]);
let outcome = validate_subagent_type("explore", &ctx);
match outcome {
SubagentValidateTypeOutcome::NotAllowed { allowed } => {
assert_eq!(allowed, vec!["plan".to_string()]);
}
other => panic!("expected NotAllowed, got {other:?}"),
}
}
#[test]
fn validate_subagent_type_allow_list_is_case_insensitive() {
for (requested, allowed) in [
("explore", vec!["EXPLORE".to_string()]),
("EXPLORE", vec!["explore".to_string()]),
("Explore", vec!["eXpLoRe".to_string()]),
("explore", vec!["plan".to_string(), "EXPLORE".to_string()]),
] {
let mut ctx = make_validation_ctx(HashMap::new());
ctx.cli_agent_names = vec![requested.to_string()];
ctx.allowed_subagent_types = Some(allowed.clone());
assert!(
matches!(validate_subagent_type(requested, & ctx),
SubagentValidateTypeOutcome::Ok,),
"{requested:?} should be permitted by allow-list {allowed:?}",
);
}
}
#[test]
fn validate_subagent_type_unknown_includes_cli_agents_in_available() {
let mut ctx = make_validation_ctx(HashMap::new());
ctx.cli_agent_names = vec!["user-defined-agent".to_string()];
match validate_subagent_type("invented", &ctx) {
SubagentValidateTypeOutcome::Unknown { available } => {
assert!(
available.iter().any(| n | n == "user-defined-agent"),
"cli agent name missing from available list: {available:?}",
);
}
other => panic!("expected Unknown, got {other:?}"),
}
}
#[test]
fn validate_subagent_type_unknown_dedupes_cli_against_builtins() {
let mut ctx = make_validation_ctx(HashMap::new());
ctx.cli_agent_names = vec!["explore".to_string()];
match validate_subagent_type("invented", &ctx) {
SubagentValidateTypeOutcome::Unknown { available } => {
let count = available.iter().filter(|n| n.as_str() == "explore").count();
assert_eq!(count, 1, "explore must appear once: {available:?}");
}
other => panic!("expected Unknown, got {other:?}"),
}
}
#[test]
fn validate_subagent_type_unknown_omits_disabled_types_from_available_list() {
let toggle = HashMap::from([("explore".to_string(), false)]);
let ctx = make_validation_ctx(toggle);
match validate_subagent_type("explor", &ctx) {
SubagentValidateTypeOutcome::Unknown { available } => {
assert!(
! available.iter().any(| n | n == "explore"),
"disabled type must not appear in available: {available:?}",
);
assert!(
available.iter().any(| n | n == "general-purpose"),
"non-disabled built-ins must still appear: {available:?}",
);
}
other => panic!("expected Unknown, got {other:?}"),
}
}
#[test]
fn validate_subagent_type_unknown_omits_disabled_cli_agents_from_available_list() {
let toggle = HashMap::from([("custom".to_string(), false)]);
let mut ctx = make_validation_ctx(toggle);
ctx.cli_agent_names = vec!["custom".to_string(), "user-defined".to_string()];
match validate_subagent_type("invented", &ctx) {
SubagentValidateTypeOutcome::Unknown { available } => {
assert!(
! available.iter().any(| n | n == "custom"),
"disabled cli agent must not appear: {available:?}",
);
assert!(
available.iter().any(| n | n == "user-defined"),
"enabled cli agent must appear: {available:?}",
);
}
other => panic!("expected Unknown, got {other:?}"),
}
}
#[test]
fn validate_subagent_type_recognizes_cli_agent_by_name() {
let mut ctx = make_validation_ctx(HashMap::new());
ctx.cli_agent_names = vec!["user-defined".to_string()];
assert!(
matches!(validate_subagent_type("user-defined", & ctx),
SubagentValidateTypeOutcome::Ok,)
);
}
#[test]
#[serial_test::serial]
fn subagent_await_budget_default_and_override() {
unsafe { std::env::remove_var("KIGI_SUBAGENT_AWAIT_BUDGET_MS") };
assert_eq!(SUBAGENT_AWAIT_BUDGET, std::time::Duration::from_secs(600));
assert_eq!(subagent_await_budget(), SUBAGENT_AWAIT_BUDGET);
unsafe { std::env::set_var("KIGI_SUBAGENT_AWAIT_BUDGET_MS", "1500") };
assert_eq!(subagent_await_budget(), std::time::Duration::from_millis(1500));
unsafe { std::env::set_var("KIGI_SUBAGENT_AWAIT_BUDGET_MS", "0") };
assert_eq!(subagent_await_budget(), SUBAGENT_AWAIT_BUDGET);
unsafe { std::env::set_var("KIGI_SUBAGENT_AWAIT_BUDGET_MS", "not-a-number") };
assert_eq!(subagent_await_budget(), SUBAGENT_AWAIT_BUDGET);
unsafe { std::env::remove_var("KIGI_SUBAGENT_AWAIT_BUDGET_MS") };
}
#[test]
fn summarize_tool_config_uses_name_override_and_strips_namespace() {
use kigi_tools::registry::types::{ToolConfig, ToolServerConfig};
use kigi_tools::types::tool::ToolKind;
let mut read = ToolConfig::from_id("GrokBuild:read_file");
read.kind = Some(ToolKind::Read);
let mut read_dup = ToolConfig::from_id("Codex:read_file");
read_dup.kind = Some(ToolKind::Read);
read_dup.name_override = Some("codex_read".to_string());
let mut grep = ToolConfig::from_id("OpenCode:grep");
grep.kind = Some(ToolKind::Search);
grep.name_override = Some("alt_grep".to_string());
let mcp = ToolConfig::from_id("MCP:custom");
let config = ToolServerConfig {
tools: vec![read, read_dup, grep, mcp],
behavior_preset: None,
};
let summary = summarize_tool_config(&config);
assert_eq!(summary.tool_names.get(& ToolKind::Read).unwrap(), "read_file");
assert_eq!(summary.tool_names.get(& ToolKind::Search).unwrap(), "alt_grep");
assert!(summary.can_read && summary.can_search && ! summary.can_execute);
assert_eq!(summary.tool_names.len(), 2);
}
#[test]
fn describe_subagent_type_unknown_returns_sorted_available() {
let ctx = ctx_with_toggle(HashMap::new());
match describe_subagent_type("totally-invented-type", None, &ctx) {
SubagentDescribeOutcome::Unknown { available } => {
let mut sorted = available.clone();
sorted.sort();
assert_eq!(available, sorted, "available must be sorted");
assert!(available.iter().any(| n | n == "general-purpose"));
}
other => panic!("expected Unknown, got {other:?}"),
}
}
#[test]
fn describe_subagent_type_disabled_when_toggled_off() {
let ctx = ctx_with_toggle(HashMap::from([("explore".to_string(), false)]));
assert!(
matches!(describe_subagent_type("explore", None, & ctx),
SubagentDescribeOutcome::Disabled)
);
}
#[test]
fn describe_subagent_type_not_allowed_outside_allow_list() {
let mut ctx = ctx_with_toggle(HashMap::new());
ctx.allowed_subagent_types = Some(vec!["plan".to_string()]);
match describe_subagent_type("explore", None, &ctx) {
SubagentDescribeOutcome::NotAllowed { allowed } => {
assert_eq!(allowed, vec!["plan".to_string()]);
}
other => panic!("expected NotAllowed, got {other:?}"),
}
}
/// Regression: on the DEFAULT grok-build host —
/// the primary `/goal` host — the `general-purpose` toolset's only
/// file-mutator is `search_replace` (`ToolKind::Edit`); the `write`
/// tool (`ToolKind::Write`) is injection-only and absent from the
/// pre-injection describe probe. The planner gate must therefore key on
/// the Edit-class capability, which this asserts is present.
#[test]
fn describe_default_host_general_purpose_has_edit_not_write() {
use kigi_tools::types::tool::ToolKind;
let ctx = ctx_with_toggle(HashMap::new());
let SubagentDescribeOutcome::Ok(summary) = describe_subagent_type(
"general-purpose",
None,
&ctx,
) else {
panic!("expected Ok for default-host general-purpose");
};
assert!(summary.can_read, "default host reads (read_file)");
assert!(
summary.tool_names.contains_key(& ToolKind::Edit),
"default host's file-mutator is search_replace (Edit): {:?}", summary.tool_names,
);
assert!(
! summary.tool_names.contains_key(& ToolKind::Write),
"the injection-only `write` tool must NOT be in the pre-injection probe",
);
}
/// Requirement 3 (fail-open trigger): an `agent_type` that does not resolve
/// to a harness `AgentDefinition` reports `Unknown`, which the `/goal`
/// resolver maps to a `ToolsetUnknown` fail-open to the session harness.
#[test]
fn goal_harness_override_unresolvable_returns_unknown() {
let ctx = ctx_with_toggle(HashMap::new());
match describe_subagent_type(
"general-purpose",
Some("totally-bogus-harness"),
&ctx,
) {
SubagentDescribeOutcome::Unknown { .. } => {}
other => {
panic!(
"an unresolvable harness override must fail open as Unknown: {other:?}"
)
}
}
}
/// The model fallback only fires for a strict harness: a custom profile
/// running a stock/vision model leaves subagents on the default harness, so
/// they keep native image input.
#[test]
fn subagent_keeps_default_flavor_when_parent_model_is_non_strict() {
use kigi_agent::config::BuiltinAgentName;
let mut ctx = ctx_with_toggle(HashMap::new());
ctx.parent_agent_name = Some("ai-oncall-bot".to_string());
ctx.parent_model_agent_type = Some(
BuiltinAgentName::GrokBuildPlan.as_ref().to_string(),
);
let mut def = resolve_agent_definition("general-purpose", &ctx).expect("resolves");
resolve_subagent_toolset("general-purpose", None, &ctx, &mut def);
assert!(
! crate ::session::is_cursor_user_template(& def.user_message_template),
"a non-strict parent model must leave subagents on the default harness",
);
}
fn make_background_request(
subagent_type: &str,
) -> (SubagentRequest, oneshot::Receiver<SubagentResult>) {
let (mut req, rx) = make_request(subagent_type);
req.run_in_background = true;
(req, rx)
}
#[tokio::test]
async fn background_unknown_type_records_failure_completion() {
let ctx = ctx_with_toggle(HashMap::new());
let coordinator = std::cell::RefCell::new(SubagentCoordinator::new());
let gateway = test_gateway();
let (request, result_rx) = make_background_request("totally-invented-type");
assert_background_pre_spawn_failure(
ctx,
&coordinator,
&gateway,
request,
result_rx,
"Unknown subagent type",
)
.await;
}
async fn assert_background_pre_spawn_failure(
ctx: SubagentSpawnContext,
coordinator: &std::cell::RefCell<SubagentCoordinator>,
gateway: &GatewaySender,
request: SubagentRequest,
result_rx: oneshot::Receiver<SubagentResult>,
expected_error_substring: &str,
) {
let subagent_id = request.id.clone();
let local = tokio::task::LocalSet::new();
local
.run_until(async {
Box::pin(handle_subagent_request(request, ctx, coordinator, gateway)).await;
})
.await;
let result = result_rx.await.expect("should receive result");
assert!(! result.success);
let err = result.error.as_deref().unwrap_or("");
assert!(
err.contains(expected_error_substring),
"expected error substring {expected_error_substring:?} in {err:?}",
);
let lookup = coordinator.borrow().lookup(&subagent_id);
match lookup {
Some(SnapshotLookup::Ready(snap)) => {
assert_eq!(snap.subagent_id, subagent_id);
assert!(matches!(snap.status, SubagentSnapshotStatus::Failed { .. }));
}
_ => panic!("expected Ready(Failed) snapshot"),
}
let summaries = coordinator.borrow_mut().drain_pending_completions();
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].subagent_id, subagent_id);
assert!(! summaries[0].success);
}
#[tokio::test]
async fn background_disabled_type_records_failure_completion() {
let toggle = HashMap::from([("explore".to_string(), false)]);
let ctx = ctx_with_toggle(toggle);
let coordinator = std::cell::RefCell::new(SubagentCoordinator::new());
let gateway = test_gateway();
let (request, result_rx) = make_background_request("explore");
assert_background_pre_spawn_failure(
ctx,
&coordinator,
&gateway,
request,
result_rx,
"[subagents.toggle]",
)
.await;
}
#[tokio::test]
async fn background_not_allowed_type_records_failure_completion() {
let mut ctx = ctx_with_toggle(HashMap::new());
ctx.allowed_subagent_types = Some(vec!["plan".to_string()]);
let coordinator = std::cell::RefCell::new(SubagentCoordinator::new());
let gateway = test_gateway();
let (request, result_rx) = make_background_request("explore");
assert_background_pre_spawn_failure(
ctx,
&coordinator,
&gateway,
request,
result_rx,
"not allowed",
)
.await;
}
async fn assert_blocking_pre_spawn_does_not_push_summary(
ctx: SubagentSpawnContext,
coordinator: &std::cell::RefCell<SubagentCoordinator>,
gateway: &GatewaySender,
request: SubagentRequest,
result_rx: oneshot::Receiver<SubagentResult>,
) {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
Box::pin(handle_subagent_request(request, ctx, coordinator, gateway)).await;
})
.await;
let result = result_rx.await.expect("should receive result");
assert!(! result.success);
let summaries = coordinator.borrow_mut().drain_pending_completions();
assert!(
summaries.is_empty(),
"blocking-mode pre-spawn failure must not push completion summaries: {summaries:?}",
);
}
#[tokio::test]
async fn blocking_unknown_type_does_not_push_completion_summary() {
let ctx = ctx_with_toggle(HashMap::new());
let coordinator = std::cell::RefCell::new(SubagentCoordinator::new());
let gateway = test_gateway();
let (request, result_rx) = make_request("totally-invented-type");
assert_blocking_pre_spawn_does_not_push_summary(
ctx,
&coordinator,
&gateway,
request,
result_rx,
)
.await;
}
#[tokio::test]
async fn blocking_disabled_type_does_not_push_completion_summary() {
let toggle = HashMap::from([("explore".to_string(), false)]);
let ctx = ctx_with_toggle(toggle);
let coordinator = std::cell::RefCell::new(SubagentCoordinator::new());
let gateway = test_gateway();
let (request, result_rx) = make_request("explore");
assert_blocking_pre_spawn_does_not_push_summary(
ctx,
&coordinator,
&gateway,
request,
result_rx,
)
.await;
}
#[tokio::test]
async fn blocking_not_allowed_type_does_not_push_completion_summary() {
let mut ctx = ctx_with_toggle(HashMap::new());
ctx.allowed_subagent_types = Some(vec!["plan".to_string()]);
let coordinator = std::cell::RefCell::new(SubagentCoordinator::new());
let gateway = test_gateway();
let (request, result_rx) = make_request("explore");
assert_blocking_pre_spawn_does_not_push_summary(
ctx,
&coordinator,
&gateway,
request,
result_rx,
)
.await;
}
#[tokio::test]
async fn background_failure_summary_includes_description() {
let ctx = ctx_with_toggle(HashMap::new());
let coordinator = std::cell::RefCell::new(SubagentCoordinator::new());
let gateway = test_gateway();
let (mut request, _result_rx) = make_background_request("invented");
request.description = "find auth middleware".into();
let id = request.id.clone();
let local = tokio::task::LocalSet::new();
local
.run_until(async {
Box::pin(handle_subagent_request(request, ctx, &coordinator, &gateway))
.await;
})
.await;
let summaries = coordinator.borrow_mut().drain_pending_completions();
assert_eq!(summaries.len(), 1);
let s = &summaries[0];
assert_eq!(s.subagent_id, id);
assert_eq!(s.subagent_type, "invented");
assert_eq!(s.description, "find auth middleware");
}
#[tokio::test]
async fn background_unknown_type_emits_subagent_finished_notification() {
use crate::test_support::lsp_runtime::{
ctx_with_toggle_and_cmd_tx, test_gateway_with_receiver,
};
let (ctx, mut cmd_rx) = ctx_with_toggle_and_cmd_tx(HashMap::new());
let coordinator = std::cell::RefCell::new(SubagentCoordinator::new());
let (gateway, mut gateway_rx) = test_gateway_with_receiver();
let (request, _result_rx) = make_background_request("invented-type");
let subagent_id = request.id.clone();
let local = tokio::task::LocalSet::new();
local
.run_until(async {
Box::pin(handle_subagent_request(request, ctx, &coordinator, &gateway))
.await;
})
.await;
let mut found_persisted = false;
while let Ok(cmd) = cmd_rx.try_recv() {
if let SessionCommand::XaiSessionNotification { notification } = cmd
&& let SessionUpdate::SubagentFinished {
subagent_id: id,
status,
error,
..
} = &notification.update
{
assert_eq!(* id, subagent_id);
assert_eq!(status, "failed");
assert!(
error.as_deref().is_some_and(| e | e.contains("Unknown subagent type")),
);
found_persisted = true;
}
}
assert!(found_persisted, "must persist SubagentFinished via parent_cmd_tx");
let mut found_live = false;
while let Ok(msg) = gateway_rx.try_recv() {
if let kigi_acp_lib::AcpClientMessage::ExtNotification(args) = msg {
let req: &acp::ExtNotification = &args.request;
assert_eq!(req.method.as_ref(), "x.ai/session_notification");
let body = req.params.get();
assert!(body.contains("subagent_finished"));
assert!(body.contains(& subagent_id));
assert!(body.contains("\"status\":\"failed\""));
assert!(body.contains("Unknown subagent type"));
assert!(body.contains("\"will_wake\":false"));
found_live = true;
break;
}
}
assert!(found_live, "must broadcast SubagentFinished via gateway");
}
/// The promote-guard teardown emits EXACTLY ONE cancelled `SubagentFinished`
/// (on both the persist + gateway channels), delivers a cancelled result, and
/// leaves the entry queryable as `Cancelled`.
#[tokio::test]
async fn cancel_pending_subagent_at_promote_emits_exactly_one_cancelled_finish() {
use crate::test_support::lsp_runtime::{
ctx_with_toggle_and_cmd_tx, test_gateway_with_receiver,
};
let (ctx, mut cmd_rx) = ctx_with_toggle_and_cmd_tx(HashMap::new());
let coordinator = std::cell::RefCell::new(SubagentCoordinator::new());
let (gateway, mut gateway_rx) = test_gateway_with_receiver();
let (request, result_rx) = make_request("explore");
let subagent_id = request.id.clone();
let child_session_id = acp::SessionId::new(subagent_id.clone());
coordinator
.borrow_mut()
.insert_pending(PendingSubagent {
subagent_id: subagent_id.clone(),
subagent_type: "explore".to_string(),
description: "killed while pending".to_string(),
persona: None,
parent_prompt_id: None,
parent_session_id: ctx.parent_session_id.clone(),
started_at: std::time::Instant::now(),
run_in_background: true,
surface_completion: true,
color: None,
cancel_token: CancellationToken::new(),
});
let child_handle = dummy_tracker(&subagent_id, "test-parent", "explore", "task")
.child_handle;
let meta_dir = std::env::temp_dir()
.join(format!("subagent-promote-test-{subagent_id}"));
cancel_pending_subagent_at_promote(
request,
&child_handle,
&subagent_id,
&child_session_id,
&meta_dir,
&coordinator,
&gateway,
&ctx.parent_session_id,
ctx.parent_cmd_tx.as_ref(),
None,
false,
42,
)
.await;
let mut persisted = 0;
while let Ok(cmd) = cmd_rx.try_recv() {
if let SessionCommand::XaiSessionNotification { notification } = cmd
&& let SessionUpdate::SubagentFinished { subagent_id: id, status, .. } = &notification
.update
{
assert_eq!(* id, subagent_id);
assert_eq!(status, "cancelled");
persisted += 1;
}
}
assert_eq!(persisted, 1, "exactly one persisted SubagentFinished");
let mut live = 0;
while let Ok(msg) = gateway_rx.try_recv() {
if let kigi_acp_lib::AcpClientMessage::ExtNotification(args) = msg {
let body = args.request.params.get();
if body.contains("subagent_finished") {
assert!(body.contains(& subagent_id));
assert!(body.contains("\"status\":\"cancelled\""));
live += 1;
}
}
}
assert_eq!(live, 1, "exactly one live SubagentFinished");
let result = result_rx.await.expect("result delivered to oneshot");
assert!(result.cancelled, "result must be cancelled");
assert!(! result.success);
match coordinator.borrow().lookup(&subagent_id) {
Some(SnapshotLookup::Ready(snap)) => {
assert!(
matches!(snap.status, SubagentSnapshotStatus::Cancelled { .. }),
"expected Cancelled, got {:?}", snap.status
)
}
_ => panic!("expected Ready(Cancelled) snapshot after promote-abort"),
}
}
/// Drive `cancel_pending_subagent_at_promote` against a real `worktree` and
/// assert it still emits EXACTLY ONE cancelled finish + leaves the entry
/// queryable as Cancelled. The caller asserts the worktree dir's fate.
async fn run_promote_cancel_with_worktree(
worktree: &Path,
worktree_freshly_created: bool,
) {
use crate::test_support::lsp_runtime::{
ctx_with_toggle_and_cmd_tx, test_gateway_with_receiver,
};
let (ctx, mut cmd_rx) = ctx_with_toggle_and_cmd_tx(HashMap::new());
let coordinator = std::cell::RefCell::new(SubagentCoordinator::new());
let (gateway, mut gateway_rx) = test_gateway_with_receiver();
let (request, result_rx) = make_request("explore");
let subagent_id = request.id.clone();
let child_session_id = acp::SessionId::new(subagent_id.clone());
coordinator
.borrow_mut()
.insert_pending(PendingSubagent {
subagent_id: subagent_id.clone(),
subagent_type: "explore".to_string(),
description: "killed while pending".to_string(),
persona: None,
parent_prompt_id: None,
parent_session_id: ctx.parent_session_id.clone(),
started_at: std::time::Instant::now(),
run_in_background: true,
surface_completion: true,
color: None,
cancel_token: CancellationToken::new(),
});
let child_handle = dummy_tracker(&subagent_id, "test-parent", "explore", "task")
.child_handle;
let meta_dir = std::env::temp_dir().join(format!("subagent-wt-test-{subagent_id}"));
cancel_pending_subagent_at_promote(
request,
&child_handle,
&subagent_id,
&child_session_id,
&meta_dir,
&coordinator,
&gateway,
&ctx.parent_session_id,
ctx.parent_cmd_tx.as_ref(),
Some(worktree),
worktree_freshly_created,
42,
)
.await;
let mut persisted = 0;
while let Ok(cmd) = cmd_rx.try_recv() {
if let SessionCommand::XaiSessionNotification { notification } = cmd
&& matches!(notification.update, SessionUpdate::SubagentFinished { .. })
{
persisted += 1;
}
}
assert_eq!(persisted, 1, "exactly one persisted SubagentFinished");
let mut live = 0;
while let Ok(msg) = gateway_rx.try_recv() {
if let kigi_acp_lib::AcpClientMessage::ExtNotification(args) = msg
&& args.request.params.get().contains("subagent_finished")
{
live += 1;
}
}
assert_eq!(live, 1, "exactly one live SubagentFinished");
let result = result_rx.await.expect("result delivered to oneshot");
assert!(result.cancelled, "result must be cancelled");
assert!(
matches!(coordinator.borrow().lookup(& subagent_id),
Some(SnapshotLookup::Ready(snap)) if matches!(snap.status,
SubagentSnapshotStatus::Cancelled { .. }))
);
}
/// The promote-abort teardown removes a FRESHLY-created worktree (this
/// subagent's own, pristine) but PRESERVES a resumed subagent's reused
/// worktree (it aliases the source's dir — deleting it would lose the
/// source's working state). Exactly one cancelled finish emits either way.
#[tokio::test]
async fn cancel_pending_at_promote_removes_fresh_worktree_preserves_resumed() {
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 fresh = temp.path().join("subagent-fresh");
kigi_fast_worktree::WorktreeBuilder::new(&repo, &fresh)
.standalone(true)
.create()
.unwrap();
assert!(fresh.exists());
run_promote_cancel_with_worktree(&fresh, true).await;
assert!(
! fresh.exists(), "freshly-created worktree must be removed on pending-kill"
);
let resumed = temp.path().join("subagent-resumed");
kigi_fast_worktree::WorktreeBuilder::new(&repo, &resumed)
.standalone(true)
.create()
.unwrap();
std::fs::write(resumed.join("tracked.txt"), "source edit").unwrap();
assert!(resumed.exists());
run_promote_cancel_with_worktree(&resumed, false).await;
assert!(
resumed.exists(),
"resumed subagent's reused worktree must be preserved (source owns it)"
);
assert_eq!(
std::fs::read_to_string(resumed.join("tracked.txt")).unwrap(), "source edit",
"the source's working state must be left untouched"
);
}
#[test]
fn record_pre_spawn_failure_populates_completed_and_summary() {
let mut coordinator = SubagentCoordinator::new();
coordinator
.record_pre_spawn_failure(
"sub-x".to_string(),
"invented".to_string(),
"bg job".to_string(),
Some("prompt-1".to_string()),
"parent-1".to_string(),
"Unknown subagent type: invented",
true,
);
let lookup = coordinator.lookup("sub-x");
match lookup {
Some(SnapshotLookup::Ready(snap)) => {
assert_eq!(snap.subagent_id, "sub-x");
match &snap.status {
SubagentSnapshotStatus::Failed { error } => {
assert!(error.contains("Unknown subagent type"));
}
other => panic!("expected Failed, got {other:?}"),
}
}
_ => panic!("expected Ready snapshot for recorded pre-spawn failure"),
}
let summaries = coordinator.drain_pending_completions();
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].subagent_id, "sub-x");
assert_eq!(summaries[0].subagent_type, "invented");
assert_eq!(summaries[0].description, "bg job");
assert!(! summaries[0].success);
}
#[test]
fn record_pre_spawn_failure_skips_buffer_when_flag_false() {
let mut coordinator = SubagentCoordinator::new();
coordinator
.record_pre_spawn_failure(
"sub-hidden-pre".to_string(),
"invented".to_string(),
"bg job".to_string(),
None,
"parent-1".to_string(),
"Unknown subagent type: invented",
false,
);
assert!(coordinator.drain_pending_completions().is_empty());
assert!(coordinator.lookup("sub-hidden-pre").is_some());
}
#[tokio::test]
async fn record_pre_spawn_failure_notifies_waiters() {
let mut coordinator = SubagentCoordinator::new();
let notify = coordinator.completion_notify();
let waiter = notify.notified();
coordinator
.record_pre_spawn_failure(
"sub-y".to_string(),
"invented".to_string(),
"bg job".to_string(),
None,
"parent-1".to_string(),
"error",
true,
);
tokio::time::timeout(std::time::Duration::from_millis(50), waiter)
.await
.expect("notify_waiters must wake pre-armed waiter");
}
#[tokio::test]
async fn record_pre_spawn_failure_notifies_all_waiters() {
let mut coordinator = SubagentCoordinator::new();
let notify = coordinator.completion_notify();
let waiter_a = notify.notified();
let waiter_b = notify.notified();
coordinator
.record_pre_spawn_failure(
"sub-multi".to_string(),
"invented".to_string(),
"bg job".to_string(),
None,
"parent-1".to_string(),
"error",
true,
);
let timeout = std::time::Duration::from_millis(50);
tokio::time::timeout(timeout, waiter_a).await.expect("waiter_a must wake");
tokio::time::timeout(timeout, waiter_b).await.expect("waiter_b must wake");
}
#[test]
fn record_pre_spawn_failure_clears_stale_pending_entry() {
let mut coordinator = SubagentCoordinator::new();
coordinator
.insert_pending(PendingSubagent {
subagent_id: "sub-z".to_string(),
subagent_type: "invented".to_string(),
description: "stale".to_string(),
persona: None,
parent_prompt_id: Some("prompt-X".to_string()),
parent_session_id: "parent-1".to_string(),
started_at: std::time::Instant::now(),
run_in_background: true,
surface_completion: true,
color: None,
cancel_token: CancellationToken::new(),
});
assert!(coordinator.pending.contains_key("sub-z"));
coordinator
.record_pre_spawn_failure(
"sub-z".to_string(),
"invented".to_string(),
"bg job".to_string(),
Some("prompt-X".to_string()),
"parent-1".to_string(),
"Unknown subagent type: invented",
true,
);
assert!(! coordinator.pending.contains_key("sub-z"));
match coordinator.lookup("sub-z") {
Some(SnapshotLookup::Ready(snap)) => {
assert!(matches!(snap.status, SubagentSnapshotStatus::Failed { .. }));
}
_ => panic!("expected Ready(Failed) post-collision"),
}
assert!(
! coordinator.outstanding_for_prompt("prompt-X").iter().any(| id | id ==
"sub-z"), "outstanding_for_prompt must not still list a recorded-failed id",
);
}
fn test_model_entry(model_id: &str) -> crate::agent::config::ModelEntry {
crate::agent::config::ModelEntry {
info: crate::agent::config::ModelInfo {
user_selectable: true,
id: None,
model: model_id.to_string(),
base_url: String::new(),
name: None,
description: None,
max_completion_tokens: None,
temperature: None,
top_p: None,
api_backend: Default::default(),
auth_scheme: Default::default(),
extra_headers: Default::default(),
context_window: std::num::NonZeroU64::new(256_000).unwrap(),
auto_compact_threshold_percent: None,
system_prompt_label: None,
use_concise: false,
agent_type: crate::agent::config::default_agent_type(),
inference_idle_timeout_secs: None,
max_retries: None,
hidden: false,
supported_in_api: true,
reasoning_effort: None,
supports_reasoning_effort: false,
reasoning_efforts: Vec::new(),
supports_backend_search: false,
compactions_remaining: None,
compaction_at_tokens: None,
show_model_fingerprint: false,
stream_tool_calls: None,
laziness_detector: crate::agent::config::LazinessDetectorPerModelConfig::default(),
},
api_key: None,
env_key: None,
api_base_url: None,
}
}
fn byok_model_entry(model_id: &str) -> crate::agent::config::ModelEntry {
crate::agent::config::ModelEntry {
api_key: Some("byok-key".to_string()),
..test_model_entry(model_id)
}
}
#[test]
fn subagent_auth_type_rule() {
use crate::agent::auth_method::{CACHED_TOKEN_AUTH_METHOD_ID, XAI_API_KEY_METHOD_ID};
use kigi_chat_state::AuthType;
let session = acp::AuthMethodId::new(CACHED_TOKEN_AUTH_METHOD_ID);
let api_key = acp::AuthMethodId::new(XAI_API_KEY_METHOD_ID);
let byok = byok_model_entry("grok-byok");
let plain = test_model_entry("grok-plain");
assert_eq!(super::subagent_auth_type(Some(& byok), & session), AuthType::ApiKey);
assert_eq!(super::subagent_auth_type(Some(& byok), & api_key), AuthType::ApiKey);
assert_eq!(
super::subagent_auth_type(Some(& plain), & session), AuthType::SessionToken,
);
assert_eq!(super::subagent_auth_type(Some(& plain), & api_key), AuthType::ApiKey);
assert_eq!(super::subagent_auth_type(None, & session), AuthType::SessionToken);
assert_eq!(super::subagent_auth_type(None, & api_key), AuthType::ApiKey);
}
#[test]
fn fresh_tool_model_accepts_visible_key_and_internal_id() {
let mut models = indexmap::IndexMap::new();
models.insert("grok-3".to_string(), test_model_entry("grok-3-2025-02-15"));
assert!(
super::handle_request::task_model_override_error(Some("grok-3"),
ModelOverrideProvenance::Tool, false, & models, false,).is_none(),
"key lookup should succeed"
);
assert!(
super::handle_request::task_model_override_error(Some("grok-3-2025-02-15"),
ModelOverrideProvenance::Tool, false, & models, false,).is_none(),
"info().model lookup should succeed"
);
}
#[test]
fn fresh_tool_model_rejects_unavailable_exact_key_over_visible_slug_collision() {
let mut models = indexmap::IndexMap::new();
models.insert("visible-alias".to_string(), test_model_entry("collision"));
let mut unavailable_exact = test_model_entry("hidden-internal");
unavailable_exact.info.hidden = true;
models.insert("collision".to_string(), unavailable_exact);
assert_eq!(
super::handle_request::task_model_override_error(Some("collision"),
ModelOverrideProvenance::Tool, false, & models, false,).as_deref(),
Some("Unknown Task.model slug 'collision'. Valid model slugs: visible-alias. \
Omit `model` to inherit the parent model."),
"validation must inspect the unavailable exact-key entry selected by execution"
);
}
#[test]
fn fresh_tool_model_rejects_unavailable_first_slug_collision() {
let mut models = indexmap::IndexMap::new();
let mut unavailable_first = test_model_entry("shared-routing-slug");
unavailable_first.info.user_selectable = false;
models.insert("blocked-first".to_string(), unavailable_first);
models.insert("visible-second".to_string(), test_model_entry("shared-routing-slug"));
assert_eq!(
super::handle_request::task_model_override_error(Some("shared-routing-slug"),
ModelOverrideProvenance::Tool, false, & models, false,).as_deref(),
Some("Unknown Task.model slug 'shared-routing-slug'. Valid model slugs: \
visible-second. Omit `model` to inherit the parent model."),
"validation must inspect the first routing-slug entry selected by execution"
);
}
#[test]
fn fresh_tool_model_rejects_unknown_and_nonavailable_entries() {
let mut models = indexmap::IndexMap::new();
models.insert("zeta".to_string(), test_model_entry("zeta-internal"));
let mut hidden = test_model_entry("hidden-internal");
hidden.info.hidden = true;
models.insert("hidden".to_string(), hidden);
let mut not_selectable = test_model_entry("disabled-internal");
not_selectable.info.user_selectable = false;
models.insert("disabled".to_string(), not_selectable);
let mut oauth_only = test_model_entry("oauth-only-internal");
oauth_only.info.supported_in_api = false;
models.insert("oauth-only".to_string(), oauth_only);
models.insert("alpha".to_string(), test_model_entry("alpha-internal"));
for requested in [
"stale-model",
"hidden",
"hidden-internal",
"disabled",
"disabled-internal",
"oauth-only",
"oauth-only-internal",
] {
let error = super::handle_request::task_model_override_error(
Some(requested),
ModelOverrideProvenance::Tool,
false,
&models,
false,
)
.unwrap();
assert_eq!(
error,
format!("Unknown Task.model slug '{requested}'. Valid model slugs: alpha, zeta. \
Omit `model` to inherit the parent model.")
);
assert!(! error.contains("grok models"));
}
assert!(
super::handle_request::task_model_override_error(Some("oauth-only"),
ModelOverrideProvenance::Tool, false, & models, true,).is_none(),
"OAuth-only model should resolve for session auth"
);
}
#[test]
fn fresh_tool_model_reports_empty_valid_list() {
let empty = indexmap::IndexMap::new();
assert_eq!(
super::handle_request::task_model_override_error(Some("anything"),
ModelOverrideProvenance::Tool, false, & empty, false,).as_deref(),
Some("Unknown Task.model slug 'anything'. No valid model slugs are currently \
available. Omit `model` to inherit the parent model.")
);
}
#[test]
fn resumed_tool_model_override_is_ignored() {
let empty = indexmap::IndexMap::new();
assert!(
super::handle_request::task_model_override_error(Some("stale-model"),
ModelOverrideProvenance::Tool, true, & empty, false,).is_none(),
"resume must preserve source-model pinning"
);
}
#[test]
fn harness_model_override_keeps_internal_fallback_behavior() {
let empty = indexmap::IndexMap::new();
assert!(
super::handle_request::task_model_override_error(Some("internal-model"),
ModelOverrideProvenance::Harness, false, & empty, false,).is_none(),
"internal role/config pins must retain downstream soft fallback"
);
}
#[test]
fn normalize_forked_context_empty_parent() {
use kigi_sampling_types::conversation::ConversationItem;
let items = vec![ConversationItem::system("sys prompt")];
let (conv, prefix_len) = kigi_subagent_resolution::context::normalize_forked_context(
items,
);
assert_eq!(conv.len(), 1);
assert_eq!(prefix_len, 1);
assert!(matches!(conv[0], ConversationItem::System(_)));
}
#[test]
fn normalize_forked_context_short_conversation() {
use kigi_sampling_types::conversation::ConversationItem;
let items = vec![
ConversationItem::system("sys"), ConversationItem::user("hello"),
ConversationItem::assistant("hi back"),
];
let (conv, prefix_len) = kigi_subagent_resolution::context::normalize_forked_context(
items,
);
assert_eq!(prefix_len, 2);
assert_eq!(conv.len(), 2);
assert!(matches!(conv[0], ConversationItem::System(_)));
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::<String>();
assert!(text.contains("<background_context>"), "should have background tag");
assert!(text.contains("[User]: hello"), "should include parent user message");
assert!(
text.contains("[Assistant]: hi back"),
"should include parent assistant message"
);
} else {
panic!("expected User message at position 1");
}
}
fn test_sampling_config(model_slug: &str) -> kigi_sampling_types::SamplingConfig {
use std::num::NonZeroU64;
kigi_sampling_types::SamplingConfig {
base_url: "https://api.test/v1".to_string(),
model: model_slug.to_string(),
max_completion_tokens: None,
temperature: None,
top_p: None,
api_backend: Default::default(),
extra_headers: Default::default(),
context_window: NonZeroU64::new(256_000).expect("non-zero context window"),
reasoning_effort: None,
stream_tool_calls: None,
}
}
fn spawn_test_parent_chat_state(model_slug: &str) -> kigi_chat_state::ChatStateHandle {
let (mock, _persistence_rx) = kigi_chat_state::MockChatPersistence::new();
let (event_tx, _event_rx) = mpsc::unbounded_channel();
let token = tokio_util::sync::CancellationToken::new();
kigi_chat_state::ChatStateActor::spawn(
vec![],
test_sampling_config(model_slug),
Box::new(mock),
event_tx,
token,
)
}
mod rest;