mod agent_files;
mod agent_types;
mod config;
mod events;
mod fleet;
mod manager;
mod memory;
mod mention;
mod model;
mod nested;
mod notify;
mod output_file;
mod prompt;
mod schedule;
mod session;
mod types;
mod widget;
mod wizard;
mod worktree;
pub(crate) mod autoresearch;
pub(crate) mod workflow;
use super::util::{choice_key, notify, notify_text, opt_str, req_str, type_arg_label};
use crate::{
core::{
extensions::{
self, AfterToolCallOutcome, CliFlagDef, Extension, ExtensionCommand, ExtensionHook,
ExtensionMode, ExtensionSetting, ExtensionSuggestion, ExtensionSuggestions,
ExtensionTool, ExtensionUiRequest, ForkProjectInfo, OverlayEvent, OverlayView,
RichSpan, SelectOption, ToolAnnotations, ToolExecCtx, ToolExposure, UiNotifyLevel,
UserPromptAction,
},
provider::AgentMessage,
session_manager::Session,
tools::{ToolError, ToolExecutionMode, ToolResult},
},
extensions::{EXTENSION_FACTORIES, ExtensionFactory, PRIORITY_SUBAGENT, command_arg},
modes::interactive::{app::App, handlers::register_slash_command},
utils::time::rel_time,
};
use agent_types::{DiscoveryOptions, DiscoveryWarning, ResolveError, Roster};
use config::SubagentConfig;
use futures_util::{
future::{BoxFuture, Either, select},
pin_mut,
};
use manager::{Caller, Dispatched};
use model::ModelSource;
use prompt::AgentToolDescription;
use serde_json::{Value, json};
use session::Verdict;
use std::{
collections::HashSet,
sync::{
Arc, Mutex, OnceLock,
atomic::{AtomicU64, Ordering},
},
time::Duration,
};
use types::{AgentRecord, AgentType, SpawnRequest};
pub const EXT: &str = "subagent";
pub const NOTIFICATION_HEAD: &str = "<subagent_result";
const CLONE_MARKER_PREFIX: &str = "__mention_clone__";
#[linkme::distributed_slice(EXTENSION_FACTORIES)]
static SUBAGENT_FACTORY: ExtensionFactory = ExtensionFactory {
priority: PRIORITY_SUBAGENT,
make: || Arc::new(Subagent),
};
pub struct Subagent;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SelectKind {
Menu,
Agents,
Types,
TypeActions,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SuggestionContext {
AgentArg(AgentArgKind),
TypeArg(TypeArgKind),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AgentArgKind {
Stop,
Result,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TypeArgKind {
Eject,
Enable,
Disable,
Delete,
Reset,
Edit,
}
fn select_id_slot() -> &'static Mutex<Option<(u64, SelectKind)>> {
static S: OnceLock<Mutex<Option<(u64, SelectKind)>>> = OnceLock::new();
S.get_or_init(|| Mutex::new(None))
}
fn session_cwd_slot() -> &'static Mutex<Option<String>> {
static S: OnceLock<Mutex<Option<String>>> = OnceLock::new();
S.get_or_init(|| Mutex::new(None))
}
fn discovery_cwd() -> String {
session_cwd_slot()
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
.or_else(|| {
std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().to_string())
})
.unwrap_or_else(|| ".".to_string())
}
fn suggestion_context(line_prefix: &str) -> Option<SuggestionContext> {
let rest = line_prefix.trim_start().strip_prefix('/')?;
let mut words = rest.split_whitespace();
if !words.next()?.eq_ignore_ascii_case("agents") {
return None;
}
match words.next()?.to_ascii_lowercase().as_str() {
"stop" => Some(SuggestionContext::AgentArg(AgentArgKind::Stop)),
"result" => Some(SuggestionContext::AgentArg(AgentArgKind::Result)),
"eject" => Some(SuggestionContext::TypeArg(TypeArgKind::Eject)),
"enable" => Some(SuggestionContext::TypeArg(TypeArgKind::Enable)),
"disable" => Some(SuggestionContext::TypeArg(TypeArgKind::Disable)),
"delete" => Some(SuggestionContext::TypeArg(TypeArgKind::Delete)),
"reset" => Some(SuggestionContext::TypeArg(TypeArgKind::Reset)),
"edit" => Some(SuggestionContext::TypeArg(TypeArgKind::Edit)),
_ => None,
}
}
fn agent_arg_suggestions(query: &str, kind: AgentArgKind) -> Vec<ExtensionSuggestion> {
let q = query.to_lowercase();
let matches = |s: &str| q.is_empty() || s.to_lowercase().contains(&q);
let mut out = Vec::new();
for rec in manager::list() {
if kind == AgentArgKind::Stop && rec.status.is_terminal() {
continue;
}
let hit = matches(&rec.id)
|| matches(&rec.description)
|| rec.name.as_deref().is_some_and(&matches)
|| rec.alias.as_deref().is_some_and(&matches)
|| rec.handle.as_deref().is_some_and(&matches);
if !hit {
continue;
}
let status = rec.status.as_str();
let description = if rec.description.is_empty() {
status.to_string()
} else {
format!("{} · {status}", rec.description)
};
out.push(ExtensionSuggestion {
name: format!("@{}", rec.id),
description,
insert: rec.id.clone(),
});
}
out
}
fn type_arg_suggestions(query: &str, kind: TypeArgKind) -> Vec<ExtensionSuggestion> {
let cwd = discovery_cwd();
let q = query.to_lowercase();
let mut out = Vec::new();
for t in agent_types::discover(&cwd).types {
if !type_arg_supported(&t, kind, &cwd) {
continue;
}
let handle = mention::handle_base(&t.name);
if !(q.is_empty() || handle.contains(&q) || t.name.to_lowercase().contains(&q)) {
continue;
}
let mark = if t.enabled { "" } else { " · disabled" };
out.push(ExtensionSuggestion {
name: format!("@{handle}"),
description: format!("{}{mark}", t.label()),
insert: handle,
});
}
out
}
fn type_arg_supported(ty: &AgentType, kind: TypeArgKind, cwd: &str) -> bool {
let has_file = agent_files::find_agent_file(ty, cwd).is_some();
match kind {
TypeArgKind::Eject => !has_file,
TypeArgKind::Enable => has_file && !ty.enabled,
TypeArgKind::Disable => has_file && ty.enabled,
TypeArgKind::Delete | TypeArgKind::Edit => has_file,
TypeArgKind::Reset => has_file && is_builtin_default(&ty.name),
}
}
impl Extension for Subagent {
fn name(&self) -> &str {
EXT
}
fn description(&self) -> &str {
"Sub-agents with custom agent types (Agent / get_subagent_result / steer_subagent)."
}
fn fork_project(&self) -> Option<ForkProjectInfo> {
Some(ForkProjectInfo {
plugin_name: "pi-subagents".to_string(),
plugin_version: "0.19.0".to_string(),
url: "https://github.com/tintinweb/pi-subagents".to_string(),
})
}
fn modes(&self) -> Vec<ExtensionMode> {
vec![ExtensionMode::Dev, ExtensionMode::Creator]
}
fn default_enabled(&self) -> bool {
false
}
fn tools(&self) -> Vec<ExtensionTool> {
let cwd = discovery_cwd();
let cfg = manager::config();
let roster = agent_types::discover_with(&cwd, discovery_opts(&cfg));
let description =
match prompt::agent_tool_description_for(&roster.types, cfg.tool_description, &cwd) {
AgentToolDescription::Text(text) => text,
AgentToolDescription::FallbackToFull { text, warning }
| AgentToolDescription::TextWithWarning { text, warning } => {
if manager::note_warning(&format!("tool-description|{warning}")) {
util_notify(&warning, UiNotifyLevel::Warning);
}
text
}
};
vec![
ExtensionTool {
exposure: ToolExposure::Direct,
namespace: None,
annotations: ToolAnnotations::default(),
output_schema: None,
name: "Agent".to_string(),
description,
label: Some("Sub-agent".to_string()),
parameters: {
let mut properties = json!({
"prompt": {
"type": "string",
"description": "The task for the agent to perform. Must be self-contained: the agent has not seen this conversation."
},
"description": {
"type": "string",
"description": "Short (3-5 word) summary of what the agent will do (shown in UI)."
},
"subagent_type": {
"type": "string",
"description": "Which agent type to use (see the available types in this tool's description)."
},
"name": {
"type": "string",
"description": "Optional memorable name for this agent, later usable with steer_subagent / get_subagent_result."
},
"model": {
"type": "string",
"description": "Exact \"provider/modelId\" to run this agent on. Defaults to the parent's model (or the agent type's pinned model)."
},
"thinking": {
"type": "string",
"description": "Thinking level: off, minimal, low, medium, high, xhigh, max. Clamped to what the model supports."
},
"max_turns": {
"type": "integer",
"minimum": 0,
"description": "Maximum agentic turns before graceful shutdown. 0 or omitted means unlimited."
},
"run_in_background": {
"type": "boolean",
"description": "Run detached and get notified on completion (this is the default; the subagent settings panel can flip it). Pass false to block and get the result inline."
},
"resume": {
"type": "string",
"description": "Agent id (or name) of a finished agent whose session should be continued instead of starting fresh."
},
"inherit_context": {
"type": "boolean",
"description": "Fork the parent conversation into this agent so it knows what has been discussed."
},
"isolated": {
"type": "boolean",
"description": "Run with built-in tools only: no extension-provided tools at all."
},
});
if let Some(param) = schedule_param() {
properties["schedule"] = param;
}
if let Some(param) = isolation_param() {
properties["isolation"] = param;
}
json!({
"type": "object",
"properties": properties,
"required": ["prompt", "description", "subagent_type"],
"additionalProperties": false
})
},
snippet: prompt::AGENT_TOOL_SNIPPET.to_string(),
prompt_guidelines: prompt::agent_tool_guidelines(),
constrained_sampling: false,
render_shell: None,
execution_mode: Some(ToolExecutionMode::Parallel), prepare_arguments: None,
grammar_sampling: None,
},
ExtensionTool {
exposure: ToolExposure::Direct,
namespace: None,
annotations: ToolAnnotations::default(),
output_schema: None,
name: "get_subagent_result".to_string(),
description: "Check the status of a sub-agent and read its result. Use wait: true to block until it finishes (only when you genuinely need the result now), and verbose: true for the full conversation transcript.".to_string(),
label: Some("Sub-agent result".to_string()),
parameters: json!({
"type": "object",
"properties": {
"agent_id": { "type": "string", "description": "Agent id (or the name it was spawned with)." },
"wait": { "type": "boolean", "description": "Block until the agent finishes. Defaults to false." },
"verbose": { "type": "boolean", "description": "Include the conversation transcript (truncated to the last 200 lines / 8 KB)." }
},
"required": ["agent_id"],
"additionalProperties": false
}),
snippet: "Check a sub-agent's status and read its result".to_string(),
prompt_guidelines: Vec::new(),
constrained_sampling: false,
render_shell: None,
execution_mode: None,
prepare_arguments: None,
grammar_sampling: None,
},
ExtensionTool {
exposure: ToolExposure::Direct,
namespace: None,
annotations: ToolAnnotations::default(),
output_schema: None,
name: "steer_subagent".to_string(),
description: "Send a steering message to a running sub-agent. The message is injected after its current tool call and redirects its work without restarting it.".to_string(),
label: Some("Steer sub-agent".to_string()),
parameters: json!({
"type": "object",
"properties": {
"agent_id": { "type": "string", "description": "Agent id (or the name it was spawned with)." },
"message": { "type": "string", "description": "Message to inject into the agent's conversation." }
},
"required": ["agent_id", "message"],
"additionalProperties": false
}),
snippet: "Send a steering message to a running sub-agent".to_string(),
prompt_guidelines: Vec::new(),
constrained_sampling: false,
render_shell: None,
execution_mode: None,
prepare_arguments: None,
grammar_sampling: None,
},
workflow::tool::tool_def(),
]
}
fn commands(&self) -> Vec<ExtensionCommand> {
vec![
ExtensionCommand {
name: "agents".to_string(),
description: "List sub-agents of this session (types, running, recent results)"
.to_string(),
busy_safe: true, subcommands: vec![
extensions::SubcommandDef {
name: "types",
description: "List available agent types (read-only)",
},
extensions::SubcommandDef {
name: "result",
description: "Show one agent's status and result: /agents result <id> (`@` picks the id)",
},
extensions::SubcommandDef {
name: "stop",
description: "Stop a running or queued agent, or a workflow run: /agents stop <id> (`@` picks the id)",
},
extensions::SubcommandDef {
name: "workflows",
description: "Open the workflow runs list (live progress)",
},
extensions::SubcommandDef {
name: "schedules",
description: "List scheduled sub-agent jobs (cancel with d)",
},
extensions::SubcommandDef {
name: "settings",
description: "Open the subagent settings panel",
},
extensions::SubcommandDef {
name: "eject",
description: "Write an agent type out as a .md file: /agents eject <type> [project] (`@` picks the type)",
},
extensions::SubcommandDef {
name: "enable",
description: "Re-enable a disabled agent .md: /agents enable <type> (`@` picks the type)",
},
extensions::SubcommandDef {
name: "disable",
description: "Disable an agent .md (line-wise edit): /agents disable <type> (`@` picks the type)",
},
extensions::SubcommandDef {
name: "delete",
description: "Delete a custom agent's .md file: /agents delete <type> (`@` picks the type)",
},
extensions::SubcommandDef {
name: "reset",
description: "Delete a built-in default's override .md, restoring the default: /agents reset <type> (`@` picks the type)",
},
extensions::SubcommandDef {
name: "edit",
description: "Edit an agent type's .md in a multi-line editor: /agents edit <type> (`@` picks the type)",
},
],
},
ExtensionCommand {
name: "autoresearch".to_string(),
description: "Iterate one sub-agent on a goal until it reports [goal-complete]: \
/autoresearch <goal> [--iterations N] starts a job, bare /autoresearch lists jobs"
.to_string(),
busy_safe: true, subcommands: vec![extensions::SubcommandDef {
name: "stop",
description: "Stop a running job: /autoresearch stop <id> (`#3` / `autoresearch-3` also work)",
}],
},
]
}
fn hooks(&self) -> Vec<ExtensionHook> {
vec![
ExtensionHook::Dock,
ExtensionHook::Overlay,
ExtensionHook::UserPrompt,
ExtensionHook::AgentEvent,
ExtensionHook::ExtensionEvent,
ExtensionHook::AfterToolCall,
ExtensionHook::Suggestions,
]
}
fn on_agent_event(&self, event: &Value) {
if event.get("type").and_then(Value::as_str) == Some("agent_end")
&& event.get("reason").and_then(Value::as_str) == Some("aborted")
{
manager::abort_top_level();
}
}
fn dock_lines(&self) -> Vec<extensions::DockLine> {
let mut lines = manager::widget_lines();
lines.extend(workflow::task::dock_lines());
lines.extend(autoresearch::dock_lines());
lines
}
fn wants_redraw(&self) -> bool {
manager::background_live() > 0 || workflow::task::has_live() || autoresearch::has_live()
}
fn on_exec_ctx(&self, ctx: &ToolExecCtx) {
fleet::remember_ctx(ctx);
launch_pending_workflow_file(ctx);
}
fn on_user_prompt_with_context(
&self,
text: &str,
messages: &[AgentMessage],
busy: bool,
) -> Option<UserPromptAction> {
let cfg = manager::config();
if !cfg.agent_mentions.enabled() {
return None;
}
if cfg.agent_mentions == config::AgentMentions::Model
&& !busy
&& let Some(action) = mention_clone(text, messages)
{
return Some(action);
}
direct_mention(text, &cfg)
}
fn on_user_submit(&self, _text: &str, _busy: bool) {
manager::dismiss_finished();
}
fn overlay_view(&self, id: u64, cols: u16) -> Option<OverlayView> {
fleet::overlay_view(id, cols)
}
fn render_custom_message(&self, custom_type: &str, data: &Value) -> Option<Vec<RichSpan>> {
if custom_type == notify::CARD_TYPE {
return Some(notify::render_card(data));
}
if custom_type == workflow::card::WORKFLOW_CARD_TYPE {
return Some(workflow::card::render_card(data));
}
None
}
fn on_overlay_event(&self, id: u64, ev: &OverlayEvent) -> bool {
fleet::on_overlay_event(id, ev)
}
fn settings(&self) -> Vec<ExtensionSetting> {
config::panel_settings(&manager::config())
}
fn cli_flags(&self) -> Vec<CliFlagDef> {
vec![CliFlagDef {
name: "subagents-workflow-file",
description: "Run a workflow script from a file at session start",
takes_value: true,
}]
}
fn apply_cli_flag_value(&self, name: &str, value: &str) {
if name == "subagents-workflow-file" && !value.trim().is_empty() {
*pending_workflow_file()
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some(value.trim().to_string());
}
}
fn apply_setting(&self, key: &str, value: &str) -> Result<(), String> {
let mut cfg = manager::config();
config::apply_panel_choice(&mut cfg, key, value)?;
config::write_config(&config::config_path(), &cfg);
manager::reload_config();
if key == "workflows" {
session::invalidate();
}
Ok(())
}
fn on_session_start(&self, cwd: &str, _messages: &[AgentMessage], session: Option<&Session>) {
*session_cwd_slot().lock().unwrap_or_else(|e| e.into_inner()) = Some(cwd.to_string());
session::set_session_key(
session
.and_then(|s| s.get_session_file())
.and_then(|p| p.file_stem())
.and_then(|s| s.to_str())
.map(str::to_string),
);
session::invalidate();
session::announce_if_needed();
schedule::sync_session();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
let cwd = cwd.to_string();
handle.spawn(async move { worktree::prune(&cwd).await });
}
events::ready(); }
fn on_session_switched(&self, session_path: Option<&str>, _messages: &[AgentMessage]) {
autoresearch::cancel_all(); manager::reset_all();
workflow::task::reset_all();
fleet::reset();
session::set_session_key(session_path.and_then(session::key_from_path));
session::invalidate();
session::announce_if_needed();
schedule::reset(); schedule::sync_session();
if let Some(path) = session_path {
for (custom_type, payload) in notify::cards_from_session(path) {
extensions::request_ui(ExtensionUiRequest::CustomMessage {
label: EXT.to_string(),
custom_type,
data: payload,
});
}
}
}
fn on_enabled_changed(&self, enabled: bool) {
if enabled {
config::ensure_config_file();
manager::reload_config();
} else {
autoresearch::cancel_all();
manager::reset_all();
workflow::task::reset_all();
fleet::reset();
session::reset();
schedule::reset();
}
session::invalidate();
if enabled {
session::announce_if_needed();
events::ready(); }
}
fn on_registered(&self) {
register_slash_command(EXT, "agents", command_agents);
register_slash_command(EXT, "autoresearch", autoresearch::command);
session::invalidate();
if extensions::is_extension_active(EXT) {
session::announce_if_needed();
}
}
fn on_extension_event(&self, name: &str, payload: &Value) {
events::on_event(name, payload);
}
fn suggestions(&self, query: &str, line_prefix: &str) -> ExtensionSuggestions {
match suggestion_context(line_prefix) {
Some(SuggestionContext::AgentArg(kind)) => {
return ExtensionSuggestions {
items: agent_arg_suggestions(query, kind),
exclusive: true,
};
}
Some(SuggestionContext::TypeArg(kind)) => {
return ExtensionSuggestions {
items: type_arg_suggestions(query, kind),
exclusive: true,
};
}
None => {}
}
if !manager::config().agent_mentions.enabled() {
return ExtensionSuggestions::default();
}
let q = query.to_lowercase();
let matches = |handle: &str, type_name: &str| {
q.is_empty()
|| handle.to_lowercase().contains(&q)
|| type_name.to_lowercase().contains(&q)
};
let mut seen = HashSet::new();
let mut out: Vec<ExtensionSuggestion> = Vec::new();
for rec in manager::list() {
let Some(h) = rec.handle.as_deref() else {
continue;
};
if matches(h, &rec.agent_type) && seen.insert(h.to_string()) {
out.push(ExtensionSuggestion {
name: format!("@{h}"),
description: format!("{} · {}", rec.display_name, rec.status.as_str()),
insert: h.to_string(),
});
}
}
for t in agent_types::discover(&discovery_cwd()).enabled() {
if out.len() >= 20 {
break;
}
let h = mention::handle_base(&t.name);
if seen.contains(&h) {
continue;
}
if matches(&h, &t.name) && seen.insert(h.clone()) {
out.push(extensions::ExtensionSuggestion {
name: format!("@{h}"),
description: t.label().to_string(),
insert: h,
});
}
}
ExtensionSuggestions {
items: out,
exclusive: false,
}
}
fn after_tool_call_ext(
&self,
_name: &str,
_args: &Value,
result_text: &str,
_is_error: bool,
) -> Result<AfterToolCallOutcome, ToolError> {
Ok(AfterToolCallOutcome {
text: result_text.to_string(),
content: None,
details: None,
is_error: None,
usage: manager::drain_pooled_usage(),
terminate: None,
})
}
fn filter_extension_tools(&self, tools: Vec<ExtensionTool>) -> Vec<ExtensionTool> {
if session::verdict().withdrawn() {
return tools
.into_iter()
.filter(|t| t.name != workflow::tool::WORKFLOW_TOOL_NAME)
.collect();
}
tools
}
fn on_ui_choice(&self, id: u64, choice: Option<String>) -> Option<String> {
let kind = {
let mut slot = select_id_slot().lock().unwrap_or_else(|e| e.into_inner());
match *slot {
Some((pending, kind)) if pending == id => {
*slot = None;
kind
}
_ => return None,
}
};
let choice = choice?;
let key = choice_key(&choice);
match kind {
SelectKind::Menu => menu_choice(key),
SelectKind::Agents => {
if let Some(rec) = manager::record(key) {
util_notify_spans(notify::record_card(&rec, false), UiNotifyLevel::Info);
}
}
SelectKind::Types => open_type_actions(key), SelectKind::TypeActions => type_actions_choice(&choice),
}
None
}
fn execute_tool_async(
&self,
name: String,
args: Value,
ctx: ToolExecCtx,
) -> BoxFuture<'static, Result<ToolResult, ToolError>> {
fleet::remember_ctx(&ctx);
Box::pin(async move { dispatch_tool(&name, args, ctx).await })
}
}
async fn dispatch_tool(name: &str, args: Value, ctx: ToolExecCtx) -> Result<ToolResult, ToolError> {
match name {
"Agent" => agent_tool(args, ctx).await,
"SubagentWorkflow" => workflow::tool::execute(args, ctx).await,
"get_subagent_result" => result_tool(args, &ctx).await,
"steer_subagent" => steer_tool(args, &ctx),
other => Err(ToolError(format!("extension {EXT}: unknown tool: {other}"))),
}
}
fn schedule_param() -> Option<Value> {
if !manager::config().schedule {
return None;
}
Some(json!({
"type": "string",
"description": "Register this run as a scheduled job instead of starting it now. Use 6-field cron (\"0 0 9 * * 1\"), an interval (\"5m\"/\"1h\"), or a one-shot (\"+10m\"/ISO). Only when the user explicitly asked for scheduled/recurring/delayed execution."
}))
}
fn isolation_param() -> Option<Value> {
if !manager::config().worktree_isolation {
return None;
}
Some(json!({
"type": "string",
"enum": ["off", "worktree"],
"description": "Isolation mode. Default \"off\". \"off\" runs the agent in the current checkout, the same as omitting the field. \"worktree\" creates a temporary git worktree so the agent works on an isolated copy of the repo (a copy cannot see uncommitted or staged changes in the main checkout)."
}))
}
struct AgentArgs {
prompt: String,
description: String,
type_name: String,
name: Option<String>,
model: Option<String>,
thinking: Option<String>,
resume: Option<String>,
schedule: Option<String>,
isolation: Option<String>,
max_turns: Option<u32>,
background: Option<bool>,
inherit_context: Option<bool>,
isolated: Option<bool>,
}
impl AgentArgs {
fn parse(args: &Value) -> Result<Self, ToolError> {
let prompt = req_str(args, "prompt")?;
let description = req_str(args, "description")?;
let type_name = req_str(args, "subagent_type")?;
let max_turns = match args.get("max_turns").and_then(|v| v.as_i64()) {
None => None,
Some(v) if v < 0 => {
return Err(ToolError("max_turns must be >= 0".to_string()));
}
Some(0) => None,
Some(v) => Some(v as u32),
};
Ok(Self {
prompt,
description,
type_name,
name: opt_str(args, "name"),
model: opt_str(args, "model"),
thinking: opt_str(args, "thinking"),
resume: opt_str(args, "resume"),
schedule: opt_str(args, "schedule"),
isolation: opt_str(args, "isolation"),
max_turns,
background: args.get("run_in_background").and_then(|v| v.as_bool()),
inherit_context: args.get("inherit_context").and_then(|v| v.as_bool()),
isolated: args.get("isolated").and_then(|v| v.as_bool()),
})
}
}
fn resolve_agent_type(
roster: &Roster,
type_name: &str,
cfg: &SubagentConfig,
) -> Result<(AgentType, Option<String>), ToolError> {
match agent_types::resolve(&roster.types, type_name) {
Ok(ty) => Ok((ty.clone(), None)),
Err(ResolveError::Ambiguous(names)) => Err(ToolError(format!(
"agent type {type_name:?} is ambiguous: {} differ only by case; rename one of them",
names.join(", ")
))),
Err(reason) => {
let why = match reason {
ResolveError::Unknown => "does not exist",
_ => "is disabled",
};
if cfg.fallback_subagent == config::FallbackMode::None {
return Err(ToolError(format!(
"agent type {type_name:?} {why} (fallbackSubagent = none)"
)));
}
let fallback = agent_types::resolve(&roster.types, "general-purpose").map_err(|_| {
ToolError(format!(
"agent type {type_name:?} {why} and the fallback type general-purpose is unavailable"
))
})?;
Ok((
fallback.clone(),
Some(format!(
"agent type {type_name:?} {why}; using general-purpose instead"
)),
))
}
}
}
fn check_allowed_subagents(
roster: &Roster,
ctx: &ToolExecCtx,
ty: &AgentType,
) -> Option<ToolResult> {
let parent_id = manager::caller_of(ctx).agent_id?;
let rec = manager::record(&parent_id)?;
let owner = agent_types::find_any(&roster.types, &rec.agent_type)?;
let list = owner.allowed_subagents.clone()?;
if list.is_empty() {
return None;
}
let allowed: Vec<String> = list
.iter()
.map(|a| {
agent_types::resolve(&roster.types, a)
.map(|t| t.name.clone())
.unwrap_or_else(|_| a.clone())
})
.collect();
if allowed.iter().any(|a| a.eq_ignore_ascii_case(&ty.name)) {
return None;
}
Some(ToolResult::text(format!(
"agent type {:?} is not allowed for {} — allowed_subagents: {}",
ty.name,
rec.agent_type,
allowed.join(", ")
)))
}
fn try_schedule(a: &AgentArgs, cfg: &SubagentConfig) -> Option<ToolResult> {
let spec = a.schedule.as_deref()?;
if !cfg.schedule {
return Some(ToolResult::text(
"Scheduling is disabled in the subagent settings. Enable it via /agents → Settings → Schedule.".to_string(),
));
}
if a.resume.is_some() {
return Some(ToolResult::text(
"Cannot combine `schedule` with `resume` — schedules create fresh agents.".to_string(),
));
}
if a.inherit_context == Some(true) {
return Some(ToolResult::text(
"Cannot combine `schedule` with `inherit_context` — there is no parent conversation at fire time.".to_string(),
));
}
if a.background == Some(false) {
return Some(ToolResult::text(
"Cannot combine `schedule` with `run_in_background: false` — scheduled jobs always run in background.".to_string(),
));
}
Some(ToolResult::text(
match schedule::add(schedule::NewJob {
name: a.description.clone(),
description: a.description.clone(),
schedule: spec.to_string(),
subagent_type: a.type_name.clone(),
prompt: a.prompt.clone(),
model: a.model.clone(),
thinking: a.thinking.clone(),
max_turns: a.max_turns,
}) {
Ok(job) => format!(
"Scheduled {:?} (id: {}, type: {}). Next run: {}. Manage via /agents → Scheduled jobs.",
job.name,
job.id,
job.schedule_type,
rel_time(schedule::next_run(&job.id))
),
Err(e) => e,
},
))
}
fn build_spawn_request(
a: &AgentArgs,
ty: &AgentType,
cfg: &SubagentConfig,
caller: &Caller,
clone_marker: Option<&str>,
) -> SpawnRequest {
let mut req = SpawnRequest::from_type(ty, cfg, a.prompt.clone(), a.description.clone());
req.name = a.name.clone();
req.model_source = if ty.model.is_some() {
ModelSource::Frontmatter
} else if a.model.is_some() {
ModelSource::Caller
} else {
ModelSource::Inherited
};
req.isolation = ty.isolation.clone().or_else(|| a.isolation.clone());
req.model = ty.model.clone().or_else(|| a.model.clone());
req.thinking = ty.thinking.clone().or_else(|| a.thinking.clone());
req.max_turns = ty.max_turns.or(a.max_turns);
req.inherit_context = ty.inherit_context.or(a.inherit_context).unwrap_or(false);
req.isolated = ty.isolated.or(a.isolated).unwrap_or(false) || ty.extensions_none;
req.resume = a.resume.clone();
req.parent_agent_id = if clone_marker.is_some() {
None
} else {
caller.agent_id.clone()
};
req.depth = if clone_marker.is_some() {
0
} else {
caller.depth + 1
};
let default_background = if caller.agent_id.is_some() {
false
} else {
cfg.background_default
};
req.run_in_background = if clone_marker.is_some() {
true } else {
ty.run_in_background
.or(a.background)
.unwrap_or(default_background)
};
req.tools = match ty.tools.as_ref() {
None => None,
Some(list) => {
let (expanded, unknown) = agent_types::expand_ext_selectors(list);
for sel in unknown {
let key = format!("ext-selector|{sel}");
if manager::note_warning(&key) {
util_notify(
&format!(
"agent type {:?}: unknown tool selector {sel:?} was ignored",
ty.name
),
UiNotifyLevel::Warning,
);
}
}
Some(expanded)
}
};
req
}
async fn dispatch_and_render(
ctx: &ToolExecCtx,
ty: &AgentType,
req: SpawnRequest,
note: Option<String>,
) -> Result<ToolResult, ToolError> {
let dispatched = manager::dispatch(ctx, ty, req).await.map_err(ToolError)?;
let Some(rec) = manager::record(&dispatched.id) else {
return Err(ToolError(format!(
"sub-agent {} disappeared before it could be reported",
dispatched.id
)));
};
let mut body = String::new();
if let Some(note) = note {
body.push_str(&format!("note: {note}\n"));
}
body.push_str(&render_dispatch(&dispatched, &rec));
let mut result = ToolResult::text(body);
result.details = Some(record_details(&rec));
Ok(result)
}
async fn agent_tool(args: Value, ctx: ToolExecCtx) -> Result<ToolResult, ToolError> {
let a = AgentArgs::parse(&args)?;
let cfg = manager::config();
let roster = agent_types::discover_with(&ctx.cwd, discovery_opts(&cfg));
emit_warnings(&roster.warnings);
if !roster.errors.is_empty() {
return Err(ToolError(format!(
"strict agent files: {} unparseable agent definition(s): {}",
roster.errors.len(),
roster.errors.join("; ")
)));
}
let (ty, note) = resolve_agent_type(&roster, &a.type_name, &cfg)?;
if let Some(denied) = check_allowed_subagents(&roster, &ctx, &ty) {
return Ok(denied);
}
if let Some(scheduled) = try_schedule(&a, &cfg) {
return Ok(scheduled);
}
let caller = manager::caller_of(&ctx);
let clone_marker = caller
.agent_id
.clone()
.filter(|id| id.starts_with(CLONE_MARKER_PREFIX));
if clone_marker.is_none()
&& caller.agent_id.is_some()
&& !nested::nesting_allowed(caller.depth, cfg.max_subagent_depth)
{
return Err(ToolError(nested::depth_exceeded(caller.depth)));
}
let req = build_spawn_request(&a, &ty, &cfg, &caller, clone_marker.as_deref());
if let Some(marker) = &clone_marker
&& !clone_mark_spawned(marker)
{
return Ok(ToolResult::text(
"Already started an agent for this mention. Stop here.",
));
}
dispatch_and_render(&ctx, &ty, req, note).await
}
async fn result_tool(args: Value, ctx: &ToolExecCtx) -> Result<ToolResult, ToolError> {
let key = req_str(&args, "agent_id")?;
let wait = args.get("wait").and_then(|v| v.as_bool()).unwrap_or(false);
let verbose = args
.get("verbose")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let caller = manager::caller_of(ctx);
if caller.agent_id.is_some()
&& let Err(e) = manager::owned_record(&caller, &key)
{
return Ok(ToolResult::text(e));
}
let rec = if wait {
let abort = ctx.parent_abort.clone();
let wait_fut = manager::wait(&key);
let watch = async move {
while !abort.load(Ordering::Relaxed) {
tokio::time::sleep(Duration::from_millis(20)).await;
}
};
pin_mut!(wait_fut, watch);
match select(wait_fut, watch).await {
Either::Left((rec, _)) => rec,
Either::Right((_, _)) => manager::record(&key),
}
} else {
manager::record(&key)
};
let Some(rec) = rec else {
let known: Vec<String> = manager::list().iter().map(|r| r.id.clone()).collect();
let known = if known.is_empty() {
"(no agents in this session)".to_string()
} else {
known.join(", ")
};
return Ok(ToolResult::text(format!(
"unknown agent id: {key}\nknown agent ids: {known}"
)));
};
let mut result = ToolResult::text(render_record(&rec, verbose));
result.details = Some(record_details(&rec));
if rec.status.is_terminal() {
manager::consume_result(&rec.id);
}
Ok(result)
}
fn steer_tool(args: Value, ctx: &ToolExecCtx) -> Result<ToolResult, ToolError> {
let key = req_str(&args, "agent_id")?;
let message = req_str(&args, "message")?;
let caller = manager::caller_of(ctx);
if caller.agent_id.is_some()
&& let Err(e) = manager::owned_record(&caller, &key)
{
return Ok(ToolResult::text(e));
}
match manager::steer(&key, &message) {
Ok(()) => Ok(ToolResult::text(format!(
"steering message delivered to sub-agent {key}"
))),
Err(e) => Ok(ToolResult::text(e)),
}
}
fn render_dispatch(dispatched: &Dispatched, rec: &AgentRecord) -> String {
if dispatched.background {
if dispatched.queued {
return format!(
"subagent {} queued ({}). The background pool is full; it starts automatically. \
You will be notified when it finishes — do not poll.",
rec.id, rec.display_name
);
}
return format!(
"subagent {} launched ({}). You will be notified when it finishes — do not sleep or \
poll for it. Use get_subagent_result(agent_id=\"{}\", wait=true) only if you must block on it now.",
rec.id, rec.display_name, rec.id
);
}
render_record(rec, false)
}
fn render_record(rec: &AgentRecord, verbose: bool) -> String {
let mut out = rec.status_header();
if let Some(result) = rec
.result
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
out.push('\n');
out.push_str(result);
} else if !rec.status.is_terminal() {
out.push('\n');
out.push_str(&format!("(still {})", rec.status.as_str()));
}
if verbose {
out.push_str("\n\n--- transcript ---\n");
out.push_str(¬ify::verbose_transcript(rec));
}
out
}
fn record_details(rec: &AgentRecord) -> Value {
json!({
"id": rec.id,
"name": rec.name,
"type": rec.agent_type,
"displayName": rec.display_name,
"status": rec.status.as_str(),
"background": rec.background,
"turns": rec.turns,
"toolUses": rec.tool_uses,
"tokens": {
"input": rec.usage.input,
"output": rec.usage.output,
"cacheRead": rec.usage.cache_read,
"cacheWrite": rec.usage.cache_write,
"total": rec.usage.display_total(),
},
"durationMs": rec.duration_ms(),
"sessionPath": rec.session_path,
})
}
fn emit_warnings(warnings: &[DiscoveryWarning]) {
for w in warnings {
let key = format!(
"{}|{}",
w.source
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default(),
w.message
);
if manager::note_warning(&key) {
util_notify(&w.message, UiNotifyLevel::Warning);
}
}
}
fn util_notify(text: &str, level: UiNotifyLevel) {
notify_text(text, level);
}
fn util_notify_spans(spans: Vec<RichSpan>, level: UiNotifyLevel) {
notify(spans, level);
}
fn pending_workflow_file() -> &'static Mutex<Option<String>> {
static SLOT: OnceLock<Mutex<Option<String>>> = OnceLock::new();
SLOT.get_or_init(|| Mutex::new(None))
}
fn launch_pending_workflow_file(ctx: &ToolExecCtx) {
if session::verdict().withdrawn() {
pending_workflow_file().lock().unwrap().take();
return;
}
let Ok(handle) = tokio::runtime::Handle::try_current() else {
return;
};
let pending = pending_workflow_file().lock().unwrap().as_ref().cloned();
let Some(path) = pending else {
return;
};
let ctx = ctx.clone();
handle.spawn(async move {
match workflow::tool::execute_file(&ctx, &path).await {
Ok(_) => {
pending_workflow_file().lock().unwrap().take(); util_notify(
&format!("workflow file started in the background ({})", path),
UiNotifyLevel::Info,
);
}
Err(e) => {
pending_workflow_file().lock().unwrap().take();
util_notify(
&format!("--subagents-workflow-file: {}", e.0),
UiNotifyLevel::Warning,
);
}
}
});
}
fn command_agents(_app: &mut App, raw: &str) -> bool {
let arg = command_arg(raw).trim();
let (sub, rest) = match arg.split_once(char::is_whitespace) {
Some((a, b)) => (a, b.trim()),
None => (arg, ""),
};
match sub {
"types" => show_types(),
"result" => {
if rest.is_empty() {
util_notify("usage: /agents result <agent-id>", UiNotifyLevel::Warning);
} else {
let key = rest.strip_prefix('@').unwrap_or(rest);
match manager::record(key) {
Some(rec) => {
util_notify_spans(notify::record_card(&rec, false), UiNotifyLevel::Info)
}
None => {
util_notify(&format!("unknown agent id: {key}"), UiNotifyLevel::Warning)
}
}
}
}
"stop" => {
if rest.is_empty() {
util_notify(
"usage: /agents stop <agent-id | workflow-run-id>",
UiNotifyLevel::Warning,
);
} else {
let key = rest.strip_prefix('@').unwrap_or(rest);
if key.starts_with("wf_") {
match workflow::task::stop(key) {
Ok(()) => {
util_notify(&format!("stopping workflow {key}"), UiNotifyLevel::Info)
}
Err(e) => util_notify(&e, UiNotifyLevel::Warning),
}
} else {
match manager::stop(key) {
Ok(()) => {
util_notify(&format!("stopping sub-agent {key}"), UiNotifyLevel::Info)
}
Err(e) => util_notify(&e, UiNotifyLevel::Warning),
}
}
}
}
"schedules" | "schedule" => open_schedules_or_warn(),
"workflows" => open_workflows_or_warn(),
"settings" => open_settings(),
"eject" => {
let (name, rest) = match rest.split_once(char::is_whitespace) {
Some((a, b)) => (a, b.trim()),
None => (rest, ""),
};
if name.is_empty() {
util_notify(
"usage: /agents eject <type> [project]",
UiNotifyLevel::Warning,
);
} else {
match eject_type(name, rest == "project") {
Ok(msg) => util_notify(&msg, UiNotifyLevel::Info),
Err(e) => util_notify(&e, UiNotifyLevel::Warning),
}
}
}
"enable" | "disable" => {
if rest.is_empty() {
util_notify(
&format!("usage: /agents {sub} <type>"),
UiNotifyLevel::Warning,
);
} else {
match toggle_type(rest, sub == "enable") {
Ok(msg) => util_notify(&msg, UiNotifyLevel::Info),
Err(e) => util_notify(&e, UiNotifyLevel::Warning),
}
}
}
"delete" => {
if rest.is_empty() {
util_notify("usage: /agents delete <type>", UiNotifyLevel::Warning);
} else {
match delete_type(rest) {
Ok(msg) => util_notify(&msg, UiNotifyLevel::Info),
Err(e) => util_notify(&e, UiNotifyLevel::Warning),
}
}
}
"reset" => {
if rest.is_empty() {
util_notify("usage: /agents reset <type>", UiNotifyLevel::Warning);
} else {
match reset_type(rest) {
Ok(msg) => util_notify(&msg, UiNotifyLevel::Info),
Err(e) => util_notify(&e, UiNotifyLevel::Warning),
}
}
}
"edit" => {
if rest.is_empty() {
util_notify("usage: /agents edit <type>", UiNotifyLevel::Warning);
} else if let Err(e) = edit_type(rest) {
util_notify(&e, UiNotifyLevel::Warning);
}
}
"" => open_menu(),
other => util_notify(
&format!("unknown subcommand: {other} (try /agents types)"),
UiNotifyLevel::Warning,
),
}
false
}
fn open_select(kind: SelectKind, title: &str, options: Vec<String>) {
let id = extensions::next_ui_id();
*select_id_slot().lock().unwrap_or_else(|e| e.into_inner()) = Some((id, kind));
extensions::request_ui(ExtensionUiRequest::Select {
id,
title: title.to_string(),
options: options.into_iter().map(SelectOption::new).collect(),
});
}
fn clone_registry() -> &'static Mutex<HashSet<String>> {
static S: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
S.get_or_init(|| Mutex::new(HashSet::new()))
}
fn next_clone_marker() -> String {
static N: AtomicU64 = AtomicU64::new(0);
format!("{CLONE_MARKER_PREFIX}{}", N.fetch_add(1, Ordering::Relaxed))
}
fn clone_mark_spawned(marker: &str) -> bool {
clone_registry()
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(marker.to_string())
}
fn agent_mention_reminder(type_name: &str) -> String {
format!(
"<system-reminder>\nThe user has expressed a desire to invoke the agent \"{type_name}\". Please invoke the agent appropriately, passing in the required context to it. \n</system-reminder>"
)
}
fn mention_clone(text: &str, messages: &[AgentMessage]) -> Option<UserPromptAction> {
let mention = mention::parse_mention(text)?;
if mention::is_reserved_handle(&mention.handle) {
return None; }
let cfg = manager::config();
let roster = agent_types::discover_with(&discovery_cwd(), discovery_opts(&cfg));
let names: Vec<String> = roster.types.iter().map(|t| t.name.clone()).collect();
let alias = mention::strip_agent_prefix(&mention.handle);
let type_name = mention::resolve_handle_to_type(&mention.handle, &names).or_else(|| {
alias
.as_deref()
.and_then(|a| mention::resolve_handle_to_type(a, &names))
})?;
let ty = agent_types::resolve(&roster.types, &type_name)
.ok()?
.clone();
let Some((ctx, rt)) = fleet::cached_ctx() else {
util_notify(
&format!("Could not start @{type_name}: no execution context yet"),
UiNotifyLevel::Warning,
);
return None;
};
let label = format!("@{}", mention::handle_base(&type_name));
util_notify(&format!("Prompting {label}…"), UiNotifyLevel::Info);
let message = mention.message.clone();
let messages = messages.to_vec();
rt.spawn(async move {
let spawned = run_mention_clone(&ctx, &ty, message.clone(), messages).await;
if !spawned {
let describe = mention::describe_mention(&message);
if fleet::spawn_background_agent(ty.clone(), message, describe, None) {
util_notify(
&format!("Started @{} directly", mention::handle_base(&ty.name)),
UiNotifyLevel::Warning,
);
}
}
});
Some(UserPromptAction::Handled)
}
async fn run_mention_clone(
ctx: &ToolExecCtx,
ty: &AgentType,
message: String,
messages: Vec<AgentMessage>,
) -> bool {
let marker = next_clone_marker();
let mut spec = extensions::SubAgentSpec::new(format!("mention-clone-{}", ty.name));
spec.tools = Some(vec!["Agent".to_string()]);
spec.context_messages = Some(messages);
spec.persist_session = false;
spec.agent_id = Some(marker.clone());
spec.depth = 0;
let mut runner = match (ctx.make_sub_agent)(spec) {
Ok(r) => r,
Err(e) => {
util_notify(
&format!("Could not prompt {}: {e}", ty.name),
UiNotifyLevel::Warning,
);
return false;
}
};
let prompt = format!("{}\n\n{}", message, agent_mention_reminder(&ty.name));
_ = runner.run(prompt).await;
clone_registry()
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&marker)
}
fn direct_mention(text: &str, cfg: &SubagentConfig) -> Option<UserPromptAction> {
let mention = mention::parse_mention(text)?;
if mention::is_reserved_handle(&mention.handle) {
return Some(UserPromptAction::Rewrite(mention.message));
}
let alias = mention::strip_agent_prefix(&mention.handle);
let resolved = manager::resolve_mention(&mention.handle)
.or_else(|| alias.as_deref().and_then(manager::resolve_mention));
if let Some(rec) = resolved {
let target = format!(
"@{}",
rec.alias
.as_deref()
.or(rec.handle.as_deref())
.unwrap_or(&mention.handle)
);
if !rec.status.is_terminal() {
if let Err(e) = manager::steer(&rec.id, &mention.message) {
util_notify(
&format!("Could not message {target}: {e}"),
UiNotifyLevel::Warning,
);
} else {
util_notify(&format!("Sent to {target}"), UiNotifyLevel::Info);
}
return Some(UserPromptAction::Handled);
}
if rec.session_path.is_some() {
let roster = agent_types::discover_with(&discovery_cwd(), discovery_opts(cfg));
match agent_types::resolve(&roster.types, &rec.agent_type) {
Ok(ty) => {
if fleet::spawn_background_agent(
ty.clone(),
mention.message.clone(),
format!("resume {}", rec.id),
Some(rec.id.clone()),
) {
util_notify(&format!("Resuming {target}"), UiNotifyLevel::Info);
} else {
util_notify(
&format!("Could not resume {target}: no execution context yet"),
UiNotifyLevel::Warning,
);
}
}
Err(_) => util_notify(
&format!(
"Could not resume {target}: the {} agent is no longer available",
rec.agent_type
),
UiNotifyLevel::Warning,
),
}
return Some(UserPromptAction::Handled);
}
}
let roster = agent_types::discover_with(&discovery_cwd(), discovery_opts(cfg));
let names: Vec<String> = roster.types.iter().map(|t| t.name.clone()).collect();
let type_name = mention::resolve_handle_to_type(&mention.handle, &names).or_else(|| {
alias
.as_deref()
.and_then(|a| mention::resolve_handle_to_type(a, &names))
});
let Some(type_name) = type_name else {
return None; };
let Ok(ty) = agent_types::resolve(&roster.types, &type_name) else {
return None;
};
let handle = mention::handle_base(&type_name);
if fleet::spawn_background_agent(
ty.clone(),
mention.message.clone(),
mention::describe_mention(&mention.message),
None,
) {
util_notify(&format!("Started @{handle}"), UiNotifyLevel::Info);
} else {
util_notify(
&format!("Could not start @{handle}: no execution context yet"),
UiNotifyLevel::Warning,
);
}
Some(UserPromptAction::Handled)
}
fn open_menu() {
let recs = manager::list();
let active = recs.iter().filter(|r| !r.status.is_terminal()).count();
let recent = recs.len().saturating_sub(active);
let types = agent_types::discover(&discovery_cwd()).enabled().count();
let cfg = manager::config();
let mut options = Vec::new();
if cfg.fleet_view {
options.push(format!(
"view Fleet view ({active} active, {recent} recent)"
));
}
options.push(format!(
"list Sub-agents ({active} active, {recent} recent)"
));
options.push(format!("types Agent types ({types} available)"));
if cfg.schedule {
options.push(format!(
"schedules Scheduled jobs ({} total)",
schedule::list().len()
));
}
if !session::verdict().withdrawn() {
options.push(format!(
"workflows Workflows ({} runs)",
workflow::task::list().len()
));
}
options.push("create Create new agent".to_string());
options.push("settings Settings".to_string());
open_select(SelectKind::Menu, "Sub-agents", options);
}
fn menu_choice(token: &str) {
match token {
"view" => fleet::open_fleet(),
"list" => show_agents(),
"types" => show_types(),
"schedules" => open_schedules_or_warn(),
"workflows" => open_workflows_or_warn(),
"create" => fleet::open_wizard(),
"settings" => open_settings(),
_ => {}
}
}
fn open_schedules_or_warn() {
if !manager::config().schedule {
util_notify(
"scheduling is off (`schedule: off` in the subagent extension config)",
UiNotifyLevel::Warning,
);
} else if !schedule::is_active() {
util_notify(
"the scheduler is not active in this session yet (send a message first)",
UiNotifyLevel::Warning,
);
} else {
fleet::open_schedules();
}
}
fn open_workflows_or_warn() {
if session::verdict().withdrawn() {
let msg = match session::verdict() {
Verdict::Disabled => {
"workflows are off (`workflows: off` in the subagent extension config)"
}
_ => {
"another extension provides a workflow tool, so this extension's workflows are stood down (`workflows: auto`; set `on` to keep both)"
}
};
util_notify(msg, UiNotifyLevel::Warning);
} else {
fleet::open_workflows();
}
}
fn open_settings() {
extensions::request_ui(ExtensionUiRequest::ShowSettings {
ext: EXT.to_string(),
});
}
fn show_agents() {
let recs = manager::list();
if recs.is_empty() {
util_notify("no sub-agents in this session yet", UiNotifyLevel::Info);
return;
}
let options: Vec<String> = recs
.iter()
.map(|r| format!("{} {}", r.id, r.list_line()))
.collect();
open_select(SelectKind::Agents, "Sub-agents", options);
}
fn show_types() {
let cfg = manager::config();
let roster = agent_types::discover_with(&discovery_cwd(), discovery_opts(&cfg));
emit_warnings(&roster.warnings);
for err in &roster.errors {
util_notify(
&format!("strict agent files: {err}"),
UiNotifyLevel::Warning,
);
}
if roster.types.is_empty() {
util_notify("(no agent types available)", UiNotifyLevel::Info);
return;
}
let options: Vec<String> = roster
.types
.iter()
.map(|ty| {
let mark = if ty.enabled { "" } else { " (disabled)" };
format!(
"{} — {} [{}] · {}{}",
ty.name,
ty.description,
ty.source.as_str(),
ty.tools_label(),
mark
)
})
.collect();
open_select(SelectKind::Types, "Agent types", options);
}
fn discovery_opts(cfg: &SubagentConfig) -> DiscoveryOptions {
DiscoveryOptions {
strict: cfg.strict_agent_files,
disable_defaults: cfg.disable_default_agents,
}
}
fn find_type_arg<'a>(types: &'a [AgentType], raw: &str) -> Option<&'a AgentType> {
if let Some(ty) = agent_types::find_any(types, raw) {
return Some(ty);
}
let handle = raw.trim().strip_prefix('@')?;
let names: Vec<String> = types.iter().map(|t| t.name.clone()).collect();
let alias = mention::strip_agent_prefix(handle);
let name = mention::resolve_handle_to_type(handle, &names).or_else(|| {
alias
.as_deref()
.and_then(|a| mention::resolve_handle_to_type(a, &names))
})?;
types.iter().find(|t| t.name == name)
}
fn eject_type(name: &str, project: bool) -> Result<String, String> {
let cfg = manager::config();
let cwd = discovery_cwd();
let roster = agent_types::discover_with(&cwd, discovery_opts(&cfg));
let lookup = find_type_arg(&roster.types, name)
.map(|t| t.name.as_str())
.unwrap_or_else(|| type_arg_label(name));
let ty = agent_types::resolve(&roster.types, lookup).map_err(|e| match e {
ResolveError::Unknown => format!("unknown agent type: {lookup}"),
ResolveError::Disabled => format!("agent type {lookup:?} is disabled"),
ResolveError::Ambiguous(_) => {
format!("agent type {lookup:?} is ambiguous (differs only by case)")
}
})?;
let path = agent_files::eject_path(&ty.name, &cwd, project);
if path.exists() {
return Err(format!(
"refusing to overwrite {}: delete it first or edit it directly",
path.display()
));
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
std::fs::write(&path, agent_files::serialize_agent_file(ty))
.map_err(|e| format!("{}: {e}", path.display()))?;
Ok(format!(
"ejected agent type {:?} to {} ({})",
ty.name,
path.display(),
if project { "project" } else { "global" }
))
}
fn toggle_type(name: &str, enable: bool) -> Result<String, String> {
let cfg = manager::config();
let cwd = discovery_cwd();
let roster = agent_types::discover_with(&cwd, discovery_opts(&cfg));
let ty = find_type_arg(&roster.types, name)
.ok_or_else(|| format!("unknown agent type: {}", type_arg_label(name)))?;
let path = agent_files::find_agent_file(ty, &cwd).ok_or_else(|| {
format!(
"agent type {:?} has no .md file yet; eject it first with /agents eject {}",
ty.name, ty.name
)
})?;
let content = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
if enable {
let (updated, changed) = agent_files::enable_in_content(&content);
if !changed {
return Err(format!(
"could not re-enable {:?} in {}: remove the enabled: line by hand",
ty.name,
path.display()
));
}
std::fs::write(&path, updated).map_err(|e| format!("{}: {e}", path.display()))?;
return Ok(format!(
"enabled agent type {:?} ({})",
ty.name,
path.display()
));
}
let (updated, outcome) = agent_files::disable_in_content(&content);
match outcome {
agent_files::DisableOutcome::Disabled => {
std::fs::write(&path, updated).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(format!(
"disabled agent type {:?} ({})",
ty.name,
path.display()
))
}
agent_files::DisableOutcome::AlreadyDisabled => {
Ok(format!("agent type {:?} is already disabled", ty.name))
}
agent_files::DisableOutcome::NoFrontmatter => Err(format!(
"{} has no YAML frontmatter block; add `enabled: false` by hand",
path.display()
)),
}
}
fn resolve_type_any(name: &str) -> Result<AgentType, String> {
let cfg = manager::config();
let cwd = discovery_cwd();
let roster = agent_types::discover_with(&cwd, discovery_opts(&cfg));
find_type_arg(&roster.types, name)
.cloned()
.ok_or_else(|| format!("unknown agent type: {}", type_arg_label(name)))
}
fn is_builtin_default(name: &str) -> bool {
agent_types::default_types()
.iter()
.any(|t| t.name.eq_ignore_ascii_case(name))
}
fn delete_type(name: &str) -> Result<String, String> {
let ty = resolve_type_any(name)?;
let cwd = discovery_cwd();
let path = agent_files::find_agent_file(&ty, &cwd).ok_or_else(|| {
format!(
"agent type {:?} has no .md file to delete (built-in defaults live in the binary)",
ty.name
)
})?;
std::fs::remove_file(&path).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(format!("Deleted {}", path.display()))
}
fn reset_type(name: &str) -> Result<String, String> {
let ty = resolve_type_any(name)?;
if !is_builtin_default(&ty.name) {
return Err(format!(
"{:?} is not a built-in default — nothing to reset (use /agents delete to remove it)",
ty.name
));
}
let cwd = discovery_cwd();
let path = agent_files::find_agent_file(&ty, &cwd).ok_or_else(|| {
format!(
"{:?} has no override file; the built-in default is already in effect",
ty.name
)
})?;
std::fs::remove_file(&path).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(format!(
"Restored built-in default {:?} (deleted {})",
ty.name,
path.display()
))
}
fn edit_type(name: &str) -> Result<(), String> {
let ty = resolve_type_any(name)?;
let cwd = discovery_cwd();
let path = agent_files::find_agent_file(&ty, &cwd).ok_or_else(|| {
format!(
"agent type {:?} has no .md file to edit; eject it first with /agents eject {}",
ty.name, ty.name
)
})?;
let content = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
fleet::open_type_editor(&ty.name, path, content);
Ok(())
}
fn open_type_actions(name: &str) {
let ty = match resolve_type_any(name) {
Ok(t) => t,
Err(e) => {
util_notify(&e, UiNotifyLevel::Warning);
return;
}
};
let cwd = discovery_cwd();
let file = agent_files::find_agent_file(&ty, &cwd);
let is_default = is_builtin_default(&ty.name);
let mut options: Vec<String> = Vec::new();
if !ty.enabled && file.is_some() {
options.push(format!("enable {}", ty.name));
}
if ty.enabled && file.is_some() {
options.push(format!("disable {}", ty.name));
}
if file.is_none() && is_default {
options.push(format!("eject {}", ty.name));
}
if file.is_some() {
options.push(format!("edit {}", ty.name));
}
if file.is_some() && !is_default {
options.push(format!("delete {}", ty.name));
}
if file.is_some() && is_default {
options.push(format!("reset {}", ty.name));
}
options.push("back".to_string());
open_select(SelectKind::TypeActions, &format!("Agent: {name}"), options);
}
fn type_actions_choice(choice: &str) {
let mut parts = choice.splitn(2, ' ');
let action = parts.next().unwrap_or("");
let name = parts.next().unwrap_or("").trim();
if action == "back" {
show_types();
return;
}
match type_action(action, name) {
Ok(Some(msg)) => util_notify(&msg, UiNotifyLevel::Info),
Ok(None) => {}
Err(e) => util_notify(&e, UiNotifyLevel::Warning),
}
}
fn type_action(action: &str, name: &str) -> Result<Option<String>, String> {
if name.is_empty() {
return Ok(None);
}
match action {
"enable" => toggle_type(name, true).map(Some),
"disable" => toggle_type(name, false).map(Some),
"eject" => eject_type(name, false).map(Some),
"edit" => edit_type(name).map(|_| None),
"delete" => delete_type(name).map(Some),
"reset" => reset_type(name).map(Some),
_ => Ok(None),
}
}
#[cfg(test)]
pub(crate) use manager::TestLock;
#[cfg(test)]
pub(crate) fn test_lock() -> manager::TestLock {
manager::test_lock()
}
#[cfg(test)]
pub(crate) fn record_tree_for_test() -> Vec<(String, Option<String>, u32)> {
manager::list()
.iter()
.map(|r| (r.id.clone(), r.parent_agent_id.clone(), r.depth))
.collect()
}
#[cfg(test)]
pub(crate) fn record_for_test(id: &str) -> Option<(String, Option<String>)> {
manager::record(id).map(|r| (r.agent_type.clone(), r.result.clone()))
}
#[cfg(test)]
pub(crate) fn agent_session_path(id: &str) -> Option<String> {
manager::record(id).and_then(|rec| rec.session_path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn required_args_are_enforced() {
assert!(req_str(&json!({}), "prompt").is_err());
assert!(req_str(&json!({"prompt": " "}), "prompt").is_err());
assert_eq!(req_str(&json!({"prompt": " x "}), "prompt").unwrap(), "x");
assert_eq!(opt_str(&json!({"model": ""}), "model"), None);
}
#[test]
fn unknown_tool_is_rejected() {
let rt = tokio::runtime::Runtime::new().unwrap();
let ctx = ToolExecCtx {
execute_tool: crate::core::extensions::unavailable_tool_exec(),
parent_tool_call_id: None,
nested_calls: Default::default(),
session_branch_entries: Default::default(),
script_tools: Default::default(),
cwd: ".".to_string(),
make_sub_agent: Arc::new(|_| Err("unused".to_string())),
parent_abort: Arc::new(std::sync::atomic::AtomicBool::new(false)),
agent_id: None,
depth: 0,
parent_model: None,
script_call: false,
};
let err = rt
.block_on(dispatch_tool("nope", json!({}), ctx))
.unwrap_err();
assert!(err.0.contains("unknown tool"));
}
#[test]
fn missing_prompt_and_type_are_tool_errors() {
let rt = tokio::runtime::Runtime::new().unwrap();
let make = || ToolExecCtx {
execute_tool: crate::core::extensions::unavailable_tool_exec(),
parent_tool_call_id: None,
nested_calls: Default::default(),
session_branch_entries: Default::default(),
script_tools: Default::default(),
cwd: ".".to_string(),
make_sub_agent: Arc::new(|_| Err("unused".to_string())),
parent_abort: Arc::new(std::sync::atomic::AtomicBool::new(false)),
agent_id: None,
depth: 0,
parent_model: None,
script_call: false,
};
let err = rt
.block_on(dispatch_tool("Agent", json!({"description": "d"}), make()))
.unwrap_err();
assert!(err.0.contains("prompt"), "{}", err.0);
let err = rt
.block_on(dispatch_tool(
"Agent",
json!({"prompt": "p", "description": "d"}),
make(),
))
.unwrap_err();
assert!(err.0.contains("subagent_type"), "{}", err.0);
}
#[test]
fn negative_max_turns_is_rejected() {
let rt = tokio::runtime::Runtime::new().unwrap();
let ctx = ToolExecCtx {
execute_tool: crate::core::extensions::unavailable_tool_exec(),
parent_tool_call_id: None,
nested_calls: Default::default(),
session_branch_entries: Default::default(),
script_tools: Default::default(),
cwd: ".".to_string(),
make_sub_agent: Arc::new(|_| Err("unused".to_string())),
parent_abort: Arc::new(std::sync::atomic::AtomicBool::new(false)),
agent_id: None,
depth: 0,
parent_model: None,
script_call: false,
};
let err = rt
.block_on(dispatch_tool(
"Agent",
json!({"prompt": "p", "description": "d", "subagent_type": "Explore", "max_turns": -1}),
ctx,
))
.unwrap_err();
assert!(err.0.contains("max_turns"), "{}", err.0);
}
#[test]
fn unknown_result_id_returns_plain_text_not_error() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
manager::reset_all();
let rt = tokio::runtime::Runtime::new().unwrap();
let out = rt
.block_on(dispatch_tool(
"get_subagent_result",
json!({"agent_id": "deadbeef"}),
ToolExecCtx {
execute_tool: crate::core::extensions::unavailable_tool_exec(),
parent_tool_call_id: None,
nested_calls: Default::default(),
session_branch_entries: Default::default(),
script_tools: Default::default(),
cwd: ".".to_string(),
make_sub_agent: Arc::new(|_| Err("unused".to_string())),
parent_abort: Arc::new(std::sync::atomic::AtomicBool::new(false)),
agent_id: None,
depth: 0,
parent_model: None,
script_call: false,
},
))
.unwrap();
assert!(out.text.contains("unknown agent id"), "{}", out.text);
}
#[test]
fn steer_unknown_target_returns_plain_text() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
manager::reset_all();
let out = rt().block_on(async {
dispatch_tool(
"steer_subagent",
json!({"agent_id": "deadbeef", "message": "hi"}),
ToolExecCtx {
execute_tool: crate::core::extensions::unavailable_tool_exec(),
parent_tool_call_id: None,
nested_calls: Default::default(),
session_branch_entries: Default::default(),
script_tools: Default::default(),
cwd: ".".to_string(),
make_sub_agent: Arc::new(|_| Err("unused".to_string())),
parent_abort: Arc::new(std::sync::atomic::AtomicBool::new(false)),
agent_id: None,
depth: 0,
parent_model: None,
script_call: false,
},
)
.await
.unwrap()
});
assert!(out.text.contains("unknown agent id"), "{}", out.text);
}
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Runtime::new().unwrap()
}
struct HoldRunner {
inbox: Arc<Mutex<Vec<String>>>,
abort: Arc<std::sync::atomic::AtomicBool>,
}
impl crate::core::extensions::SubAgentRunner for HoldRunner {
fn controls(&self) -> crate::core::extensions::SubAgentControls {
let inbox = self.inbox.clone();
crate::core::extensions::SubAgentControls {
steer: Arc::new(move |t: String| inbox.lock().unwrap().push(t)),
abort: self.abort.clone(),
session_path: None,
}
}
fn run(&mut self, _prompt: String) -> BoxFuture<'_, std::result::Result<String, String>> {
let abort = self.abort.clone();
Box::pin(async move {
while !abort.load(std::sync::atomic::Ordering::Relaxed) {
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
Err("Operation aborted".to_string())
})
}
}
struct EchoRunner;
impl crate::core::extensions::SubAgentRunner for EchoRunner {
fn controls(&self) -> crate::core::extensions::SubAgentControls {
crate::core::extensions::SubAgentControls {
steer: Arc::new(|_t: String| {}),
abort: Arc::new(std::sync::atomic::AtomicBool::new(false)),
session_path: None,
}
}
fn run(&mut self, prompt: String) -> BoxFuture<'_, std::result::Result<String, String>> {
Box::pin(async move { Ok(format!("R:{prompt}")) })
}
}
fn echo_ctx(cwd: &str) -> ToolExecCtx {
ToolExecCtx {
execute_tool: crate::core::extensions::unavailable_tool_exec(),
parent_tool_call_id: None,
nested_calls: Default::default(),
session_branch_entries: Default::default(),
script_tools: Default::default(),
cwd: cwd.to_string(),
make_sub_agent: Arc::new(|_spec: crate::core::extensions::SubAgentSpec| {
Ok(Box::new(EchoRunner) as Box<dyn crate::core::extensions::SubAgentRunner>)
}),
parent_abort: Arc::new(std::sync::atomic::AtomicBool::new(false)),
agent_id: None,
depth: 0,
parent_model: None,
script_call: false,
}
}
struct FailRunner;
impl crate::core::extensions::SubAgentRunner for FailRunner {
fn controls(&self) -> crate::core::extensions::SubAgentControls {
crate::core::extensions::SubAgentControls {
steer: Arc::new(|_t: String| {}),
abort: Arc::new(std::sync::atomic::AtomicBool::new(false)),
session_path: None,
}
}
fn run(&mut self, _prompt: String) -> BoxFuture<'_, std::result::Result<String, String>> {
Box::pin(async move { Err("provider exploded".to_string()) })
}
}
fn fail_ctx(cwd: &str) -> ToolExecCtx {
ToolExecCtx {
execute_tool: crate::core::extensions::unavailable_tool_exec(),
parent_tool_call_id: None,
nested_calls: Default::default(),
session_branch_entries: Default::default(),
script_tools: Default::default(),
cwd: cwd.to_string(),
make_sub_agent: Arc::new(|_spec: crate::core::extensions::SubAgentSpec| {
Ok(Box::new(FailRunner) as Box<dyn crate::core::extensions::SubAgentRunner>)
}),
parent_abort: Arc::new(std::sync::atomic::AtomicBool::new(false)),
agent_id: None,
depth: 0,
parent_model: None,
script_call: false,
}
}
fn await_run(run_id: &str) -> workflow::task::WorkflowRun {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
let run = workflow::task::find(run_id).expect("run registered");
if run.status.is_terminal() {
return run;
}
assert!(
std::time::Instant::now() < deadline,
"run 未在预期时间内结束"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
#[test]
fn workflow_tool_runs_a_saved_workflow_and_reports_its_path() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
workflow::task::reset_all();
while crate::core::extensions::take_pending_ui().is_some() {}
let dir = crate::core::settings_manager::agent_dir()
.join("extensions")
.join("workflows");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("greet.js"),
"export const meta = { name: 'greet', description: 'd' }\nconst a = await agent('hi')\nreturn { a }\n",
)
.unwrap();
let rt = rt();
let out = rt
.block_on(dispatch_tool(
"SubagentWorkflow",
json!({ "name": "greet" }),
echo_ctx("."),
))
.unwrap();
let run_id = out.details.as_ref().unwrap()["taskId"]
.as_str()
.unwrap()
.to_string();
assert!(out.text.contains("Script: "), "{}", out.text);
assert!(out.text.contains(".workflow.js"), "{}", out.text);
assert!(out.text.contains("To iterate"), "{}", out.text);
let run = await_run(&run_id);
assert_eq!(run.status, workflow::task::RunStatus::Completed);
assert_eq!(
run.value,
Some(json!({ "a": "R:hi" })),
"具名脚本被读到并跑通"
);
let saved = run.script_path.expect("script_path");
let text = std::fs::read_to_string(&saved).unwrap();
assert!(text.contains("name: 'greet'"), "{text}");
let _ = std::fs::remove_file(&saved);
while crate::core::extensions::take_pending_ui().is_some() {}
config::write_config(&config::config_path(), &config::SubagentConfig::default());
manager::reload_config();
workflow::task::reset_all();
manager::reset_all();
}
#[test]
fn workflow_agent_failure_returns_null() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
workflow::task::reset_all();
while crate::core::extensions::take_pending_ui().is_some() {}
let rt = rt();
let out = rt
.block_on(dispatch_tool(
"SubagentWorkflow",
json!({
"script": "export const meta = { name: 'f', description: 'd' }\nconst r = await agent('boom')\nreturn { r }"
}),
fail_ctx("."),
))
.unwrap();
let run_id = out.details.as_ref().unwrap()["taskId"]
.as_str()
.unwrap()
.to_string();
let run = await_run(&run_id);
assert_eq!(
run.status,
workflow::task::RunStatus::Completed,
"脚本本身跑完"
);
assert_eq!(
run.value,
Some(json!({ "r": null })),
"失败的 agent 应是 null 而不是空串"
);
while crate::core::extensions::take_pending_ui().is_some() {}
config::write_config(&config::config_path(), &config::SubagentConfig::default());
manager::reload_config();
workflow::task::reset_all();
manager::reset_all();
}
#[test]
fn workflow_tool_runs_a_nested_named_workflow() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
workflow::task::reset_all();
while crate::core::extensions::take_pending_ui().is_some() {}
let dir = tempfile::tempdir().unwrap();
let child_path = dir.path().join("child.js");
std::fs::write(
&child_path,
"export const meta = { name: 'child', description: 'd' }\nconst a = await agent('hi')\nreturn { a }\n",
)
.unwrap();
let parent = format!(
"export const meta = {{ name: 'parent', description: 'd' }}\nconst got = await workflow({{ scriptPath: {} }})\nreturn {{ nested: got }}\n",
serde_json::to_string(&child_path.display().to_string()).unwrap()
);
let rt = rt();
let out = rt
.block_on(dispatch_tool(
"SubagentWorkflow",
json!({ "script": parent }),
echo_ctx("."),
))
.unwrap();
let run_id = out.details.as_ref().unwrap()["taskId"]
.as_str()
.unwrap()
.to_string();
let run = await_run(&run_id);
assert_eq!(
run.status,
workflow::task::RunStatus::Completed,
"err: {:?}",
run.error
);
assert_eq!(
run.value,
Some(json!({ "nested": { "a": "R:hi" } })),
"子脚本的 return 值应原样交回外层"
);
while crate::core::extensions::take_pending_ui().is_some() {}
config::write_config(&config::config_path(), &config::SubagentConfig::default());
manager::reload_config();
workflow::task::reset_all();
manager::reset_all();
}
#[test]
fn agent_gate_failure_fails_the_agent_and_returns_null() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
workflow::task::reset_all();
while crate::core::extensions::take_pending_ui().is_some() {}
#[cfg(windows)]
let (pass_gate, fail_gate) = ("exit 0", "echo gate-broke 1>&2 & exit 3");
#[cfg(not(windows))]
let (pass_gate, fail_gate) = ("true", "echo gate-broke >&2; exit 3");
let script = format!(
"export const meta = {{ name: 'g', description: 'd' }}\n\
const ok = await agent('pass', {{ gate: '{pass_gate}' }})\n\
const bad = await agent('fail', {{ gate: '{fail_gate}' }})\n\
return {{ ok, bad }}"
);
let rt = rt();
let out = rt
.block_on(dispatch_tool(
"SubagentWorkflow",
json!({ "script": script }),
echo_ctx("."),
))
.unwrap();
let run_id = out.details.as_ref().unwrap()["taskId"]
.as_str()
.unwrap()
.to_string();
let run = await_run(&run_id);
assert_eq!(
run.status,
workflow::task::RunStatus::Completed,
"{:?}",
run.error
);
assert_eq!(
run.value,
Some(json!({ "ok": "R:pass", "bad": null })),
"gate 通过的不受影响;gate 失败的那个变成 null"
);
let log = run.log();
let failed = log
.iter()
.find(|e| e["type"] == "workflow_agent" && e["state"] == "error")
.expect("应有失败条目");
assert!(
failed["error"]
.as_str()
.unwrap_or_default()
.contains("gate-broke"),
"{failed}"
);
while crate::core::extensions::take_pending_ui().is_some() {}
workflow::task::reset_all();
manager::reset_all();
}
#[test]
fn workflow_tool_stands_down_when_another_extension_provides_one() {
struct ForeignWorkflow;
impl Extension for ForeignWorkflow {
fn name(&self) -> &str {
"foreign-workflow-test"
}
fn tools(&self) -> Vec<ExtensionTool> {
vec![ExtensionTool::simple(
"Workflow",
"another orchestrator",
json!({ "type": "object" }),
"run a workflow",
)]
}
}
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
session::reset();
let visible = |ext: &Subagent| {
ext.filter_extension_tools(ext.tools())
.iter()
.any(|t| t.name == workflow::tool::WORKFLOW_TOOL_NAME)
};
assert!(visible(&Subagent));
crate::core::extensions::register_extension(ForeignWorkflow);
session::invalidate();
assert!(!visible(&Subagent), "auto 遇冲突必须收起工作流工具");
assert_eq!(session::verdict(), session::Verdict::StandDown);
let mut cfg = manager::config();
cfg.workflows = config::WorkflowsMode::On;
config::write_config(&config::config_path(), &cfg);
manager::reload_config();
session::invalidate();
assert!(visible(&Subagent), "workflows: on 时不让位");
assert_eq!(session::verdict(), session::Verdict::Available);
cfg.workflows = config::WorkflowsMode::Off;
config::write_config(&config::config_path(), &cfg);
manager::reload_config();
session::invalidate();
assert!(!visible(&Subagent), "workflows: off 时必须收起");
assert_eq!(session::verdict(), session::Verdict::Disabled);
_ = crate::core::extensions::unregister_extension("foreign-workflow-test");
session::reset();
}
#[test]
fn resume_from_run_id_replays_the_unchanged_prefix() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
workflow::task::reset_all();
while crate::core::extensions::take_pending_ui().is_some() {}
session::reset();
session::set_session_key(Some("test-session".to_string()));
let cwd = std::env::temp_dir().to_string_lossy().to_string();
let script = |last: &str| {
format!(
"export const meta = {{ name: 'r', description: 'd' }}\n\
const a = await agent('one')\n\
const b = await agent('two')\n\
const c = await agent('{last}')\n\
return {{ a, b, c }}"
)
};
let rt = rt();
let first = rt
.block_on(dispatch_tool(
"SubagentWorkflow",
json!({ "script": script("three") }),
echo_ctx(&cwd),
))
.unwrap();
let first_id = first.details.as_ref().unwrap()["taskId"]
.as_str()
.unwrap()
.to_string();
let run = await_run(&first_id);
assert_eq!(
run.value,
Some(json!({ "a": "R:one", "b": "R:two", "c": "R:three" }))
);
let journal_path = workflow::tool::journal_path_for_test(&cwd, &first_id);
assert!(
journal_path.exists(),
"第一遍应留下 journal: {}",
journal_path.display()
);
let second = rt
.block_on(dispatch_tool(
"SubagentWorkflow",
json!({ "script": script("three-changed"), "resumeFromRunId": first_id }),
echo_ctx(&cwd),
))
.unwrap();
let second_id = second.details.as_ref().unwrap()["taskId"]
.as_str()
.unwrap()
.to_string();
assert_ne!(second_id, first_id, "恢复产生新的 run id");
let run = await_run(&second_id);
assert_eq!(
run.value,
Some(json!({ "a": "R:one", "b": "R:two", "c": "R:three-changed" })),
"前缀来自 journal,改动那个真跑"
);
let log = run.log();
let cached: Vec<&serde_json::Value> = log
.iter()
.filter(|e| e["type"] == "workflow_agent" && e["cached"] == true)
.collect();
assert_eq!(cached.len(), 2, "前两个 agent 应标 cached: {log:?}");
assert!(cached.iter().all(|e| e["durationMs"] == 0));
let live: Vec<&serde_json::Value> = log
.iter()
.filter(|e| {
e["type"] == "workflow_agent" && e["state"] == "done" && e["cached"] != true
})
.collect();
assert_eq!(live.len(), 1, "只有改动的那个真跑");
let env = workflow::notify::envelope(&run);
assert!(env.contains("2 of 3 agents replayed from"), "{env}");
let err = rt
.block_on(dispatch_tool(
"SubagentWorkflow",
json!({ "script": script("x"), "resumeFromRunId": "wf_nope" }),
echo_ctx(&cwd),
))
.unwrap_err();
assert!(err.0.contains("no workflow run"), "{}", err.0);
let _ = std::fs::remove_file(&journal_path);
while crate::core::extensions::take_pending_ui().is_some() {}
session::reset();
workflow::task::reset_all();
manager::reset_all();
}
#[test]
fn agent_resume_unknown_label_fails_that_agent_only() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
workflow::task::reset_all();
session::reset();
session::set_session_key(Some("test-session".to_string()));
while crate::core::extensions::take_pending_ui().is_some() {}
let script = "export const meta = { name: 'res', description: 'd' }\n\
const a = await agent('start')\n\
const b = await agent('more', { resume: 'never-ran' })\n\
return { a, b }";
let rt = rt();
let out = rt
.block_on(dispatch_tool(
"SubagentWorkflow",
json!({ "script": script }),
echo_ctx("."),
))
.unwrap();
let run_id = out.details.as_ref().unwrap()["taskId"]
.as_str()
.unwrap()
.to_string();
let run = await_run(&run_id);
let value = run.value.clone().unwrap();
assert_eq!(value["a"], "R:start");
assert_eq!(value["b"], serde_json::Value::Null);
assert!(
run.log().iter().any(|e| e["error"]
.as_str()
.is_some_and(|m| m.contains("Cannot resume \"never-ran\""))),
"错误要说清是哪个 label: {:?}",
run.log()
);
while crate::core::extensions::take_pending_ui().is_some() {}
session::reset();
workflow::task::reset_all();
manager::reset_all();
}
#[test]
fn cli_flag_workflow_file_launches_a_run() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
workflow::task::reset_all();
session::reset();
session::set_session_key(Some("cli-session".to_string()));
while crate::core::extensions::take_pending_ui().is_some() {}
pending_workflow_file().lock().unwrap().take();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cli-workflow.js");
std::fs::write(
&path,
"export const meta = { name: 'cli', description: 'd' }\nreturn { ok: await agent('hi') }\n",
)
.unwrap();
Subagent.apply_cli_flag_value("subagents-workflow-file", path.to_str().unwrap());
assert_eq!(
pending_workflow_file().lock().unwrap().as_deref(),
Some(path.to_str().unwrap())
);
let rt = rt();
rt.block_on(async {
Subagent.on_exec_ctx(&echo_ctx("."));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if let Some(run) = workflow::task::list().into_iter().next() {
assert_eq!(run.name, "cli");
break;
}
assert!(
std::time::Instant::now() < deadline,
"flag 指定的工作流没启动"
);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert!(pending_workflow_file().lock().unwrap().is_none());
});
workflow::task::reset_all();
while crate::core::extensions::take_pending_ui().is_some() {}
let mut cfg = manager::config();
cfg.workflows = config::WorkflowsMode::Off;
config::write_config(&config::config_path(), &cfg);
manager::reload_config();
session::invalidate();
Subagent.apply_cli_flag_value("subagents-workflow-file", path.to_str().unwrap());
rt.block_on(async { Subagent.on_exec_ctx(&echo_ctx(".")) });
assert!(workflow::task::list().is_empty(), "off 时不该启动");
assert!(
pending_workflow_file().lock().unwrap().is_none(),
"待跑项应被清掉"
);
cfg.workflows = config::WorkflowsMode::Auto;
config::write_config(&config::config_path(), &cfg);
manager::reload_config();
session::reset();
workflow::task::reset_all();
manager::reset_all();
}
#[test]
fn nested_calls_past_the_depth_limit_are_refused() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
let max = config::SubagentConfig::default().max_subagent_depth;
let mut ctx = echo_ctx(".");
ctx.agent_id = Some("deep".to_string());
ctx.depth = max;
let rt = rt();
let err = rt
.block_on(dispatch_tool(
"Agent",
json!({"prompt": "x", "description": "d", "subagent_type": "general-purpose"}),
ctx,
))
.unwrap_err();
assert!(err.0.contains("nesting limit reached"), "{}", err.0);
assert!(err.0.contains("maxSubagentDepth"), "{}", err.0);
let out = rt
.block_on(dispatch_tool(
"Agent",
json!({"prompt": "x", "description": "d", "subagent_type": "general-purpose", "run_in_background": false}),
echo_ctx("."),
))
.unwrap();
assert!(
out.text.contains("Agent ID") || out.text.contains("subagent"),
"{}",
out.text
);
while crate::core::extensions::take_pending_ui().is_some() {}
manager::reset_all();
}
#[test]
fn workflow_tool_returns_task_id_and_runs_in_the_background() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
workflow::task::reset_all();
while crate::core::extensions::take_pending_ui().is_some() {}
let cfg = config::SubagentConfig {
join: config::JoinMode::Async,
..Default::default()
};
config::write_config(&config::config_path(), &cfg);
manager::reload_config();
let rt = rt();
let out = rt
.block_on(async {
dispatch_tool(
"SubagentWorkflow",
json!({
"script": "export const meta = { name: 't', description: 'd' }\nconst a = await agent('one')\nconst b = await parallel([() => agent('two'), () => agent('three')])\nreturn { a, b }",
"args": {}
}),
echo_ctx("."),
)
.await
.unwrap()
});
assert!(
out.text.contains("started in the background"),
"{}",
out.text
);
let details = out.details.clone().unwrap();
let run_id = details["taskId"]
.as_str()
.expect("details.taskId")
.to_string();
assert!(run_id.starts_with("wf_"), "{run_id}");
assert_eq!(details["status"], "running");
assert!(out.text.contains(&run_id), "{}", out.text);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
let run = workflow::task::find(&run_id).expect("run 仍在注册表");
if run.status.is_terminal() {
assert_eq!(run.status, workflow::task::RunStatus::Completed);
let value = run.value.clone().unwrap();
assert_eq!(value["a"], "R:one");
assert_eq!(value["b"][0], "R:two");
assert_eq!(value["b"][1], "R:three");
let log = run.log();
let agents: Vec<&serde_json::Value> = log
.iter()
.filter(|e| e["type"] == "workflow_agent" && e["state"] == "done")
.collect();
assert_eq!(agents.len(), 3, "{log:?}");
assert!(agents.iter().all(|a| a["recordId"].is_string()));
break;
}
assert!(
std::time::Instant::now() < deadline,
"运行没有在预期时间内结束"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
let mut saw_continuation = false;
let mut saw_card = false;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while !(saw_continuation && saw_card) && std::time::Instant::now() < deadline {
while let Some(req) = crate::core::extensions::take_pending_ui() {
match req {
extensions::ExtensionUiRequest::Continuation { message } => {
let text = serde_json::to_string(&message).unwrap_or_default();
assert!(text.contains(&run_id), "{text}");
assert!(text.contains("workflow_result"), "{text}");
saw_continuation = true;
}
extensions::ExtensionUiRequest::CustomMessage { custom_type, .. }
if custom_type == workflow::card::WORKFLOW_CARD_TYPE =>
{
saw_card = true;
}
_ => {}
}
}
if !(saw_continuation && saw_card) {
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
assert!(saw_continuation, "完成后必须通知模型");
assert!(saw_card, "完成后必须投递卡片");
workflow::task::reset_all();
manager::reset_all();
}
#[test]
fn workflow_tool_auto_opens_the_dock() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
workflow::task::reset_all();
while crate::core::extensions::take_pending_ui().is_some() {}
rt().block_on(async {
dispatch_tool(
"SubagentWorkflow",
json!({
"script": "export const meta = { name: 't', description: 'd' }\nreturn 1"
}),
echo_ctx("."),
)
.await
.unwrap()
});
let mut saw_dock = false;
while let Some(req) = crate::core::extensions::take_pending_ui() {
if matches!(req, extensions::ExtensionUiRequest::ShowDock) {
saw_dock = true;
}
}
assert!(saw_dock, "工作流开跑应请求打开停靠面板");
workflow::task::reset_all();
manager::reset_all();
}
fn notice_texts() -> Vec<String> {
std::iter::from_fn(crate::core::extensions::take_pending_ui)
.filter_map(|r| match r {
extensions::ExtensionUiRequest::Notify { text, .. } => Some(text),
extensions::ExtensionUiRequest::NotifyRich { spans, .. } => {
Some(spans.iter().map(|s| s.text.as_str()).collect())
}
_ => None,
})
.collect()
}
#[test]
fn dock_shows_workflow_runs_and_requests_redraw() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
workflow::task::reset_all();
let ext = Subagent;
assert!(!ext.wants_redraw());
let id = workflow::task::new_id();
let _abort = workflow::task::register(&id, "demo-run", "d", None, None, None);
assert!(ext.wants_redraw(), "有运行中的 run 必须请求重绘");
let dock = ext.dock_lines();
let text: Vec<String> = dock
.iter()
.map(|l| l.iter().map(|sp| sp.text.as_str()).collect())
.collect();
assert!(text.iter().any(|l| l.contains("Workflows")), "{text:?}");
assert!(text.iter().any(|l| l.contains("demo-run")), "{text:?}");
assert!(text.iter().any(|l| l.contains(&id)), "{text:?}");
workflow::task::finish(&id, workflow::task::RunStatus::Completed, None, None);
assert!(!ext.wants_redraw());
let text: Vec<String> = ext
.dock_lines()
.iter()
.map(|l| l.iter().map(|sp| sp.text.as_str()).collect())
.collect();
assert!(!text.iter().any(|l| l.contains("demo-run")), "{text:?}");
workflow::task::reset_all();
}
#[test]
fn workflows_overlay_is_read_only_and_lists_runs() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
workflow::task::reset_all();
while crate::core::extensions::take_pending_ui().is_some() {}
let id = workflow::task::new_id();
let _abort = workflow::task::register(&id, "demo-run", "why", None, None, None);
workflow::task::set_agent_count(&id, 1);
workflow::task::find(&id)
.unwrap()
.progress
.lock()
.unwrap()
.push(json!({
"type": "workflow_agent", "index": 0, "label": "scan a", "state": "done",
"tokens": 10
}));
fleet::open_workflows();
let Some(extensions::ExtensionUiRequest::ShowOverlay { id: overlay_id }) =
crate::core::extensions::take_pending_ui()
else {
panic!("expected a ShowOverlay request");
};
let view = Subagent
.overlay_view(overlay_id, 100)
.expect("workflows view");
assert!(view.title.contains("Workflows"), "{}", view.title);
assert!(view.title.contains("1 running"), "{}", view.title);
let text: Vec<String> = view
.lines
.iter()
.map(|l| l.iter().map(|sp| sp.text.as_str()).collect())
.collect();
assert!(text.iter().any(|l| l.contains("demo-run")), "{text:?}");
assert!(text.iter().any(|l| l.contains("scan a")), "{text:?}");
assert!(
text.iter().any(|l| l.contains("▶")),
"选中行有标记: {text:?}"
);
let key = |k: extensions::OverlayKey| extensions::OverlayEvent::Key {
key: k,
input: None,
};
assert!(!Subagent.on_overlay_event(overlay_id, &key(extensions::OverlayKey::Char('s'))));
assert!(Subagent.on_overlay_event(overlay_id, &key(extensions::OverlayKey::Down)));
assert!(Subagent.on_overlay_event(overlay_id, &key(extensions::OverlayKey::Enter)));
let Some(extensions::ExtensionUiRequest::ShowOverlay { id: ins_id }) =
crate::core::extensions::take_pending_ui()
else {
panic!("expected the inspector overlay request");
};
let ins = Subagent.overlay_view(ins_id, 100).expect("inspector view");
assert!(ins.title.contains("demo-run"), "{}", ins.title);
assert!(matches!(ins.size, extensions::OverlaySize::Fullscreen));
assert!(
ins.lines
.iter()
.any(|l| l.iter().any(|sp| sp.text.contains('│'))),
"两栏"
);
assert!(Subagent.on_overlay_event(ins_id, &key(extensions::OverlayKey::Char('f'))));
for c in ['s', 'p', 'r', 'x'] {
assert!(Subagent.on_overlay_event(ins_id, &key(extensions::OverlayKey::Char(c))));
}
while crate::core::extensions::take_pending_ui().is_some() {}
workflow::task::reset_all();
}
#[test]
fn agents_stop_routes_workflow_run_ids() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
workflow::task::reset_all();
while crate::core::extensions::take_pending_ui().is_some() {}
let id = workflow::task::new_id();
let abort = workflow::task::register(&id, "demo-run", "d", None, None, None);
let mut app = App::new();
command_agents(&mut app, &format!("/agents stop {id}"));
assert!(abort.load(std::sync::atomic::Ordering::Relaxed));
let notices = notice_texts();
assert!(
notices.iter().any(|t| t.contains("stopping workflow")),
"{notices:?}"
);
command_agents(&mut app, "/agents stop wf_nope");
let notices = notice_texts();
assert!(
notices.iter().any(|t| t.contains("unknown workflow run")),
"{notices:?}"
);
workflow::task::reset_all();
}
#[test]
fn workflow_tool_rejects_unimplemented_params_and_bad_meta() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
manager::reset_all();
let rt = rt();
let err = rt
.block_on(dispatch_tool(
"SubagentWorkflow",
json!({ "name": "no-such-workflow" }),
echo_ctx("."),
))
.unwrap_err();
assert!(err.0.contains("no saved workflow named"), "{}", err.0);
let err = rt
.block_on(dispatch_tool(
"SubagentWorkflow",
json!({ "name": "../../etc/passwd" }),
echo_ctx("."),
))
.unwrap_err();
assert!(err.0.contains("not a usable workflow name"), "{}", err.0);
let err = rt
.block_on(dispatch_tool(
"SubagentWorkflow",
json!({ "resumeFromRunId": "wf_x" }),
echo_ctx("."),
))
.unwrap_err();
assert!(err.0.contains("no workflow run"), "{}", err.0);
let err = rt
.block_on(dispatch_tool(
"SubagentWorkflow",
json!({ "script": "const x = 1" }),
echo_ctx("."),
))
.unwrap_err();
assert!(err.0.contains("meta"), "{}", err.0);
}
fn hold_ctx(inbox: Arc<Mutex<Vec<String>>>) -> ToolExecCtx {
let make = Arc::new(move |_spec: crate::core::extensions::SubAgentSpec| {
Ok(Box::new(HoldRunner {
inbox: inbox.clone(),
abort: Arc::new(std::sync::atomic::AtomicBool::new(false)),
})
as Box<dyn crate::core::extensions::SubAgentRunner>)
}) as Arc<crate::core::extensions::MakeSubAgentFn>;
ToolExecCtx {
execute_tool: crate::core::extensions::unavailable_tool_exec(),
parent_tool_call_id: None,
nested_calls: Default::default(),
session_branch_entries: Default::default(),
script_tools: Default::default(),
cwd: ".".to_string(),
make_sub_agent: make,
parent_abort: Arc::new(std::sync::atomic::AtomicBool::new(false)),
agent_id: None,
depth: 0,
parent_model: None,
script_call: false,
}
}
#[test]
fn mention_main_rewrites_and_unknown_continues() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
configure_mentions(config::AgentMentions::Direct);
assert_eq!(
Subagent.on_user_prompt_with_context("@main hello there", &[], false),
Some(UserPromptAction::Rewrite("hello there".into()))
);
assert_eq!(
Subagent.on_user_prompt_with_context("@MAIN hi", &[], false),
Some(UserPromptAction::Rewrite("hi".into()))
);
assert_eq!(
Subagent.on_user_prompt_with_context("@explore", &[], false),
None
);
assert_eq!(
Subagent.on_user_prompt_with_context("@zzz-no-such-type hi", &[], false),
None
);
assert_eq!(
Subagent.on_user_prompt_with_context("just prose", &[], false),
None
);
}
fn configure_mentions(mode: config::AgentMentions) {
let cfg = config::SubagentConfig {
agent_mentions: mode,
..Default::default()
};
config::write_config(&config::config_path(), &cfg);
manager::reload_config();
}
#[test]
fn mention_to_live_agent_steers() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
configure_mentions(config::AgentMentions::Direct);
let inbox = Arc::new(Mutex::new(Vec::new()));
rt().block_on(async {
dispatch_tool(
"Agent",
json!({
"prompt": "go",
"description": "d",
"subagent_type": "Explore",
"run_in_background": true
}),
hold_ctx(inbox.clone()),
)
.await
.unwrap();
});
let recs = manager::list();
assert_eq!(recs.len(), 1, "应有一个存活代理");
assert_eq!(recs[0].handle.as_deref(), Some("explore"));
let action = Subagent.on_user_prompt_with_context("@Explore check again", &[], false);
assert_eq!(action, Some(UserPromptAction::Handled));
assert_eq!(
inbox.lock().unwrap().as_slice(),
&["check again".to_string()]
);
manager::reset_all();
}
#[test]
fn mention_to_type_spawns_background() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
configure_mentions(config::AgentMentions::Direct);
let inbox = Arc::new(Mutex::new(Vec::new()));
rt().block_on(async {
let ctx = hold_ctx(inbox.clone());
Subagent.on_exec_ctx(&ctx);
let action =
Subagent.on_user_prompt_with_context("@explore audit the rpc path", &[], false);
assert_eq!(action, Some(UserPromptAction::Handled));
for _ in 0..40 {
if !manager::list().is_empty() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
let recs = manager::list();
assert_eq!(recs.len(), 1, "mention 应后台起一个 Explore");
assert_eq!(recs[0].agent_type, "Explore");
assert_eq!(recs[0].handle.as_deref(), Some("explore"));
assert_eq!(recs[0].description, "audit the rpc path");
});
manager::reset_all();
}
struct CloneRunner {
marker: Option<String>,
}
impl crate::core::extensions::SubAgentRunner for CloneRunner {
fn controls(&self) -> crate::core::extensions::SubAgentControls {
crate::core::extensions::SubAgentControls {
steer: Arc::new(|_t: String| {}),
abort: Arc::new(std::sync::atomic::AtomicBool::new(false)),
session_path: None,
}
}
fn run(&mut self, _prompt: String) -> BoxFuture<'_, std::result::Result<String, String>> {
let marker = self.marker.clone();
Box::pin(async move {
let mut c = echo_ctx(".");
c.agent_id = marker;
c.depth = 0;
dispatch_tool(
"Agent",
json!({
"prompt": "inner prompt",
"description": "inner",
"subagent_type": "Plan",
"run_in_background": false
}),
c,
)
.await
.map(|r| r.text)
.map_err(|e| e.0)
})
}
}
#[test]
fn mention_clone_prompts_offscreen_and_spawns_top_level() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
configure_mentions(config::AgentMentions::Model);
let seen: Arc<Mutex<Vec<crate::core::extensions::SubAgentSpec>>> =
Arc::new(Mutex::new(Vec::new()));
let make = {
let seen = seen.clone();
Arc::new(move |spec: crate::core::extensions::SubAgentSpec| {
seen.lock().unwrap().push(spec.clone());
Ok(Box::new(CloneRunner {
marker: spec.agent_id.clone(),
})
as Box<dyn crate::core::extensions::SubAgentRunner>)
}) as Arc<crate::core::extensions::MakeSubAgentFn>
};
let ctx = ToolExecCtx {
execute_tool: crate::core::extensions::unavailable_tool_exec(),
parent_tool_call_id: None,
nested_calls: Default::default(),
session_branch_entries: Default::default(),
script_tools: Default::default(),
cwd: ".".to_string(),
make_sub_agent: make,
parent_abort: Arc::new(std::sync::atomic::AtomicBool::new(false)),
agent_id: None,
depth: 0,
parent_model: Some("anthropic/parent".to_string()),
script_call: false,
};
let prior = vec![crate::core::provider::AgentMessage::user_text("prior turn")];
let rt = rt();
rt.block_on(async {
fleet::remember_ctx(&ctx);
let action = Subagent.on_user_prompt_with_context("@explore inner", &prior, false);
assert_eq!(action, Some(UserPromptAction::Handled));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if let Some(rec) = manager::list().into_iter().find(|r| r.agent_type == "Plan") {
assert!(rec.parent_agent_id.is_none(), "clone 的 spawn 应是顶层");
assert_eq!(rec.depth, 0);
assert!(rec.background, "clone 强制后台");
break;
}
assert!(
std::time::Instant::now() < deadline,
"clone 未发起顶层 spawn"
);
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
});
let specs = seen.lock().unwrap();
assert_eq!(specs.len(), 1, "应物化一个隐藏副本");
assert_eq!(specs[0].tools, Some(vec!["Agent".to_string()]));
assert_eq!(
specs[0].context_messages.as_ref().map(|m| m.len()),
Some(1),
"副本应拿到实时对话"
);
assert!(
specs[0]
.agent_id
.as_deref()
.unwrap()
.starts_with("__mention_clone__")
);
assert!(!specs[0].persist_session);
drop(specs);
manager::reset_all();
}
#[test]
fn extension_identity_and_tools() {
let ext = Subagent;
assert_eq!(ext.name(), "subagent");
assert!(!ext.default_enabled());
let names: Vec<String> = ext.tools().into_iter().map(|t| t.name).collect();
assert_eq!(
names,
vec![
"Agent",
"get_subagent_result",
"steer_subagent",
"SubagentWorkflow"
]
);
let agent = ext.tools().into_iter().find(|t| t.name == "Agent").unwrap();
assert!(agent.description.contains("- general-purpose:"));
assert!(agent.description.contains("- Explore:"));
assert!(agent.description.contains(".prux/agents/"));
let required = agent.parameters["required"].as_array().unwrap();
assert!(required.iter().any(|v| v == "subagent_type"));
assert_eq!(agent.execution_mode, Some(ToolExecutionMode::Parallel));
}
#[test]
fn commands_declare_subcommands() {
let cmds = Subagent.commands();
assert_eq!(cmds.len(), 2);
assert_eq!(cmds[0].name, "agents");
assert!(cmds[0].busy_safe);
let subs: Vec<&str> = cmds[0].subcommands.iter().map(|s| s.name).collect();
assert_eq!(
subs,
vec![
"types",
"result",
"stop",
"workflows",
"schedules",
"settings",
"eject",
"enable",
"disable",
"delete",
"reset",
"edit"
]
);
assert_eq!(cmds[1].name, "autoresearch");
assert!(cmds[1].busy_safe);
let subs: Vec<&str> = cmds[1].subcommands.iter().map(|s| s.name).collect();
assert_eq!(subs, vec!["stop"]);
}
#[test]
fn settings_panel_reflects_config_and_apply_persists() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
while crate::core::extensions::take_pending_ui().is_some() {}
let items = Subagent.settings();
assert_eq!(
items.len(),
config::panel_settings(&config::SubagentConfig::default()).len()
);
let widget = items.iter().find(|s| s.key == "widget").unwrap();
assert_eq!(widget.value, "all");
assert_eq!(widget.choices, vec!["all", "background", "off"]);
Subagent.apply_setting("widget", "background").unwrap();
assert_eq!(manager::config().widget, config::WidgetMode::Background);
assert_eq!(
config::load_config().widget,
config::WidgetMode::Background,
"面板变更应写入 agent_dir()/extensions/subagent.json"
);
assert!(Subagent.apply_setting("nope", "all").is_err());
assert!(Subagent.apply_setting("widget", "sometimes").is_err());
manager::reset_all();
}
#[test]
fn strict_and_fallback_and_frontmatter_locking() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
let cwd = tempfile::tempdir().unwrap();
let cwd_s = cwd.path().to_string_lossy().to_string();
let global = prux_agent_dir_join(agent_types::GLOBAL_AGENT_DIR);
std::fs::create_dir_all(&global).unwrap();
std::fs::write(global.join("broken.md"), "no frontmatter here").unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let (ctx, seen) = capturing_ctx(&cwd_s);
let out = rt
.block_on(Subagent.execute_tool_async(
"Agent".to_string(),
json!({"prompt": "p", "description": "d", "subagent_type": "Explore"}),
ctx.clone(),
))
.expect("默认:坏文件只是告警");
assert_eq!(
out.details.as_ref().unwrap()["status"],
"error",
"物化失败仍如实回报"
);
Subagent.apply_setting("strictAgentFiles", "on").unwrap();
let err = rt
.block_on(Subagent.execute_tool_async(
"Agent".to_string(),
json!({"prompt": "p", "description": "d", "subagent_type": "Explore"}),
ctx.clone(),
))
.unwrap_err();
assert!(err.0.contains("strict agent files"), "{}", err.0);
assert!(err.0.contains("broken.md"), "{}", err.0);
Subagent.apply_setting("strictAgentFiles", "off").unwrap();
Subagent.apply_setting("fallbackSubagent", "none").unwrap();
let err = rt
.block_on(Subagent.execute_tool_async(
"Agent".to_string(),
json!({"prompt": "p", "description": "d", "subagent_type": "nope"}),
ctx.clone(),
))
.unwrap_err();
assert!(err.0.contains("fallbackSubagent = none"), "{}", err.0);
Subagent
.apply_setting("fallbackSubagent", "general-purpose")
.unwrap();
let out = rt
.block_on(Subagent.execute_tool_async(
"Agent".to_string(),
json!({"prompt": "p", "description": "d", "subagent_type": "nope"}),
ctx.clone(),
))
.expect("默认回退");
assert!(
out.text.contains("using general-purpose instead"),
"{}",
out.text
);
std::fs::remove_file(global.join("broken.md")).unwrap();
std::fs::write(
global.join("pinned.md"),
"---\nname: pinned\ndescription: pinned agent\nrun_in_background: false\ninherit_context: true\n---\nbody",
)
.unwrap();
let out = rt
.block_on(Subagent.execute_tool_async(
"Agent".to_string(),
json!({"prompt": "p", "description": "d", "subagent_type": "pinned", "run_in_background": true, "inherit_context": false}),
ctx.clone(),
))
.expect("物化失败也如实回报");
let d = out.details.as_ref().unwrap();
assert_eq!(
d["background"], false,
"frontmatter 锁定的前台必须生效: {d}"
);
let specs = seen.lock().unwrap();
let spec = specs.last().expect("应捕获一次物化请求");
assert!(
spec.inherit_context,
"frontmatter 锁定的 inherit_context 必须生效"
);
assert!(!spec.clear_context_files || spec.system_prompt.is_some());
Subagent
.apply_setting("disableDefaultAgents", "on")
.unwrap();
let err = rt
.block_on(Subagent.execute_tool_async(
"Agent".to_string(),
json!({"prompt": "p", "description": "d", "subagent_type": "Explore"}),
ctx,
))
.unwrap_err();
assert!(
err.0.contains("general-purpose is unavailable"),
"关掉默认类型后回退也无处可去: {}",
err.0
);
Subagent
.apply_setting("disableDefaultAgents", "off")
.unwrap();
manager::reset_all();
}
#[test]
fn isolated_param_extension_scope_and_skill_preload() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
crate::core::extensions::register_extension(Subagent);
crate::core::extensions::set_extension_enabled(EXT, true);
let cwd = tempfile::tempdir().unwrap();
let cwd_s = cwd.path().to_string_lossy().to_string();
Subagent.on_session_start(&cwd_s, &[], None);
let (ctx, seen) = capturing_ctx(&cwd_s);
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(Subagent.execute_tool_async(
"Agent".to_string(),
json!({"prompt": "p", "description": "d", "subagent_type": "Explore", "isolated": true}),
ctx.clone(),
))
.unwrap();
assert!(
seen.lock().unwrap().last().unwrap().isolated,
"isolated 参数应透传到 spec"
);
let global = prux_agent_dir_join(agent_types::GLOBAL_AGENT_DIR);
std::fs::create_dir_all(&global).unwrap();
std::fs::write(
global.join("no-ext.md"),
"---\nname: no-ext\ndescription: d\nextensions: false\n---\nbody",
)
.unwrap();
rt.block_on(Subagent.execute_tool_async(
"Agent".to_string(),
json!({"prompt": "p", "description": "d", "subagent_type": "no-ext"}),
ctx.clone(),
))
.unwrap();
assert!(
seen.lock().unwrap().last().unwrap().isolated,
"extensions:false 应等价 isolated"
);
std::fs::write(
global.join("scoped.md"),
"---\nname: scoped\ndescription: d\nextensions: subagent\nexclude_extensions: noisy\n---\nbody",
)
.unwrap();
rt.block_on(Subagent.execute_tool_async(
"Agent".to_string(),
json!({"prompt": "p", "description": "d", "subagent_type": "scoped"}),
ctx.clone(),
))
.unwrap();
{
let specs = seen.lock().unwrap();
let spec = specs.last().unwrap();
assert_eq!(
spec.extensions.as_ref().unwrap(),
&vec!["subagent".to_string()]
);
assert_eq!(spec.exclude_extensions, vec!["noisy".to_string()]);
assert!(!spec.isolated, "白名单不是 isolated");
}
std::fs::write(
global.join("selector.md"),
"---\nname: selector\ndescription: d\ntools: read, ext:subagent/steer_subagent\n---\nbody",
)
.unwrap();
rt.block_on(Subagent.execute_tool_async(
"Agent".to_string(),
json!({"prompt": "p", "description": "d", "subagent_type": "selector"}),
ctx.clone(),
))
.unwrap();
{
let specs = seen.lock().unwrap();
let tools = specs.last().unwrap().tools.clone().unwrap();
assert!(tools.contains(&"read".to_string()), "{tools:?}");
assert!(tools.contains(&"steer_subagent".to_string()), "{tools:?}");
assert!(!tools.iter().any(|t| t.starts_with("ext:")), "{tools:?}");
}
let skill_dir = prux_agent_dir_join("skills").join("demo");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: demo\ndescription: demo skill\n---\n\nPRELOADED BODY\n",
)
.unwrap();
std::fs::write(
global.join("with-skills.md"),
"---\nname: with-skills\ndescription: d\nskills: demo\n---\nbody",
)
.unwrap();
rt.block_on(Subagent.execute_tool_async(
"Agent".to_string(),
json!({"prompt": "p", "description": "d", "subagent_type": "with-skills"}),
ctx.clone(),
))
.unwrap();
{
let specs = seen.lock().unwrap();
let spec = specs.last().unwrap();
let prompt = spec
.system_prompt
.clone()
.or_else(|| spec.append_system_prompt.clone())
.unwrap();
assert!(prompt.contains("<preloaded_skills>"), "{prompt}");
assert!(prompt.contains("PRELOADED BODY"), "{prompt}");
assert!(!spec.clear_skills);
}
std::fs::write(
global.join("no-skills.md"),
"---\nname: no-skills\ndescription: d\nskills: false\n---\nbody",
)
.unwrap();
rt.block_on(Subagent.execute_tool_async(
"Agent".to_string(),
json!({"prompt": "p", "description": "d", "subagent_type": "no-skills"}),
ctx.clone(),
))
.unwrap();
assert!(
seen.lock().unwrap().last().unwrap().clear_skills,
"skills:false 应清空继承的技能索引"
);
manager::reset_all();
crate::core::extensions::set_extension_enabled(EXT, false);
_ = crate::core::extensions::unregister_extension(EXT);
}
#[test]
fn eject_disable_enable_round_trip() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
let cwd = tempfile::tempdir().unwrap();
let cwd_s = cwd.path().to_string_lossy().to_string();
Subagent.on_session_start(&cwd_s, &[], None);
let msg = eject_type("Explore", false).expect("eject");
assert!(msg.contains("ejected agent type \"Explore\""), "{msg}");
let path = prux_agent_dir_join(agent_types::GLOBAL_AGENT_DIR).join("Explore.md");
assert!(path.is_file(), "{}", path.display());
let err = eject_type("Explore", false).unwrap_err();
assert!(err.contains("refusing to overwrite"), "{err}");
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("prompt_mode: replace"), "{text}");
let msg = toggle_type("Explore", false).expect("disable");
assert!(msg.contains("disabled agent type"), "{msg}");
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.starts_with("---\nenabled: false\n"), "{text}");
assert!(agent_files::is_disabled_content(&text));
assert!(matches!(
agent_types::resolve(
&agent_types::discover_with(&cwd_s, discovery_opts(&manager::config())).types,
"Explore"
),
Err(ResolveError::Disabled)
));
let msg = toggle_type("Explore", false).expect("disable twice");
assert!(msg.contains("already disabled"), "{msg}");
let msg = toggle_type("Explore", true).expect("enable");
assert!(msg.contains("enabled agent type"), "{msg}");
let restored = std::fs::read_to_string(&path).unwrap();
assert!(!restored.contains("enabled: false"), "{restored}");
assert_eq!(restored, text.replacen("---\nenabled: false\n", "---\n", 1));
let err = toggle_type("general-purpose", false).unwrap_err();
assert!(err.contains("has no .md file"), "{err}");
assert!(
eject_type("nope", false)
.unwrap_err()
.contains("unknown agent type")
);
manager::reset_all();
}
#[test]
fn delete_and_reset_manage_agent_files() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
let cwd = tempfile::tempdir().unwrap();
let cwd_s = cwd.path().to_string_lossy().to_string();
Subagent.on_session_start(&cwd_s, &[], None);
eject_type("Explore", false).expect("eject");
let path = prux_agent_dir_join(agent_types::GLOBAL_AGENT_DIR).join("Explore.md");
assert!(path.is_file(), "{}", path.display());
let msg = reset_type("Explore").expect("reset");
assert!(msg.contains("Restored built-in default"), "{msg}");
assert!(!path.exists(), "reset 应删掉覆盖文件");
let err = reset_type("Explore").unwrap_err();
assert!(err.contains("already in effect"), "{err}");
let custom = prux_agent_dir_join(agent_types::GLOBAL_AGENT_DIR).join("mybot.md");
std::fs::write(&custom, "---\nname: mybot\ndescription: d\n---\nbody").unwrap();
let msg = delete_type("mybot").expect("delete");
assert!(msg.contains("Deleted"), "{msg}");
assert!(!custom.exists());
let err = delete_type("Plan").unwrap_err();
assert!(err.contains("no .md file to delete"), "{err}");
std::fs::write(&custom, "---\nname: mybot\ndescription: d\n---\nbody").unwrap();
let err = reset_type("mybot").unwrap_err();
assert!(err.contains("not a built-in default"), "{err}");
assert!(type_action("wat", "mybot").unwrap().is_none());
assert!(
delete_type("nope")
.unwrap_err()
.contains("unknown agent type")
);
manager::reset_all();
}
#[test]
fn type_actions_menu_hides_redundant_delete_for_builtin_defaults() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
let cwd = tempfile::tempdir().unwrap();
let cwd_s = cwd.path().to_string_lossy().to_string();
Subagent.on_session_start(&cwd_s, &[], None);
fn take_select() -> (u64, Vec<String>) {
while let Some(req) = crate::core::extensions::take_pending_ui() {
if let extensions::ExtensionUiRequest::Select { id, options, .. } = req {
return (id, options.into_iter().map(|o| o.text).collect());
}
}
panic!("expected a Select panel");
}
eject_type("Explore", false).expect("eject");
while crate::core::extensions::take_pending_ui().is_some() {}
open_type_actions("Explore");
let (_, builtin_options) = take_select();
assert!(
builtin_options.contains(&"reset Explore".to_string()),
"{builtin_options:?}"
);
assert!(
!builtin_options.iter().any(|o| o.starts_with("delete ")),
"内置默认不应再列 delete: {builtin_options:?}"
);
assert!(builtin_options.contains(&"edit Explore".to_string()));
assert_eq!(builtin_options.last().map(String::as_str), Some("back"));
let custom = prux_agent_dir_join(agent_types::GLOBAL_AGENT_DIR).join("mybot.md");
std::fs::write(&custom, "---\nname: mybot\ndescription: d\n---\nbody").unwrap();
while crate::core::extensions::take_pending_ui().is_some() {}
open_type_actions("mybot");
let (_, custom_options) = take_select();
assert!(
custom_options.contains(&"delete mybot".to_string()),
"{custom_options:?}"
);
assert!(
!custom_options.iter().any(|o| o.starts_with("reset ")),
"自定义类型不应列 reset: {custom_options:?}"
);
manager::reset_all();
}
#[test]
fn at_prefixed_type_args_are_accepted_by_type_commands() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
let cwd = tempfile::tempdir().unwrap();
let cwd_s = cwd.path().to_string_lossy().to_string();
let mut app = App::new();
Subagent.on_session_start(&cwd_s, &[], None);
let msg = eject_type("@Explore", false).expect("eject @Explore");
assert!(msg.contains("Explore"), "{msg}");
let path = prux_agent_dir_join(agent_types::GLOBAL_AGENT_DIR).join("Explore.md");
assert!(path.is_file(), "{}", path.display());
command_agents(&mut app, "/agents disable @Explore");
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("enabled: false"), "{content}");
command_agents(&mut app, "/agents enable @Explore");
let content = std::fs::read_to_string(&path).unwrap();
assert!(!content.contains("enabled: false"), "{content}");
let msg = reset_type("@Explore").expect("reset @Explore");
assert!(msg.contains("Restored built-in default"), "{msg}");
assert!(!path.exists());
let custom = prux_agent_dir_join(agent_types::GLOBAL_AGENT_DIR).join("mybot.md");
std::fs::write(&custom, "---\nname: mybot\ndescription: d\n---\nbody").unwrap();
let msg = delete_type("@mybot").expect("delete @mybot");
assert!(msg.contains("Deleted"), "{msg}");
assert!(!custom.exists());
let spaced = prux_agent_dir_join(agent_types::GLOBAL_AGENT_DIR).join("review.md");
std::fs::write(
&spaced,
"---\nname: Code Review!\ndescription: d\n---\nbody",
)
.unwrap();
assert_eq!(
resolve_type_any("@code-review").unwrap().name,
"Code Review!"
);
assert_eq!(
resolve_type_any("@agent-code-review").unwrap().name,
"Code Review!"
);
assert_eq!(
resolve_type_any("Code Review!").unwrap().name,
"Code Review!"
);
assert_eq!(
resolve_type_any("@nope").unwrap_err(),
"unknown agent type: nope"
);
manager::reset_all();
}
#[test]
fn edit_type_opens_an_editor_and_saves_the_whole_file() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
let cwd = tempfile::tempdir().unwrap();
let cwd_s = cwd.path().to_string_lossy().to_string();
Subagent.on_session_start(&cwd_s, &[], None);
let path = prux_agent_dir_join(agent_types::GLOBAL_AGENT_DIR).join("mybot.md");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "---\nname: mybot\ndescription: d\n---\nold body").unwrap();
let err = edit_type("Plan").unwrap_err();
assert!(err.contains("no .md file to edit"), "{err}");
while crate::core::extensions::take_pending_ui().is_some() {}
edit_type("mybot").expect("edit should open an overlay");
let mut id = None;
while let Some(r) = crate::core::extensions::take_pending_ui() {
if let crate::core::extensions::ExtensionUiRequest::ShowOverlay { id: i } = r {
id = Some(i);
}
}
let id = id.expect("ShowOverlay request");
let view = Subagent
.overlay_view(id, 80)
.expect("the editor overlay is claimed");
let editor = view.editor.expect("view carries an editor");
assert!(editor.value.contains("old body"), "{}", editor.value);
assert!(editor.label.ends_with("mybot.md"), "{}", editor.label);
let updated = "---\nname: mybot\ndescription: changed\n---\nnew body\n";
assert!(Subagent.on_overlay_event(
id,
&crate::core::extensions::OverlayEvent::EditorSubmit {
text: updated.to_string()
}
));
assert_eq!(std::fs::read_to_string(&path).unwrap(), updated);
manager::reset_all();
}
fn prux_agent_dir_join(sub: &str) -> std::path::PathBuf {
crate::core::settings_manager::agent_dir().join(sub)
}
fn capturing_ctx(
cwd: &str,
) -> (
ToolExecCtx,
Arc<Mutex<Vec<crate::core::extensions::SubAgentSpec>>>,
) {
let seen: Arc<Mutex<Vec<crate::core::extensions::SubAgentSpec>>> =
Arc::new(Mutex::new(Vec::new()));
let sink = seen.clone();
let make = Arc::new(move |spec: crate::core::extensions::SubAgentSpec| {
sink.lock().unwrap().push(spec);
Err("materialization is not expected here".to_string())
}) as Arc<crate::core::extensions::MakeSubAgentFn>;
(
ToolExecCtx {
execute_tool: crate::core::extensions::unavailable_tool_exec(),
parent_tool_call_id: None,
nested_calls: Default::default(),
session_branch_entries: Default::default(),
script_tools: Default::default(),
cwd: cwd.to_string(),
make_sub_agent: make,
parent_abort: Arc::new(std::sync::atomic::AtomicBool::new(false)),
agent_id: None,
depth: 0,
parent_model: None,
script_call: false,
},
seen,
)
}
#[test]
fn custom_message_renders_and_replays_on_session_switch() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
manager::reset_all();
while crate::core::extensions::take_pending_ui().is_some() {}
let payload = json!({
"id": "a1b2",
"displayName": "Explore",
"name": null,
"type": "Explore",
"status": "completed",
"turns": 3,
"toolUses": 1,
"tokens": 1200,
"cost": 0.0,
"durationMs": 4200,
"sessionPath": "/tmp/s.jsonl",
"preview": "all good",
"previewTruncated": false,
});
let spans = Subagent
.render_custom_message(notify::CARD_TYPE, &payload)
.expect("本扩展认领卡片类型");
let text: String = spans.iter().map(|s| s.text.as_str()).collect();
assert!(text.contains("Explore a1b2"), "{text}");
assert!(text.contains("all good"), "{text}");
assert!(text.contains("/tmp/s.jsonl"), "{text}");
assert!(
Subagent
.render_custom_message("someone-elses-type", &payload)
.is_none(),
"不认领其它扩展的类型"
);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.jsonl");
let line = json!({
"kind": "custom",
"customType": notify::CARD_TYPE,
"data": payload,
});
std::fs::write(&path, format!("{line}\n")).unwrap();
Subagent.on_session_switched(Some(&path.to_string_lossy()), &[]);
match crate::core::extensions::take_pending_ui() {
Some(extensions::ExtensionUiRequest::CustomMessage {
label,
custom_type,
data,
}) => {
assert_eq!(label, EXT);
assert_eq!(custom_type, notify::CARD_TYPE);
assert_eq!(data["id"], "a1b2");
}
other => panic!("expected a replayed CustomMessage, got {other:?}"),
}
assert!(
crate::core::extensions::take_pending_ui().is_none(),
"重放不应额外产生请求"
);
Subagent.on_session_switched(None, &[]);
assert!(crate::core::extensions::take_pending_ui().is_none());
manager::reset_all();
}
#[test]
fn agents_menu_routes_to_submenus() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
manager::reset_all();
while crate::core::extensions::take_pending_ui().is_some() {}
let mut app = App::new();
command_agents(&mut app, "/agents");
let Some(extensions::ExtensionUiRequest::Select { id, options, .. }) =
crate::core::extensions::take_pending_ui()
else {
panic!("expected a Select panel");
};
let options: Vec<String> = options.into_iter().map(|o| o.text).collect();
assert_eq!(options.len(), 7);
assert!(options[0].starts_with("view"), "{options:?}");
assert!(options[1].starts_with("list"), "{options:?}");
assert!(options[2].starts_with("types"), "{options:?}");
assert!(options[3].starts_with("schedules"), "{options:?}");
assert!(options[4].starts_with("workflows"), "{options:?}");
assert!(options[5].starts_with("create"), "{options:?}");
assert!(options[6].starts_with("settings"), "{options:?}");
assert!(
Subagent
.on_ui_choice(id, Some(options[6].clone()))
.is_none()
);
assert!(matches!(
crate::core::extensions::take_pending_ui(),
Some(extensions::ExtensionUiRequest::ShowSettings { ext }) if ext == "subagent"
));
command_agents(&mut app, "/agents");
let Some(extensions::ExtensionUiRequest::Select { id, options, .. }) =
crate::core::extensions::take_pending_ui()
else {
panic!("expected a Select panel");
};
let options: Vec<String> = options.into_iter().map(|o| o.text).collect();
assert!(
Subagent
.on_ui_choice(id, Some(options[2].clone()))
.is_none()
);
fn take_select() -> Option<(u64, Vec<String>)> {
while let Some(req) = crate::core::extensions::take_pending_ui() {
if let extensions::ExtensionUiRequest::Select { id, options, .. } = req {
return Some((id, options.into_iter().map(|o| o.text).collect()));
}
}
None
}
let (list_id, list_options) = take_select().expect("类型列表应开一个 Select");
assert!(
list_options[0].starts_with("general-purpose "),
"{list_options:?}"
);
assert!(
Subagent
.on_ui_choice(list_id, Some(list_options[0].clone()))
.is_none()
);
let (type_id, type_options) = take_select().expect("操作菜单应开一个 Select");
assert_eq!(
type_options.last().map(String::as_str),
Some("back"),
"{type_options:?}"
);
assert!(
Subagent
.on_ui_choice(type_id, Some("back".to_string()))
.is_none()
);
let (back_id, back_options) = take_select().expect("back 应重开类型清单");
assert_eq!(back_options, list_options);
assert!(
Subagent
.on_ui_choice(type_id, Some("eject general-purpose".to_string()))
.is_none()
);
assert!(crate::core::extensions::take_pending_ui().is_none());
assert!(Subagent.on_ui_choice(back_id, None).is_none());
assert!(
Subagent
.on_ui_choice(id, Some("list x".to_string()))
.is_none()
);
assert!(crate::core::extensions::take_pending_ui().is_none());
command_agents(&mut app, "/agents settings");
assert!(matches!(
crate::core::extensions::take_pending_ui(),
Some(extensions::ExtensionUiRequest::ShowSettings { .. })
));
command_agents(&mut app, "/agents");
let Some(extensions::ExtensionUiRequest::Select { id, options, .. }) =
crate::core::extensions::take_pending_ui()
else {
panic!("expected a Select panel");
};
let options: Vec<String> = options.into_iter().map(|o| o.text).collect();
assert!(
Subagent
.on_ui_choice(id, Some(options[1].clone()))
.is_none()
);
match crate::core::extensions::take_pending_ui() {
Some(extensions::ExtensionUiRequest::NotifyRich { spans, .. }) => {
assert!(spans.iter().any(|s| s.text.contains("no sub-agents")));
}
other => panic!("expected a not-rich notice, got {other:?}"),
}
}
#[derive(Clone)]
struct CollectorExt {
name: String,
seen: Arc<Mutex<Vec<(String, Value)>>>,
}
impl Extension for CollectorExt {
fn name(&self) -> &str {
&self.name
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn hooks(&self) -> Vec<ExtensionHook> {
vec![ExtensionHook::ExtensionEvent]
}
fn on_extension_event(&self, name: &str, payload: &Value) {
self.seen
.lock()
.unwrap()
.push((name.to_string(), payload.clone()));
}
}
const COLLECTOR: &str = "s6c-collector";
fn with_bus(list: &Arc<Mutex<Vec<(String, Value)>>>) {
crate::core::extensions::unregister_extension(EXT);
crate::core::extensions::register_extension(Subagent);
crate::core::extensions::set_extension_enabled(EXT, true);
crate::core::extensions::unregister_extension(COLLECTOR);
crate::core::extensions::register_extension(CollectorExt {
name: COLLECTOR.to_string(),
seen: list.clone(),
});
}
fn teardown_bus() {
crate::core::extensions::unregister_extension(COLLECTOR);
crate::core::extensions::unregister_extension(EXT);
manager::reset_all();
}
fn find_event(list: &Arc<Mutex<Vec<(String, Value)>>>, name: &str) -> Option<Value> {
list.lock()
.unwrap()
.iter()
.find(|(n, _)| n == name)
.map(|(_, p)| p.clone())
}
fn wait_for_event(list: &Arc<Mutex<Vec<(String, Value)>>>, name: &str, secs: u64) -> Value {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs);
loop {
if let Some(p) = find_event(list, name) {
return p;
}
assert!(
std::time::Instant::now() < deadline,
"event {name} not seen in time"
);
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
fn spawn_and_settle_background(
rt: &tokio::runtime::Runtime,
_seen: &Arc<Mutex<Vec<(String, Value)>>>,
) -> String {
let ctx = echo_ctx(".");
rt.block_on(async {
dispatch_tool(
"Agent",
json!({"prompt": "hi", "description": "t", "subagent_type": "Explore", "run_in_background": true}),
ctx.clone(),
)
.await
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if let Some(rec) = manager::list().into_iter().find(|r| r.status.is_terminal()) {
return rec.id;
}
assert!(std::time::Instant::now() < deadline, "agent 未终结");
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
})
}
#[test]
fn lifecycle_events_reach_subscribers() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
let seen = Arc::new(Mutex::new(Vec::new()));
with_bus(&seen);
let rt = rt();
let id = spawn_and_settle_background(&rt, &seen);
let _ = wait_for_event(&seen, "subagents:completed", 5);
let names: Vec<String> = seen
.lock()
.unwrap()
.iter()
.map(|(n, _)| n.clone())
.collect();
assert!(names.iter().any(|n| n == "subagents:created"), "{names:?}");
assert!(names.iter().any(|n| n == "subagents:started"), "{names:?}");
assert!(
names
.iter()
.any(|n| n == "subagents:completed" || n == "subagents:failed"),
"{names:?}"
);
let created = find_event(&seen, "subagents:created").unwrap();
assert_eq!(created["id"], id);
assert_eq!(created["type"], "Explore");
teardown_bus();
}
#[test]
fn rpc_ping_replies_with_protocol_version() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let seen = Arc::new(Mutex::new(Vec::new()));
with_bus(&seen);
events::on_event("subagents:rpc:ping", &json!({"requestId": "ping-1"}));
let reply = find_event(&seen, "subagents:rpc:ping:reply:ping-1").expect("reply");
assert_eq!(reply["success"], true);
assert_eq!(reply["data"]["version"], events::PROTOCOL_VERSION);
assert_eq!(events::PROTOCOL_VERSION, 2);
teardown_bus();
}
#[test]
fn rpc_spawn_without_session_errors() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
manager::reset_all();
fleet::reset();
fleet::clear_ctx_for_test();
let seen = Arc::new(Mutex::new(Vec::new()));
with_bus(&seen);
events::on_event(
"subagents:rpc:spawn",
&json!({"requestId": "s0", "type": "Explore", "prompt": "go"}),
);
let reply = find_event(&seen, "subagents:rpc:spawn:reply:s0").expect("reply");
assert_eq!(reply["success"], false);
assert_eq!(reply["error"], "No active session");
teardown_bus();
}
#[test]
fn rpc_spawn_returns_id_and_starts_agent() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
let seen = Arc::new(Mutex::new(Vec::new()));
with_bus(&seen);
let rt = rt();
rt.block_on(async {
let ctx = echo_ctx(".");
fleet::remember_ctx(&ctx);
events::on_event(
"subagents:rpc:spawn",
&json!({"requestId": "s1", "type": "Explore", "prompt": "go"}),
);
let reply = wait_for_event(&seen, "subagents:rpc:spawn:reply:s1", 5);
assert_eq!(reply["success"], true, "{reply}");
let id = reply["data"]["id"].as_str().expect("id");
assert!(!id.is_empty());
let rec = manager::record(id).expect("record");
assert!(rec.parent_agent_id.is_none());
assert!(rec.background);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while manager::record(id).is_some_and(|r| !r.status.is_terminal()) {
assert!(std::time::Instant::now() < deadline);
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
});
teardown_bus();
}
#[test]
fn rpc_stop_refuses_nested_agents() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
let seen = Arc::new(Mutex::new(Vec::new()));
with_bus(&seen);
let rt = rt();
let id = rt.block_on(async {
let mut ctx = echo_ctx(".");
ctx.agent_id = Some("parent-x".to_string());
ctx.depth = 1;
let out = dispatch_tool(
"Agent",
json!({"prompt": "hi", "description": "t", "subagent_type": "Explore", "run_in_background": true}),
ctx.clone(),
)
.await
.unwrap();
let _ = out;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if let Some(rec) = manager::list().into_iter().find(|r| r.parent_agent_id.is_some()) {
return rec.id;
}
assert!(std::time::Instant::now() < deadline, "嵌套记录未创建");
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
});
events::on_event(
"subagents:rpc:stop",
&json!({"requestId": "x1", "agentId": id}),
);
let reply = find_event(&seen, "subagents:rpc:stop:reply:x1").expect("reply");
assert_eq!(reply["success"], false);
assert!(
reply["error"]
.as_str()
.unwrap()
.contains("owned by another"),
"{reply}"
);
let _ = manager::stop(&id);
teardown_bus();
}
#[test]
fn rpc_consume_marks_result_read() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
let seen = Arc::new(Mutex::new(Vec::new()));
with_bus(&seen);
let rt = rt();
let id = spawn_and_settle_background(&rt, &seen);
assert!(!manager::record(&id).unwrap().consumed);
events::on_event(
"subagents:rpc:consume",
&json!({"requestId": "c1", "agentId": id}),
);
let reply = find_event(&seen, "subagents:rpc:consume:reply:c1").expect("reply");
assert_eq!(reply["success"], true, "{reply}");
assert!(manager::record(&id).unwrap().consumed);
events::on_event(
"subagents:rpc:consume",
&json!({"requestId": "c2", "agentId": "nope"}),
);
let reply = find_event(&seen, "subagents:rpc:consume:reply:c2").expect("reply");
assert_eq!(reply["success"], false);
teardown_bus();
}
#[test]
fn allowed_subagents_restricts_nested_dispatch() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
let global = crate::core::settings_manager::agent_dir().join(agent_types::GLOBAL_AGENT_DIR);
std::fs::create_dir_all(&global).unwrap();
std::fs::write(
global.join("boss.md"),
"---\nname: Boss\ndescription: b\nallowed_subagents: Explore\n---\nbody",
)
.unwrap();
manager::reset_all();
let rt = rt();
rt.block_on(async {
let ctx = echo_ctx(".");
dispatch_tool(
"Agent",
json!({"prompt": "x", "description": "d", "subagent_type": "Boss", "run_in_background": true}),
ctx.clone(),
)
.await
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let boss_id = loop {
if let Some(rec) = manager::list().into_iter().find(|r| r.agent_type == "Boss") {
break rec.id;
}
assert!(std::time::Instant::now() < deadline, "Boss 记录未创建");
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
};
let mut as_boss = ctx.clone();
as_boss.agent_id = Some(boss_id.clone());
as_boss.depth = 1;
let denied = dispatch_tool(
"Agent",
json!({"prompt": "y", "description": "d", "subagent_type": "Plan", "run_in_background": true}),
as_boss.clone(),
)
.await
.unwrap();
assert!(denied.text.contains("not allowed for Boss"), "{}", denied.text);
let ok = dispatch_tool(
"Agent",
json!({"prompt": "z", "description": "d", "subagent_type": "Explore", "run_in_background": true}),
as_boss,
)
.await
.unwrap();
assert!(!ok.text.contains("not allowed"), "{}", ok.text);
});
manager::reset_all();
}
#[test]
fn report_usage_hook_drains_pool_once() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
manager::reset_all();
let out = Subagent
.after_tool_call_ext("read", &json!({}), "x", false)
.unwrap();
assert!(out.usage.is_none());
manager::pool_usage(crate::core::provider::Usage {
input: 10,
output: 5,
total_tokens: 15,
..Default::default()
});
let out = Subagent
.after_tool_call_ext("read", &json!({}), "x", false)
.unwrap();
let u = out.usage.expect("pooled usage 应被挂上");
assert_eq!((u.input, u.output, u.total_tokens), (10, 5, 15));
let out = Subagent
.after_tool_call_ext("read", &json!({}), "x", false)
.unwrap();
assert!(out.usage.is_none());
}
#[test]
fn record_model_falls_back_to_parent_model() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
let rt = rt();
rt.block_on(async {
let mut ctx = echo_ctx(".");
ctx.parent_model = Some("anthropic/claude-test".to_string());
dispatch_tool(
"Agent",
json!({"prompt": "hi", "description": "d", "subagent_type": "Explore", "run_in_background": true}),
ctx,
)
.await
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if let Some(rec) = manager::list().into_iter().next() {
assert_eq!(rec.model.as_deref(), Some("anthropic/claude-test"));
return;
}
assert!(std::time::Instant::now() < deadline);
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
});
manager::reset_all();
}
#[test]
fn wait_cancels_without_stopping_agent() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
let inbox = Arc::new(Mutex::new(Vec::new()));
let rt = rt();
rt.block_on(async {
let ctx = hold_ctx(inbox.clone());
dispatch_tool(
"Agent",
json!({"prompt": "x", "description": "d", "subagent_type": "Explore", "run_in_background": true}),
ctx.clone(),
)
.await
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let id = loop {
if let Some(rec) = manager::list().into_iter().find(|r| r.status == types::AgentStatus::Running) {
break rec.id;
}
assert!(std::time::Instant::now() < deadline, "agent 未进入 running");
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
};
ctx.parent_abort.store(true, Ordering::Relaxed);
let out = dispatch_tool(
"get_subagent_result",
json!({"agent_id": id, "wait": true}),
ctx.clone(),
)
.await
.unwrap();
assert!(out.text.contains("still running"), "{}", out.text);
assert!(
!manager::record(&id).unwrap().status.is_terminal(),
"取消等待不该取消 agent"
);
let _ = manager::stop(&id);
});
manager::reset_all();
}
#[test]
fn mention_suggestions_list_types() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
let s = Subagent.suggestions("", "");
assert!(!s.exclusive, "普通 `@` 不独占(文件候选仍会并入)");
assert!(s.items.iter().any(|i| i.insert == "explore"), "{s:?}");
let s = Subagent.suggestions("EXP", "");
assert!(s.items.iter().any(|i| i.insert == "explore"), "{s:?}");
let s = Subagent.suggestions("zzz-nope", "");
assert!(!s.items.iter().any(|i| i.insert == "explore"), "{s:?}");
}
#[test]
fn suggestion_context_detects_agent_and_type_args() {
assert_eq!(
suggestion_context("/agents stop @"),
Some(SuggestionContext::AgentArg(AgentArgKind::Stop))
);
assert_eq!(
suggestion_context("/AGENTS result @exp"),
Some(SuggestionContext::AgentArg(AgentArgKind::Result))
);
for (sub, kind) in [
("eject", TypeArgKind::Eject),
("enable", TypeArgKind::Enable),
("disable", TypeArgKind::Disable),
("delete", TypeArgKind::Delete),
("reset", TypeArgKind::Reset),
("edit", TypeArgKind::Edit),
] {
assert_eq!(
suggestion_context(&format!("/agents {sub} @co")),
Some(SuggestionContext::TypeArg(kind)),
"{sub}"
);
}
assert_eq!(suggestion_context("/agents types @"), None);
assert_eq!(suggestion_context("/agents"), None);
assert_eq!(suggestion_context("@explore hi"), None);
assert_eq!(suggestion_context("plain text"), None);
}
#[test]
fn stop_and_result_suggestions_list_agent_ids() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
manager::reset_all();
let inbox = Arc::new(Mutex::new(Vec::new()));
let rt = rt();
rt.block_on(async {
let ctx = hold_ctx(inbox.clone());
dispatch_tool(
"Agent",
json!({"prompt": "x", "description": "d", "subagent_type": "Explore", "run_in_background": true}),
ctx.clone(),
)
.await
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let rec = loop {
if let Some(rec) = manager::list().into_iter().next() {
break rec;
}
assert!(std::time::Instant::now() < deadline, "agent 未登记");
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
};
let id = rec.id.clone();
let handle = rec.handle.clone().unwrap_or_else(|| id.clone());
let s = Subagent.suggestions("", "/agents stop @");
assert!(s.exclusive, "代理 id 候选应独占(不混文件/类型)");
assert!(
s.items.iter().any(|i| i.insert == id && i.name == format!("@{id}")),
"stop 应以 id 作为候选: {:?}",
s.items
);
assert!(
s.items.iter().all(|i| !i.description.contains(&rec.display_name)),
"不应显示 agent type: {:?}",
s.items
);
assert!(
!Subagent.suggestions(&id, "/agents stop @").items.is_empty(),
"按 id 应筛得到"
);
assert!(
!Subagent.suggestions(&handle, "/agents result @").items.is_empty(),
"按 handle 应筛得到"
);
assert!(
Subagent
.suggestions("zzz-nope", "/agents stop @zzz-nope")
.items
.is_empty()
);
let s = Subagent.suggestions("", "");
assert!(s.items.iter().any(|i| i.insert == "explore"), "{s:?}");
assert!(
manager::stop(&format!("@{id}")).is_ok(),
"stop 应接受 @id"
);
});
manager::reset_all();
}
#[test]
fn type_arg_suggestions_are_exclusive_and_type_only() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
let cwd = tempfile::tempdir().unwrap();
let cwd_s = cwd.path().to_string_lossy().to_string();
manager::reset_all();
Subagent.on_session_start(&cwd_s, &[], None);
let path = prux_agent_dir_join(agent_types::GLOBAL_AGENT_DIR).join("disabledbot.md");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
"---\nname: disabledbot\ndescription: d\nenabled: false\n---\nbody",
)
.unwrap();
let s = Subagent.suggestions("", "/agents enable @");
assert!(s.exclusive, "类型实参候选应独占(不扫描/并入文件)");
assert!(
s.items.iter().any(|i| i.insert == "disabledbot"),
"enable 应列出已禁用类型: {:?}",
s.items
);
assert!(
s.items.iter().any(|i| i.description.contains("disabled")),
"已禁用类型应标注: {:?}",
s.items
);
let s = Subagent.suggestions("disabled", "/agents enable @disabled");
assert_eq!(s.items.len(), 1, "{:?}", s.items);
assert_eq!(s.items[0].insert, "disabledbot");
manager::reset_all();
}
#[test]
fn type_arg_suggestions_hide_builtins_without_files() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = manager::test_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
let cwd = tempfile::tempdir().unwrap();
let cwd_s = cwd.path().to_string_lossy().to_string();
manager::reset_all();
Subagent.on_session_start(&cwd_s, &[], None);
let inserts = |line: &str| -> Vec<String> {
Subagent
.suggestions("", line)
.items
.into_iter()
.map(|i| i.insert)
.collect()
};
for line in [
"/agents enable @",
"/agents disable @",
"/agents delete @",
"/agents edit @",
"/agents reset @",
] {
let got = inserts(line);
assert!(!got.iter().any(|i| i == "explore"), "{line}: {got:?}");
assert!(!got.iter().any(|i| i == "plan"), "{line}: {got:?}");
assert!(
!got.iter().any(|i| i == "general-purpose"),
"{line}: {got:?}"
);
}
let got = inserts("/agents eject @");
assert!(got.iter().any(|i| i == "explore"), "{got:?}");
eject_type("Explore", false).expect("eject");
assert!(
!inserts("/agents eject @").iter().any(|i| i == "explore"),
"已有 .md 的类型不应再列在 eject 候选里(会拒绝覆盖)"
);
assert!(
inserts("/agents disable @").iter().any(|i| i == "explore"),
"eject 后的内置类型应可被 disable"
);
assert!(
inserts("/agents reset @").iter().any(|i| i == "explore"),
"eject 后的内置类型应可被 reset"
);
assert!(
!inserts("/agents enable @").iter().any(|i| i == "explore"),
"已启用的类型不应列在 enable 候选里"
);
toggle_type("Explore", false).expect("disable");
assert!(
inserts("/agents enable @").iter().any(|i| i == "explore"),
"已禁用的类型应列在 enable 候选里"
);
assert!(
!inserts("/agents disable @").iter().any(|i| i == "explore"),
"已禁用的类型不应再列在 disable 候选里"
);
manager::reset_all();
}
}