//! `task` tool — launches a subagent to handle a task autonomously. //! //! The TaskTool delegates subagent operations to a [`SubagentBackend`] //! (injected as [`SubagentBackendResource`]). The backend abstracts over the //! transport mechanism (in-process channels for the local host, remote //! backends, etc.). //! //! ## Resources //! //! - `SubagentBackendResource` — backend for spawn/query/cancel (required) //! - `SubagentDepthCounter` — current nesting depth (optional, defaults to 0) //! - `SessionIdResource` — current session ID for parent scoping (optional) //! - `TaskModelValidator` — validates explicit model slugs before spawn pub mod backend; pub mod types; use self::backend::SubagentBackendResource; use self::types::CurrentPromptIdResource; use self::types::*; use crate::types::output::ToolOutput; use crate::types::requirements::{Expr, ToolRequirement}; #[allow(unused_imports)] use crate::types::resources::SharedResources; use crate::types::tool::{ToolKind, ToolNamespace}; use kigi_tool_types::{SubagentCompletedOutput, SubagentIsolationMode, TaskToolInput}; /// Maximum nesting depth for subagents. A top-level session is depth 0; /// the first subagent is depth 1. Subagents cannot spawn further subagents. pub const MAX_SUBAGENT_DEPTH: u32 = 1; // ─────────────────────────────────────────────────────────────────────────── // Tool implementation // ─────────────────────────────────────────────────────────────────────────── #[derive(Debug, Default)] pub struct TaskTool; // ─────────────────────────────────────────────────────────────────────────── // Tests // ─────────────────────────────────────────────────────────────────────────── impl crate::types::tool_metadata::ToolMetadata for TaskTool { fn kind(&self) -> ToolKind { ToolKind::Task } fn tool_namespace(&self) -> ToolNamespace { ToolNamespace::GrokBuild } fn description_template(&self) -> &str { // The Task tool description for Grok Build is *never* taken from here. // It is always supplied via `ToolConfig::with_description(...)` using // the dynamically built string from `build_task_description()` in // kigi-agent/src/builder.rs (HEADER + per-subagent blocks + FOOTER). // // This path is only hit by low-level ToolsetBuilder registration or // direct calls to ToolMetadata::description_template in tests. "" } fn requires_expr(&self) -> Expr { // The task tool can only be used when both get_task_output // (BackgroundTaskAction) and kill_task (KillTaskAction) are present, // so the agent can manage background subagents it spawns. Expr::And(vec![ Expr::Value(ToolRequirement::tool_kind(ToolKind::BackgroundTaskAction)), Expr::Value(ToolRequirement::tool_kind(ToolKind::KillTaskAction)), ]) } fn is_read_only(&self) -> bool { false } } impl kigi_tool_runtime::Tool for TaskTool { type Args = TaskToolInput; type Output = ToolOutput; fn id(&self) -> kigi_tool_protocol::ToolId { kigi_tool_protocol::ToolId::new("task").expect("valid tool id") } fn description( &self, _ctx: &::kigi_tool_runtime::ListToolsContext, ) -> kigi_tool_types::ToolDescription { kigi_tool_types::ToolDescription::new( "task", crate::types::tool_metadata::ToolMetadata::description_template(self), ) } fn capabilities(&self) -> kigi_tool_protocol::ToolCapabilities { kigi_tool_protocol::ToolCapabilities { is_read_only: false, tool_scope: Some(kigi_tool_protocol::ToolScope::Write), ..Default::default() } } #[tracing::instrument( name = "tool.task", skip_all, fields( subagent_type = %input.subagent_type, ) )] async fn run( &self, ctx: kigi_tool_runtime::ToolCallContext, input: TaskToolInput, ) -> Result { use crate::types::tool_metadata::shared_resources; let resources = shared_resources(&ctx)?; // 1. Depth check let (depth, backend, model_validator, parent_session_id, parent_prompt_id) = { let res = resources.lock().await; let depth = res.get::().map(|d| d.0).unwrap_or(0); let backend = res .get::() .ok_or_else(|| { kigi_tool_runtime::ToolError::custom( "missing_resource", "SubagentBackendResource (subagent support not initialized)", ) })? .clone(); let model_validator = res.get::().cloned(); let parent_session_id = res .get::() .map(|s| s.0.clone()) .unwrap_or_default(); let parent_prompt_id = res .get::() .map(|p| p.0.clone()) .filter(|prompt_id| !prompt_id.is_empty()); ( depth, backend, model_validator, parent_session_id, parent_prompt_id, ) }; if depth >= MAX_SUBAGENT_DEPTH { return Err(kigi_tool_runtime::ToolError::invalid_arguments(format!( "Subagent depth limit exceeded (current depth: {depth}, max: {MAX_SUBAGENT_DEPTH}). \ Cannot spawn further nested subagents." ))); } // Treat blank/empty/"null" resume_from as absent (models sometimes emit these). let resume_from = input.resume_from.and_then(|s| { let trimmed = s.trim(); is_valid_resume_id(trimmed).then(|| trimmed.to_string()) }); // Model overrides are soft-ignored on resume (source model is always pinned). let model = kigi_tool_types::sanitize_optional_arg(input.model); let model = if resume_from.is_some() { if let Some(ref ignored) = model { tracing::debug!( model = %ignored, "ignoring model override because resume_from is set" ); } None } else { model }; // Treat blank/empty/"null" cwd as absent (models sometimes emit these). // Also strip stray surrounding quote characters and expand `~`. let cwd = input.cwd.as_deref().and_then(sanitize_cwd_value); // Validate mutual exclusion: cwd and isolation=worktree cannot both // be set. Both set the effective cwd — setting both is ambiguous. // However, if the cwd path doesn't exist as a real directory on disk, // the model likely passed a nonsense path — just clear it so worktree wins. let cwd = if cwd.is_some() && input.isolation == Some(SubagentIsolationMode::Worktree) { if cwd .as_deref() .is_some_and(|p| std::path::Path::new(p).is_dir()) { return Err(kigi_tool_runtime::ToolError::invalid_arguments( "cwd and isolation=\"worktree\" are mutually exclusive. \ Use cwd to point the subagent at an existing directory, \ or isolation=\"worktree\" to create a new isolated worktree, \ but not both.", )); } // Non-existent path alongside worktree — clear it so worktree wins. tracing::debug!( cwd = %cwd.as_deref().unwrap_or(""), "clearing non-existent cwd path because isolation=worktree is set" ); None } else { cwd }; // Validate that cwd points to an existing directory (skip when resuming). if let Some(ref cwd_path) = cwd && resume_from.is_none() { let p = std::path::Path::new(cwd_path); if !p.is_dir() { let detail = if p.exists() { format!("cwd \"{cwd_path}\" exists but is not a directory") } else { format!("cwd \"{cwd_path}\" does not exist") }; return Err(kigi_tool_runtime::ToolError::invalid_arguments(detail)); } } // 2. Eager validation — catch unknown / disabled / not-allowed // types before the fire-and-forget background spawn. match backend .backend() .validate_type(&input.subagent_type, &parent_session_id) .await { SubagentValidateTypeOutcome::Ok => {} SubagentValidateTypeOutcome::Unknown { available } => { let suffix = if available.is_empty() { String::new() } else { format!(". Available types: {}", available.join(", ")) }; return Err(kigi_tool_runtime::ToolError::invalid_arguments(format!( "Unknown subagent type: {}{suffix}", input.subagent_type ))); } SubagentValidateTypeOutcome::Disabled => { return Err(kigi_tool_runtime::ToolError::invalid_arguments(format!( "Subagent '{}' is disabled via [subagents.toggle] in config.toml", input.subagent_type ))); } SubagentValidateTypeOutcome::NotAllowed { allowed } => { return Err(kigi_tool_runtime::ToolError::invalid_arguments(format!( "agent can only spawn: {}; '{}' not allowed", allowed.join(", "), input.subagent_type ))); } SubagentValidateTypeOutcome::ValidationUnavailable => { // `custom` (not `invalid_arguments`) so the model doesn't // retry with a different name on transport faults. return Err(kigi_tool_runtime::ToolError::custom( "validation_unavailable", format!( "Cannot validate subagent type '{}': the subagent coordinator is \ unreachable. Retry shortly or notify ops.", input.subagent_type ), )); } } if let Some(ref requested) = model { let validator = model_validator.ok_or_else(|| { kigi_tool_runtime::ToolError::custom( "validation_unavailable", "Cannot validate Task.model: model catalog validator is unavailable.", ) })?; if let Some(error) = validator.error_for(requested) { return Err(kigi_tool_runtime::ToolError::invalid_arguments(error)); } } // 3. Build the subagent request let id = input .task_id .clone() .unwrap_or_else(|| uuid::Uuid::now_v7().to_string()); // Placeholder; `ChannelBackend::spawn` replaces it with a fresh one. let (result_tx, _) = tokio::sync::oneshot::channel(); let request = SubagentRequest { id: id.clone(), prompt: input.prompt.clone(), description: input.description.clone(), subagent_type: input.subagent_type.clone(), parent_session_id, parent_prompt_id, resume_from, cwd, runtime_overrides: SubagentRuntimeOverrides { model, model_override_provenance: ModelOverrideProvenance::Tool, reasoning_effort: None, persona: None, capability_mode: input.capability_mode, isolation: input.isolation, // Model-issued `task` spawns never override the harness; the // parent agent decides the flavor (the `/goal` harness override // is set only by the harness-internal role spawners). harness_agent_type: None, }, run_in_background: input.run_in_background, // Model-spawned subagents must still appear in the idle reminder. surface_completion: true, fork_context: false, result_tx, }; // 4. Background mode: fire-and-forget via backend.spawn(). // Coordinator stores the result for TaskOutputTool polling. // Both transport errors and coordinator rejections are logged so // late failures (worktree creation, etc.) remain visible. if input.run_in_background { let bg_backend = backend.clone(); let bg_id = id.clone(); let bg_type = input.subagent_type.clone(); tokio::spawn(async move { match bg_backend.backend().spawn(request).await { Err(e) => { tracing::error!( subagent_id = %bg_id, subagent_type = %bg_type, "background spawn transport error: {e:#}", ); } Ok(r) if !r.success => { tracing::error!( subagent_id = %bg_id, subagent_type = %bg_type, error = ?r.error, "background spawn rejected by coordinator", ); } Ok(_) => {} } }); let task_output_name = crate::types::template_renderer::TemplateRenderer::resolve( &resources, "${{ tools.by_kind.background_task_action }}", ) .await .unwrap_or_else(|_| "get_command_or_subagent_output".to_string()); return Ok(ToolOutput::Text( kigi_tool_types::format_subagent_started_background( &id, &input.subagent_type, &input.description, &task_output_name, ) .into(), )); } // 5. Blocking mode (default): spawn via backend and await result let result = backend.backend().spawn(request).await?; // 5b. The await budget expired and the coordinator auto-backgrounded the // still-running child — return a task_id to poll, like the background // branch above (the result arrives via auto-wake or a later poll). if result.backgrounded { let task_output_name = crate::types::template_renderer::TemplateRenderer::resolve( &resources, "${{ tools.by_kind.background_task_action }}", ) .await .unwrap_or_else(|_| "get_command_or_subagent_output".to_string()); return Ok(ToolOutput::Text( format!( "Subagent took longer than the foreground budget and was moved to the \ background to keep the conversation responsive. It is still running — you \ will be notified when it completes.\n\ subagent_id: {id}\n\ type: {}\n\ description: {}\n\n\ Use {task_output_name} with task_ids=[\"{id}\"] and timeout_ms to wait for results.", input.subagent_type, input.description, ) .into(), )); } // 6. Return result if result.success { let resume_from_hint = result.subagent_id.clone(); let persona_hint: Option = None; Ok(ToolOutput::SubagentCompleted(SubagentCompletedOutput { // SubagentCompletedOutput.output is `String` (serde-visible // boundary). One allocation per completion; cheaper paths // (pending_completions / snapshot) keep the Arc. output: result.output.to_string(), subagent_id: result.subagent_id, subagent_type: input.subagent_type, tool_calls: result.tool_calls, turns: result.turns, duration_ms: result.duration_ms, worktree_path: result.worktree_path, persona: None, resume_from_hint, persona_hint, })) } else { Err(kigi_tool_runtime::ToolError::invalid_arguments( result .error .unwrap_or_else(|| "Unknown subagent error".to_string()), )) } } } #[cfg(test)] mod tests { use super::*; use crate::implementations::grok_build::task::backend::{ ChannelBackend, SubagentBackendResource, }; use crate::types::resources::Resources; use crate::types::tool_metadata::test_ctx; use kigi_tool_types::SubagentCapabilityMode; use std::sync::Arc; use tokio::sync::mpsc; /// Backend whose `ValidateType` events are auto-acked with `Ok`. fn make_backend() -> ( SubagentBackendResource, mpsc::UnboundedReceiver, ) { make_backend_with_validation(SubagentValidateTypeOutcome::Ok) } /// Backend that replays `outcome` for every `ValidateType` event. fn make_backend_with_validation( outcome: SubagentValidateTypeOutcome, ) -> ( SubagentBackendResource, mpsc::UnboundedReceiver, ) { make_backend_with_validation_fn(move |_, _| outcome.clone()) } /// Backend whose `ValidateType` outcome is computed per (type, session) pair. fn make_backend_with_validation_fn( outcome_fn: F, ) -> ( SubagentBackendResource, mpsc::UnboundedReceiver, ) where F: Fn(&str, &str) -> SubagentValidateTypeOutcome + Send + 'static, { let (raw_tx, mut raw_rx) = mpsc::unbounded_channel::(); let (proxy_tx, proxy_rx) = mpsc::unbounded_channel::(); let backend = SubagentBackendResource(Arc::new(ChannelBackend::new(raw_tx))); tokio::spawn(async move { while let Some(event) = raw_rx.recv().await { match event { SubagentEvent::ValidateType(req) => { let outcome = outcome_fn(&req.subagent_type, &req.parent_session_id); let _ = req.respond_to.send(outcome); } other => { if proxy_tx.send(other).is_err() { break; } } } } }); (backend, proxy_rx) } /// Extract a `SubagentRequest` from a `SubagentEvent`, panicking on wrong variant. fn unwrap_spawn(event: SubagentEvent) -> SubagentRequest { match event { SubagentEvent::Spawn(r) => *r, _ => panic!("Expected SubagentEvent::Spawn"), } } #[tokio::test] async fn depth_limit_exceeded() { let (backend, _rx) = make_backend(); let mut resources = Resources::new(); resources.insert(backend); resources.insert(SubagentDepthCounter(MAX_SUBAGENT_DEPTH)); // at limit resources.insert(SessionIdResource("test-session".to_string())); resources.insert(CurrentPromptIdResource("prompt-123".to_string())); let tool = TaskTool; let result = kigi_tool_runtime::Tool::run( &tool, test_ctx(resources.into_shared()), TaskToolInput { description: "test task".into(), prompt: "do something".into(), subagent_type: "general-purpose".into(), run_in_background: false, capability_mode: None, isolation: None, resume_from: None, cwd: None, model: None, task_id: None, }, ) .await; assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("depth limit exceeded"), "error: {err}"); } #[tokio::test] async fn subagent_cannot_spawn_nested_subagent() { let (backend, _rx) = make_backend(); let mut resources = Resources::new(); resources.insert(backend); resources.insert(SubagentDepthCounter(1)); // first-level subagent resources.insert(SessionIdResource("child-session".to_string())); resources.insert(CurrentPromptIdResource("prompt-456".to_string())); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(resources.into_shared()), TaskToolInput { description: "nested spawn".into(), prompt: "should be rejected".into(), subagent_type: "explore".into(), run_in_background: false, capability_mode: None, isolation: None, resume_from: None, cwd: None, model: None, task_id: None, }, ) .await; assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!( err.contains("depth limit exceeded"), "subagent at depth 1 must not spawn: {err}" ); } #[tokio::test] async fn missing_backend_returns_error() { let resources = Resources::new(); let tool = TaskTool; let result = kigi_tool_runtime::Tool::run( &tool, test_ctx(resources.into_shared()), TaskToolInput { description: "test task".into(), prompt: "do something".into(), subagent_type: "general-purpose".into(), run_in_background: false, capability_mode: None, isolation: None, resume_from: None, cwd: None, model: None, task_id: None, }, ) .await; assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!( err.contains("SubagentBackendResource"), "error should mention missing resource: {err}" ); } #[tokio::test] async fn successful_subagent_returns_text_output() { let (backend, mut rx) = make_backend(); let mut resources = Resources::new(); resources.insert(backend); resources.insert(SubagentDepthCounter(0)); resources.insert(SessionIdResource("parent-session".to_string())); resources.insert(CurrentPromptIdResource("prompt-123".to_string())); let tool = TaskTool; let shared = resources.into_shared(); // Spawn a task that will handle the request let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); assert_eq!(request.subagent_type, "explore"); assert_eq!(request.parent_session_id, "parent-session"); assert_eq!(request.parent_prompt_id.as_deref(), Some("prompt-123")); request .result_tx .send(SubagentResult { success: true, output: std::sync::Arc::from("Found 3 auth middleware files"), subagent_id: request.id.clone(), child_session_id: request.id.clone(), tool_calls: 5, turns: 2, duration_ms: 1234, ..Default::default() }) .unwrap(); }); let result = kigi_tool_runtime::Tool::run( &tool, test_ctx(shared), TaskToolInput { description: "Find auth middleware".into(), prompt: "Search for authentication middleware files".into(), subagent_type: "explore".into(), run_in_background: false, capability_mode: None, isolation: None, resume_from: None, cwd: None, model: None, task_id: None, }, ) .await .unwrap(); handle.await.unwrap(); match result { ToolOutput::SubagentCompleted(sub) => { assert!(sub.output.contains("Found 3 auth middleware files")); assert_eq!(sub.tool_calls, 5); assert_eq!(sub.turns, 2); assert_eq!(sub.duration_ms, 1234); assert_eq!(sub.subagent_type, "explore"); } other => panic!("Expected SubagentCompleted output, got {:?}", other), } } #[tokio::test] async fn failed_subagent_returns_error() { let (backend, mut rx) = make_backend(); let mut resources = Resources::new(); resources.insert(backend); resources.insert(SubagentDepthCounter(0)); // top-level session resources.insert(SessionIdResource("parent-session".to_string())); resources.insert(CurrentPromptIdResource("prompt-123".to_string())); let tool = TaskTool; let shared = resources.into_shared(); let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); request .result_tx .send(SubagentResult { success: false, error: Some("Child session crashed".to_string()), ..Default::default() }) .unwrap(); }); let result = kigi_tool_runtime::Tool::run( &tool, test_ctx(shared), TaskToolInput { description: "test task".into(), prompt: "do something".into(), subagent_type: "general-purpose".into(), run_in_background: false, capability_mode: None, isolation: None, resume_from: None, cwd: None, model: None, task_id: None, }, ) .await; handle.await.unwrap(); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("Child session crashed"), "error: {err}"); } #[tokio::test] async fn dropped_result_channel_returns_error() { let (backend, mut rx) = make_backend(); let mut resources = Resources::new(); resources.insert(backend); resources.insert(SubagentDepthCounter(0)); resources.insert(SessionIdResource("parent-session".to_string())); resources.insert(CurrentPromptIdResource("prompt-123".to_string())); let tool = TaskTool; let shared = resources.into_shared(); // Spawn a task that drops the result_tx without sending let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); drop(request.result_tx); }); let result = kigi_tool_runtime::Tool::run( &tool, test_ctx(shared), TaskToolInput { description: "test task".into(), prompt: "do something".into(), subagent_type: "general-purpose".into(), run_in_background: false, capability_mode: None, isolation: None, resume_from: None, cwd: None, model: None, task_id: None, }, ) .await; handle.await.unwrap(); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("result channel dropped"), "error: {err}"); } /// A `backgrounded: true` result must surface a task_id notice (not a /// completion, not an error) so the model can poll the still-running child. #[tokio::test] async fn auto_backgrounded_result_returns_task_id_text() { let (backend, mut rx) = make_backend(); let resources = resources_for_task(backend); let drain = tokio::spawn(async move { if let Some(SubagentEvent::Spawn(boxed)) = rx.recv().await { let _ = boxed.result_tx.send(SubagentResult { backgrounded: true, subagent_id: boxed.id.clone(), child_session_id: boxed.id.clone(), ..Default::default() }); } }); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(resources.into_shared()), task_input("general-purpose", false), // blocking mode ) .await .expect("auto-backgrounded blocking spawn returns Ok"); match result { ToolOutput::Text(text) => { assert!( text.text.contains("moved to the background"), "expected background notice, got: {}", text.text ); assert!( text.text.contains("subagent_id:"), "should include a task_id to poll: {}", text.text ); assert!( text.text.contains("timeout_ms"), "should instruct the model to wait: {}", text.text ); } other => panic!("expected Text output, got {other:?}"), } let _ = tokio::time::timeout(std::time::Duration::from_millis(500), drain).await; } #[tokio::test] async fn default_subagent_type_and_background() { let input: TaskToolInput = serde_json::from_str(r#"{"description": "test", "prompt": "do it"}"#).unwrap(); assert_eq!(input.subagent_type, "general-purpose"); assert!( input.run_in_background, "run_in_background should default to true" ); } fn task_input(subagent_type: &str, background: bool) -> TaskToolInput { TaskToolInput { description: "test".into(), prompt: "do it".into(), subagent_type: subagent_type.into(), run_in_background: background, capability_mode: None, isolation: None, resume_from: None, cwd: None, model: None, task_id: None, } } fn resources_for_task(backend: SubagentBackendResource) -> Resources { let mut resources = Resources::new(); resources.insert(backend); resources.insert(SubagentDepthCounter(0)); resources.insert(SessionIdResource("parent-session".to_string())); resources.insert(CurrentPromptIdResource("prompt-123".to_string())); resources.insert(TaskModelValidator::new(|_| None)); resources } #[tokio::test] async fn unknown_subagent_type_returns_error_before_spawn() { let available = vec!["general-purpose".to_string(), "explore".to_string()]; let (backend, mut rx) = make_backend_with_validation(SubagentValidateTypeOutcome::Unknown { available: available.clone(), }); let resources = resources_for_task(backend); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(resources.into_shared()), task_input("invented-agent", true), ) .await; let msg = result.expect_err("must reject").to_string(); assert!(msg.contains("Unknown subagent type: invented-agent")); for name in &available { assert!(msg.contains(name)); } let last = available.last().expect("non-empty"); assert!(msg.ends_with(last.as_str())); assert!(rx.try_recv().is_err()); } #[tokio::test] async fn disabled_subagent_type_returns_error_before_spawn() { let (backend, mut rx) = make_backend_with_validation(SubagentValidateTypeOutcome::Disabled); let resources = resources_for_task(backend); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(resources.into_shared()), task_input("explore", true), ) .await; let msg = result.expect_err("must reject").to_string(); assert!(msg.contains("disabled via [subagents.toggle]")); assert!(rx.try_recv().is_err()); } #[tokio::test] async fn not_allowed_subagent_type_returns_error_before_spawn() { let allowed = vec!["explore".to_string(), "plan".to_string()]; let (backend, mut rx) = make_backend_with_validation(SubagentValidateTypeOutcome::NotAllowed { allowed: allowed.clone(), }); let resources = resources_for_task(backend); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(resources.into_shared()), task_input("general-purpose", true), ) .await; let msg = result.expect_err("must reject").to_string(); assert!(msg.contains("'general-purpose' not allowed") && msg.contains("explore")); assert!(rx.try_recv().is_err()); } #[tokio::test] async fn unknown_subagent_type_with_empty_available_omits_suffix() { let (backend, _rx) = make_backend_with_validation(SubagentValidateTypeOutcome::Unknown { available: vec![], }); let resources = resources_for_task(backend); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(resources.into_shared()), task_input("invented", false), ) .await; let msg = result.unwrap_err().to_string(); assert!(msg.contains("Unknown subagent type: invented")); assert!(!msg.contains("Available types:")); } #[tokio::test] async fn validation_gates_blocking_mode_too() { let (backend, mut rx) = make_backend_with_validation(SubagentValidateTypeOutcome::Disabled); let resources = resources_for_task(backend); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(resources.into_shared()), task_input("explore", false), ) .await; assert!( result .unwrap_err() .to_string() .contains("disabled via [subagents.toggle]"), ); assert!(rx.try_recv().is_err()); } #[tokio::test] async fn invalid_model_returns_error_before_background_spawn() { let (backend, mut rx) = make_backend(); let mut resources = resources_for_task(backend); resources.insert(TaskModelValidator::new(|requested| { (requested == "invented-model").then(|| { "Unknown Task.model slug 'invented-model'. Valid model slugs: alpha, zeta. \ Omit `model` to inherit the parent model." .to_string() }) })); let mut input = task_input("general-purpose", true); input.model = Some("invented-model".to_string()); let result = kigi_tool_runtime::Tool::run(&TaskTool, test_ctx(resources.into_shared()), input).await; let msg = result .expect_err("invalid model must reject before spawn") .to_string(); assert!(msg.contains("Unknown Task.model slug 'invented-model'")); assert!(msg.contains("Valid model slugs: alpha, zeta")); assert!( rx.try_recv().is_err(), "spawn must not reach the coordinator" ); } #[tokio::test] async fn background_spawn_succeeds_when_coordinator_accepts() { let (backend, mut rx) = make_backend(); let resources = resources_for_task(backend); let drain = tokio::spawn(async move { if let Some(SubagentEvent::Spawn(boxed)) = rx.recv().await { let _ = boxed.result_tx.send(SubagentResult { success: true, output: std::sync::Arc::from(""), subagent_id: boxed.id.clone(), child_session_id: boxed.id.clone(), ..Default::default() }); } }); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(resources.into_shared()), task_input("general-purpose", true), ) .await .expect("background spawn should succeed"); match result { ToolOutput::Text(text) => { assert!(text.text.contains("Subagent started in background")); } other => panic!("expected text output, got {other:?}"), } let _ = tokio::time::timeout(std::time::Duration::from_millis(500), drain).await; } #[tokio::test] async fn background_spawn_emits_error_log_on_coordinator_rejection() { use super::types::test_capture; let captured = test_capture::capture(); let (backend, mut rx) = make_backend(); let resources = resources_for_task(backend); // done_tx signals after the spawn has been replied to so the // test can wait for Fix A's match arm to execute. let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>(); let drain = tokio::spawn(async move { if let Some(SubagentEvent::Spawn(boxed)) = rx.recv().await { let _ = boxed.result_tx.send(SubagentResult { success: false, error: Some("worktree creation failed".to_string()), subagent_id: boxed.id.clone(), ..Default::default() }); } let _ = done_tx.send(()); }); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(resources.into_shared()), task_input("general-purpose", true), ) .await .expect("background tool call returns Ok regardless of coordinator outcome"); let text = match result { ToolOutput::Text(t) => t.text, other => panic!("expected text output, got {other:?}"), }; assert!(text.contains("Subagent started in background")); // Let the fire-and-forget bg task advance past `.await` on spawn. let _ = tokio::time::timeout(std::time::Duration::from_millis(500), done_rx).await; for _ in 0..20 { tokio::task::yield_now().await; } let mut events_rx = captured.events_rx; let mut saw_error = false; while let Ok(event) = events_rx.try_recv() { if event.level == tracing::Level::ERROR && event .fields .contains("background spawn rejected by coordinator") && event.fields.contains("subagent_id=") && event.fields.contains("subagent_type=general-purpose") && event.fields.contains("worktree creation failed") { saw_error = true; break; } } assert!(saw_error, "Fix A must emit an ERROR with required fields"); let _ = tokio::time::timeout(std::time::Duration::from_millis(100), drain).await; } #[tokio::test] async fn background_spawn_survives_transport_error_after_validation() { // Smoke test of the fire-and-forget contract when the spawn // channel is closed; companion test covers the Ok(success:false) // arm with tracing assertions. let (backend, rx) = make_backend(); drop(rx); let resources = resources_for_task(backend); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(resources.into_shared()), task_input("general-purpose", true), ) .await .expect("transport error must not break the fire-and-forget contract"); match result { ToolOutput::Text(t) => { assert!( t.text.contains("Subagent started in background"), "{}", t.text, ); } other => panic!("expected text output, got {other:?}"), } } #[tokio::test] async fn validation_unavailable_returns_custom_error_not_invalid_arguments() { let (backend, _rx) = make_backend_with_validation(SubagentValidateTypeOutcome::ValidationUnavailable); let resources = resources_for_task(backend); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(resources.into_shared()), task_input("explore", true), ) .await; let msg = result.expect_err("must error").to_string(); assert!( msg.contains("subagent coordinator is unreachable") && msg.contains("Cannot validate subagent type"), ); assert!(!msg.contains("Unknown subagent type")); } #[tokio::test] async fn validate_request_threads_session_id_to_coordinator() { let (capture_tx, mut capture_rx) = mpsc::unbounded_channel::(); let (backend, _rx) = make_backend_with_validation_fn(move |_t, parent_session_id| { let _ = capture_tx.send(parent_session_id.to_string()); SubagentValidateTypeOutcome::Ok }); let mut resources = Resources::new(); resources.insert(backend); resources.insert(SubagentDepthCounter(0)); resources.insert(SessionIdResource("special-session-id".to_string())); resources.insert(CurrentPromptIdResource("prompt-x".to_string())); let _ = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(resources.into_shared()), task_input("explore", true), ) .await; let seen = capture_rx.try_recv().expect("must fire at least once"); assert_eq!(seen, "special-session-id"); assert!(capture_rx.try_recv().is_err(), "must fire exactly once"); } // ── Runtime overrides serde tests ───────────────── #[test] fn runtime_overrides_parse() { let input: TaskToolInput = serde_json::from_str( r#"{ "description": "d", "prompt": "p", "capability_mode": "read-only" }"#, ) .unwrap(); assert_eq!( input.capability_mode, Some(SubagentCapabilityMode::ReadOnly) ); } #[test] fn partial_overrides_leave_rest_none() { let input: TaskToolInput = serde_json::from_str( r#"{"description": "d", "prompt": "p", "capability_mode": "execute"}"#, ) .unwrap(); assert_eq!(input.capability_mode, Some(SubagentCapabilityMode::Execute)); assert!(input.model.is_none()); } #[test] fn task_tool_input_schema_includes_model() { let schema = serde_json::to_value(schemars::schema_for!(TaskToolInput)).unwrap(); assert_eq!( schema["properties"]["model"]["description"], "Optional model slug for this agent. If provided, it must resolve to one of the \ available model slugs. If omitted, the subagent uses the same model as the parent \ agent. Do not pass if resume_from is set (prior model will be used). Only choose \ an explicit model when the user directly requests it." ); } #[test] fn runtime_overrides_struct_default_is_all_none() { let overrides = SubagentRuntimeOverrides::default(); assert!(overrides.model.is_none()); assert!(overrides.reasoning_effort.is_none()); assert!(overrides.persona.is_none()); assert!(overrides.capability_mode.is_none()); } #[test] fn task_input_roundtrips_through_json() { let input = TaskToolInput { description: "find bugs".into(), prompt: "search for bugs".into(), subagent_type: "explore".into(), run_in_background: true, capability_mode: Some(SubagentCapabilityMode::ReadOnly), isolation: Some(SubagentIsolationMode::Worktree), resume_from: None, cwd: None, model: Some("test-model".into()), task_id: Some("task-123".into()), }; let json = serde_json::to_string(&input).unwrap(); let parsed: TaskToolInput = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.description, "find bugs"); assert_eq!( parsed.capability_mode, Some(SubagentCapabilityMode::ReadOnly) ); assert_eq!(parsed.model.as_deref(), Some("test-model")); } #[test] fn capability_mode_all_variants_parse() { for (json_val, expected) in [ ("read-only", SubagentCapabilityMode::ReadOnly), ("read-write", SubagentCapabilityMode::ReadWrite), ("execute", SubagentCapabilityMode::Execute), ("all", SubagentCapabilityMode::All), ] { let json = format!(r#"{{"description":"d","prompt":"p","capability_mode":"{json_val}"}}"#); let input: TaskToolInput = serde_json::from_str(&json).unwrap(); assert_eq!(input.capability_mode, Some(expected), "for {json_val}"); } } #[test] fn capability_mode_rejects_invalid_value() { let json = r#"{"description":"d","prompt":"p","capability_mode":"invalid_mode"}"#; let result = serde_json::from_str::(json); assert!(result.is_err(), "unknown value should be rejected"); } #[test] fn capability_mode_aliases_roundtrip() { for (alias, expected, canonical) in [ ("readonly", SubagentCapabilityMode::ReadOnly, "read-only"), ("readOnly", SubagentCapabilityMode::ReadOnly, "read-only"), ("read_only", SubagentCapabilityMode::ReadOnly, "read-only"), ("ReadOnly", SubagentCapabilityMode::ReadOnly, "read-only"), ("readwrite", SubagentCapabilityMode::ReadWrite, "read-write"), ("readWrite", SubagentCapabilityMode::ReadWrite, "read-write"), ( "read_write", SubagentCapabilityMode::ReadWrite, "read-write", ), ("ReadWrite", SubagentCapabilityMode::ReadWrite, "read-write"), ("Execute", SubagentCapabilityMode::Execute, "execute"), ("EXECUTE", SubagentCapabilityMode::Execute, "execute"), ("All", SubagentCapabilityMode::All, "all"), ("ALL", SubagentCapabilityMode::All, "all"), ] { let json = format!(r#"{{"description":"d","prompt":"p","capability_mode":"{alias}"}}"#); let input: TaskToolInput = serde_json::from_str(&json) .unwrap_or_else(|e| panic!("alias {alias:?} should parse: {e}")); assert_eq!(input.capability_mode, Some(expected), "parse {alias:?}"); assert_eq!( serde_json::to_value(expected).unwrap(), canonical, "serialize {alias:?} back to canonical", ); } } #[test] fn capability_mode_serializes_to_kebab_case() { for (mode, expected) in [ (SubagentCapabilityMode::ReadOnly, "read-only"), (SubagentCapabilityMode::ReadWrite, "read-write"), (SubagentCapabilityMode::Execute, "execute"), (SubagentCapabilityMode::All, "all"), ] { assert_eq!(serde_json::to_value(mode).unwrap(), expected, "{mode:?}"); } } // -- Isolation mode tests -- #[test] fn isolation_defaults_to_none() { let input: TaskToolInput = serde_json::from_str(r#"{"description": "d", "prompt": "p"}"#).unwrap(); assert_eq!(input.isolation, None); } #[test] fn isolation_worktree_parses() { let input: TaskToolInput = serde_json::from_str(r#"{"description": "d", "prompt": "p", "isolation": "worktree"}"#) .unwrap(); assert_eq!(input.isolation, Some(SubagentIsolationMode::Worktree)); } #[test] fn isolation_none_parses() { let input: TaskToolInput = serde_json::from_str(r#"{"description": "d", "prompt": "p", "isolation": "none"}"#) .unwrap(); assert_eq!(input.isolation, Some(SubagentIsolationMode::None)); } #[test] fn isolation_invalid_rejected() { let result = serde_json::from_str::( r#"{"description": "d", "prompt": "p", "isolation": "sandbox"}"#, ); assert!(result.is_err()); } #[test] fn isolation_mode_aliases_roundtrip() { for (alias, expected, canonical) in [ ("None", SubagentIsolationMode::None, "none"), ("Worktree", SubagentIsolationMode::Worktree, "worktree"), ("work_tree", SubagentIsolationMode::Worktree, "worktree"), ("work-tree", SubagentIsolationMode::Worktree, "worktree"), ] { let json = format!(r#"{{"description":"d","prompt":"p","isolation":"{alias}"}}"#); let input: TaskToolInput = serde_json::from_str(&json) .unwrap_or_else(|e| panic!("alias {alias:?} should parse: {e}")); assert_eq!(input.isolation, Some(expected), "parse {alias:?}"); assert_eq!( serde_json::to_value(expected).unwrap(), canonical, "serialize {alias:?} back to canonical", ); } } #[test] fn isolation_serializes_to_kebab_case() { for (mode, expected) in [ (SubagentIsolationMode::None, "none"), (SubagentIsolationMode::Worktree, "worktree"), ] { assert_eq!(serde_json::to_value(mode).unwrap(), expected, "{mode:?}"); } } // -- Capability mode enforcement tests -- fn tc(id: &str, kind: crate::types::tool::ToolKind) -> crate::registry::types::ToolConfig { let mut c = crate::registry::types::ToolConfig::from_id(id); c.kind = Some(kind); c } #[test] fn filter_read_only_removes_edit_and_execute() { use crate::registry::types::ToolServerConfig; use crate::types::tool::ToolKind; let mut config = ToolServerConfig { tools: vec![ tc("read_file", ToolKind::Read), tc("grep", ToolKind::Search), tc("list_dir", ToolKind::List), tc("search_replace", ToolKind::Edit), tc("bash", ToolKind::Execute), tc("task", ToolKind::Task), ], behavior_preset: None, }; SubagentCapabilityMode::ReadOnly.filter_tool_config(&mut config); let ids: Vec<&str> = config.tools.iter().map(|t| t.id.as_str()).collect(); assert!(ids.contains(&"read_file")); assert!(ids.contains(&"grep")); assert!(ids.contains(&"list_dir")); assert!(ids.contains(&"task")); assert!(!ids.contains(&"search_replace"), "edit should be removed"); assert!(!ids.contains(&"bash"), "execute should be removed"); } #[test] fn filter_read_write_keeps_edit_removes_execute() { use crate::registry::types::ToolServerConfig; use crate::types::tool::ToolKind; let mut config = ToolServerConfig { tools: vec![ tc("read_file", ToolKind::Read), tc("search_replace", ToolKind::Edit), tc("bash", ToolKind::Execute), ], behavior_preset: None, }; SubagentCapabilityMode::ReadWrite.filter_tool_config(&mut config); let ids: Vec<&str> = config.tools.iter().map(|t| t.id.as_str()).collect(); assert!(ids.contains(&"read_file")); assert!(ids.contains(&"search_replace"), "edit should be kept"); assert!(!ids.contains(&"bash"), "execute should be removed"); } #[test] fn filter_execute_keeps_bash_removes_edit() { use crate::registry::types::ToolServerConfig; use crate::types::tool::ToolKind; let mut config = ToolServerConfig { tools: vec![ tc("read_file", ToolKind::Read), tc("search_replace", ToolKind::Edit), tc("bash", ToolKind::Execute), ], behavior_preset: None, }; SubagentCapabilityMode::Execute.filter_tool_config(&mut config); let ids: Vec<&str> = config.tools.iter().map(|t| t.id.as_str()).collect(); assert!(ids.contains(&"read_file")); assert!(ids.contains(&"bash"), "execute should be kept"); assert!(!ids.contains(&"search_replace"), "edit should be removed"); } #[test] fn filter_all_keeps_everything() { use crate::registry::types::ToolServerConfig; use crate::types::tool::ToolKind; let mut config = ToolServerConfig { tools: vec![ tc("read_file", ToolKind::Read), tc("search_replace", ToolKind::Edit), tc("bash", ToolKind::Execute), ], behavior_preset: None, }; SubagentCapabilityMode::All.filter_tool_config(&mut config); assert_eq!(config.tools.len(), 3); } #[test] fn filter_preserves_tools_without_kind() { use crate::registry::types::ToolServerConfig; use crate::types::tool::ToolKind; let mut config = ToolServerConfig { tools: vec![ tc("read_file", ToolKind::Read), crate::registry::types::ToolConfig::from_id("mcp_custom_tool"), ], behavior_preset: None, }; SubagentCapabilityMode::ReadOnly.filter_tool_config(&mut config); let ids: Vec<&str> = config.tools.iter().map(|t| t.id.as_str()).collect(); assert!(ids.contains(&"read_file")); assert!( ids.contains(&"mcp_custom_tool"), "tools without kind preserved" ); } // ── resume_from tests ──────────────────────────────────────────── #[test] fn task_tool_input_has_no_fork_context_field() { let _: TaskToolInput = serde_json::from_str(r#"{"description": "d", "prompt": "p", "fork_context": true}"#) .unwrap(); let schema_json = serde_json::to_string(&schemars::schema_for!(TaskToolInput)).unwrap(); assert!( !schema_json.contains("fork_context"), "TaskToolInput JSON schema must not expose fork_context" ); let serialized = serde_json::to_string(&TaskToolInput { description: "d".into(), prompt: "p".into(), subagent_type: "general-purpose".into(), run_in_background: false, capability_mode: None, isolation: None, resume_from: None, cwd: None, model: None, task_id: None, }) .unwrap(); assert!( !serialized.contains("fork_context"), "TaskToolInput serialization must not include fork_context" ); } #[tokio::test] async fn model_task_spawn_sets_fork_context_false() { let (backend, mut rx) = make_backend(); let mut resources = Resources::new(); resources.insert(backend); resources.insert(SubagentDepthCounter(0)); resources.insert(SessionIdResource("parent".to_string())); resources.insert(CurrentPromptIdResource("prompt-1".to_string())); let shared = resources.into_shared(); let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); assert!( !request.fork_context, "model-spawned task must not set fork_context" ); request .result_tx .send(SubagentResult { success: true, output: "ok".into(), subagent_id: request.id.clone(), child_session_id: request.id.clone(), ..Default::default() }) .unwrap(); }); let _ = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(shared), TaskToolInput { description: "d".into(), prompt: "p".into(), subagent_type: "general-purpose".into(), run_in_background: false, capability_mode: None, isolation: None, resume_from: None, cwd: None, model: None, task_id: None, }, ) .await .unwrap(); handle.await.unwrap(); } #[test] fn resume_from_defaults_to_none() { let input: TaskToolInput = serde_json::from_str(r#"{"description": "d", "prompt": "p"}"#).unwrap(); assert!(input.resume_from.is_none()); } #[test] fn resume_from_parses() { let input: TaskToolInput = serde_json::from_str( r#"{"description": "d", "prompt": "p", "resume_from": "prev-agent-id"}"#, ) .unwrap(); assert_eq!(input.resume_from.as_deref(), Some("prev-agent-id")); } #[test] fn resume_from_not_serialized_when_none() { let input = TaskToolInput { description: "d".into(), prompt: "p".into(), subagent_type: "general-purpose".into(), run_in_background: false, capability_mode: None, isolation: None, resume_from: None, cwd: None, model: None, task_id: None, }; let json = serde_json::to_string(&input).unwrap(); assert!( !json.contains("resume_from"), "None resume_from should be skipped in serialization" ); } #[tokio::test] async fn resume_from_threads_to_request() { let (backend, mut rx) = make_backend(); let mut resources = Resources::new(); resources.insert(backend); resources.insert(SubagentDepthCounter(0)); resources.insert(SessionIdResource("parent".to_string())); resources.insert(CurrentPromptIdResource("prompt-1".to_string())); let shared = resources.into_shared(); let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); assert_eq!(request.resume_from.as_deref(), Some("prev-id")); request .result_tx .send(SubagentResult { success: true, output: "resumed".into(), subagent_id: request.id.clone(), child_session_id: request.id.clone(), ..Default::default() }) .unwrap(); }); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(shared), TaskToolInput { description: "resume".into(), prompt: "continue".into(), subagent_type: "general-purpose".into(), run_in_background: false, capability_mode: None, isolation: None, resume_from: Some("prev-id".into()), cwd: None, model: None, task_id: None, }, ) .await .unwrap(); handle.await.unwrap(); match result { ToolOutput::SubagentCompleted(sub) => { assert!(sub.output.contains("resumed")); } other => panic!("Expected SubagentCompleted, got {:?}", other), } } #[tokio::test] async fn resume_from_sentinel_values_treated_as_none() { for sentinel in [ "", " ", "null", "Null", "NULL", "none", "None", "undefined", ] { let (backend, mut rx) = make_backend(); let mut resources = Resources::new(); resources.insert(backend); resources.insert(SubagentDepthCounter(0)); resources.insert(SessionIdResource("parent".to_string())); resources.insert(CurrentPromptIdResource("prompt-1".to_string())); let shared = resources.into_shared(); let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); assert!( request.resume_from.is_none(), "sentinel {sentinel:?} must normalize to None, got {:?}", request.resume_from ); request .result_tx .send(SubagentResult { success: true, output: "fresh".into(), subagent_id: request.id.clone(), child_session_id: request.id.clone(), ..Default::default() }) .unwrap(); }); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(shared), TaskToolInput { description: "test sentinel".into(), prompt: "work".into(), subagent_type: "general-purpose".into(), run_in_background: false, capability_mode: None, isolation: None, resume_from: Some(sentinel.into()), cwd: None, model: None, task_id: None, }, ) .await .unwrap_or_else(|e| panic!("sentinel {sentinel:?} should not fail: {e}")); handle.await.unwrap(); match result { ToolOutput::SubagentCompleted(sub) => { assert!(sub.output.contains("fresh"), "sentinel {sentinel:?}"); } other => panic!("sentinel {sentinel:?}: expected SubagentCompleted, got {other:?}"), } } } // ── cwd tests ──────────────────────────────────────────────────── #[test] fn cwd_defaults_to_none() { let input: TaskToolInput = serde_json::from_str(r#"{"description": "d", "prompt": "p"}"#).unwrap(); assert!(input.cwd.is_none()); } #[test] fn cwd_parses_from_json() { let input: TaskToolInput = serde_json::from_str( r#"{"description": "d", "prompt": "p", "cwd": "/tmp/my-worktree"}"#, ) .unwrap(); assert_eq!(input.cwd.as_deref(), Some("/tmp/my-worktree")); } #[test] fn cwd_not_serialized_when_none() { let input = TaskToolInput { description: "d".into(), prompt: "p".into(), subagent_type: "general-purpose".into(), run_in_background: false, capability_mode: None, isolation: None, resume_from: None, cwd: None, model: None, task_id: None, }; let json = serde_json::to_string(&input).unwrap(); assert!(!json.contains("cwd"), "None cwd should be skipped: {json}"); } #[tokio::test] async fn cwd_and_worktree_isolation_are_mutually_exclusive() { let (backend, _rx) = make_backend(); let mut resources = Resources::new(); resources.insert(backend); resources.insert(SubagentDepthCounter(0)); resources.insert(SessionIdResource("parent".to_string())); resources.insert(CurrentPromptIdResource("prompt-1".to_string())); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(resources.into_shared()), TaskToolInput { description: "test cwd conflict".into(), prompt: "work".into(), subagent_type: "general-purpose".into(), run_in_background: false, capability_mode: None, isolation: Some(SubagentIsolationMode::Worktree), resume_from: None, cwd: Some("/tmp".into()), model: None, task_id: None, }, ) .await; assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!( err.contains("mutually exclusive"), "should reject cwd + isolation=worktree: {err}" ); } #[tokio::test] async fn cwd_empty_string_with_worktree_is_allowed() { let (backend, mut rx) = make_backend(); let mut resources = Resources::new(); resources.insert(backend); resources.insert(SubagentDepthCounter(0)); resources.insert(SessionIdResource("parent".to_string())); resources.insert(CurrentPromptIdResource("prompt-1".to_string())); let shared = resources.into_shared(); let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); assert!( request.cwd.is_none(), "empty cwd should normalize to None, got {:?}", request.cwd ); request .result_tx .send(SubagentResult { success: true, output: "ok".into(), subagent_id: request.id.clone(), child_session_id: request.id.clone(), ..Default::default() }) .unwrap(); }); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(shared), TaskToolInput { description: "test empty cwd".into(), prompt: "work".into(), subagent_type: "general-purpose".into(), run_in_background: false, capability_mode: None, isolation: Some(SubagentIsolationMode::Worktree), resume_from: None, cwd: Some("".into()), model: None, task_id: None, }, ) .await; handle.await.unwrap(); assert!(result.is_ok(), "empty cwd + worktree should be allowed"); } #[tokio::test] async fn cwd_null_string_with_worktree_is_allowed() { let (backend, mut rx) = make_backend(); let mut resources = Resources::new(); resources.insert(backend); resources.insert(SubagentDepthCounter(0)); resources.insert(SessionIdResource("parent".to_string())); resources.insert(CurrentPromptIdResource("prompt-1".to_string())); let shared = resources.into_shared(); let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); assert!( request.cwd.is_none(), "'null' cwd should normalize to None, got {:?}", request.cwd ); request .result_tx .send(SubagentResult { success: true, output: "ok".into(), subagent_id: request.id.clone(), child_session_id: request.id.clone(), ..Default::default() }) .unwrap(); }); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(shared), TaskToolInput { description: "test null cwd".into(), prompt: "work".into(), subagent_type: "general-purpose".into(), run_in_background: false, capability_mode: None, isolation: Some(SubagentIsolationMode::Worktree), resume_from: None, cwd: Some("null".into()), model: None, task_id: None, }, ) .await; handle.await.unwrap(); assert!(result.is_ok(), "'null' cwd + worktree should be allowed"); } #[tokio::test] async fn cwd_whitespace_with_worktree_is_allowed() { let (backend, mut rx) = make_backend(); let mut resources = Resources::new(); resources.insert(backend); resources.insert(SubagentDepthCounter(0)); resources.insert(SessionIdResource("parent".to_string())); resources.insert(CurrentPromptIdResource("prompt-1".to_string())); let shared = resources.into_shared(); let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); assert!( request.cwd.is_none(), "whitespace cwd should normalize to None, got {:?}", request.cwd ); request .result_tx .send(SubagentResult { success: true, output: "ok".into(), subagent_id: request.id.clone(), child_session_id: request.id.clone(), ..Default::default() }) .unwrap(); }); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(shared), TaskToolInput { description: "test whitespace cwd".into(), prompt: "work".into(), subagent_type: "general-purpose".into(), run_in_background: false, capability_mode: None, isolation: Some(SubagentIsolationMode::Worktree), resume_from: None, cwd: Some(" ".into()), model: None, task_id: None, }, ) .await; handle.await.unwrap(); assert!( result.is_ok(), "whitespace cwd + worktree should be allowed" ); } #[tokio::test] async fn cwd_nonexistent_path_with_worktree_is_cleared() { let (backend, mut rx) = make_backend(); let mut resources = Resources::new(); resources.insert(backend); resources.insert(SubagentDepthCounter(0)); resources.insert(SessionIdResource("parent".to_string())); resources.insert(CurrentPromptIdResource("prompt-1".to_string())); let shared = resources.into_shared(); let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); assert!( request.cwd.is_none(), "non-existent cwd should be cleared when worktree is set, got {:?}", request.cwd ); request .result_tx .send(SubagentResult { success: true, output: "ok".into(), subagent_id: request.id.clone(), child_session_id: request.id.clone(), ..Default::default() }) .unwrap(); }); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(shared), TaskToolInput { description: "test nonexistent cwd".into(), prompt: "work".into(), subagent_type: "general-purpose".into(), run_in_background: false, capability_mode: None, isolation: Some(SubagentIsolationMode::Worktree), resume_from: None, cwd: Some("/nonexistent/path/that/does/not/exist".into()), model: None, task_id: None, }, ) .await; handle.await.unwrap(); assert!( result.is_ok(), "non-existent cwd + worktree should clear cwd and proceed" ); } #[tokio::test] async fn cwd_nonexistent_path_without_worktree_errors() { let (backend, _rx) = make_backend(); let mut resources = Resources::new(); resources.insert(backend); resources.insert(SubagentDepthCounter(0)); resources.insert(SessionIdResource("parent".to_string())); resources.insert(CurrentPromptIdResource("prompt-1".to_string())); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(resources.into_shared()), TaskToolInput { description: "test nonexistent cwd no worktree".into(), prompt: "work".into(), subagent_type: "general-purpose".into(), run_in_background: false, capability_mode: None, isolation: None, resume_from: None, cwd: Some("/nonexistent/path/that/does/not/exist".into()), model: None, task_id: None, }, ) .await; assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!( err.contains("does not exist"), "should reject non-existent cwd without worktree: {err}" ); } #[tokio::test] async fn cwd_sentinel_without_worktree_is_normalized() { for sentinel in ["undefined", "null", "none", "", " "] { let (backend, mut rx) = make_backend(); let mut resources = Resources::new(); resources.insert(backend); resources.insert(SubagentDepthCounter(0)); resources.insert(SessionIdResource("parent".to_string())); resources.insert(CurrentPromptIdResource("prompt-1".to_string())); let shared = resources.into_shared(); let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); assert!( request.cwd.is_none(), "sentinel {sentinel:?} must normalize to None without worktree, got {:?}", request.cwd ); request .result_tx .send(SubagentResult { success: true, output: "ok".into(), subagent_id: request.id.clone(), child_session_id: request.id.clone(), ..Default::default() }) .unwrap(); }); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(shared.clone()), TaskToolInput { description: "test sentinel cwd".into(), prompt: "work".into(), subagent_type: "general-purpose".into(), run_in_background: false, capability_mode: None, isolation: None, resume_from: None, cwd: Some(sentinel.into()), model: None, task_id: None, }, ) .await .unwrap_or_else(|e| panic!("sentinel {sentinel:?} should not fail: {e}")); handle.await.unwrap(); match result { ToolOutput::SubagentCompleted(sub) => { assert!(sub.output.contains("ok"), "sentinel {sentinel:?}"); } other => panic!("sentinel {sentinel:?}: expected SubagentCompleted, got {other:?}"), } } } #[tokio::test] async fn cwd_threads_to_request() { let (backend, mut rx) = make_backend(); let mut resources = Resources::new(); resources.insert(backend); resources.insert(SubagentDepthCounter(0)); resources.insert(SessionIdResource("parent".to_string())); resources.insert(CurrentPromptIdResource("prompt-1".to_string())); let shared = resources.into_shared(); let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); assert_eq!(request.cwd.as_deref(), Some("/tmp")); request .result_tx .send(SubagentResult { success: true, output: "done".into(), subagent_id: request.id.clone(), child_session_id: request.id.clone(), ..Default::default() }) .unwrap(); }); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(shared), TaskToolInput { description: "cwd test".into(), prompt: "work".into(), subagent_type: "general-purpose".into(), run_in_background: false, capability_mode: None, isolation: None, resume_from: None, cwd: Some("/tmp".into()), model: None, task_id: None, }, ) .await .unwrap(); handle.await.unwrap(); match result { ToolOutput::SubagentCompleted(sub) => { assert!(sub.output.contains("done")); } other => panic!("Expected SubagentCompleted, got {:?}", other), } } #[tokio::test] async fn cwd_strips_stray_leading_quote() { // Regression: model-emitted `"/tmp` should reach the backend as `/tmp`. let (backend, mut rx) = make_backend(); let mut resources = Resources::new(); resources.insert(backend); resources.insert(SubagentDepthCounter(0)); resources.insert(SessionIdResource("parent".to_string())); resources.insert(CurrentPromptIdResource("prompt-1".to_string())); let shared = resources.into_shared(); let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); assert_eq!( request.cwd.as_deref(), Some("/tmp"), "stray leading quote should be stripped before reaching the backend", ); request .result_tx .send(SubagentResult { success: true, output: "ok".into(), subagent_id: request.id.clone(), child_session_id: request.id.clone(), ..Default::default() }) .unwrap(); }); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(shared), TaskToolInput { description: "stray quote cwd".into(), prompt: "work".into(), subagent_type: "general-purpose".into(), run_in_background: false, capability_mode: None, isolation: None, resume_from: None, cwd: Some("\"/tmp".into()), model: None, task_id: None, }, ) .await .unwrap_or_else(|e| panic!("sanitized cwd should succeed: {e}")); handle.await.unwrap(); match result { ToolOutput::SubagentCompleted(sub) => { assert!(sub.output.contains("ok")); } other => panic!("Expected SubagentCompleted, got {:?}", other), } } #[tokio::test] async fn cwd_with_isolation_none_is_allowed() { let (backend, mut rx) = make_backend(); let mut resources = Resources::new(); resources.insert(backend); resources.insert(SubagentDepthCounter(0)); resources.insert(SessionIdResource("parent".to_string())); resources.insert(CurrentPromptIdResource("prompt-1".to_string())); let shared = resources.into_shared(); let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); assert_eq!(request.cwd.as_deref(), Some("/tmp")); request .result_tx .send(SubagentResult { success: true, output: "ok".into(), subagent_id: request.id.clone(), child_session_id: request.id.clone(), ..Default::default() }) .unwrap(); }); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(shared), TaskToolInput { description: "cwd with none".into(), prompt: "work".into(), subagent_type: "general-purpose".into(), run_in_background: false, capability_mode: None, isolation: Some(SubagentIsolationMode::None), resume_from: None, cwd: Some("/tmp".into()), model: None, task_id: None, }, ) .await; handle.await.unwrap(); assert!(result.is_ok(), "cwd with isolation=none should be allowed"); } #[tokio::test] async fn cwd_with_resume_from_is_accepted() { let (backend, mut rx) = make_backend(); let mut resources = Resources::new(); resources.insert(backend); resources.insert(SubagentDepthCounter(0)); resources.insert(SessionIdResource("parent".to_string())); resources.insert(CurrentPromptIdResource("prompt-1".to_string())); let shared = resources.into_shared(); let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); // Both values are threaded through — coordinator decides precedence. assert_eq!(request.cwd.as_deref(), Some("/tmp/some-dir")); assert_eq!(request.resume_from.as_deref(), Some("prev-id")); request .result_tx .send(SubagentResult { success: true, output: "resumed".into(), subagent_id: request.id.clone(), child_session_id: request.id.clone(), ..Default::default() }) .unwrap(); }); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(shared), TaskToolInput { description: "cwd + resume".into(), prompt: "work".into(), subagent_type: "general-purpose".into(), run_in_background: false, capability_mode: None, isolation: None, resume_from: Some("prev-id".into()), cwd: Some("/tmp/some-dir".into()), model: None, task_id: None, }, ) .await .unwrap(); handle.await.unwrap(); match result { ToolOutput::SubagentCompleted(sub) => { assert!(sub.output.contains("resumed")); } other => panic!("Expected SubagentCompleted, got {:?}", other), } } // ── model override tests ───────────────────────────────────── #[tokio::test] async fn model_threads_to_runtime_overrides() { let (backend, mut rx) = make_backend(); let resources = resources_for_task(backend); let shared = resources.into_shared(); let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); assert_eq!( request.runtime_overrides.model.as_deref(), Some("test-model"), "explicit model must reach SubagentRuntimeOverrides" ); assert_eq!( request.runtime_overrides.model_override_provenance, ModelOverrideProvenance::Tool, ); assert!(request.runtime_overrides.reasoning_effort.is_none()); assert!(request.runtime_overrides.persona.is_none()); request .result_tx .send(SubagentResult { success: true, output: "ok".into(), subagent_id: request.id.clone(), child_session_id: request.id.clone(), ..Default::default() }) .unwrap(); }); let mut input = task_input("general-purpose", false); input.model = Some("test-model".into()); let result = kigi_tool_runtime::Tool::run(&TaskTool, test_ctx(shared), input) .await .unwrap(); handle.await.unwrap(); match result { ToolOutput::SubagentCompleted(sub) => assert!(sub.output.contains("ok")), other => panic!("Expected SubagentCompleted, got {other:?}"), } } #[tokio::test] async fn omitted_model_stays_none_on_runtime_overrides() { let (backend, mut rx) = make_backend(); let resources = resources_for_task(backend); let shared = resources.into_shared(); let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); assert!( request.runtime_overrides.model.is_none(), "omitted model must stay None, got {:?}", request.runtime_overrides.model ); request .result_tx .send(SubagentResult { success: true, output: "ok".into(), subagent_id: request.id.clone(), child_session_id: request.id.clone(), ..Default::default() }) .unwrap(); }); let result = kigi_tool_runtime::Tool::run( &TaskTool, test_ctx(shared), task_input("general-purpose", false), ) .await .unwrap(); handle.await.unwrap(); match result { ToolOutput::SubagentCompleted(sub) => assert!(sub.output.contains("ok")), other => panic!("Expected SubagentCompleted, got {other:?}"), } } #[tokio::test] async fn model_sentinel_values_treated_as_none() { for sentinel in [ "", " ", "null", "Null", "NULL", "none", "None", "undefined", " none ", ] { let (backend, mut rx) = make_backend(); let resources = resources_for_task(backend); let shared = resources.into_shared(); let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); assert!( request.runtime_overrides.model.is_none(), "sentinel {sentinel:?} must normalize to None, got {:?}", request.runtime_overrides.model ); request .result_tx .send(SubagentResult { success: true, output: "ok".into(), subagent_id: request.id.clone(), child_session_id: request.id.clone(), ..Default::default() }) .unwrap(); }); let mut input = task_input("general-purpose", false); input.model = Some(sentinel.into()); let result = kigi_tool_runtime::Tool::run(&TaskTool, test_ctx(shared), input) .await .unwrap_or_else(|e| panic!("sentinel {sentinel:?} should not fail: {e}")); handle.await.unwrap(); match result { ToolOutput::SubagentCompleted(sub) => { assert!(sub.output.contains("ok"), "sentinel {sentinel:?}"); } other => panic!("sentinel {sentinel:?}: expected SubagentCompleted, got {other:?}"), } } } #[tokio::test] async fn model_whitespace_is_trimmed() { let (backend, mut rx) = make_backend(); let resources = resources_for_task(backend); let shared = resources.into_shared(); let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); assert_eq!( request.runtime_overrides.model.as_deref(), Some("test-model"), "leading/trailing whitespace should be trimmed" ); request .result_tx .send(SubagentResult { success: true, output: "ok".into(), subagent_id: request.id.clone(), child_session_id: request.id.clone(), ..Default::default() }) .unwrap(); }); let mut input = task_input("general-purpose", false); input.model = Some(" test-model ".into()); let _ = kigi_tool_runtime::Tool::run(&TaskTool, test_ctx(shared), input) .await .unwrap(); handle.await.unwrap(); } #[tokio::test] async fn resume_from_with_model_soft_ignores_model() { let (backend, mut rx) = make_backend(); let resources = resources_for_task(backend); let shared = resources.into_shared(); let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); assert_eq!(request.resume_from.as_deref(), Some("prev-id")); assert!( request.runtime_overrides.model.is_none(), "model must be soft-ignored when resume_from is set, got {:?}", request.runtime_overrides.model ); assert_eq!( request.runtime_overrides.model_override_provenance, ModelOverrideProvenance::Tool, ); assert!(request.runtime_overrides.reasoning_effort.is_none()); assert!(request.runtime_overrides.persona.is_none()); request .result_tx .send(SubagentResult { success: true, output: "resumed".into(), subagent_id: request.id.clone(), child_session_id: request.id.clone(), ..Default::default() }) .unwrap(); }); let mut input = task_input("general-purpose", false); input.resume_from = Some("prev-id".into()); input.model = Some("test-model".into()); let result = kigi_tool_runtime::Tool::run(&TaskTool, test_ctx(shared), input) .await .unwrap(); handle.await.unwrap(); match result { ToolOutput::SubagentCompleted(sub) => assert!(sub.output.contains("resumed")), other => panic!("Expected SubagentCompleted, got {other:?}"), } } #[tokio::test] async fn resume_from_without_model_still_spawns() { let (backend, mut rx) = make_backend(); let resources = resources_for_task(backend); let shared = resources.into_shared(); let handle = tokio::spawn(async move { let request = unwrap_spawn(rx.recv().await.unwrap()); assert_eq!(request.resume_from.as_deref(), Some("prev-id")); assert!(request.runtime_overrides.model.is_none()); request .result_tx .send(SubagentResult { success: true, output: "resumed".into(), subagent_id: request.id.clone(), child_session_id: request.id.clone(), ..Default::default() }) .unwrap(); }); let mut input = task_input("general-purpose", false); input.resume_from = Some("prev-id".into()); let result = kigi_tool_runtime::Tool::run(&TaskTool, test_ctx(shared), input) .await .unwrap(); handle.await.unwrap(); match result { ToolOutput::SubagentCompleted(sub) => assert!(sub.output.contains("resumed")), other => panic!("Expected SubagentCompleted, got {other:?}"), } } }