Files
yoi/crates/worker/src/spawn/tool.rs
T

1590 lines
58 KiB
Rust

//! `SubWorkerSpawn` tool — start a parent-owned Internal Worker session.
//!
//! Resolves a child profile, validates filesystem delegation, constructs a normal Worker with the
//! parent's explicit Workspace authority, installs its enabled features, and hands it to the
//! in-process Internal Worker session actor. No Runtime Worker record, OS process, PID, Unix socket,
//! or machine-wide child allocation is created.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use async_trait::async_trait;
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use manifest::{
CompactionConfigPartial, DelegationScope, EngineManifestConfig, FileUploadLimitsPartial,
Permission, PermissionConfigPartial, ProfileDiscovery, ProfileError, ProfileRegistry,
ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector, Scope,
ScopeConfig, ScopeRule, SessionConfigPartial, SharedScope, ToolOutputLimitsPartial,
WorkerManifest, WorkerManifestConfig, WorkerMetaConfig,
};
use serde::Deserialize;
use crate::PromptLoader;
use crate::controller::register_worker_tools;
use crate::internal_worker::{EphemeralSessionStore, spawn_prepared_internal_worker_session};
use crate::prompt::catalog::PromptCatalog;
use crate::spawn::registry::SpawnedWorkerRegistry;
use crate::worker::{Worker, WorkerFilesystemAuthority};
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct SubWorkerSpawnInput {
/// Identifier for the spawned Internal SubWorker. Must be unique among this Worker's direct children.
name: String,
/// Profile selector for child role configuration. Omit or use `default`
/// for the effective child default profile, use `inherit` to derive
/// reusable config from the spawner, or use a registry selector such as
/// `project:coder`, `project:reviewer`, `builtin:companion`, or an
/// unambiguous profile slug. Raw/path selectors are rejected.
#[serde(default)]
profile: Option<String>,
/// Instruction-file reference (e.g. `$yoi/default`, `$user/my-agent`).
#[serde(default)]
instruction: Option<String>,
/// Child process/tool working directory. This is not the runtime workspace
/// root and grants no filesystem authority. When omitted, the spawned SubWorker
/// starts in the spawner's current working directory.
#[serde(default)]
cwd: Option<PathBuf>,
/// First message sent to the spawned SubWorker via `Method::Run`.
task: String,
/// Allow rules delegated to the spawned SubWorker. Must be a subset of the
/// spawner's explicit delegation authority; direct tool scope alone is not
/// sufficient. Omit `recursive` for normal workspace/worktree delegation; it defaults to true.
scope: Vec<ScopeRuleInput>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct ScopeRuleInput {
/// Absolute target path. Relative paths are rejected.
target: PathBuf,
/// `"read"` or `"write"`.
permission: PermissionInput,
/// When `false`, the rule matches the target itself and its direct
/// children only. Defaults to `true`.
#[serde(default = "default_true")]
recursive: bool,
}
#[derive(Debug, Deserialize, schemars::JsonSchema, Clone, Copy)]
#[serde(rename_all = "lowercase")]
enum PermissionInput {
Read,
Write,
}
fn default_true() -> bool {
true
}
impl From<PermissionInput> for Permission {
fn from(p: PermissionInput) -> Self {
match p {
PermissionInput::Read => Permission::Read,
PermissionInput::Write => Permission::Write,
}
}
}
#[derive(Debug, Clone)]
struct AvailableProfiles {
registry: Option<ProfileRegistry>,
diagnostic: Option<String>,
}
impl AvailableProfiles {
fn discover(cwd: &Path) -> Self {
match ProfileDiscovery::for_cwd(cwd).discover() {
Ok(registry) => Self {
registry: Some(registry),
diagnostic: None,
},
Err(error) => Self {
registry: None,
diagnostic: Some(error.to_string()),
},
}
}
fn compact_list(&self) -> String {
let Some(registry) = &self.registry else {
return "- profile discovery failed; use `inherit` or retry after fixing discovery"
.into();
};
if registry.entries().is_empty() {
return "- no registry profiles discovered; `inherit` is still available".into();
}
registry
.entries()
.iter()
.map(|entry| {
let default = if entry.is_default { " (default)" } else { "" };
let desc = entry
.description
.as_deref()
.map(|d| format!(" — {d}"))
.unwrap_or_default();
format!("- `{}`{}{}", entry.qualified_name(), default, desc)
})
.collect::<Vec<_>>()
.join("\n")
}
fn default_label(&self) -> String {
self.registry
.as_ref()
.and_then(|registry| registry.default_entry().ok())
.map(|entry| entry.qualified_name())
.unwrap_or_else(|| "none resolved".into())
}
fn diagnostic(&self) -> &str {
self.diagnostic.as_deref().unwrap_or("")
}
fn error_suffix(&self) -> String {
format!(
"\nUse `default`, `inherit`, or one of these registry selectors:\n{}",
self.compact_list()
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SpawnProfileSelector {
Default,
Inherit,
Registry(ProfileSelector),
}
fn parse_spawn_profile_selector(raw: Option<&str>) -> Result<SpawnProfileSelector, String> {
let Some(raw) = raw.map(str::trim).filter(|s| !s.is_empty()) else {
return Ok(SpawnProfileSelector::Default);
};
if raw == "default" {
return Ok(SpawnProfileSelector::Default);
}
if raw == "inherit" {
return Ok(SpawnProfileSelector::Inherit);
}
if raw.starts_with("path:")
|| raw.starts_with('/')
|| raw.starts_with("./")
|| raw.starts_with("../")
|| raw.contains('/')
|| raw.ends_with(".dcdl")
|| raw.ends_with(".json")
|| raw.ends_with(".toml")
|| raw.ends_with(".nix")
{
return Err(format!(
"SubWorkerSpawn.profile accepts `default`, `inherit`, or registry selectors only; path-like selector `{raw}` is not allowed"
));
}
if let Some((prefix, name)) = raw.split_once(':') {
let source = match prefix {
"builtin" => ProfileRegistrySource::Builtin,
"user" => ProfileRegistrySource::User,
"project" => ProfileRegistrySource::Project,
_ => {
return Err(format!(
"unsupported SubWorkerSpawn.profile selector prefix `{prefix}`; use builtin:, user:, project:, default, or inherit"
));
}
};
if name.is_empty() {
return Err(
"SubWorkerSpawn.profile registry selector has an empty profile name".into(),
);
}
return Ok(SpawnProfileSelector::Registry(
ProfileSelector::source_named(source, name),
));
}
Ok(SpawnProfileSelector::Registry(ProfileSelector::named(raw)))
}
/// Runtime dependencies the `SubWorkerSpawn` tool needs in order to launch a
/// child SubWorker and record the handoff locally. Constructed by the Worker
/// controller once per Worker lifetime.
pub struct SubWorkerSpawnTool {
/// Spawner's own Worker name, used for direct-child identity collision checks.
spawner_name: String,
workspace_context: crate::worker::WorkerWorkspaceContext,
parent_notifies: crate::ipc::notify_buffer::NotifyBuffer,
/// Runtime-owned root used only for bounded Internal Worker tool artifacts such as Bash spill
/// output. It is not an Internal Worker identity or catalog location.
runtime_base: PathBuf,
/// Inherited runtime workspace root for Profile/project/Ticket/workflow/
/// memory context. SubWorkerSpawn `cwd` must not affect this value.
workspace_root: PathBuf,
/// Directory the spawned SubWorker's tools should use when the LLM did not
/// override it. Defaults to the spawner's cwd.
spawner_cwd: PathBuf,
/// Parent-owned in-memory registry shared by the five SubWorker tools.
registry: Arc<SpawnedWorkerRegistry>,
/// Spawner's resolved Manifest. `profile = "inherit"` derives the
/// child config from reusable fields here, and selected profiles are
/// merged into the same internal handoff shape before launch.
spawner_manifest: WorkerManifest,
/// Compact selector list shared by tool description and diagnostics.
available_profiles: AvailableProfiles,
/// Spawner's runtime scope. After a successful spawn, the
/// `Permission::Write` rules in the delegated scope are revoked
/// from the spawner's in-memory view (a `deny(Write, target)` is
/// pushed on top, downgrading the spawner's effective access on
/// those paths to `Read`). Mirrors the worker-allocation's
/// `effective_write` semantics: Write is the only permission
/// tracked across Workers, so revocation only touches Write.
spawner_scope: SharedScope,
/// Filesystem scope this Worker is allowed to subdelegate to children.
/// This is intentionally separate from `spawner_scope`, which authorizes
/// the current Worker's own direct tools.
delegation_scope: DelegationScope,
internal_client_override: Option<Box<dyn llm_engine::llm_client::LlmClient>>,
}
impl SubWorkerSpawnTool {
#[cfg(test)]
fn with_internal_client(mut self, client: Box<dyn llm_engine::llm_client::LlmClient>) -> Self {
self.internal_client_override = Some(client);
self
}
fn new(
spawner_name: String,
workspace_context: crate::worker::WorkerWorkspaceContext,
parent_notifies: crate::ipc::notify_buffer::NotifyBuffer,
runtime_base: PathBuf,
workspace_root: PathBuf,
spawner_cwd: PathBuf,
registry: Arc<SpawnedWorkerRegistry>,
spawner_manifest: WorkerManifest,
available_profiles: AvailableProfiles,
spawner_scope: SharedScope,
delegation_scope: DelegationScope,
) -> Self {
Self {
spawner_name,
workspace_context,
parent_notifies,
runtime_base,
workspace_root,
spawner_cwd,
registry,
spawner_manifest,
available_profiles,
spawner_scope,
delegation_scope,
internal_client_override: None,
}
}
}
#[async_trait]
impl Tool for SubWorkerSpawnTool {
async fn execute(
&self,
input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let input: SubWorkerSpawnInput = serde_json::from_str(input_json).map_err(|e| {
ToolError::InvalidArgument(format!("invalid SubWorkerSpawn input: {e}"))
})?;
// `delegate_scope` catches this too (as `DuplicateWorkerName`), but
// the dedicated message is kinder to the LLM — which gets the
// error back verbatim — than the generic duplicate-name error.
if input.name == self.spawner_name {
return Err(ToolError::InvalidArgument(format!(
"spawned worker name `{}` collides with spawner's own name",
input.name
)));
}
let scope_allow = parse_scope(&input.scope)?;
self.validate_delegation_scope(&scope_allow)?;
let child_cwd = validate_spawn_cwd(input.cwd.as_deref(), &scope_allow, &self.spawner_cwd)?;
let spawn_selector =
parse_spawn_profile_selector(input.profile.as_deref()).map_err(|msg| {
ToolError::InvalidArgument(format!(
"{msg}{}",
self.available_profiles.error_suffix()
))
})?;
let spawn_config_json = self
.build_spawn_config_json(
&input.name,
input.instruction.as_deref(),
&scope_allow,
spawn_selector,
)
.map_err(|e| ToolError::InvalidArgument(format!("{e}")))?;
let mut child_config: WorkerManifestConfig = serde_json::from_str(&spawn_config_json)
.map_err(|error| {
ToolError::ExecutionFailed(format!("resolve child manifest: {error}"))
})?;
child_config.delegation_scope = ScopeConfig {
allow: scope_allow.clone(),
deny: Vec::new(),
};
let child_manifest =
WorkerManifest::try_from(WorkerManifestConfig::builtin_defaults().merge(child_config))
.map_err(|error| {
ToolError::ExecutionFailed(format!("resolve child manifest: {error}"))
})?;
let store = EphemeralSessionStore::default();
let filesystem_authority =
WorkerFilesystemAuthority::local(self.workspace_root.clone(), child_cwd.clone());
let mut child = Worker::<Box<dyn llm_engine::llm_client::LlmClient>, EphemeralSessionStore>::from_internal_manifest_with_context(
child_manifest,
store.clone(),
PromptLoader::builtins_only(),
self.workspace_context.clone(),
filesystem_authority,
self.internal_client_override
.as_ref()
.map(|client| client.clone_boxed()),
)
.await
.map_err(|error| ToolError::ExecutionFailed(format!("build Internal Worker: {error}")))?;
let child_scope = child.scope().clone();
let child_registry = SpawnedWorkerRegistry::new_internal(input.name.clone(), child_scope);
register_worker_tools(
&mut child,
self.runtime_base
.join("internal-workers")
.join(&input.name)
.join("bash-output"),
self.runtime_base.clone(),
child_registry,
)
.await
.map_err(|error| {
ToolError::ExecutionFailed(format!("install Internal Worker features: {error}"))
})?;
let child_name = input.name.clone();
let parent_notifies = self.parent_notifies.clone();
let session = spawn_prepared_internal_worker_session(
child,
store,
input.task.clone(),
Some(Arc::new(move |status| {
parent_notifies.push_notify(
format!("SubWorker `{child_name}` turn ended with status {status:?}. Read its output before making completion decisions."),
false,
);
})),
)
.await
.map_err(|error| {
ToolError::ExecutionFailed(format!("start Internal Worker session: {error}"))
})?;
// Transfer delegated Write authority within this parent-owned process. The machine-wide
// allocation remains owned by the parent Worker; no fake child PID/socket identity is
// introduced.
let revoke_write: Vec<ScopeRule> = scope_allow
.iter()
.filter(|rule| rule.permission == Permission::Write)
.cloned()
.collect();
if !revoke_write.is_empty() {
self.spawner_scope
.update(|current| current.with_added_deny_rules(revoke_write.clone()))
.map_err(|error| {
ToolError::ExecutionFailed(format!("revoke spawner scope: {error}"))
})?;
}
let record = crate::spawn::registry::InternalSpawnedWorkerRecord {
worker_name: input.name.clone(),
scope_delegated: scope_allow,
session: session.clone(),
};
if let Err(error) = self.registry.add_internal(record) {
let _ = session.stop().await;
if !revoke_write.is_empty() {
let _ = self
.spawner_scope
.update(|current| current.with_removed_deny_rules(revoke_write));
}
return Err(ToolError::ExecutionFailed(format!(
"register Internal Worker session: {error}"
)));
}
Ok(ToolOutput {
summary: format!("spawned internal worker `{}`", input.name),
content: None,
})
}
}
impl SubWorkerSpawnTool {
fn validate_delegation_scope(&self, scope_allow: &[ScopeRule]) -> Result<(), ToolError> {
if self.delegation_scope.is_empty() && !scope_allow.is_empty() {
return Err(ToolError::InvalidArgument(
"SubWorkerSpawn requires delegation authority, but this Worker has no delegation scope grant; direct filesystem scope only authorizes this Worker's own tools".into(),
));
}
for rule in scope_allow {
let allowed = self
.delegation_scope
.allows_rule(rule)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
if !allowed {
return Err(ToolError::InvalidArgument(format!(
"requested child scope {} {:?} is outside this Worker's delegation scope grant",
rule.target.display(),
rule.permission
)));
}
}
Ok(())
}
}
fn parse_scope(rules: &[ScopeRuleInput]) -> Result<Vec<ScopeRule>, ToolError> {
if rules.is_empty() {
return Err(ToolError::InvalidArgument("scope must not be empty".into()));
}
rules
.iter()
.map(|r| {
if !r.target.is_absolute() {
return Err(ToolError::InvalidArgument(format!(
"scope.target must be absolute: {}",
r.target.display()
)));
}
Ok(ScopeRule {
target: r.target.clone(),
permission: r.permission.into(),
recursive: r.recursive,
})
})
.collect()
}
fn validate_spawn_cwd(
cwd: Option<&Path>,
scope_allow: &[ScopeRule],
default_cwd: &Path,
) -> Result<PathBuf, ToolError> {
let Some(cwd) = cwd else {
return Ok(default_cwd.to_path_buf());
};
if !cwd.is_absolute() {
return Err(ToolError::InvalidArgument(format!(
"SubWorkerSpawn.cwd must be absolute: {}",
cwd.display()
)));
}
let metadata = std::fs::metadata(cwd).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
ToolError::InvalidArgument(format!(
"SubWorkerSpawn.cwd does not exist: {}",
cwd.display()
))
} else {
ToolError::InvalidArgument(format!(
"SubWorkerSpawn.cwd is not usable: {}: {e}",
cwd.display()
))
}
})?;
if !metadata.is_dir() {
return Err(ToolError::InvalidArgument(format!(
"SubWorkerSpawn.cwd must be a directory: {}",
cwd.display()
)));
}
let canonical = std::fs::canonicalize(cwd).map_err(|e| {
ToolError::InvalidArgument(format!(
"SubWorkerSpawn.cwd is not usable: {}: {e}",
cwd.display()
))
})?;
let child_scope = Scope::from_config(&ScopeConfig {
allow: scope_allow.to_vec(),
deny: Vec::new(),
})
.map_err(|e| {
ToolError::InvalidArgument(format!(
"requested child scope cannot validate SubWorkerSpawn.cwd: {e}"
))
})?;
if !child_scope.is_readable(&canonical) {
return Err(ToolError::InvalidArgument(format!(
"SubWorkerSpawn.cwd {} is outside the child's delegated readable scope; cwd grants no authority, so add an explicit read or write scope rule covering it",
cwd.display()
)));
}
Ok(canonical)
}
/// Serialise the internal manifest config that gets handed to the child
/// Worker runtime process via the hidden `--spawn-config-json` flag.
/// `WorkerManifestConfig`'s `Serialize` impl is the single source of truth for the
/// internal handoff shape.
///
/// The child's tool working directory is carried separately through
/// the child runtime entrypoint; it is not part of the manifest.
impl SubWorkerSpawnTool {
fn build_spawn_config_json(
&self,
name: &str,
instruction_override: Option<&str>,
scope_allow: &[ScopeRule],
selector: SpawnProfileSelector,
) -> Result<String, String> {
build_spawn_config_json_for_profile(
&self.spawner_manifest,
&self.available_profiles,
&self.workspace_root,
name,
instruction_override,
scope_allow,
selector,
)
}
}
fn build_spawn_config_json_for_profile(
spawner_manifest: &WorkerManifest,
available_profiles: &AvailableProfiles,
workspace_root: &Path,
name: &str,
instruction_override: Option<&str>,
scope_allow: &[ScopeRule],
selector: SpawnProfileSelector,
) -> Result<String, String> {
let mut config = match selector {
SpawnProfileSelector::Inherit => manifest_to_reusable_config(spawner_manifest),
SpawnProfileSelector::Default | SpawnProfileSelector::Registry(_) => {
let registry = available_profiles.registry.as_ref().ok_or_else(|| {
format!(
"profile discovery failed for SubWorkerSpawn: {}{}",
available_profiles.diagnostic().if_empty("unknown error"),
available_profiles.error_suffix()
)
})?;
let profile_selector = match selector {
SpawnProfileSelector::Default => ProfileSelector::Default,
SpawnProfileSelector::Registry(selector) => selector,
SpawnProfileSelector::Inherit => unreachable!(),
};
let resolved = ProfileResolver::new()
.with_workspace_base(workspace_root)
.resolve_from_registry(
&profile_selector,
registry,
ProfileResolveOptions::with_worker_name(name),
)
.map_err(|e| profile_error_with_available(e, available_profiles))?;
manifest_to_reusable_config(&resolved.manifest)
}
};
config.worker.name = Some(name.to_string());
config.scope = ScopeConfig {
allow: scope_allow.to_vec(),
deny: Vec::new(),
};
if let Some(instruction) = instruction_override {
config.engine.instruction = Some(instruction.to_string());
}
serde_json::to_string(&config).map_err(|e| format!("spawn config serialisation: {e}"))
}
#[cfg(test)]
fn build_spawn_config_json(
name: &str,
instruction: &str,
scope_allow: &[ScopeRule],
model: &manifest::ModelManifest,
record_event_trace: bool,
) -> Result<String, serde_json::Error> {
let config = WorkerManifestConfig {
worker: WorkerMetaConfig {
name: Some(name.to_string()),
prompt_pack: None,
},
model: model.clone(),
engine: EngineManifestConfig {
instruction: Some(instruction.to_string()),
..Default::default()
},
scope: ScopeConfig {
allow: scope_allow.to_vec(),
deny: Vec::new(),
},
session: record_event_trace.then_some(SessionConfigPartial {
record_event_trace: Some(true),
}),
..Default::default()
};
serde_json::to_string(&config)
}
trait IfEmpty {
fn if_empty(&self, fallback: &str) -> String;
}
impl IfEmpty for str {
fn if_empty(&self, fallback: &str) -> String {
if self.is_empty() {
fallback.into()
} else {
self.into()
}
}
}
fn profile_error_with_available(error: ProfileError, available: &AvailableProfiles) -> String {
format!(
"invalid SubWorkerSpawn.profile: {error}{}",
available.error_suffix()
)
}
fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfig {
WorkerManifestConfig {
worker: WorkerMetaConfig {
name: Some(manifest.worker.name.clone()),
prompt_pack: manifest.worker.prompt_pack.clone(),
},
model: manifest.model.clone(),
engine: EngineManifestConfig {
instruction: Some(manifest.engine.instruction.clone()),
language: Some(manifest.engine.language.clone()),
max_tokens: manifest.engine.max_tokens,
max_turns: manifest.engine.max_turns,
temperature: manifest.engine.temperature,
top_p: manifest.engine.top_p,
top_k: manifest.engine.top_k,
stop_sequences: (!manifest.engine.stop_sequences.is_empty())
.then_some(manifest.engine.stop_sequences.clone()),
reasoning: manifest.engine.reasoning.clone(),
tool_output: ToolOutputLimitsPartial {
default_max_bytes: Some(manifest.engine.tool_output.default_max_bytes),
per_tool: manifest.engine.tool_output.per_tool.clone(),
},
file_upload: FileUploadLimitsPartial {
max_bytes: Some(manifest.engine.file_upload.max_bytes),
},
},
scope: ScopeConfig {
allow: manifest.scope.allow.clone(),
deny: manifest.scope.deny.clone(),
},
// `inherit` reuses behavioral configuration, not subdelegation authority.
delegation_scope: ScopeConfig::default(),
session: Some(SessionConfigPartial {
record_event_trace: Some(manifest.session.record_event_trace),
}),
permissions: manifest
.permissions
.as_ref()
.map(|p| PermissionConfigPartial {
default_action: Some(p.default_action),
rules: p.rules.clone(),
}),
feature: manifest.feature.clone().into(),
plugins: manifest.plugins.clone(),
mcp: manifest.mcp.clone(),
compaction: manifest
.compaction
.as_ref()
.map(|c| CompactionConfigPartial {
prune_protected_tokens: Some(c.prune_protected_tokens),
prune_min_savings: Some(c.prune_min_savings),
threshold: c.threshold,
request_threshold: c.request_threshold,
retained_tokens: Some(c.retained_tokens),
overview_target_tokens: Some(c.overview_target_tokens),
overview_warning_tokens: Some(c.overview_warning_tokens),
overview_deadline_tokens: Some(c.overview_deadline_tokens),
worker_context_max_tokens: Some(c.worker_context_max_tokens),
finish_warning_remaining_tokens: Some(c.finish_warning_remaining_tokens),
final_reserve_tokens: Some(c.final_reserve_tokens),
worker_max_turns: c.worker_max_turns,
summary_target_tokens: Some(c.summary_target_tokens),
summary_max_tokens: Some(c.summary_max_tokens),
auto_read_budget_tokens: Some(c.auto_read_budget_tokens),
result_context_max_tokens: Some(c.result_context_max_tokens),
model: c.model.clone(),
}),
web: manifest.web.clone(),
memory: manifest.memory.clone(),
skills: manifest.skills.clone(),
}
}
/// Tail of the spawned child's `stderr.log` to splice into a startup
/// failure message. Capped so a chatty child can't blow up the LLM's
/// tool-result budget — debugging beyond this should read the file
/// directly.
/// Factory for the `SubWorkerSpawn` tool.
pub fn sub_worker_spawn_tool(
spawner_name: String,
workspace_context: crate::worker::WorkerWorkspaceContext,
parent_notifies: crate::ipc::notify_buffer::NotifyBuffer,
runtime_base: PathBuf,
workspace_root: PathBuf,
spawner_cwd: PathBuf,
registry: Arc<SpawnedWorkerRegistry>,
spawner_manifest: WorkerManifest,
spawner_scope: SharedScope,
prompts: Arc<PromptCatalog>,
) -> ToolDefinition {
sub_worker_spawn_tool_impl(
spawner_name,
workspace_context,
parent_notifies,
runtime_base,
workspace_root,
spawner_cwd,
registry,
spawner_manifest,
spawner_scope,
prompts,
)
}
fn sub_worker_spawn_tool_impl(
spawner_name: String,
workspace_context: crate::worker::WorkerWorkspaceContext,
parent_notifies: crate::ipc::notify_buffer::NotifyBuffer,
runtime_base: PathBuf,
workspace_root: PathBuf,
spawner_cwd: PathBuf,
registry: Arc<SpawnedWorkerRegistry>,
spawner_manifest: WorkerManifest,
spawner_scope: SharedScope,
prompts: Arc<PromptCatalog>,
) -> ToolDefinition {
Arc::new(move || {
let schema = schemars::schema_for!(SubWorkerSpawnInput);
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
let available_profiles = AvailableProfiles::discover(&workspace_root);
let description = prompts
.sub_worker_spawn_tool_description(
&available_profiles.compact_list(),
&available_profiles.default_label(),
available_profiles.diagnostic(),
)
.unwrap_or_else(|e| {
format!(
"Spawn an in-process Internal SubWorker session to split context for a delegated task. Profile description rendering failed: {e}. Available profiles:\n{}",
available_profiles.compact_list()
)
});
let meta = ToolMeta::new("SubWorkerSpawn")
.description(description)
.input_schema(schema_value);
let tool: Arc<dyn Tool> = Arc::new(SubWorkerSpawnTool::new(
spawner_name.clone(),
workspace_context.clone(),
parent_notifies.clone(),
runtime_base.clone(),
workspace_root.clone(),
spawner_cwd.clone(),
registry.clone(),
spawner_manifest.clone(),
available_profiles,
spawner_scope.clone(),
DelegationScope::from_config(&spawner_manifest.delegation_scope)
.expect("resolved Worker manifest has a valid delegation scope"),
));
(meta, tool)
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::pin::Pin;
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::WorkspaceId;
use async_trait::async_trait;
use futures::Stream;
use llm_engine::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use llm_engine::llm_client::{ClientError, LlmClient, Request};
use manifest::{AuthRef, ModelManifest, SchemeKind, WorkerManifest};
use tempfile::TempDir;
use crate::worker::{
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceResponse,
};
fn abs_rule(path: &Path, permission: Permission) -> ScopeRule {
ScopeRule {
target: path.to_path_buf(),
permission,
recursive: true,
}
}
#[tokio::test]
async fn reviewer_profile_spawns_as_workspace_aware_internal_session() {
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.unwrap();
let runtime = TempDir::new().unwrap();
let mut manifest = parent_manifest(&workspace_root, None);
manifest.delegation_scope = ScopeConfig {
allow: vec![abs_rule(&workspace_root, Permission::Write)],
deny: Vec::new(),
};
let spawner_scope = SharedScope::new(Scope::from_config(&manifest.scope).unwrap());
let registry = SpawnedWorkerRegistry::new_internal("parent".into(), spawner_scope.clone());
let workspace_context = crate::worker::WorkerWorkspaceContext::with_client(
Some(WorkspaceId::new("workspace-test").unwrap()),
Arc::new(AvailableWorkspaceClient),
);
let calls = Arc::new(AtomicUsize::new(0));
let parent_notifies = crate::ipc::notify_buffer::NotifyBuffer::new();
let tool = SubWorkerSpawnTool::new(
"parent".into(),
workspace_context,
parent_notifies.clone(),
runtime.path().to_path_buf(),
workspace_root.clone(),
workspace_root.clone(),
registry.clone(),
manifest.clone(),
AvailableProfiles::discover(&workspace_root),
spawner_scope.clone(),
DelegationScope::from_config(&manifest.delegation_scope).unwrap(),
)
.with_internal_client(Box::new(ScriptedInternalClient {
calls: calls.clone(),
}));
let input = serde_json::json!({
"name": "reviewer-child",
"profile": "builtin:reviewer",
"task": "review immutable commit",
"scope": [{
"target": workspace_root.clone(),
"permission": "write",
"recursive": true
}]
});
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
let output = tool
.execute(
&serde_json::to_string(&input).unwrap(),
llm_engine::tool::ToolExecutionContext::direct(),
)
.await
.expect("spawn project reviewer as Internal Worker");
assert!(output.summary.contains("internal worker `reviewer-child`"));
assert!(!spawner_scope.snapshot().is_writable(&workspace_root));
let record = registry
.get_internal("reviewer-child")
.expect("Internal reviewer registry record");
assert_eq!(
record.session.wait_until_idle().await,
crate::internal_worker::InternalWorkerSessionStatus::Idle
);
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert_eq!(parent_notifies.len(), 1);
assert!(!runtime.path().join("reviewer-child/sock").exists());
let context = llm_engine::tool::ToolExecutionContext::direct();
let list = (crate::spawn::comm_tools::sub_worker_list_tool(registry.clone()))().1;
let listed = list.execute("{}", context.clone()).await.unwrap();
assert!(
listed
.content
.unwrap_or_default()
.contains("reviewer-child")
);
let read = (crate::spawn::comm_tools::sub_worker_read_output_tool(registry.clone()))().1;
let first_output = read
.execute(r#"{"name":"reviewer-child"}"#, context.clone())
.await
.unwrap();
assert!(
first_output
.content
.unwrap_or_default()
.contains("reviewed")
);
let second_output = read
.execute(r#"{"name":"reviewer-child"}"#, context.clone())
.await
.unwrap();
assert!(second_output.content.is_none());
let send = (crate::spawn::comm_tools::sub_worker_send_tool(registry.clone()))().1;
send.execute(
r#"{"name":"reviewer-child","message":"review follow-up"}"#,
context.clone(),
)
.await
.unwrap();
assert_eq!(
record.session.wait_until_idle().await,
crate::internal_worker::InternalWorkerSessionStatus::Idle
);
assert_eq!(calls.load(Ordering::SeqCst), 2);
let stop = (crate::spawn::comm_tools::sub_worker_stop_tool(registry.clone()))().1;
stop.execute(r#"{"name":"reviewer-child"}"#, context)
.await
.unwrap();
assert!(registry.get_internal("reviewer-child").is_none());
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
}
#[test]
fn spawn_worker_input_schema_includes_optional_cwd() {
let schema = serde_json::to_value(schemars::schema_for!(SubWorkerSpawnInput)).unwrap();
let properties = schema
.get("properties")
.and_then(serde_json::Value::as_object)
.expect("schema properties");
assert!(properties.contains_key("cwd"), "schema: {schema}");
let required = schema
.get("required")
.and_then(serde_json::Value::as_array)
.expect("schema required list");
assert!(
!required.iter().any(|value| value.as_str() == Some("cwd")),
"cwd must remain optional: {schema}"
);
}
#[test]
fn spawn_worker_validate_cwd_requires_absolute_existing_directory_in_child_scope() {
let root = TempDir::new().unwrap();
let child_cwd = root.path().join("child");
std::fs::create_dir(&child_cwd).unwrap();
let file_path = root.path().join("file.txt");
std::fs::write(&file_path, "not a dir").unwrap();
let outside = TempDir::new().unwrap();
let missing = root.path().join("missing");
let rules = vec![abs_rule(root.path(), Permission::Write)];
assert_eq!(
validate_spawn_cwd(None, &rules, root.path()).unwrap(),
root.path()
);
assert_eq!(
validate_spawn_cwd(Some(&child_cwd), &rules, root.path()).unwrap(),
std::fs::canonicalize(&child_cwd).unwrap()
);
for (cwd, expected) in [
(Path::new("relative"), "must be absolute"),
(missing.as_path(), "does not exist"),
(file_path.as_path(), "must be a directory"),
(
outside.path(),
"outside the child's delegated readable scope",
),
] {
let err = validate_spawn_cwd(Some(cwd), &rules, root.path()).unwrap_err();
match err {
ToolError::InvalidArgument(message) => {
assert!(message.contains(expected), "{message}")
}
other => panic!("expected InvalidArgument, got {other:?}"),
}
}
}
#[test]
fn orchestration_delegation_allows_root_read_and_worktree_writes_not_root_writes() {
let tmp = TempDir::new().unwrap();
let workspace_root = tmp.path().join("original");
let implementation_worktree = workspace_root.join(".worktree/ticket-1");
std::fs::create_dir_all(&implementation_worktree).unwrap();
let delegation = DelegationScope::from_config(&ScopeConfig {
allow: vec![
abs_rule(&workspace_root, Permission::Read),
abs_rule(&workspace_root.join(".worktree"), Permission::Write),
],
deny: Vec::new(),
})
.unwrap();
let coder_scope = vec![
abs_rule(&workspace_root, Permission::Read),
abs_rule(&implementation_worktree, Permission::Write),
];
assert!(
coder_scope
.iter()
.all(|rule| delegation.allows_rule(rule).unwrap())
);
let reviewer_scope = vec![abs_rule(&workspace_root, Permission::Read)];
assert!(
reviewer_scope
.iter()
.all(|rule| delegation.allows_rule(rule).unwrap())
);
let root_writer_scope = vec![abs_rule(&workspace_root, Permission::Write)];
assert!(
root_writer_scope
.iter()
.any(|rule| !delegation.allows_rule(rule).unwrap())
);
}
#[derive(Clone)]
struct ScriptedInternalClient {
calls: Arc<AtomicUsize>,
}
#[async_trait]
impl LlmClient for ScriptedInternalClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
_request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(Box::pin(futures::stream::iter(vec![
Ok(LlmEvent::text_block_start(0)),
Ok(LlmEvent::text_delta(0, "reviewed")),
Ok(LlmEvent::text_block_stop(0, None)),
Ok(LlmEvent::Status(StatusEvent {
status: ResponseStatus::Completed,
})),
])))
}
}
#[derive(Debug)]
struct AvailableWorkspaceClient;
impl WorkspaceClient for AvailableWorkspaceClient {
fn workspace_id(&self) -> Option<&str> {
Some("workspace-test")
}
fn kind(&self) -> &str {
"test"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
_request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
Err(WorkspaceClientError::Unavailable("not invoked".into()))
}
}
fn parent_manifest(root: &Path, deny: Option<&Path>) -> WorkerManifest {
WorkerManifestConfig {
worker: WorkerMetaConfig {
name: Some("parent".into()),
prompt_pack: None,
},
model: ModelManifest {
scheme: Some(SchemeKind::Anthropic),
model_id: Some("parent-model".into()),
auth: Some(AuthRef::None),
..Default::default()
},
engine: EngineManifestConfig {
instruction: Some("$yoi/parent".into()),
language: Some("Parentish".into()),
max_tokens: Some(1234),
stop_sequences: Some(vec!["STOP".into()]),
..Default::default()
},
scope: ScopeConfig {
allow: vec![abs_rule(root, Permission::Write)],
deny: deny
.map(|path| vec![abs_rule(path, Permission::Read)])
.unwrap_or_default(),
},
session: Some(SessionConfigPartial {
record_event_trace: Some(true),
}),
..Default::default()
}
.try_into()
.unwrap()
}
fn write_project_profile_registry(
project: &Path,
default: Option<&str>,
profiles: &[(&str, &str, &str)],
) -> AvailableProfiles {
let yoi = project.join(".yoi");
let profile_dir = yoi.join("profiles");
std::fs::create_dir_all(&profile_dir).unwrap();
let mut registry_toml = String::new();
if let Some(default) = default {
registry_toml.push_str(&format!("default = \"{default}\"\n"));
}
registry_toml.push_str("[profile]\n");
for (name, file, body) in profiles {
std::fs::write(profile_dir.join(file), body).unwrap();
registry_toml.push_str(&format!("{name} = \"profiles/{file}\"\n"));
}
let registry_path = yoi.join("profiles.toml");
std::fs::write(&registry_path, registry_toml).unwrap();
AvailableProfiles {
registry: Some(
ProfileDiscovery::with_sources(None, Some(registry_path))
.discover()
.unwrap(),
),
diagnostic: None,
}
}
fn child_config_from_profile(
spawner_manifest: &WorkerManifest,
available: &AvailableProfiles,
cwd: &Path,
name: &str,
instruction_override: Option<&str>,
scope: &[ScopeRule],
selector: SpawnProfileSelector,
) -> WorkerManifestConfig {
let json = build_spawn_config_json_for_profile(
spawner_manifest,
available,
cwd,
name,
instruction_override,
scope,
selector,
)
.unwrap();
serde_json::from_str(&json).unwrap()
}
const CODER_PROFILE: &str = r#"
slug = "coder"
scope = "workspace_write"
[model]
scheme = "anthropic"
model_id = "coder-model"
[engine]
instruction = "$yoi/coder"
language = "Coderish"
max_tokens = 2222
"#;
const REVIEWER_PROFILE: &str = r#"
slug = "reviewer"
scope = "workspace_write"
[model]
scheme = "anthropic"
model_id = "reviewer-model"
[engine]
instruction = "$yoi/reviewer"
language = "Reviewerish"
max_tokens = 3333
"#;
#[test]
fn spawn_config_inherits_inline_spawner_model() {
let model = ModelManifest {
scheme: Some(SchemeKind::Anthropic),
base_url: Some("https://example.test".into()),
model_id: Some("claude-sonnet-4".into()),
auth: Some(AuthRef::ApiKey {
file: Some(PathBuf::from("/etc/keys/anthropic")),
}),
..Default::default()
};
let config_json =
build_spawn_config_json("child", "$yoi/default", &[], &model, false).unwrap();
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
assert_eq!(parsed.model.scheme, Some(SchemeKind::Anthropic));
assert_eq!(parsed.model.model_id.as_deref(), Some("claude-sonnet-4"));
assert_eq!(
parsed.model.base_url.as_deref(),
Some("https://example.test")
);
let file = match parsed.model.auth {
Some(AuthRef::ApiKey { file, .. }) => file,
_ => panic!("expected ApiKey"),
};
assert_eq!(file.as_deref(), Some(Path::new("/etc/keys/anthropic")));
}
#[test]
fn spawn_config_inherits_ref_spawner_model() {
let model = ModelManifest {
ref_: Some("anthropic/claude-sonnet-4-6".into()),
..Default::default()
};
let config_json =
build_spawn_config_json("child", "$yoi/default", &[], &model, false).unwrap();
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
assert_eq!(
parsed.model.ref_.as_deref(),
Some("anthropic/claude-sonnet-4-6")
);
}
#[test]
fn spawn_config_preserves_record_event_trace_when_enabled() {
let model = ModelManifest {
ref_: Some("anthropic/claude-sonnet-4-6".into()),
..Default::default()
};
let scope = vec![ScopeRule {
target: PathBuf::from("/tmp/child"),
permission: Permission::Read,
recursive: true,
}];
let config_json =
build_spawn_config_json("child", "$yoi/default", &scope, &model, true).unwrap();
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
assert_eq!(
parsed.session.as_ref().and_then(|s| s.record_event_trace),
Some(true)
);
let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults()
.merge(parsed)
.try_into()
.unwrap();
assert!(manifest.session.record_event_trace);
}
#[test]
fn spawn_config_omits_record_event_trace_when_disabled() {
let model = ModelManifest {
ref_: Some("anthropic/claude-sonnet-4-6".into()),
..Default::default()
};
let config_json =
build_spawn_config_json("child", "$yoi/default", &[], &model, false).unwrap();
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
assert!(parsed.session.is_none());
}
#[test]
fn omitted_profile_resolves_effective_registry_default() {
let tmp = TempDir::new().unwrap();
let project = tmp.path().join("project");
let delegated = tmp.path().join("delegated");
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(&delegated).unwrap();
let available = write_project_profile_registry(
&project,
Some("reviewer"),
&[
("coder", "coder.toml", CODER_PROFILE),
("reviewer", "reviewer.toml", REVIEWER_PROFILE),
],
);
let parent = parent_manifest(&project, None);
let scope = vec![abs_rule(&delegated, Permission::Read)];
let config = child_config_from_profile(
&parent,
&available,
&project,
"child-default",
None,
&scope,
SpawnProfileSelector::Default,
);
assert_eq!(config.worker.name.as_deref(), Some("child-default"));
assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model"));
assert_eq!(config.engine.instruction.as_deref(), Some("$yoi/reviewer"));
assert_eq!(config.engine.language.as_deref(), Some("Reviewerish"));
assert_eq!(config.scope.allow, scope);
assert!(config.scope.deny.is_empty());
}
#[test]
fn source_qualified_profile_role_config_reaches_spawn_config() {
let tmp = TempDir::new().unwrap();
let project = tmp.path().join("project");
let delegated = tmp.path().join("delegated");
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(&delegated).unwrap();
let available = write_project_profile_registry(
&project,
Some("coder"),
&[
("coder", "coder.toml", CODER_PROFILE),
("reviewer", "reviewer.toml", REVIEWER_PROFILE),
],
);
let parent = parent_manifest(&project, None);
let scope = vec![abs_rule(&delegated, Permission::Write)];
let config = child_config_from_profile(
&parent,
&available,
&project,
"review-child",
None,
&scope,
SpawnProfileSelector::Registry(ProfileSelector::source_named(
ProfileRegistrySource::Project,
"reviewer",
)),
);
assert_eq!(config.worker.name.as_deref(), Some("review-child"));
assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model"));
assert_eq!(config.engine.instruction.as_deref(), Some("$yoi/reviewer"));
assert_eq!(config.engine.language.as_deref(), Some("Reviewerish"));
assert_eq!(config.engine.max_tokens, Some(3333));
assert_eq!(config.scope.allow, scope);
assert!(config.scope.deny.is_empty());
}
#[test]
fn inherit_copies_reusable_parent_fields_and_replaces_runtime_authority() {
let tmp = TempDir::new().unwrap();
let parent_root = tmp.path().join("parent-root");
let parent_deny = parent_root.join("secret");
let delegated = tmp.path().join("delegated");
std::fs::create_dir_all(&parent_deny).unwrap();
std::fs::create_dir_all(&delegated).unwrap();
let parent = parent_manifest(&parent_root, Some(&parent_deny));
let scope = vec![abs_rule(&delegated, Permission::Read)];
let available = AvailableProfiles {
registry: None,
diagnostic: None,
};
let config = child_config_from_profile(
&parent,
&available,
tmp.path(),
"inherited-child",
None,
&scope,
SpawnProfileSelector::Inherit,
);
assert_eq!(config.worker.name.as_deref(), Some("inherited-child"));
assert_eq!(config.model.model_id.as_deref(), Some("parent-model"));
assert_eq!(config.engine.instruction.as_deref(), Some("$yoi/parent"));
assert_eq!(config.engine.language.as_deref(), Some("Parentish"));
assert_eq!(config.engine.max_tokens, Some(1234));
assert_eq!(
config.engine.stop_sequences.as_deref(),
Some(&["STOP".to_string()][..])
);
assert_eq!(
config.session.as_ref().and_then(|s| s.record_event_trace),
Some(true)
);
assert_eq!(config.scope.allow, scope);
assert!(config.scope.deny.is_empty());
}
#[test]
fn instruction_override_changes_only_worker_instruction() {
let tmp = TempDir::new().unwrap();
let project = tmp.path().join("project");
let delegated = tmp.path().join("delegated");
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(&delegated).unwrap();
let available = write_project_profile_registry(
&project,
Some("reviewer"),
&[("reviewer", "reviewer.toml", REVIEWER_PROFILE)],
);
let parent = parent_manifest(&project, None);
let scope = vec![abs_rule(&delegated, Permission::Write)];
let config = child_config_from_profile(
&parent,
&available,
&project,
"override-child",
Some("$user/custom-reviewer"),
&scope,
SpawnProfileSelector::Default,
);
assert_eq!(
config.engine.instruction.as_deref(),
Some("$user/custom-reviewer")
);
assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model"));
assert_eq!(config.engine.language.as_deref(), Some("Reviewerish"));
assert_eq!(config.engine.max_tokens, Some(3333));
assert_eq!(config.scope.allow, scope);
}
#[test]
fn profile_and_inherited_scope_are_replaced_by_delegated_scope() {
let tmp = TempDir::new().unwrap();
let project = tmp.path().join("project");
let delegated = tmp.path().join("delegated");
let parent_root = tmp.path().join("parent-root");
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(&delegated).unwrap();
std::fs::create_dir_all(&parent_root).unwrap();
let available = write_project_profile_registry(
&project,
Some("reviewer"),
&[("reviewer", "reviewer.toml", REVIEWER_PROFILE)],
);
let parent = parent_manifest(&parent_root, Some(&parent_root.join("deny")));
let scope = vec![abs_rule(&delegated, Permission::Read)];
let profile_config = child_config_from_profile(
&parent,
&available,
&project,
"profile-child",
None,
&scope,
SpawnProfileSelector::Default,
);
let inherit_config = child_config_from_profile(
&parent,
&available,
&project,
"inherit-child",
None,
&scope,
SpawnProfileSelector::Inherit,
);
for config in [profile_config, inherit_config] {
assert_eq!(config.scope.allow, scope);
assert!(config.scope.deny.is_empty());
assert!(!config.scope.allow.iter().any(|rule| rule.target == project));
assert!(
!config
.scope
.allow
.iter()
.any(|rule| rule.target == parent_root)
);
}
}
#[test]
fn invalid_ambiguous_and_no_default_diagnostics_include_available_selectors() {
let tmp = TempDir::new().unwrap();
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
let available = write_project_profile_registry(
&project,
None,
&[("coder", "coder.toml", CODER_PROFILE)],
);
let parent = parent_manifest(&project, None);
let scope = vec![abs_rule(&project, Permission::Read)];
let invalid = parse_spawn_profile_selector(Some("./reviewer.toml"))
.map_err(|msg| format!("{msg}{}", available.error_suffix()))
.unwrap_err();
assert!(invalid.contains("Use `default`, `inherit`"));
assert!(invalid.contains("`project:coder`"));
let default_error = build_spawn_config_json_for_profile(
&parent,
&available,
&project,
"child",
None,
&scope,
SpawnProfileSelector::Default,
)
.unwrap_err();
assert!(default_error.contains("no default profile is configured"));
let user_config = tmp.path().join("user-profiles.toml");
std::fs::write(&user_config, "[profile]\ncoder = \"user-coder.toml\"\n").unwrap();
let project_config = project.join(".yoi/profiles.toml");
let ambiguous = AvailableProfiles {
registry: Some(
ProfileDiscovery::with_sources(Some(user_config), Some(project_config))
.discover()
.unwrap(),
),
diagnostic: None,
};
let ambiguous_error = build_spawn_config_json_for_profile(
&parent,
&ambiguous,
&project,
"child",
None,
&scope,
SpawnProfileSelector::Registry(ProfileSelector::named("coder")),
)
.unwrap_err();
assert!(ambiguous_error.contains("ambiguous"), "{ambiguous_error}");
assert!(ambiguous_error.contains("user:coder"));
assert!(ambiguous_error.contains("project:coder"));
assert!(ambiguous_error.contains("Use `default`, `inherit`"));
}
#[test]
fn spawn_profile_selector_rejects_path_like_values() {
for raw in [
"./reviewer.toml",
"path:./reviewer.toml",
"/tmp/reviewer.toml",
"legacy.nix",
] {
let err = parse_spawn_profile_selector(Some(raw)).unwrap_err();
assert!(err.contains("registry selectors only"), "{raw}: {err}");
}
}
#[test]
fn spawn_profile_selector_accepts_default_inherit_and_registry() {
assert_eq!(
parse_spawn_profile_selector(None).unwrap(),
SpawnProfileSelector::Default
);
assert_eq!(
parse_spawn_profile_selector(Some("inherit")).unwrap(),
SpawnProfileSelector::Inherit
);
assert_eq!(
parse_spawn_profile_selector(Some("project:reviewer")).unwrap(),
SpawnProfileSelector::Registry(ProfileSelector::source_named(
ProfileRegistrySource::Project,
"reviewer"
))
);
assert_eq!(
parse_spawn_profile_selector(Some("coder")).unwrap(),
SpawnProfileSelector::Registry(ProfileSelector::named("coder"))
);
}
}