use super::{
EXT,
config::{self, ConfigKey, JoinMode, SubagentConfig},
events, memory, mention,
model::{self, ScopeVerdict},
nested::{self, NESTED_TOOL_NAMES},
notify::{self, CARD_TYPE},
output_file,
prompt::{self, SUB_AGENT_BRIDGE},
session,
types::{AgentRecord, AgentStatus, AgentType, PromptMode, SpawnRequest, UsageTotals},
util_notify, widget, workflow,
worktree::{self, Worktree},
};
use crate::{
core::{
extensions::{
DockLine, ExtensionUiRequest, SubAgentControls, SubAgentEventSink, SubAgentRunner,
SubAgentSpec, ToolExecCtx, UiNotifyLevel, is_nested_call_event, request_show_dock,
request_ui,
},
provider::{AgentMessage, Usage},
},
extensions::util::next_id,
utils::time::now_ms,
};
use futures_util::future::{Either, select};
use serde_json::Value;
use std::{
collections::{HashMap, HashSet, VecDeque},
path::PathBuf,
sync::{
Arc, Mutex, OnceLock,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use tokio::sync::watch::Sender;
pub const GRACE_TURNS: u32 = 5;
const MAX_FINISHED_RECORDS: usize = 50;
const PARENT_ABORT_POLL: Duration = Duration::from_millis(20);
struct PendingSpawn {
req: SpawnRequest,
ty: AgentType,
done_tx: Option<Sender<bool>>,
pending_steer: Vec<String>,
}
#[derive(Default)]
struct State {
records: HashMap<String, AgentRecord>,
order: Vec<String>,
pending: HashMap<String, PendingSpawn>,
running_bg: usize,
running_fg: usize,
queue: VecDeque<String>,
warned: HashSet<String>,
batch: Vec<BatchItem>,
batch_generation: u64,
dismissed_before_ms: u64,
cfg: SubagentConfig,
cfg_key: ConfigKey,
#[cfg(test)]
cfg_pinned: bool,
#[cfg(test)]
cfg_pin_owner: Option<std::thread::ThreadId>,
}
fn state() -> &'static Mutex<State> {
static S: OnceLock<Mutex<State>> = OnceLock::new();
S.get_or_init(|| Mutex::new(State::default()))
}
fn with_state<R>(f: impl FnOnce(&mut State) -> R) -> R {
let mut guard = state().lock().unwrap_or_else(|e| e.into_inner());
f(&mut guard)
}
pub fn config() -> SubagentConfig {
#[cfg(test)]
if with_state(|st| st.cfg_pinned && st.cfg_pin_owner != Some(std::thread::current().id())) {
return with_state(|st| st.cfg.clone());
}
let key = config::config_key();
with_state(|st| {
if st.cfg_key != key {
st.cfg = config::load_config();
st.cfg_key = key;
}
st.cfg.clone()
})
}
pub fn reload_config() {
let cfg = config::load_config();
let key = config::config_key();
#[cfg(test)]
if with_state(|st| st.cfg_pinned && st.cfg_pin_owner != Some(std::thread::current().id())) {
return;
}
with_state(|st| {
st.cfg = cfg;
st.cfg_key = key;
});
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Dispatched {
pub id: String,
pub background: bool,
pub queued: bool,
}
pub(crate) fn request_for_type(
ty: &AgentType,
prompt: String,
description: String,
resume: Option<String>,
) -> SpawnRequest {
let cfg = config();
let mut req = SpawnRequest::from_type(ty, &cfg, prompt, description);
req.resume = resume;
req
}
pub(crate) fn materialize(
ctx: &ToolExecCtx,
spec: SubAgentSpec,
) -> Result<Box<dyn SubAgentRunner>, String> {
(ctx.make_sub_agent)(spec)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_spec(
ty: &AgentType,
req: &SpawnRequest,
cwd: &str,
events: Option<SubAgentEventSink>,
resume_session_path: Option<String>,
max_depth: u32, default_max_turns: u32, grace_turns: u32,
) -> SubAgentSpec {
let tag = prompt::active_agent_tag(&ty.name);
let skills_section = preload_skills_section(ty, &req.skills, cwd);
let (mut system_prompt, mut append_system_prompt, clear_context_files) = build_prompt(ty, &tag);
append_skills_section(
&mut system_prompt,
&mut append_system_prompt,
&skills_section,
);
let mut tools = req.tools.clone();
apply_delegation_tools(&mut tools, ty, req, max_depth);
apply_memory(&mut tools, &mut append_system_prompt, ty, req, cwd);
SubAgentSpec {
agent_type: ty.name.clone(),
system_prompt,
append_system_prompt,
clear_context_files,
model: req.model.clone(),
thinking: req.thinking.clone(),
tools,
max_turns: req
.max_turns
.or((default_max_turns > 0).then_some(default_max_turns)),
grace_turns: Some(grace_turns),
inherit_context: req.inherit_context,
context_messages: None,
cwd: None,
persist_session: req.persist_session,
resume_session_path,
session_parent_id: None,
session_dir: req.session_dir.clone().map(PathBuf::from),
name: req.name.clone(),
agent_id: None, depth: req.depth,
events,
isolated: req.isolated,
disallowed_tools: req.disallowed_tools.clone().unwrap_or_default(),
extensions: req.extensions.clone(),
exclude_extensions: req.exclude_extensions.clone(),
clear_skills: req.clear_skills,
injected_tools: req.injected_tools.clone(),
}
}
fn preload_skills_section(ty: &AgentType, skills: &[String], cwd: &str) -> String {
let (section, missing) = prompt::preload_skills(skills, cwd);
for name in missing {
let key = format!("skill-preload|{name}");
if note_warning(&key) {
util_notify(
&format!(
"agent type {:?}: preloaded skill {name:?} was not found",
ty.name
),
UiNotifyLevel::Warning,
);
}
}
section
}
fn build_prompt(ty: &AgentType, tag: &str) -> (Option<String>, Option<String>, bool) {
match ty.prompt_mode {
PromptMode::Replace => {
let body = ty.system_prompt.trim();
let text = if body.is_empty() {
tag.to_string()
} else {
format!("{tag}\n\n{body}")
};
(Some(text), None, true) }
PromptMode::Append => {
let mut text = format!("{}\n\n{}", SUB_AGENT_BRIDGE, tag);
let body = ty.system_prompt.trim();
if !body.is_empty() {
text.push_str(&format!(
"\n\n<agent_instructions>\n{body}\n</agent_instructions>"
));
}
(None, Some(text), false)
}
}
}
fn append_skills_section(
system_prompt: &mut Option<String>,
append_system_prompt: &mut Option<String>,
section: &str,
) {
if section.is_empty() {
return;
}
match system_prompt.as_mut() {
Some(prompt) => prompt.push_str(&format!("\n\n{section}")),
None => {
let slot = append_system_prompt.get_or_insert_with(String::new);
slot.push_str(&format!("\n\n{section}"));
}
}
}
fn apply_delegation_tools(
tools: &mut Option<Vec<String>>,
ty: &AgentType,
req: &SpawnRequest,
max_depth: u32,
) {
if ty.allowed_subagents.is_some()
&& !req.isolated
&& nested::nesting_allowed(req.depth, max_depth)
{
let list = tools.get_or_insert_with(Vec::new);
for name in NESTED_TOOL_NAMES {
if !list.iter().any(|t| t == name) {
list.push((*name).to_string());
}
}
}
}
fn apply_memory(
tools: &mut Option<Vec<String>>,
append_system_prompt: &mut Option<String>,
ty: &AgentType,
req: &SpawnRequest,
cwd: &str,
) {
let Some(scope) = ty.memory else {
return;
};
let disallowed = req.disallowed_tools.clone().unwrap_or_default();
match memory::plan(&ty.name, scope, cwd, tools, &disallowed) {
Ok(plan) => {
memory::apply_tools(tools, &plan.add_tools);
let slot = append_system_prompt.get_or_insert_with(String::new);
if !slot.is_empty() {
slot.push_str("\n\n");
}
slot.push_str(&plan.block);
}
Err(e) => {
let key = format!("memory|{}|{e}", ty.name);
if note_warning(&key) {
util_notify(
&format!("agent type {:?}: persistent memory skipped — {e}", ty.name),
UiNotifyLevel::Warning,
);
}
}
}
}
fn open_output_file(cwd: &str, id: &str) -> Option<PathBuf> {
let session = session::session_key();
let meta = serde_json::json!({
"type": "agent_start",
"id": id,
"cwd": cwd,
"startedAtEpochMs": now_ms(),
});
output_file::create_output_file(cwd, session.as_deref(), id, &meta)
}
fn usage_pool() -> &'static Mutex<Option<Usage>> {
static P: OnceLock<Mutex<Option<Usage>>> = OnceLock::new();
P.get_or_init(|| Mutex::new(None))
}
pub(crate) fn pool_usage(u: Usage) {
let mut p = usage_pool().lock().unwrap_or_else(|e| e.into_inner());
match p.as_mut() {
Some(acc) => {
acc.input = acc.input.saturating_add(u.input);
acc.output = acc.output.saturating_add(u.output);
acc.cache_read = acc.cache_read.saturating_add(u.cache_read);
acc.cache_write = acc.cache_write.saturating_add(u.cache_write);
acc.total_tokens = acc.total_tokens.saturating_add(u.total_tokens);
acc.cost.input += u.cost.input;
acc.cost.output += u.cost.output;
acc.cost.cache_read += u.cost.cache_read;
acc.cost.cache_write += u.cost.cache_write;
acc.cost.total += u.cost.total;
}
None => *p = Some(u),
}
}
pub fn drain_pooled_usage() -> Option<Usage> {
usage_pool()
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
}
pub fn reset_usage_pool() {
*usage_pool().lock().unwrap_or_else(|e| e.into_inner()) = None;
}
fn make_sink(id: &str, output: Option<PathBuf>) -> SubAgentEventSink {
let id = id.to_string();
let handler = move |ev: Value| {
if let Some(path) = output.as_deref() {
output_file::append_event(path, &ev);
}
let kind = ev.get("type").and_then(|v| v.as_str()).unwrap_or("");
match kind {
"turn_end" => with_state(|st| {
if let Some(r) = st.records.get_mut(&id) {
r.turns += 1;
r.activity = None;
}
}),
"message_end" => {
let usage_val = with_state(|st| {
let r = st.records.get_mut(&id)?;
let m = ev.get("message")?;
r.usage.add_from_message(m);
r.transcript.push(m.clone());
m.get("usage").cloned()
});
if let Some(v) = usage_val
&& config().report_usage
&& let Ok(u) = serde_json::from_value::<Usage>(v)
{
pool_usage(u);
}
}
"tool_execution_start" if !is_nested_call_event(&ev) => with_state(|st| {
if let Some(r) = st.records.get_mut(&id) {
r.activity = ev
.get("toolName")
.and_then(|v| v.as_str())
.map(str::to_string);
}
}),
"tool_execution_end" if !is_nested_call_event(&ev) => with_state(|st| {
if let Some(r) = st.records.get_mut(&id) {
r.tool_uses += 1;
r.activity = None;
}
}),
"compaction_end" => {
let snap = with_state(|st| {
let r = st.records.get_mut(&id)?;
r.compaction_count += 1;
Some(r.clone())
});
if let Some(rec) = snap {
let reason = ev.get("reason").and_then(|v| v.as_str()).unwrap_or("");
let tokens_before =
ev.get("tokensBefore").and_then(|v| v.as_u64()).unwrap_or(0);
events::compacted(&rec, reason, tokens_before);
}
}
_ => {}
}
};
Arc::new(Mutex::new(Box::new(handler) as Box<dyn FnMut(Value) + Send>))
}
#[derive(Debug, Clone, Default)]
pub(crate) struct Caller {
pub agent_id: Option<String>,
pub depth: u32,
}
pub(crate) fn caller_of(ctx: &ToolExecCtx) -> Caller {
Caller {
agent_id: ctx.agent_id.clone(),
depth: ctx.depth,
}
}
pub(crate) fn owned_record(caller: &Caller, key: &str) -> Result<AgentRecord, String> {
let rec = record(key).ok_or_else(|| format!("unknown agent id: {key}"))?;
match caller.agent_id.as_deref() {
None => Ok(rec),
Some(owner) if rec.parent_agent_id.as_deref() == Some(owner) => Ok(rec),
Some(_) => Err(format!(
"agent {} was not spawned by this agent — nested delegation can only see its own children",
rec.id
)),
}
}
fn abort_owned_children(parent_id: &str) {
let owned: Vec<String> = with_state(|st| {
st.records
.values()
.filter(|r| r.parent_agent_id.as_deref() == Some(parent_id) && !r.status.is_terminal())
.map(|r| r.id.clone())
.collect()
});
for id in owned {
_ = stop(&id);
}
}
pub async fn dispatch(
ctx: &ToolExecCtx,
ty: &AgentType,
req: SpawnRequest,
) -> Result<Dispatched, String> {
dispatch_with_id(ctx, ty, req, None).await
}
pub async fn dispatch_with_id(
ctx: &ToolExecCtx,
ty: &AgentType,
mut req: SpawnRequest,
on_id: Option<Arc<dyn Fn(String) + Send + Sync>>,
) -> Result<Dispatched, String> {
let (id, resume_path) = resolve_resume(&req)?;
resolve_and_scope_model(&mut req, ty)?;
let output = req
.output_transcript
.then(|| open_output_file(&ctx.cwd, &id))
.flatten();
let effective_model = req.model.clone().or_else(|| ctx.parent_model.clone());
let background = req.run_in_background;
let (done_tx, done_rx) = tokio::sync::watch::channel(false);
let new_record = AgentRecord {
id: id.clone(),
name: req.name.clone(),
handle: None,
alias: None,
agent_type: ty.name.clone(),
display_name: ty.label().to_string(),
description: req.description.clone(),
status: AgentStatus::Queued,
background,
color: ty.color.clone(),
activity: None,
turns: 0,
tool_uses: 0,
usage: UsageTotals::default(),
started_ms: now_ms(),
ended_ms: None,
result: None,
transcript: Vec::new(),
session_path: resume_path.clone(),
output_path: output.as_ref().map(|p| p.display().to_string()),
max_turns: req.max_turns,
steer: None,
abort: None,
stop_requested: false,
consumed: false,
worktree: None,
worktree_result: None,
parent_agent_id: req.parent_agent_id.clone(),
depth: req.depth,
workflow_owned: req.workflow_owned,
model: effective_model,
compaction_count: 0,
done_rx: Some(done_rx),
};
let dock_visible = widget::visible(&new_record, config().widget);
let queued = insert_run_record(&id, new_record, &req, ty, background, &done_tx);
if dock_visible {
request_show_dock();
}
if let Some(cb) = on_id.as_ref() {
cb(id.clone());
}
if let Some(rec) = self::record(&id) {
events::created(&rec);
}
if background {
if !queued {
start_background(ctx, &id).await;
}
return Ok(Dispatched {
id,
background: true,
queued,
});
}
mark_running(&id);
if let Some(rec) = self::record(&id) {
events::started(&rec);
}
let fg_ctx = ctx.clone();
let fg_ty = ty.clone();
let fg_req = req.clone();
let fg_id = id.clone();
let handle = tokio::spawn(async move {
run_foreground(&fg_ctx, &fg_ty, &fg_req, &fg_id, resume_path, output).await;
});
_ = handle.await;
Ok(Dispatched {
id,
background: false,
queued: false,
})
}
fn resolve_resume(req: &SpawnRequest) -> Result<(String, Option<String>), String> {
match req.resume.as_deref() {
None => Ok((next_id(), None)),
Some(key) => {
let (id, path) = with_state(|st| {
let Some(rec) = find_record(st, key) else {
return Err(format!("unknown agent id: {key}"));
};
if !rec.status.is_terminal() {
return Err(format!(
"agent {} is still {}; use steer_subagent instead of resume",
rec.id,
rec.status.as_str()
));
}
Ok((rec.id.clone(), rec.session_path.clone()))
})?;
let path =
path.ok_or_else(|| format!("agent {id} has no persisted session to resume"))?;
Ok((id, Some(path)))
}
}
}
fn resolve_and_scope_model(req: &mut SpawnRequest, ty: &AgentType) -> Result<(), String> {
if let Some(input) = req.model.as_deref() {
let canonical = model::resolve(input)?;
req.model = Some(canonical);
}
match model::check_scope(
config().scope_models,
req.model.as_deref(),
req.model_source,
ty.label(),
req.model.as_deref(),
) {
ScopeVerdict::Ok => {}
ScopeVerdict::Refuse(message) => return Err(message),
ScopeVerdict::Warn(message) => {
let key = format!("scope|{}|{message}", ty.name);
if note_warning(&key) {
util_notify(&message, UiNotifyLevel::Warning);
}
}
}
Ok(())
}
fn insert_run_record(
id: &str,
record: AgentRecord,
req: &SpawnRequest,
ty: &AgentType,
background: bool,
done_tx: &Sender<bool>,
) -> bool {
with_state(|st| {
match st.records.get_mut(id) {
Some(existing) => {
existing.name = record.name.clone();
existing.description = record.description.clone();
existing.status = AgentStatus::Queued;
existing.background = background;
existing.color = record.color.clone();
existing.activity = None;
existing.turns = 0;
existing.tool_uses = 0;
existing.usage = UsageTotals::default();
existing.started_ms = now_ms();
existing.ended_ms = None;
existing.result = None;
existing.max_turns = record.max_turns;
existing.workflow_owned = record.workflow_owned;
existing.model = record.model.clone();
existing.compaction_count = 0;
existing.steer = None;
existing.abort = None;
existing.stop_requested = false;
existing.consumed = false;
existing.done_rx = record.done_rx.clone();
}
None => {
let (handle, alias) = allocate_handles(st, &ty.name, req.name.as_deref());
let mut record = record;
record.handle = Some(handle);
record.alias = alias;
st.order.push(id.to_string());
st.records.insert(id.to_string(), record);
}
}
st.pending.insert(
id.to_string(),
PendingSpawn {
req: req.clone(),
ty: ty.clone(),
done_tx: Some(done_tx.clone()),
pending_steer: Vec::new(),
},
);
prune_finished(st);
let should_queue =
background && !req.bypass_queue && st.running_bg >= st.cfg.max_concurrent;
if should_queue {
st.queue.push_back(id.to_string());
if let Some(r) = st.records.get_mut(id) {
r.status = AgentStatus::Queued;
}
}
should_queue
})
}
async fn run_foreground(
ctx: &ToolExecCtx,
ty: &AgentType,
req: &SpawnRequest,
id: &str,
resume_path: Option<String>,
output: Option<PathBuf>,
) {
acquire_foreground_slot(&ctx.parent_abort).await;
let base_cwd = ctx.cwd.clone();
let work_cwd = match prepare_cwd(req, &base_cwd, id).await {
Ok(cwd) => cwd,
Err(e) => {
finish(id, Err(e), false);
return;
}
};
let mut runner =
match build_child_runner(ctx, ty, req, id, &work_cwd, &base_cwd, output, resume_path) {
Ok(r) => r,
Err(e) => {
finish(id, Err(e), false);
return;
}
};
let controls = runner.controls();
register_controls(id, &controls);
let outcome = run_linked(&mut runner, req.prompt.clone(), ctx.parent_abort.clone()).await;
let outcome = close_worktree(&base_cwd, id, outcome).await;
finish(id, outcome, false);
}
#[allow(clippy::too_many_arguments)]
fn build_child_runner(
ctx: &ToolExecCtx,
ty: &AgentType,
req: &SpawnRequest,
id: &str,
work_cwd: &str,
base_cwd: &str,
output: Option<PathBuf>,
resume_path: Option<String>,
) -> Result<Box<dyn SubAgentRunner>, String> {
let cfg = config();
let mut spec = build_spec(
ty,
req,
work_cwd,
Some(make_sink(id, output)),
resume_path,
cfg.max_subagent_depth,
cfg.default_max_turns,
cfg.grace_turns,
);
apply_work_cwd(&mut spec, base_cwd, work_cwd);
set_child_identity(&mut spec, id, req.depth);
materialize(ctx, spec)
}
fn mark_running(id: &str) {
with_state(|st| {
if let Some(r) = st.records.get_mut(id) {
r.status = AgentStatus::Running;
r.started_ms = now_ms();
}
});
}
fn set_child_identity(spec: &mut SubAgentSpec, id: &str, depth: u32) {
spec.agent_id = Some(id.to_string());
spec.depth = depth;
}
async fn prepare_cwd(req: &SpawnRequest, cwd: &str, id: &str) -> Result<String, String> {
if req.isolation.as_deref() != Some("worktree") {
return Ok(cwd.to_string());
}
if !config().worktree_isolation {
return Ok(cwd.to_string());
}
let wt = worktree::create(cwd, id).await.map_err(|e| {
format!(
"Cannot run with isolation: \"worktree\" — {e}. \
Initialize git and commit at least once, or omit `isolation`."
)
})?;
let work = wt.work_path.clone();
with_state(|st| {
if let Some(r) = st.records.get_mut(id) {
r.worktree = Some(wt);
}
});
Ok(work)
}
fn apply_work_cwd(spec: &mut SubAgentSpec, base_cwd: &str, work_cwd: &str) {
if work_cwd != base_cwd {
spec.cwd = Some(work_cwd.to_string());
}
}
async fn close_worktree(
base_cwd: &str,
id: &str,
outcome: Result<String, String>,
) -> Result<String, String> {
let found: Option<(Worktree, String)> = with_state(|st| {
st.records
.get(id)
.and_then(|r| r.worktree.clone().map(|w| (w, r.description.clone())))
});
let Some((mut wt, description)) = found else {
return outcome;
};
let result = worktree::cleanup(base_cwd, &mut wt, &description).await;
let note = match (result.has_changes, result.branch.as_deref()) {
(true, Some(branch)) => format!(
"\n\n[isolation: worktree — changes committed to branch {branch} in the main checkout]"
),
_ => String::new(),
};
with_state(|st| {
if let Some(r) = st.records.get_mut(id) {
r.worktree_result = Some(result.clone());
}
});
outcome.map(|text| format!("{text}{note}"))
}
async fn start_background(ctx: &ToolExecCtx, id: &str) {
let prepared = with_state(|st| {
if st.records.get(id).map(|r| r.status) != Some(AgentStatus::Queued) {
return None;
}
let p = st.pending.get(id)?;
let req = p.req.clone();
let ty = p.ty.clone();
let pending_steer = p.pending_steer.clone();
st.running_bg += 1;
if let Some(r) = st.records.get_mut(id) {
r.status = AgentStatus::Running;
r.started_ms = now_ms();
}
Some((req, ty, pending_steer))
});
let Some((req, ty, pending_steer)) = prepared else {
return;
};
if let Some(rec) = record(id) {
events::started(&rec);
}
let resume_path = if req.resume.is_some() {
with_state(|st| st.records.get(id).and_then(|r| r.session_path.clone()))
} else {
None
};
let output = req
.output_transcript
.then(|| open_output_file(&ctx.cwd, id))
.flatten();
if let Some(display) = output.as_ref().map(|p| p.display().to_string()) {
with_state(|st| {
if let Some(r) = st.records.get_mut(id) {
r.output_path = Some(display);
}
});
}
let base_cwd = ctx.cwd.clone();
let work_cwd = match prepare_cwd(&req, &base_cwd, id).await {
Ok(cwd) => cwd,
Err(e) => {
finish(id, Err(e), true);
start_next_queued(ctx);
return;
}
};
let mut runner = match build_child_runner(
ctx,
&ty,
&req,
id,
&work_cwd,
&base_cwd,
output,
resume_path,
) {
Ok(r) => r,
Err(e) => {
let e = close_worktree(&base_cwd, id, Err(e)).await.unwrap_err();
finish(id, Err(e), true);
start_next_queued(ctx);
return;
}
};
let controls = runner.controls();
register_controls(id, &controls);
let id_owned = id.to_string();
let ctx_owned = ctx.clone();
let prompt = req.prompt.clone();
let parent_abort = ctx.parent_abort.clone();
tokio::spawn(async move {
let outcome = run_linked(&mut runner, prompt, parent_abort).await;
let outcome = close_worktree(&base_cwd, &id_owned, outcome).await;
complete_background(&ctx_owned, &id_owned, outcome);
});
for msg in pending_steer {
controls.steer_text(msg);
}
}
fn start_next_queued(ctx: &ToolExecCtx) {
let ctx_owned = ctx.clone();
let Ok(handle) = tokio::runtime::Handle::try_current() else {
return;
};
handle.spawn(async move { start_next_queued_inner(&ctx_owned).await });
}
async fn start_next_queued_inner(ctx: &ToolExecCtx) {
let next = with_state(|st| {
while let Some(id) = st.queue.pop_front() {
match st.records.get(&id) {
Some(r) if r.status == AgentStatus::Queued => return Some(id),
_ => continue, }
}
None
});
if let Some(id) = next {
start_background(ctx, &id).await;
}
}
async fn acquire_foreground_slot(parent_abort: &Arc<AtomicBool>) {
loop {
let acquired = with_state(|st| {
let limit = st.cfg.foreground_max_concurrent;
if limit == 0 || st.running_fg < limit {
st.running_fg += 1;
true
} else {
false
}
});
if acquired || parent_abort.load(Ordering::Relaxed) {
return;
}
tokio::time::sleep(PARENT_ABORT_POLL).await;
}
}
async fn run_linked(
runner: &mut Box<dyn SubAgentRunner>,
prompt: String,
parent_abort: Arc<AtomicBool>,
) -> Result<String, String> {
let controls = runner.controls();
let run = runner.run(prompt);
let watch = wait_for_parent_abort(parent_abort);
futures_util::pin_mut!(run, watch);
match select(run, watch).await {
Either::Left((res, _)) => res,
Either::Right((_, run)) => {
controls.request_abort();
let _ = run.await;
Err("Operation aborted".to_string())
}
}
}
async fn wait_for_parent_abort(flag: Arc<AtomicBool>) {
while !flag.load(Ordering::Relaxed) {
tokio::time::sleep(PARENT_ABORT_POLL).await;
}
}
fn register_controls(id: &str, controls: &SubAgentControls) {
with_state(|st| {
if let Some(r) = st.records.get_mut(id) {
r.steer = Some(controls.steer.clone());
r.abort = Some(controls.abort.clone());
if controls.session_path.is_some() {
r.session_path = controls.session_path.clone();
}
}
});
}
fn finish(id: &str, outcome: Result<String, String>, background: bool) -> Option<AgentRecord> {
abort_owned_children(id);
let (notify_rec, event_rec) = with_state(|st| {
let failed = outcome.is_err();
if let Some(r) = st.records.get_mut(id) {
match &outcome {
Ok(text) if !text.trim().is_empty() => r.result = Some(text.clone()),
Ok(_) => {}
Err(e) => r.result = Some(e.clone()),
}
r.status = r.infer_terminal_status(failed);
if r.ended_ms.is_none() {
r.ended_ms = Some(now_ms());
}
}
let rollup = st
.records
.get(id)
.and_then(|r| r.parent_agent_id.clone().map(|pid| (pid, r.usage)));
if let Some((pid, usage)) = rollup
&& let Some(parent) = st.records.get_mut(&pid)
{
parent.usage.merge(&usage);
}
if let Some(tx) = st.pending.get(id).and_then(|p| p.done_tx.as_ref()) {
_ = tx.send(true);
}
if background {
st.running_bg = st.running_bg.saturating_sub(1);
} else {
st.running_fg = st.running_fg.saturating_sub(1);
}
let notify_rec = st
.records
.get(id)
.cloned()
.filter(|r| r.background && !r.consumed);
let event_rec = st.records.get(id).cloned();
prune_finished(st);
(notify_rec, event_rec)
});
if let Some(rec) = event_rec {
events::settled(&rec);
}
dismiss_finished();
notify_rec
}
fn prune_finished(st: &mut State) {
let finished: Vec<String> = st
.order
.iter()
.filter(|id| st.records.get(*id).is_some_and(|r| r.status.is_terminal()))
.cloned()
.collect();
if finished.len() <= MAX_FINISHED_RECORDS {
return;
}
let drop_count = finished.len() - MAX_FINISHED_RECORDS;
for id in finished.into_iter().take(drop_count) {
st.records.remove(&id);
st.pending.remove(&id);
st.order.retain(|o| o != &id);
}
}
fn find_record<'a>(st: &'a State, key: &str) -> Option<&'a AgentRecord> {
let key = key.trim();
let key = key.strip_prefix('@').unwrap_or(key);
if let Some(r) = st.records.get(key) {
return Some(r);
}
st.order
.iter()
.rev()
.filter_map(|id| st.records.get(id))
.find(|r| {
r.name.as_deref() == Some(key)
|| r.handle
.as_deref()
.is_some_and(|h| h.eq_ignore_ascii_case(key))
|| r.alias
.as_deref()
.is_some_and(|a| a.eq_ignore_ascii_case(key))
})
}
fn taken_handles(st: &State) -> HashSet<String> {
let mut set = HashSet::new();
for r in st.records.values() {
if let Some(h) = &r.handle {
set.insert(h.clone());
}
if let Some(a) = &r.alias {
set.insert(a.clone());
}
}
set
}
fn allocate_handles(st: &State, type_name: &str, name: Option<&str>) -> (String, Option<String>) {
let taken = taken_handles(st);
let handle = mention::assign_handle(&mention::handle_base(type_name), &taken);
let alias = name.map(|n| {
let mut scope = taken.clone();
scope.insert(handle.clone());
mention::assign_handle(&mention::handle_base(n), &scope)
});
(handle, alias)
}
pub fn resolve_mention(handle: &str) -> Option<AgentRecord> {
with_state(|st| {
st.order
.iter()
.filter_map(|id| st.records.get(id))
.find(|r| {
r.handle
.as_deref()
.is_some_and(|h| h.eq_ignore_ascii_case(handle))
|| r.alias
.as_deref()
.is_some_and(|a| a.eq_ignore_ascii_case(handle))
})
.cloned()
})
}
pub fn record(key: &str) -> Option<AgentRecord> {
with_state(|st| find_record(st, key).cloned())
}
pub async fn wait(key: &str) -> Option<AgentRecord> {
let mut rx = with_state(|st| find_record(st, key).and_then(|r| r.done_rx.clone()))?;
if !*rx.borrow() {
_ = rx.changed().await;
}
record(key)
}
pub fn consume_result(key: &str) -> bool {
with_state(|st| match find_record(st, key) {
Some(r) if r.status.is_terminal() => {
let id = r.id.clone();
if let Some(r) = st.records.get_mut(&id) {
r.consumed = true;
}
true
}
_ => false,
})
}
pub fn steer(key: &str, message: &str) -> Result<(), String> {
if message.trim().is_empty() {
return Err("steer message must not be empty".to_string());
}
let id = with_state(|st| -> Result<String, String> {
let Some(rec) = find_record(st, key) else {
return Err(format!("unknown agent id: {key}"));
};
let id = rec.id.clone();
let status = rec.status;
let steer = rec.steer.clone();
match status {
AgentStatus::Running => match steer {
Some(f) => {
f(message.to_string());
Ok(id)
}
None => Err(format!("agent {id} is starting; retry in a moment")),
},
AgentStatus::Queued => {
if let Some(p) = st.pending.get_mut(&id) {
p.pending_steer.push(message.to_string());
}
Ok(id)
}
other => Err(format!(
"agent {id} is {} and cannot be steered",
other.as_str()
)),
}
})?;
events::steered(&id, message);
Ok(())
}
pub fn stop(key: &str) -> Result<(), String> {
let (id, notify) = with_state(|st| {
let Some(rec) = find_record(st, key) else {
return Err(format!("unknown agent id: {key}"));
};
let id = rec.id.clone();
let status = rec.status;
if status.is_terminal() {
return Err(format!("agent {id} is already {}", status.as_str()));
}
let abort = rec.abort.clone();
let background = rec.background;
if let Some(r) = st.records.get_mut(&id) {
r.stop_requested = true;
}
let mut notify = false;
match abort {
Some(a) => a.store(true, Ordering::Relaxed),
None => {
st.queue.retain(|q| q != &id);
if let Some(r) = st.records.get_mut(&id) {
r.status = AgentStatus::Stopped;
r.ended_ms = Some(now_ms());
}
if let Some(tx) = st.pending.get(&id).and_then(|p| p.done_tx.as_ref()) {
_ = tx.send(true);
}
notify = background;
}
}
Ok((id, notify))
})?;
if notify {
notify_by_id(&id);
}
Ok(())
}
pub fn abort_top_level() {
let ids: Vec<String> = with_state(|st| {
st.order
.iter()
.filter_map(|id| st.records.get(id))
.filter(|r| events::is_top_level(r) && !r.status.is_terminal())
.map(|r| r.id.clone())
.collect()
});
for id in ids {
_ = stop(&id);
}
}
pub fn reset_all() {
with_state(|st| {
for id in st.order.clone() {
let abort = st.records.get(&id).and_then(|r| r.abort.clone());
if let Some(r) = st.records.get_mut(&id) {
r.stop_requested = true;
}
if let Some(a) = abort {
a.store(true, Ordering::Relaxed);
}
if let Some(tx) = st.pending.get(&id).and_then(|p| p.done_tx.as_ref()) {
let _ = tx.send(true);
}
}
st.records.clear();
st.pending.clear();
st.order.clear();
st.queue.clear();
st.running_bg = 0;
st.running_fg = 0;
st.warned.clear();
st.batch.clear();
st.batch_generation = st.batch_generation.wrapping_add(1);
st.dismissed_before_ms = 0;
});
reset_usage_pool();
}
pub(crate) enum BatchItem {
Agent(String),
Rendered {
envelope: String,
card: Option<(String, Value)>,
tokens: u64,
duration_ms: u64,
},
}
pub(crate) fn background_live() -> usize {
with_state(|st| st.running_bg)
}
pub(crate) fn batch_push(item: BatchItem, peers_live: bool) {
let (mode, generation) = with_state(|st| {
st.batch.push(item);
(st.cfg.join, st.batch_generation)
});
if mode == JoinMode::Async || !peers_live {
flush_batch();
return;
}
let window = mode.window_ms();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
tokio::time::sleep(Duration::from_millis(window)).await;
let stale = with_state(|st| st.batch_generation != generation);
if !stale {
flush_batch();
}
});
} else {
flush_batch();
}
}
pub fn list() -> Vec<AgentRecord> {
with_state(|st| {
let mut recs: Vec<AgentRecord> = st
.order
.iter()
.filter_map(|id| st.records.get(id).cloned())
.collect();
recs.sort_by_key(|r| {
(
if r.status.is_terminal() { 1 } else { 0 },
std::cmp::Reverse(r.ended_ms.unwrap_or(r.started_ms)),
)
});
recs
})
}
pub fn widget_lines() -> Vec<DockLine> {
let (records, queued, cfg) = with_state(|st| {
let dismissed_before = st.dismissed_before_ms;
let mut recs: Vec<AgentRecord> = st
.order
.iter()
.filter_map(|id| st.records.get(id).cloned())
.filter(|r| !r.status.is_terminal() || r.ended_ms.is_some_and(|e| e > dismissed_before))
.collect();
recs.sort_by_key(|r| {
(
if r.status.is_terminal() { 1 } else { 0 },
std::cmp::Reverse(r.ended_ms.unwrap_or(r.started_ms)),
)
});
(recs, st.queue.len(), st.cfg.clone())
});
widget::lines(&records, queued, &cfg)
}
pub(crate) fn dismiss_finished() {
let busy = with_state(|st| st.running_bg > 0 || st.running_fg > 0 || !st.queue.is_empty());
if busy || workflow::task::has_live() {
return;
}
let now = now_ms();
with_state(|st| st.dismissed_before_ms = now);
}
pub fn note_warning(key: &str) -> bool {
with_state(|st| st.warned.insert(key.to_string()))
}
fn notify_by_id(id: &str) {
let rec = with_state(|st| st.records.get(id).cloned());
if let Some(rec) = rec {
request_ui(ExtensionUiRequest::Continuation {
message: Box::new(notify::continuation_message(&rec)),
});
push_card(&rec);
}
}
fn deliver_card(custom_type: &str, payload: Value) {
request_ui(ExtensionUiRequest::CustomMessage {
label: EXT.to_string(),
custom_type: custom_type.to_string(),
data: payload.clone(),
});
request_ui(ExtensionUiRequest::PersistSessionEntry {
custom_type: custom_type.to_string(),
data: payload,
});
}
fn push_card(rec: &AgentRecord) {
deliver_card(CARD_TYPE, notify::card_payload(rec));
}
fn complete_background(ctx: &ToolExecCtx, id: &str, outcome: Result<String, String>) {
if finish(id, outcome, true).is_none() {
start_next_queued(ctx);
return;
}
let peers_live = with_state(|st| st.running_bg > 0);
batch_push(BatchItem::Agent(id.to_string()), peers_live);
start_next_queued(ctx);
}
fn flush_batch() {
let items = with_state(|st| {
let items = std::mem::take(&mut st.batch);
st.batch_generation = st.batch_generation.wrapping_add(1);
items
});
if items.is_empty() {
return;
}
let mut total_tokens: u64 = 0;
let mut total_duration: u64 = 0;
let mut envelopes: Vec<String> = Vec::new();
let mut cards: Vec<(String, Value)> = Vec::new();
for item in items {
match item {
BatchItem::Agent(id) => {
let rec = with_state(|st| st.records.get(&id).cloned());
if let Some(rec) = rec
&& !rec.consumed
{
total_tokens = total_tokens.saturating_add(rec.usage.display_total());
total_duration = total_duration.saturating_add(rec.duration_ms());
envelopes.push(notify::result_envelope(&rec));
cards.push((CARD_TYPE.to_string(), notify::card_payload(&rec)));
}
}
BatchItem::Rendered {
envelope,
card,
tokens,
duration_ms,
} => {
total_tokens = total_tokens.saturating_add(tokens);
total_duration = total_duration.saturating_add(duration_ms);
envelopes.push(envelope);
if let Some(c) = card {
cards.push(c);
}
}
}
}
if envelopes.is_empty() {
return;
}
let text = if envelopes.len() == 1 {
envelopes.remove(0)
} else {
notify::batch_envelope(&envelopes, total_tokens, total_duration)
};
request_ui(ExtensionUiRequest::Continuation {
message: Box::new(AgentMessage::user_text(&text)),
});
for (custom_type, data) in cards {
deliver_card(&custom_type, data);
}
}
#[cfg(test)]
pub(crate) fn pin_config_for_test() {
let me = std::thread::current().id();
with_state(|st| {
st.cfg_pinned = true;
st.cfg_pin_owner = Some(me);
});
}
#[cfg(test)]
pub(crate) fn unpin_config_for_test() {
with_state(|st| {
st.cfg_pinned = false;
st.cfg_pin_owner = None;
});
}
#[cfg(test)]
pub(crate) fn test_lock() -> TestLock {
static L: OnceLock<Mutex<()>> = OnceLock::new();
let guard = L
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|e| e.into_inner());
pin_config_for_test();
TestLock { _guard: guard }
}
#[cfg(test)]
pub(crate) struct TestLock {
_guard: std::sync::MutexGuard<'static, ()>,
}
#[cfg(test)]
impl Drop for TestLock {
fn drop(&mut self) {
unpin_config_for_test();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::extensions::{MakeSubAgentFn, SubAgentSpec};
use crate::extensions::subagent::types::AgentSource;
struct FakeRunner {
abort: Arc<AtomicBool>,
inbox: Arc<Mutex<Vec<String>>>,
hold_ms: u64,
}
impl SubAgentRunner for FakeRunner {
fn controls(&self) -> SubAgentControls {
let inbox = self.inbox.clone();
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,
) -> futures_util::future::BoxFuture<'_, Result<String, String>> {
Box::pin(async move {
if self.hold_ms > 0 {
let steps = self.hold_ms / 5;
for _ in 0..steps.max(1) {
if self.abort.load(Ordering::Relaxed) {
return Err("Operation aborted".to_string());
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
}
if self.abort.load(Ordering::Relaxed) {
return Err("Operation aborted".to_string());
}
Ok(format!("echo:{prompt}"))
})
}
}
#[derive(Default, Clone)]
struct SeenSpec {
inner: Arc<Mutex<Vec<SubAgentSpec>>>,
hold_ms: u64,
}
fn fake_ctx(seen: SeenSpec, parent_abort: Arc<AtomicBool>) -> ToolExecCtx {
let hold_ms = seen.hold_ms;
let make = Arc::new(move |spec: SubAgentSpec| {
seen.inner.lock().unwrap().push(spec.clone());
Ok(Box::new(FakeRunner {
abort: Arc::new(AtomicBool::new(false)),
inbox: Arc::new(Mutex::new(Vec::new())),
hold_ms,
}) as Box<dyn SubAgentRunner>)
}) as Arc<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,
agent_id: None,
depth: 0,
parent_model: None,
script_call: false,
}
}
fn ty(name: &str) -> AgentType {
AgentType {
name: name.to_string(),
display_name: name.to_string(),
description: "d".to_string(),
color: None,
tools: Some(vec!["read".to_string()]),
model: None,
thinking: None,
max_turns: None,
prompt_mode: PromptMode::Replace,
enabled: true,
persist_session: None,
session_dir: None,
system_prompt: "you are a specialist".to_string(),
source: AgentSource::Builtin,
source_path: None,
disallowed_tools: None,
allowed_subagents: Some(Vec::new()),
memory: None,
isolated: None,
isolation: None,
extensions: None,
extensions_none: false,
exclude_extensions: Vec::new(),
skills: None,
output_transcript: None,
run_in_background: None,
inherit_context: None,
ignored_fields: Vec::new(),
}
}
fn req(prompt: &str, background: bool) -> SpawnRequest {
SpawnRequest {
prompt: prompt.to_string(),
description: "task".to_string(),
name: None,
tools: Some(vec!["read".to_string()]),
model: None,
model_source: crate::extensions::subagent::model::ModelSource::Inherited,
bypass_queue: false,
isolation: None,
parent_agent_id: None,
depth: 0,
thinking: None,
max_turns: None,
run_in_background: background,
inherit_context: false,
disallowed_tools: None,
isolated: false,
extensions: None,
exclude_extensions: Vec::new(),
skills: Vec::new(),
clear_skills: false,
output_transcript: false,
persist_session: false,
session_dir: None,
resume: None,
injected_tools: Vec::new(),
workflow_owned: false,
}
}
fn lock() -> super::TestLock {
let guard = super::test_lock();
super::pin_config_for_test();
let key = config::config_key();
#[cfg(test)]
eprintln!("DEBUG lock() acquire");
with_state(|st| {
st.cfg = SubagentConfig::default();
st.cfg_key = key;
});
guard
}
fn widget_text(line: &DockLine) -> String {
line.iter().map(|s| s.text.as_str()).collect()
}
#[test]
fn dispatch_allocates_handles_and_resolves_mentions() {
let _g = lock();
reset_all();
rt().block_on(async {
let ctx = fake_ctx(SeenSpec::default(), Arc::new(AtomicBool::new(false)));
let d1 = dispatch(&ctx, &ty("Explore"), req("a", true))
.await
.unwrap();
let r1 = record(&d1.id).unwrap();
assert_eq!(r1.handle.as_deref(), Some("explore"));
assert!(r1.alias.is_none());
let d2 = dispatch(&ctx, &ty("Explore"), req("b", true))
.await
.unwrap();
assert_eq!(record(&d2.id).unwrap().handle.as_deref(), Some("explore-2"));
assert_eq!(resolve_mention("EXPLORE-2").unwrap().id, d2.id);
let mut named = req("c", true);
named.name = Some("Probe One".to_string());
let d3 = dispatch(&ctx, &ty("Explore"), named).await.unwrap();
let r3 = record(&d3.id).unwrap();
assert_eq!(r3.handle.as_deref(), Some("explore-3"));
assert_eq!(r3.alias.as_deref(), Some("probe-one"));
assert_eq!(resolve_mention("probe-one").unwrap().id, d3.id);
with_state(|st| {
if let Some(r) = st.records.get_mut(&d1.id) {
r.session_path = Some("/tmp/s.jsonl".to_string());
}
});
let mut resume = req("more", true);
resume.resume = Some(d1.id.clone());
let dr = dispatch(&ctx, &ty("Explore"), resume).await.unwrap();
assert_eq!(dr.id, d1.id);
assert_eq!(record(&d1.id).unwrap().handle.as_deref(), Some("explore"));
});
reset_all();
}
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Runtime::new().unwrap()
}
fn drain_dock_requests() -> usize {
let mut n = 0;
while let Some(req) = crate::core::extensions::take_pending_ui() {
if matches!(req, ExtensionUiRequest::ShowDock) {
n += 1;
}
}
n
}
#[test]
fn dispatch_auto_opens_the_dock_for_visible_records() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = lock();
reset_all();
drain_dock_requests();
let ctx = fake_ctx(SeenSpec::default(), Arc::new(AtomicBool::new(false)));
rt().block_on(async {
dispatch(&ctx, &ty("Explore"), req("go", true))
.await
.unwrap();
dispatch(&ctx, &ty("Explore"), req("also", true))
.await
.unwrap();
});
assert_eq!(
drain_dock_requests(),
1,
"可见代理应请求打开停靠面板(重复请求合并为一条)"
);
with_state(|st| st.cfg.widget = config::WidgetMode::Off);
rt().block_on(async {
dispatch(&ctx, &ty("Explore"), req("again", true))
.await
.unwrap();
});
assert_eq!(drain_dock_requests(), 0, "widget=off 不应请求打开面板");
reset_all();
}
#[test]
fn foreground_spec_uses_replace_prompt_and_eager_finish() {
let _g = lock();
reset_all();
let seen = SeenSpec::default();
let ctx = fake_ctx(seen.clone(), Arc::new(AtomicBool::new(false)));
rt().block_on(async {
let d = dispatch(&ctx, &ty("Explore"), req("go", false))
.await
.unwrap();
assert!(!d.background);
let rec = record(&d.id).unwrap();
assert_eq!(rec.status, AgentStatus::Completed);
assert_eq!(rec.result.as_deref(), Some("echo:go"));
assert!(rec.ended_ms.is_some());
});
let specs = seen.inner.lock().unwrap();
let spec = &specs[0];
assert!(spec.clear_context_files);
assert!(
spec.system_prompt
.as_deref()
.unwrap()
.starts_with("<active_agent name=\"Explore\"/>")
);
assert!(
spec.system_prompt
.as_deref()
.unwrap()
.contains("you are a specialist")
);
assert!(spec.append_system_prompt.is_none());
assert!(spec.resume_session_path.is_none());
drop(specs);
reset_all();
}
#[test]
fn memory_injects_block_and_tools() {
let _g = lock();
let _ad = crate::test_support::AgentDirGuard::temp();
reset_all();
let seen = SeenSpec::default();
let ctx = fake_ctx(seen.clone(), Arc::new(AtomicBool::new(false)));
let mut t = ty("Explore");
t.memory = Some(crate::extensions::subagent::types::MemoryScope::User);
rt().block_on(async {
dispatch(&ctx, &t, req("go", false)).await.unwrap();
});
{
let specs = seen.inner.lock().unwrap();
let ap = specs[0].append_system_prompt.as_deref().unwrap();
assert!(ap.contains("Agent Memory"), "{ap}");
assert!(ap.contains("read-only"), "{ap}");
}
seen.inner.lock().unwrap().clear();
let mut rw = req("go", false);
rw.tools = Some(vec!["read".into(), "write".into()]);
rt().block_on(async {
dispatch(&ctx, &t, rw).await.unwrap();
});
{
let specs = seen.inner.lock().unwrap();
let ap = specs[0].append_system_prompt.as_deref().unwrap();
assert!(ap.contains("Memory Instructions"), "{ap}");
let tools = specs[0].tools.as_ref().unwrap();
for n in ["read", "write", "edit"] {
assert!(tools.iter().any(|t| t == n), "{tools:?}");
}
}
reset_all();
}
#[test]
fn append_mode_wraps_instructions_and_bridge() {
let mut t = ty("general-purpose");
t.prompt_mode = PromptMode::Append;
let spec = build_spec(&t, &req("go", false), "/tmp", None, None, 2, 0, 5);
assert!(spec.system_prompt.is_none());
assert!(!spec.clear_context_files);
let ap = spec.append_system_prompt.unwrap();
assert!(ap.contains("<sub_agent_context>"));
assert!(ap.contains("<agent_instructions>"));
assert!(ap.contains("<active_agent name=\"general-purpose\"/>"));
}
#[test]
fn scope_models_refuses_caller_override_and_warns_for_frontmatter() {
let _ad = crate::test_support::AgentDirGuard::temp();
let _g = test_lock();
let cwd = tempfile::tempdir().unwrap();
let cwd = cwd.path().to_string_lossy().to_string();
let rt = tokio::runtime::Runtime::new().unwrap();
let mut ty = ty("audit");
let cfg = config::SubagentConfig {
scope_models: true,
..Default::default()
};
config::write_config(&config::config_path(), &cfg);
reload_config();
crate::extensions::subagent::model::set_catalog_for_test(&[
("anthropic", "claude-haiku-4-5", "Claude Haiku 4.5"),
("anthropic", "claude-sonnet-4-5", "Claude Sonnet 4.5"),
]);
crate::core::settings_manager::write_enabled_models(Some(&[
"anthropic/claude-haiku-4-5".to_string()
]))
.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: cwd.clone(),
make_sub_agent: std::sync::Arc::new(|_| Err("unused".to_string())),
parent_abort: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
agent_id: None,
depth: 0,
parent_model: None,
script_call: false,
};
let mut caller_req = req("x", false);
caller_req.model = Some("anthropic/claude-sonnet-4-5".to_string());
caller_req.model_source = crate::extensions::subagent::model::ModelSource::Caller;
let err = rt.block_on(dispatch(&ctx, &ty, caller_req)).unwrap_err();
assert!(err.contains("Model not in scope"), "{err}");
ty.model = Some("anthropic/claude-sonnet-4-5".to_string());
let mut fm_req = req("x", false);
fm_req.model = Some("anthropic/claude-sonnet-4-5".to_string());
fm_req.model_source = crate::extensions::subagent::model::ModelSource::Frontmatter;
let dispatched = rt
.block_on(dispatch(&ctx, &ty, fm_req))
.expect("frontmatter 越界不该拒绝");
assert!(record(&dispatched.id).is_some());
for _ in 0..100 {
if record(&dispatched.id).is_some_and(|r| r.status.is_terminal()) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
let mut bad_req = req("x", false);
bad_req.model = Some("no-such-model-xyz".to_string());
bad_req.model_source = crate::extensions::subagent::model::ModelSource::Caller;
let err = rt.block_on(dispatch(&ctx, &ty, bad_req)).unwrap_err();
assert!(err.contains("Model not found"), "{err}");
config::write_config(&config::config_path(), &config::SubagentConfig::default());
reload_config();
crate::core::settings_manager::write_enabled_models(None).unwrap();
crate::extensions::subagent::model::set_catalog_for_test(&[]);
reset_all();
}
#[test]
fn worktree_isolation_runs_in_a_copy_and_fails_loudly_outside_a_repo() {
let _ad = crate::test_support::AgentDirGuard::temp();
let _g = test_lock();
let rt = tokio::runtime::Runtime::new().unwrap();
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path().to_string_lossy().to_string();
let git = |args: &[&str]| {
std::process::Command::new("git")
.args(args)
.current_dir(&cwd)
.output()
.expect("git")
};
if !git(&["init", "-q"]).status.success() {
return; }
git(&["config", "user.email", "t@example.com"]);
git(&["config", "user.name", "t"]);
std::fs::write(dir.path().join("a.txt"), "one\n").unwrap();
git(&["add", "-A"]);
git(&["commit", "-qm", "init"]);
let wait_terminal = |id: &str| {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
if let Some(r) = record(id)
&& r.status.is_terminal()
{
return r;
}
assert!(
std::time::Instant::now() < deadline,
"agent 没在预期时间内结束"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
};
reset_all();
let seen = SeenSpec {
hold_ms: 300,
..Default::default()
};
let mut ctx = fake_ctx(seen.clone(), Arc::new(AtomicBool::new(false)));
ctx.cwd = cwd.clone(); let mut isolated = req("x", true);
isolated.isolation = Some("worktree".to_string());
let dispatched = rt
.block_on(dispatch(&ctx, &ty("general-purpose"), isolated))
.expect("dispatch");
let spec_cwd = seen.inner.lock().unwrap()[0]
.cwd
.clone()
.expect("隔离 spawn 的 spec 应带 cwd");
assert!(spec_cwd.contains("prux-agent-"), "工作在副本里: {spec_cwd}");
assert!(
std::path::Path::new(&spec_cwd).join("a.txt").exists(),
"副本应是仓库的拷贝: {spec_cwd}"
);
std::fs::write(std::path::Path::new(&spec_cwd).join("a.txt"), "two\n").unwrap();
let rec = wait_terminal(&dispatched.id);
assert_eq!(rec.status, AgentStatus::Completed, "{:?}", rec.result);
let wt_result = rec.worktree_result.clone().expect("worktree 收尾结果");
assert!(wt_result.has_changes, "有改动应如实报");
let branch = wt_result.branch.clone().expect("分支名");
assert!(branch.starts_with("prux-agent-"), "{branch}");
assert!(
rec.result
.as_deref()
.is_some_and(|r| r.contains(&branch) && r.contains("worktree")),
"结果正文要点名分支: {:?}",
rec.result
);
assert!(!std::path::Path::new(&spec_cwd).exists(), "副本收尾后删掉");
let outside = tempfile::tempdir().unwrap();
let outside_ctx = fake_ctx(SeenSpec::default(), Arc::new(AtomicBool::new(false)));
let mut ctx_outside = outside_ctx.clone();
ctx_outside.cwd = outside.path().to_string_lossy().to_string();
reset_all();
let mut isolated = req("x", true);
isolated.isolation = Some("worktree".to_string());
let dispatched = rt
.block_on(dispatch(&ctx_outside, &ty("general-purpose"), isolated))
.expect("dispatch 本身成功(失败落在记录里)");
let rec = wait_terminal(&dispatched.id);
assert_eq!(rec.status, AgentStatus::Error);
assert!(
rec.result
.as_deref()
.is_some_and(|r| r.contains("Cannot run with isolation")),
"{:?}",
rec.result
);
let cfg = config::SubagentConfig {
worktree_isolation: false,
..Default::default()
};
config::write_config(&config::config_path(), &cfg);
reload_config();
reset_all();
let seen2 = SeenSpec::default();
let mut ctx2 = fake_ctx(seen2.clone(), Arc::new(AtomicBool::new(false)));
ctx2.cwd = cwd.clone();
let mut isolated = req("x", true);
isolated.isolation = Some("worktree".to_string());
let dispatched = rt
.block_on(dispatch(&ctx2, &ty("general-purpose"), isolated))
.expect("dispatch");
let spec_cwd = seen2.inner.lock().unwrap()[0].cwd.clone();
assert!(
spec_cwd.is_none(),
"关掉后不建副本:spec.cwd 留空 = 继承父级目录(也就是原目录)"
);
wait_terminal(&dispatched.id);
config::write_config(&config::config_path(), &config::SubagentConfig::default());
reload_config();
reset_all();
}
#[test]
fn tools_and_model_are_forwarded_to_spec() {
let t = ty("E");
let mut r = req("go", false);
r.tools = Some(vec!["read".to_string(), "grep".to_string()]);
r.model = Some("anthropic/claude-haiku-4-5".to_string());
r.thinking = Some("high".to_string());
r.max_turns = Some(7);
let spec = build_spec(
&t,
&r,
"/tmp",
None,
Some("/tmp/s.jsonl".to_string()),
2,
0,
5,
);
let tools = spec.tools.as_ref().unwrap();
assert_eq!(&tools[..2], &["read".to_string(), "grep".to_string()]);
for name in crate::extensions::subagent::nested::NESTED_TOOL_NAMES {
assert!(tools.iter().any(|t| t == name), "缺 {name}: {tools:?}");
}
assert_eq!(spec.model.as_deref(), Some("anthropic/claude-haiku-4-5"));
assert_eq!(spec.thinking.as_deref(), Some("high"));
assert_eq!(spec.max_turns, Some(7));
assert_eq!(spec.resume_session_path.as_deref(), Some("/tmp/s.jsonl"));
let mut deep = req("go", false);
deep.tools = Some(vec!["read".to_string()]);
deep.depth = 2;
let spec = build_spec(&t, &deep, "/tmp", None, None, 2, 0, 5);
assert_eq!(
spec.tools.as_ref().unwrap(),
&vec!["read".to_string()],
"到顶就不再写进白名单"
);
let mut child = req("go", false);
child.tools = Some(vec!["read".to_string()]);
child.depth = 1;
let spec = build_spec(&t, &child, "/tmp", None, None, 1, 0, 5);
assert_eq!(
spec.tools.as_ref().unwrap(),
&vec!["read".to_string()],
"上限 1 = 子代理不能再派(depth 1 是主会话的子代理)"
);
let spec = build_spec(&t, &child, "/tmp", None, None, 2, 0, 5);
assert!(
spec.tools.as_ref().unwrap().iter().any(|t| t == "Agent"),
"上限 2 时 depth 1 还能再派一代"
);
}
#[test]
fn build_spec_applies_default_max_turns_and_grace_turns() {
let t = ty("E");
let spec = build_spec(&t, &req("go", false), "/tmp", None, None, 2, 12, 3);
assert_eq!(spec.max_turns, Some(12), "调用方未给时取配置缺省");
assert_eq!(spec.grace_turns, Some(3));
let mut r = req("go", false);
r.max_turns = Some(2);
let spec = build_spec(&t, &r, "/tmp", None, None, 2, 12, 3);
assert_eq!(spec.max_turns, Some(2));
let spec = build_spec(&t, &req("go", false), "/tmp", None, None, 2, 0, 5);
assert_eq!(spec.max_turns, None);
}
#[test]
fn nested_tools_are_opt_in_via_allowed_subagents() {
let mut t = ty("Boss");
t.allowed_subagents = None;
let spec = build_spec(&t, &req("go", false), "/tmp", None, None, 2, 0, 5);
assert!(
!spec.tools.as_ref().unwrap().iter().any(|x| x == "Agent"),
"没写 allowed_subagents 不应放行委派工具: {:?}",
spec.tools
);
t.allowed_subagents = Some(vec!["Explore".to_string()]);
let spec = build_spec(&t, &req("go", false), "/tmp", None, None, 2, 0, 5);
assert!(spec.tools.as_ref().unwrap().iter().any(|x| x == "Agent"));
let mut r = req("go", false);
r.isolated = true;
let spec = build_spec(&t, &r, "/tmp", None, None, 2, 0, 5);
assert!(
!spec.tools.as_ref().unwrap().iter().any(|x| x == "Agent"),
"isolated 不应放行委派工具: {:?}",
spec.tools
);
}
#[test]
fn caller_identity_and_ownership_scope() {
let _g = test_lock();
reset_all();
let parent_abort = Arc::new(AtomicBool::new(false));
let child_abort = Arc::new(AtomicBool::new(false));
let mut rec = crate::extensions::subagent::types::AgentRecord {
id: "child1".to_string(),
name: None,
handle: None,
alias: None,
agent_type: "general-purpose".to_string(),
display_name: "g".to_string(),
description: "d".to_string(),
status: AgentStatus::Running,
background: false,
color: None,
activity: None,
turns: 0,
tool_uses: 0,
usage: Default::default(),
started_ms: 0,
ended_ms: None,
result: None,
transcript: Vec::new(),
session_path: None,
output_path: None,
max_turns: None,
steer: None,
abort: Some(child_abort.clone()),
stop_requested: false,
worktree: None,
worktree_result: None,
parent_agent_id: None,
depth: 1,
workflow_owned: false,
model: None,
compaction_count: 0,
consumed: false,
done_rx: None,
};
rec.id = "child1".to_string();
with_state(|st| {
st.records.insert("child1".to_string(), rec);
});
let ctx_for = |abort: &Arc<AtomicBool>, id: Option<&str>, depth: u32| 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: abort.clone(),
agent_id: id.map(str::to_string),
depth,
parent_model: None,
script_call: false,
};
let main = caller_of(&ctx_for(&parent_abort, None, 0));
assert!(main.agent_id.is_none());
assert_eq!(main.depth, 0);
let child = caller_of(&ctx_for(&child_abort, Some("child1"), 1));
assert_eq!(child.agent_id.as_deref(), Some("child1"));
assert_eq!(child.depth, 1);
assert!(owned_record(&main, "child1").is_ok(), "主会话不受限");
assert!(owned_record(&child, "child1").is_err(), "不能看自己");
let mut grandchild = crate::extensions::subagent::types::AgentRecord {
id: "g1".to_string(),
name: None,
handle: None,
alias: None,
agent_type: "general-purpose".to_string(),
display_name: "g".to_string(),
description: "d".to_string(),
status: AgentStatus::Completed,
background: false,
color: None,
activity: None,
turns: 0,
tool_uses: 0,
usage: Default::default(),
started_ms: 0,
ended_ms: None,
result: None,
transcript: Vec::new(),
session_path: None,
output_path: None,
max_turns: None,
steer: None,
abort: None,
stop_requested: false,
worktree: None,
worktree_result: None,
parent_agent_id: Some("child1".to_string()),
depth: 2,
workflow_owned: false,
model: None,
compaction_count: 0,
consumed: false,
done_rx: None,
};
grandchild.parent_agent_id = Some("child1".to_string());
with_state(|st| {
st.records.insert("g1".to_string(), grandchild);
});
assert!(owned_record(&child, "g1").is_ok(), "自己的子代理可见");
match owned_record(&main, "nope") {
Err(e) => assert!(e.contains("unknown agent id"), "{e}"),
Ok(_) => panic!("未知 id 不该查到记录"),
}
reset_all();
}
#[test]
fn nested_child_usage_rolls_up_to_parent() {
let _g = lock();
reset_all();
let make = |id: &str, parent: Option<&str>, depth: u32, usage: UsageTotals| AgentRecord {
id: id.to_string(),
name: None,
handle: None,
alias: None,
agent_type: "general-purpose".to_string(),
display_name: id.to_string(),
description: "d".to_string(),
status: AgentStatus::Running,
background: false,
color: None,
activity: None,
turns: 0,
tool_uses: 0,
usage,
started_ms: 0,
ended_ms: None,
result: None,
transcript: Vec::new(),
session_path: None,
output_path: None,
max_turns: None,
steer: None,
abort: None,
stop_requested: false,
worktree: None,
worktree_result: None,
parent_agent_id: parent.map(str::to_string),
depth,
workflow_owned: false,
model: None,
compaction_count: 0,
consumed: false,
done_rx: None,
};
with_state(|st| {
st.records.insert(
"p1".to_string(),
make("p1", None, 0, UsageTotals::default()),
);
st.records.insert(
"c1".to_string(),
make(
"c1",
Some("p1"),
1,
UsageTotals {
input: 100,
output: 50,
cache_read: 0,
cache_write: 10,
cost: 0.01,
},
),
);
});
finish("c1", Ok("done".into()), false);
let parent = record("p1").expect("parent");
assert_eq!(parent.usage.input, 100);
assert_eq!(parent.usage.output, 50);
assert_eq!(parent.usage.cache_write, 10);
assert!((parent.usage.cost - 0.01).abs() < 1e-9);
reset_all();
}
#[test]
fn foreground_follows_parent_abort() {
let _g = lock();
reset_all();
let seen = SeenSpec {
hold_ms: 1_000,
..Default::default()
};
let parent_abort = Arc::new(AtomicBool::new(false));
let ctx = fake_ctx(seen, parent_abort.clone());
let flag = parent_abort.clone();
rt().block_on(async move {
let handle =
tokio::spawn(
async move { dispatch(&ctx, &ty("E"), req("go", false)).await.unwrap() },
);
tokio::time::sleep(Duration::from_millis(60)).await;
flag.store(true, Ordering::Relaxed);
let d = handle.await.unwrap();
let rec = record(&d.id).unwrap();
assert!(rec.status.is_terminal(), "status={}", rec.status.as_str());
assert_eq!(rec.result.as_deref(), Some("Operation aborted"));
});
reset_all();
}
#[test]
fn background_follows_parent_abort() {
let _g = lock();
reset_all();
let seen = SeenSpec {
hold_ms: 1_000,
..Default::default()
};
let parent_abort = Arc::new(AtomicBool::new(false));
let ctx = fake_ctx(seen, parent_abort.clone());
let flag = parent_abort.clone();
rt().block_on(async move {
let d = dispatch(&ctx, &ty("E"), req("go", true)).await.unwrap();
assert!(d.background);
tokio::time::sleep(Duration::from_millis(60)).await;
assert_eq!(record(&d.id).unwrap().status, AgentStatus::Running);
flag.store(true, Ordering::Relaxed);
for _ in 0..200 {
if record(&d.id)
.map(|r| r.status.is_terminal())
.unwrap_or(true)
{
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let rec = record(&d.id).unwrap();
assert!(rec.status.is_terminal(), "status={}", rec.status.as_str());
assert_eq!(rec.result.as_deref(), Some("Operation aborted"));
});
reset_all();
}
#[test]
fn abort_top_level_spares_nested_and_workflow_owned_records() {
let _g = lock();
reset_all();
let seen = SeenSpec {
hold_ms: 1_000,
..Default::default()
};
let ctx = fake_ctx(seen, Arc::new(AtomicBool::new(false)));
rt().block_on(async {
let d = dispatch(&ctx, &ty("E"), req("go", true)).await.unwrap();
tokio::time::sleep(Duration::from_millis(60)).await;
assert_eq!(record(&d.id).unwrap().status, AgentStatus::Running);
let make = |id: &str, parent: Option<&str>, workflow_owned: bool| AgentRecord {
id: id.to_string(),
name: None,
handle: None,
alias: None,
agent_type: "E".to_string(),
display_name: id.to_string(),
description: "d".to_string(),
status: AgentStatus::Running,
background: true,
color: None,
activity: None,
turns: 0,
tool_uses: 0,
usage: UsageTotals::default(),
started_ms: 0,
ended_ms: None,
result: None,
transcript: Vec::new(),
session_path: None,
output_path: None,
max_turns: None,
steer: None,
abort: None,
stop_requested: false,
worktree: None,
worktree_result: None,
parent_agent_id: parent.map(str::to_string),
depth: 1,
workflow_owned,
model: None,
compaction_count: 0,
consumed: false,
done_rx: None,
};
with_state(|st| {
for rec in [make("nested", Some("p"), false), make("wf", None, true)] {
st.order.push(rec.id.clone());
st.records.insert(rec.id.clone(), rec);
}
});
abort_top_level();
for _ in 0..200 {
if record(&d.id)
.map(|r| r.status.is_terminal())
.unwrap_or(true)
{
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(record(&d.id).unwrap().status.is_terminal());
assert_eq!(record("nested").unwrap().status, AgentStatus::Running);
assert_eq!(record("wf").unwrap().status, AgentStatus::Running);
});
reset_all();
}
#[test]
fn foreground_run_survives_dropped_dispatch_and_finalizes() {
let _g = lock();
reset_all();
let seen = SeenSpec {
hold_ms: 1_000,
..Default::default()
};
let parent_abort = Arc::new(AtomicBool::new(false));
let ctx = fake_ctx(seen, parent_abort.clone());
let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let cap = captured.clone();
rt().block_on(async move {
let outer = tokio::spawn(async move {
let cb = Arc::new(move |id: String| {
*cap.lock().unwrap() = Some(id);
});
dispatch_with_id(&ctx, &ty("E"), req("go", false), Some(cb)).await
});
for _ in 0..200 {
if captured.lock().unwrap().is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
let id = captured.lock().unwrap().clone().expect("记录应已分配 id");
outer.abort();
let _ = outer.await;
parent_abort.store(true, Ordering::Relaxed);
for _ in 0..200 {
if record(&id).map(|r| r.status.is_terminal()).unwrap_or(true) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let rec = record(&id).expect("记录不应消失");
assert!(
rec.status.is_terminal(),
"前台 run 必须自收尾,不能停在 running: {}",
rec.status.as_str()
);
});
reset_all();
}
#[test]
fn background_queues_when_pool_is_full_and_can_be_steered_and_stopped() {
let _g = lock();
reset_all();
with_state(|st| st.running_bg = st.cfg.max_concurrent);
let seen = SeenSpec::default();
let ctx = fake_ctx(seen, Arc::new(AtomicBool::new(false)));
rt().block_on(async {
let d = dispatch(&ctx, &ty("E"), req("t", true)).await.unwrap();
assert!(d.queued, "pool is full → must queue");
assert_eq!(record(&d.id).unwrap().status, AgentStatus::Queued);
assert!(
widget_lines()
.first()
.is_some_and(|l| widget_text(l).contains("1 queued")),
"标题行应显示排队数"
);
steer(&d.id, "hurry").unwrap();
with_state(|st| {
assert_eq!(st.pending.get(&d.id).unwrap().pending_steer, vec!["hurry"]);
});
stop(&d.id).unwrap();
assert_eq!(record(&d.id).unwrap().status, AgentStatus::Stopped);
let head = widget_lines().first().cloned().unwrap_or_default();
assert!(
!widget_text(&head).contains("queued"),
"queued agent left the pool: {}",
widget_text(&head)
);
});
reset_all();
}
#[test]
fn wait_resolves_for_queued_agent_that_is_stopped() {
let _g = lock();
reset_all();
with_state(|st| st.running_bg = st.cfg.max_concurrent);
let seen = SeenSpec::default();
let ctx = fake_ctx(seen, Arc::new(AtomicBool::new(false)));
rt().block_on(async {
let d = dispatch(&ctx, &ty("E"), req("t", true)).await.unwrap();
assert!(d.queued);
let id = d.id.clone();
let waiter = tokio::spawn(async move { wait(&id).await });
tokio::time::sleep(Duration::from_millis(20)).await;
stop(&d.id).unwrap();
let rec = waiter.await.unwrap().unwrap();
assert_eq!(rec.status, AgentStatus::Stopped);
});
reset_all();
}
#[test]
fn background_completion_releases_slot_and_notifies() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = lock();
reset_all();
while crate::core::extensions::take_pending_ui().is_some() {}
let seen = SeenSpec::default();
let ctx = fake_ctx(seen, Arc::new(AtomicBool::new(false)));
let mut kinds: Vec<&'static str> = Vec::new();
let mut card_data = None;
rt().block_on(async {
let d = dispatch(&ctx, &ty("E"), req("hello", true)).await.unwrap();
assert!(!d.queued);
let rec = wait(&d.id).await.unwrap();
assert_eq!(rec.status, AgentStatus::Completed);
assert_eq!(rec.result.as_deref(), Some("echo:hello"));
for _ in 0..50 {
while let Some(req) = crate::core::extensions::take_pending_ui() {
match req {
ExtensionUiRequest::Continuation { message } => {
let text = message.text();
if text.contains("<subagent_result") {
assert!(text.contains("echo:hello"), "{text}");
kinds.push("continuation");
}
}
ExtensionUiRequest::CustomMessage {
custom_type, data, ..
} if custom_type == notify::CARD_TYPE => {
assert_eq!(data["status"], "completed");
assert!(data["preview"].as_str().unwrap().contains("echo:hello"));
card_data = Some(data);
kinds.push("card");
}
ExtensionUiRequest::PersistSessionEntry { custom_type, data }
if custom_type == notify::CARD_TYPE =>
{
assert_eq!(Some(data), card_data.clone(), "落盘载荷与卡片一致");
kinds.push("persist");
}
_ => {}
}
}
if kinds.len() >= 3 {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
});
kinds.sort_unstable();
assert_eq!(kinds, vec!["card", "continuation", "persist"]);
reset_all();
}
#[test]
fn rendered_batch_items_merge_with_agent_notifications() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = lock();
reset_all();
while crate::core::extensions::take_pending_ui().is_some() {}
with_state(|st| {
st.cfg = SubagentConfig {
join: JoinMode::Smart,
..Default::default()
}
});
batch_push(
BatchItem::Rendered {
envelope: "<workflow_result id=\"wf_x\">ok</workflow_result>".to_string(),
card: Some((
"subagent-workflow".to_string(),
serde_json::json!({ "name": "demo" }),
)),
tokens: 120,
duration_ms: 500,
},
false,
);
let mut contracts: Vec<String> = Vec::new();
let mut cards: Vec<String> = Vec::new();
while let Some(r) = crate::core::extensions::take_pending_ui() {
match r {
ExtensionUiRequest::Continuation { message } => contracts.push(message.text()),
ExtensionUiRequest::CustomMessage { custom_type, .. } => cards.push(custom_type),
_ => {}
}
}
assert_eq!(contracts.len(), 1, "{contracts:?}");
assert!(contracts[0].contains("workflow_result"), "{}", contracts[0]);
assert_eq!(cards, vec!["subagent-workflow".to_string()]);
let mut merged: Vec<String> = Vec::new();
rt().block_on(async {
batch_push(
BatchItem::Rendered {
envelope: "<workflow_result id=\"wf_y\">ok</workflow_result>".to_string(),
card: None,
tokens: 30,
duration_ms: 200,
},
true,
);
batch_push(
BatchItem::Rendered {
envelope: "<subagent_result id=\"ab\">hi</subagent_result>".to_string(),
card: None,
tokens: 10,
duration_ms: 100,
},
true,
);
flush_batch();
while let Some(r) = crate::core::extensions::take_pending_ui() {
if let ExtensionUiRequest::Continuation { message } = r {
merged.push(message.text());
}
}
});
assert_eq!(merged.len(), 1, "{merged:?}");
assert!(
merged[0].contains("<subagent_results count=\"2\" tokens=\""),
"{}",
merged[0]
);
assert!(
merged[0].contains("wf_y") && merged[0].contains("ab"),
"{}",
merged[0]
);
reset_all();
}
#[test]
fn join_batches_concurrent_background_completions() {
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _g = lock();
reset_all();
while crate::core::extensions::take_pending_ui().is_some() {}
with_state(|st| {
st.cfg = SubagentConfig {
join: JoinMode::Smart,
..Default::default()
}
});
let slow = SeenSpec {
hold_ms: 200,
..Default::default()
};
let fast = SeenSpec {
hold_ms: 10,
..Default::default()
};
let ctx_slow = fake_ctx(slow, Arc::new(AtomicBool::new(false)));
let ctx_fast = fake_ctx(fast, Arc::new(AtomicBool::new(false)));
rt().block_on(async {
let a = dispatch(&ctx_slow, &ty("E"), req("slow", true))
.await
.unwrap();
let b = dispatch(&ctx_fast, &ty("E"), req("fast", true))
.await
.unwrap();
wait(&a.id).await;
wait(&b.id).await;
let mut conts: Vec<String> = Vec::new();
for _ in 0..50 {
while let Some(r) = crate::core::extensions::take_pending_ui() {
if let ExtensionUiRequest::Continuation { message } = r {
conts.push(message.text());
}
}
if !conts.is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(
conts.len(),
1,
"两个并发后台代理应合并为一条通知: {conts:?}"
);
assert!(
conts[0].contains("<subagent_results count=\"2\" tokens=\""),
"{}",
conts[0]
);
assert!(conts[0].contains("echo:slow"), "{}", conts[0]);
assert!(conts[0].contains("echo:fast"), "{}", conts[0]);
});
with_state(|st| {
st.cfg = SubagentConfig {
join: JoinMode::Async,
..Default::default()
}
});
while crate::core::extensions::take_pending_ui().is_some() {}
let slow = SeenSpec {
hold_ms: 100,
..Default::default()
};
let ctx_slow = fake_ctx(slow, Arc::new(AtomicBool::new(false)));
let ctx_fast = fake_ctx(SeenSpec::default(), Arc::new(AtomicBool::new(false)));
rt().block_on(async {
let a = dispatch(&ctx_slow, &ty("E"), req("slow", true))
.await
.unwrap();
let b = dispatch(&ctx_fast, &ty("E"), req("fast", true))
.await
.unwrap();
wait(&a.id).await;
wait(&b.id).await;
let mut count = 0;
for _ in 0..50 {
while let Some(r) = crate::core::extensions::take_pending_ui() {
if matches!(r, ExtensionUiRequest::Continuation { .. }) {
count += 1;
}
}
if count >= 2 {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(count, 2, "async 模式应逐个通知");
});
reset_all();
}
#[test]
fn output_transcript_writes_jsonl_and_records_path() {
let _g = lock();
reset_all();
let seen = SeenSpec::default();
let ctx = fake_ctx(seen, Arc::new(AtomicBool::new(false)));
rt().block_on(async {
let mut r = req("ot", false);
r.output_transcript = true;
let d = dispatch(&ctx, &ty("E"), r).await.unwrap();
let rec = record(&d.id).unwrap();
let path = rec.output_path.clone().expect("应记录 .output 路径");
assert!(path.ends_with(&format!("{}.output", d.id)), "{path}");
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("agent_start"), "{text}");
let _ = std::fs::remove_file(&path);
let d = dispatch(&ctx, &ty("E"), req("off", false)).await.unwrap();
assert!(record(&d.id).unwrap().output_path.is_none());
});
reset_all();
}
#[test]
fn spawn_request_spec_fields_reach_the_spec() {
let t = ty("E");
let mut r = req("go", false);
r.disallowed_tools = Some(vec!["bash".to_string()]);
r.isolated = true;
r.output_transcript = true;
let spec = build_spec(&t, &r, "/tmp", None, None, 2, 0, 5);
assert_eq!(spec.disallowed_tools, vec!["bash".to_string()]);
assert!(spec.isolated);
}
#[test]
fn resume_rejects_non_terminal_agent() {
let _g = lock();
reset_all();
with_state(|st| st.running_bg = st.cfg.max_concurrent);
let seen = SeenSpec::default();
let ctx = fake_ctx(seen, Arc::new(AtomicBool::new(false)));
rt().block_on(async {
let d = dispatch(&ctx, &ty("E"), req("t", true)).await.unwrap();
let mut r = req("again", false);
r.resume = Some(d.id.clone());
let err = dispatch(&ctx, &ty("E"), r).await.unwrap_err();
assert!(err.contains("still queued"), "{err}");
});
reset_all();
}
#[test]
fn background_resume_actually_restarts_the_agent() {
let _g = lock();
reset_all();
rt().block_on(async {
let seen = SeenSpec::default();
let ctx = fake_ctx(seen, Arc::new(AtomicBool::new(false)));
let d = dispatch(&ctx, &ty("Explore"), req("first", true))
.await
.unwrap();
let first = wait(&d.id).await.expect("first run settles");
assert_eq!(first.status, AgentStatus::Completed);
assert_eq!(first.result.as_deref(), Some("echo:first"));
with_state(|st| {
if let Some(r) = st.records.get_mut(&d.id) {
r.session_path = Some("/tmp/s.jsonl".to_string());
}
});
let mut resume = req("second", true);
resume.resume = Some(d.id.clone());
let dr = dispatch(&ctx, &ty("Explore"), resume).await.unwrap();
assert_eq!(dr.id, d.id, "resume 复用原记录 id");
let resumed = tokio::time::timeout(std::time::Duration::from_secs(5), wait(&d.id))
.await
.expect("resumed run must start/settle in time")
.expect("resumed record");
assert_eq!(resumed.status, AgentStatus::Completed);
assert_eq!(resumed.result.as_deref(), Some("echo:second"));
});
reset_all();
}
#[test]
fn unknown_key_errors_are_explicit_and_warnings_dedupe() {
let _g = lock();
reset_all();
assert!(record("nope").is_none());
assert!(
steer("nope", "hi")
.unwrap_err()
.contains("unknown agent id")
);
assert!(stop("nope").unwrap_err().contains("unknown agent id"));
assert!(note_warning("a|b"));
assert!(!note_warning("a|b"));
assert!(note_warning("a|c"));
reset_all();
}
#[test]
fn reset_all_clears_everything_and_unblocks_waiters() {
let _g = lock();
reset_all();
with_state(|st| st.running_bg = st.cfg.max_concurrent);
let seen = SeenSpec::default();
let ctx = fake_ctx(seen, Arc::new(AtomicBool::new(false)));
rt().block_on(async {
let d = dispatch(&ctx, &ty("E"), req("t", true)).await.unwrap();
reset_all();
assert!(record(&d.id).is_none());
assert!(list().is_empty());
assert!(widget_lines().is_empty());
});
}
#[test]
fn widget_lines_follow_config_and_show_badges() {
let _g = lock();
reset_all();
let mut t = ty("auditor");
t.display_name = "Auditor".to_string();
t.color = Some("teal".to_string());
let ctx = fake_ctx(
SeenSpec {
hold_ms: 100_000,
..Default::default()
},
Arc::new(AtomicBool::new(false)),
);
rt().block_on(async {
let d = dispatch(&ctx, &t, req("hi", true)).await.unwrap();
let rec = record(&d.id).unwrap();
assert_eq!(rec.color.as_deref(), Some("teal"), "类型色随记录传递");
});
let lines = widget_lines();
assert_eq!(lines.len(), 2, "标题 + 一条运行中的记录");
assert!(widget_text(&lines[1]).contains("Auditor"));
let badge = lines[1]
.iter()
.find(|s| s.text.contains("Auditor"))
.expect("badge span");
assert_eq!(badge.fallback.as_ref(), "#008080", "teal → 主题外 hex");
let mut off = SubagentConfig::default();
crate::extensions::subagent::config::apply_panel_choice(&mut off, "widget", "off").unwrap();
with_state(|st| st.cfg = off);
assert!(widget_lines().is_empty());
reset_all();
}
#[test]
fn settled_records_leave_the_dock_immediately() {
let _g = lock();
reset_all();
let ctx = fake_ctx(SeenSpec::default(), Arc::new(AtomicBool::new(false)));
rt().block_on(async {
dispatch(&ctx, &ty("E"), req("hi", false)).await.unwrap();
});
assert!(widget_lines().is_empty(), "全部收尾后 dock 不再占位");
assert_eq!(list().len(), 1, "记录保留:/agents 与 resume 不受影响");
let ctx = fake_ctx(
SeenSpec {
hold_ms: 100_000,
..Default::default()
},
Arc::new(AtomicBool::new(false)),
);
rt().block_on(async {
dispatch(&ctx, &ty("E"), req("again", true)).await.unwrap();
});
assert_eq!(widget_lines().len(), 2, "新派的运行中记录照常占 dock");
reset_all();
}
#[test]
fn dismiss_finished_is_a_noop_while_work_is_live() {
let _g = lock();
reset_all();
with_state(|st| st.running_bg = 1);
dismiss_finished();
assert_eq!(
with_state(|st| st.dismissed_before_ms),
0,
"有后台代理在跑 → 水位不动"
);
with_state(|st| st.running_bg = 0);
with_state(|st| st.queue.push_back("queued-id".to_string()));
dismiss_finished();
assert_eq!(
with_state(|st| st.dismissed_before_ms),
0,
"有排队记录 → 水位不动"
);
reset_all();
}
#[test]
fn foreground_limit_serializes_foreground_runs() {
let _g = lock();
reset_all();
let cfg = config::SubagentConfig {
foreground_max_concurrent: 1,
..Default::default()
};
config::write_config(&config::config_path(), &cfg);
reload_config();
let seen = SeenSpec {
hold_ms: 200,
..Default::default()
};
let ctx = fake_ctx(seen, Arc::new(AtomicBool::new(false)));
rt().block_on(async {
let a = tokio::spawn({
let ctx = ctx.clone();
async move { dispatch(&ctx, &ty("E"), req("a", false)).await.unwrap() }
});
tokio::time::sleep(Duration::from_millis(30)).await;
let b = tokio::spawn({
let ctx = ctx.clone();
async move { dispatch(&ctx, &ty("E"), req("b", false)).await.unwrap() }
});
tokio::time::sleep(Duration::from_millis(30)).await;
let (running, live): (usize, Vec<(String, String, bool)>) = with_state(|st| {
(
st.running_fg,
st.records
.values()
.filter(|r| !r.status.is_terminal())
.map(|r| (r.id.clone(), r.status.as_str().to_string(), r.background))
.collect(),
)
});
let limit = with_state(|st| st.cfg.foreground_max_concurrent);
assert_eq!(
running, 1,
"前台额度=1 时同时只有 1 个在跑 (live={live:?}, limit={limit})"
);
a.await.unwrap();
b.await.unwrap();
assert_eq!(with_state(|st| st.running_fg), 0, "收尾后释放额度");
});
config::write_config(&config::config_path(), &config::SubagentConfig::default());
reload_config();
reset_all();
}
}