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).
2521 lines
93 KiB
Rust
2521 lines
93 KiB
Rust
//! `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.
|
|
"<see build_task_description() in kigi-agent>"
|
|
}
|
|
|
|
fn requires_expr(&self) -> Expr<ToolRequirement> {
|
|
// 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<ToolOutput, kigi_tool_runtime::ToolError> {
|
|
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::<SubagentDepthCounter>().map(|d| d.0).unwrap_or(0);
|
|
|
|
let backend = res
|
|
.get::<SubagentBackendResource>()
|
|
.ok_or_else(|| {
|
|
kigi_tool_runtime::ToolError::custom(
|
|
"missing_resource",
|
|
"SubagentBackendResource (subagent support not initialized)",
|
|
)
|
|
})?
|
|
.clone();
|
|
|
|
let model_validator = res.get::<TaskModelValidator>().cloned();
|
|
|
|
let parent_session_id = res
|
|
.get::<SessionIdResource>()
|
|
.map(|s| s.0.clone())
|
|
.unwrap_or_default();
|
|
|
|
let parent_prompt_id = res
|
|
.get::<CurrentPromptIdResource>()
|
|
.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<String> = None;
|
|
Ok(ToolOutput::SubagentCompleted(SubagentCompletedOutput {
|
|
// SubagentCompletedOutput.output is `String` (serde-visible
|
|
// boundary). One allocation per completion; cheaper paths
|
|
// (pending_completions / snapshot) keep the Arc<str>.
|
|
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<SubagentEvent>,
|
|
) {
|
|
make_backend_with_validation(SubagentValidateTypeOutcome::Ok)
|
|
}
|
|
|
|
/// Backend that replays `outcome` for every `ValidateType` event.
|
|
fn make_backend_with_validation(
|
|
outcome: SubagentValidateTypeOutcome,
|
|
) -> (
|
|
SubagentBackendResource,
|
|
mpsc::UnboundedReceiver<SubagentEvent>,
|
|
) {
|
|
make_backend_with_validation_fn(move |_, _| outcome.clone())
|
|
}
|
|
|
|
/// Backend whose `ValidateType` outcome is computed per (type, session) pair.
|
|
fn make_backend_with_validation_fn<F>(
|
|
outcome_fn: F,
|
|
) -> (
|
|
SubagentBackendResource,
|
|
mpsc::UnboundedReceiver<SubagentEvent>,
|
|
)
|
|
where
|
|
F: Fn(&str, &str) -> SubagentValidateTypeOutcome + Send + 'static,
|
|
{
|
|
let (raw_tx, mut raw_rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
|
let (proxy_tx, proxy_rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
|
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::<String>();
|
|
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::<TaskToolInput>(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::<TaskToolInput>(
|
|
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:?}"),
|
|
}
|
|
}
|
|
}
|