use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use supercode::session::{Session, SessionSource};
use supercode::store::SessionStore;
use supercode::subagents::{BackgroundPromptsPolicy, NamedAgentDefinition};
use supercode::tools::SandboxPolicy;
use supercode::{
Agent, ChatMessage, ChatRequest, Config, Error, FunctionCall, Provider, Role, ToolCall, Usage,
};
fn temp_store() -> (Arc<SessionStore>, std::path::PathBuf) {
static N: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!(
"supercode-subagents-test-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::SeqCst)
));
(Arc::new(SessionStore::open(&dir).unwrap()), dir)
}
fn tool_call(id: &str, name: &str, args: serde_json::Value) -> ToolCall {
ToolCall {
id: id.to_string(),
kind: "function".to_string(),
function: FunctionCall {
name: name.to_string(),
arguments: args.to_string(),
},
}
}
fn assistant_with_calls(calls: Vec<ToolCall>) -> ChatMessage {
ChatMessage {
role: Role::Assistant,
content: None,
content_parts: None,
tool_calls: Some(calls),
tool_call_id: None,
name: None,
metadata: Default::default(),
}
}
fn last_tool_content(req: &ChatRequest) -> String {
let last = req.messages.last().expect("at least one message");
assert_eq!(
last.role,
Role::Tool,
"expected the last message to be a tool result"
);
last.content.clone().unwrap_or_default()
}
struct PlainProvider;
#[async_trait]
impl Provider for PlainProvider {
async fn complete(
&self,
_req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
Ok((ChatMessage::assistant("ok"), Usage::default()))
}
}
#[tokio::test]
async fn default_off_never_advertises_spawn_subagent() {
let config = Config::builder().build();
let agent = Agent::with_provider(config, Box::new(PlainProvider));
let schemas = agent.tool_schemas();
let names: Vec<&str> = schemas.iter().map(|s| s.name.as_str()).collect();
assert!(!names.contains(&"spawn_subagent"));
assert!(!names.contains(&"subagent_status"));
}
#[tokio::test]
async fn default_off_agent_still_answers_normally() {
let config = Config::builder().build();
let mut agent = Agent::with_provider(config, Box::new(PlainProvider));
let reply = agent.send("hi").await.unwrap();
assert_eq!(reply, "ok");
}
struct ForegroundJoinProvider {
calls: AtomicUsize,
}
#[async_trait]
impl Provider for ForegroundJoinProvider {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
match self.calls.fetch_add(1, Ordering::SeqCst) {
0 => {
assert!(req.tools.iter().any(|t| t.name == "spawn_subagent"));
Ok((
assistant_with_calls(vec![tool_call(
"call_1",
"spawn_subagent",
serde_json::json!({"task": "say hi"}),
)]),
Usage::default(),
))
}
1 => {
assert_eq!(req.messages.len(), 2, "system + user only");
assert_eq!(req.messages[1].content.as_deref(), Some("say hi"));
Ok((
ChatMessage::assistant("hello from the child"),
Usage::default(),
))
}
2 => {
assert_eq!(last_tool_content(req), "hello from the child");
Ok((ChatMessage::assistant("parent done"), Usage::default()))
}
n => panic!("unexpected call {n}"),
}
}
}
#[tokio::test]
async fn foreground_spawn_joins_child_result_and_persists_lossless_lineage_transcript() {
let config = Config::builder().subagents_enabled(true).build();
let mut agent = Agent::with_provider(
config,
Box::new(ForegroundJoinProvider {
calls: AtomicUsize::new(0),
}),
);
let (store, dir) = temp_store();
store.save("parent-sess", "t", "[]").unwrap();
agent.set_subagent_store(store.clone(), "parent-sess");
let reply = agent.send("start").await.unwrap();
assert_eq!(reply, "parent done");
let ids = store.list_subagent_ids("parent-sess").unwrap();
assert_eq!(ids.len(), 1, "exactly one child spawned");
let child_id = &ids[0];
let lineage = store
.load_subagent_lineage("parent-sess", child_id)
.unwrap()
.expect("lineage persisted");
assert_eq!(lineage.parent_tool_use_id, "call_1");
assert_eq!(lineage.parent_session_id.as_deref(), Some("parent-sess"));
assert_eq!(lineage.depth, 1);
assert_eq!(lineage.task, "say hi");
assert!(!lineage.background);
let sidecar = store
.load_subagent_transcript("parent-sess", child_id)
.unwrap()
.expect("transcript persisted");
let parsed = Session::from_native_str(&sidecar).unwrap();
assert_eq!(parsed.meta.agent_id.as_deref(), Some(child_id.as_str()));
assert_eq!(parsed.meta.parent_tool_use_id.as_deref(), Some("call_1"));
assert_eq!(
parsed.meta.lineage.get("parent_thread_id"),
Some(&"parent-sess".to_string())
);
let texts: Vec<String> = parsed
.messages
.iter()
.filter_map(|m| m.content.clone())
.collect();
assert!(texts.iter().any(|t| t == "say hi"));
assert!(texts.iter().any(|t| t == "hello from the child"));
let _ = std::fs::remove_dir_all(&dir);
}
struct NamedDefProvider {
calls: AtomicUsize,
}
#[async_trait]
impl Provider for NamedDefProvider {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
match self.calls.fetch_add(1, Ordering::SeqCst) {
0 => Ok((
assistant_with_calls(vec![tool_call(
"call_1",
"spawn_subagent",
serde_json::json!({"task": "find x", "agent_type": "researcher"}),
)]),
Usage::default(),
)),
1 => {
assert_eq!(
req.messages[0].content.as_deref(),
Some("You are a researcher.")
);
Ok((ChatMessage::assistant("found it"), Usage::default()))
}
2 => {
assert_eq!(last_tool_content(req), "found it");
Ok((ChatMessage::assistant("done"), Usage::default()))
}
n => panic!("unexpected call {n}"),
}
}
}
#[tokio::test]
async fn named_agent_type_uses_its_own_system_prompt() {
let config = Config::builder()
.subagents_enabled(true)
.subagent_definition(NamedAgentDefinition {
name: "researcher".to_string(),
system_prompt: "You are a researcher.".to_string(),
tools: None,
model: None,
})
.build();
let mut agent = Agent::with_provider(
config,
Box::new(NamedDefProvider {
calls: AtomicUsize::new(0),
}),
);
let reply = agent.send("start").await.unwrap();
assert_eq!(reply, "done");
}
struct ClaudeAgentAliasProvider {
calls: AtomicUsize,
}
#[async_trait]
impl Provider for ClaudeAgentAliasProvider {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
match self.calls.fetch_add(1, Ordering::SeqCst) {
0 => {
assert!(req.tools.iter().any(|tool| tool.name == "Agent"));
Ok((
assistant_with_calls(vec![tool_call(
"call_claude_agent",
"Agent",
serde_json::json!({
"prompt": "run the pilot drill",
"subagent_type": "pilot-tick",
"model": "sonnet",
"run_in_background": false
}),
)]),
Usage::default(),
))
}
1 => {
assert_eq!(req.model, "anthropic/claude-sonnet-4-6");
assert_eq!(req.messages[0].content.as_deref(), Some("Pilot exactly."));
assert_eq!(
req.messages[1].content.as_deref(),
Some("run the pilot drill")
);
Ok((ChatMessage::assistant("pilot complete"), Usage::default()))
}
2 => {
assert_eq!(last_tool_content(req), "pilot complete");
Ok((ChatMessage::assistant("done"), Usage::default()))
}
n => panic!("unexpected call {n}"),
}
}
}
#[tokio::test]
async fn claude_agent_alias_translates_names_and_resolves_child_model() {
let config = Config::builder()
.model("z-ai/glm-5.2")
.subagents_enabled(true)
.subagents_claude_agent_alias(true)
.subagent_definition(NamedAgentDefinition {
name: "pilot-tick".to_string(),
system_prompt: "Pilot exactly.".to_string(),
tools: None,
model: Some("anthropic/claude-sonnet-4-6".to_string()),
})
.build();
let mut agent = Agent::with_provider(
config,
Box::new(ClaudeAgentAliasProvider {
calls: AtomicUsize::new(0),
}),
);
assert_eq!(agent.send("continue").await.unwrap(), "done");
}
struct ClaudeGeneralPurposeProvider {
calls: AtomicUsize,
}
#[async_trait]
impl Provider for ClaudeGeneralPurposeProvider {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
match self.calls.fetch_add(1, Ordering::SeqCst) {
0 => Ok((
assistant_with_calls(vec![tool_call(
"call_general",
"Agent",
serde_json::json!({
"prompt": "do general work",
"subagent_type": "general-purpose",
"run_in_background": false
}),
)]),
Usage::default(),
)),
1 => {
assert_eq!(req.messages[1].content.as_deref(), Some("do general work"));
Ok((ChatMessage::assistant("general complete"), Usage::default()))
}
2 => {
assert_eq!(last_tool_content(req), "general complete");
Ok((ChatMessage::assistant("done"), Usage::default()))
}
n => panic!("unexpected call {n}"),
}
}
}
#[tokio::test]
async fn claude_general_purpose_agent_maps_to_an_ad_hoc_child() {
let config = Config::builder()
.subagents_enabled(true)
.subagents_claude_agent_alias(true)
.build();
let mut agent = Agent::with_provider(
config,
Box::new(ClaudeGeneralPurposeProvider {
calls: AtomicUsize::new(0),
}),
);
assert_eq!(agent.send("continue").await.unwrap(), "done");
}
struct UnknownAgentTypeProvider {
calls: AtomicUsize,
}
#[async_trait]
impl Provider for UnknownAgentTypeProvider {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
match self.calls.fetch_add(1, Ordering::SeqCst) {
0 => Ok((
assistant_with_calls(vec![tool_call(
"call_1",
"spawn_subagent",
serde_json::json!({"task": "x", "agent_type": "does-not-exist"}),
)]),
Usage::default(),
)),
1 => {
let content = last_tool_content(req);
assert!(
content.contains("unknown subagent agent_type"),
"content: {content}"
);
Ok((ChatMessage::assistant("handled"), Usage::default()))
}
n => panic!("unexpected call {n}"),
}
}
}
#[tokio::test]
async fn unknown_agent_type_is_refused_with_a_named_error() {
let config = Config::builder().subagents_enabled(true).build();
let mut agent = Agent::with_provider(
config,
Box::new(UnknownAgentTypeProvider {
calls: AtomicUsize::new(0),
}),
);
let reply = agent.send("start").await.unwrap();
assert_eq!(reply, "handled");
}
struct DepthCapProvider {
calls: AtomicUsize,
}
#[async_trait]
impl Provider for DepthCapProvider {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
match self.calls.fetch_add(1, Ordering::SeqCst) {
0 => Ok((
assistant_with_calls(vec![tool_call(
"call_1",
"spawn_subagent",
serde_json::json!({"task": "go"}),
)]),
Usage::default(),
)),
1 => {
Ok((
assistant_with_calls(vec![tool_call(
"call_2",
"spawn_subagent",
serde_json::json!({"task": "nested"}),
)]),
Usage::default(),
))
}
2 => {
let content = last_tool_content(req);
assert!(
content.contains("SubagentDepthExceeded") || content.contains("would exceed"),
"content: {content}"
);
Ok((
ChatMessage::assistant("nested spawn correctly refused"),
Usage::default(),
))
}
3 => {
assert_eq!(last_tool_content(req), "nested spawn correctly refused");
Ok((ChatMessage::assistant("parent finished"), Usage::default()))
}
n => panic!("unexpected call {n}"),
}
}
}
#[tokio::test]
async fn depth_cap_refuses_a_nested_spawn_past_max_depth() {
let config = Config::builder()
.subagents_enabled(true)
.subagents_max_depth(1)
.build();
let mut agent = Agent::with_provider(
config,
Box::new(DepthCapProvider {
calls: AtomicUsize::new(0),
}),
);
let reply = agent.send("start").await.unwrap();
assert_eq!(reply, "parent finished");
}
#[tokio::test]
async fn zero_max_depth_refuses_the_very_first_spawn() {
struct ZeroDepthProvider {
calls: AtomicUsize,
}
#[async_trait]
impl Provider for ZeroDepthProvider {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
match self.calls.fetch_add(1, Ordering::SeqCst) {
0 => Ok((
assistant_with_calls(vec![tool_call(
"call_1",
"spawn_subagent",
serde_json::json!({"task": "go"}),
)]),
Usage::default(),
)),
1 => {
let content = last_tool_content(req);
assert!(content.contains("would exceed"), "content: {content}");
Ok((
ChatMessage::assistant("refused immediately"),
Usage::default(),
))
}
n => panic!("unexpected call {n}"),
}
}
}
let config = Config::builder()
.subagents_enabled(true)
.subagents_max_depth(0)
.build();
let mut agent = Agent::with_provider(
config,
Box::new(ZeroDepthProvider {
calls: AtomicUsize::new(0),
}),
);
let reply = agent.send("start").await.unwrap();
assert_eq!(reply, "refused immediately");
}
struct ConcurrencyCapProvider {
calls: AtomicUsize,
block_forever: Arc<tokio::sync::Notify>,
}
#[async_trait]
impl Provider for ConcurrencyCapProvider {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
if req.messages[0].content.as_deref() == Some("BLOCK_FOREVER") {
self.block_forever.notified().await;
unreachable!("test never signals this notify");
}
match self.calls.fetch_add(1, Ordering::SeqCst) {
0 => Ok((
assistant_with_calls(vec![
tool_call(
"call_1",
"spawn_subagent",
serde_json::json!({
"task": "block",
"system_prompt": "BLOCK_FOREVER",
"background": true
}),
),
tool_call(
"call_2",
"spawn_subagent",
serde_json::json!({"task": "second"}),
),
]),
Usage::default(),
)),
1 => {
let n = req.messages.len();
let first = req.messages[n - 2].content.clone().unwrap_or_default();
let second = req.messages[n - 1].content.clone().unwrap_or_default();
assert!(first.contains("\"status\":\"spawned\""), "first: {first}");
assert!(
second.contains("SubagentConcurrencyExceeded")
|| second.contains("already running"),
"second: {second}"
);
Ok((ChatMessage::assistant("both handled"), Usage::default()))
}
n => panic!("unexpected call {n}"),
}
}
}
#[tokio::test]
async fn concurrency_cap_refuses_a_spawn_past_max_concurrent() {
let config = Config::builder()
.subagents_enabled(true)
.subagents_max_depth(2)
.subagents_max_concurrent(1)
.subagents_background(true)
.subagents_background_prompts(BackgroundPromptsPolicy::AutoPolicy)
.build();
let mut agent = Agent::with_provider(
config,
Box::new(ConcurrencyCapProvider {
calls: AtomicUsize::new(0),
block_forever: Arc::new(tokio::sync::Notify::new()),
}),
);
let reply = agent.send("start").await.unwrap();
assert_eq!(reply, "both handled");
}
struct BackgroundStatusProvider {
calls: AtomicUsize,
captured_id: Mutex<Option<String>>,
}
#[async_trait]
impl Provider for BackgroundStatusProvider {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
match self.calls.fetch_add(1, Ordering::SeqCst) {
0 => Ok((
assistant_with_calls(vec![tool_call(
"call_1",
"spawn_subagent",
serde_json::json!({"task": "bg task", "background": true}),
)]),
Usage::default(),
)),
1 => {
let content = last_tool_content(req);
let v: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(v["status"], "spawned");
*self.captured_id.lock().unwrap() =
Some(v["subagent_id"].as_str().unwrap().to_string());
Ok((ChatMessage::assistant("spawned"), Usage::default()))
}
2 => Ok((ChatMessage::assistant("bg done"), Usage::default())),
3 => {
let id = self.captured_id.lock().unwrap().clone().unwrap();
Ok((
assistant_with_calls(vec![tool_call(
"call_2",
"subagent_status",
serde_json::json!({"subagent_id": id}),
)]),
Usage::default(),
))
}
4 => {
let content = last_tool_content(req);
let v: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(v["status"], "done", "content: {content}");
assert_eq!(v["result"], "bg done");
Ok((ChatMessage::assistant("poll complete"), Usage::default()))
}
n => panic!("unexpected call {n}"),
}
}
}
#[tokio::test]
async fn background_spawn_returns_immediately_then_status_polls_to_done() {
let config = Config::builder()
.subagents_enabled(true)
.subagents_background(true)
.subagents_background_prompts(BackgroundPromptsPolicy::AutoPolicy)
.build();
let mut agent = Agent::with_provider(
config,
Box::new(BackgroundStatusProvider {
calls: AtomicUsize::new(0),
captured_id: Mutex::new(None),
}),
);
let reply = tokio::time::timeout(std::time::Duration::from_secs(10), agent.send("start"))
.await
.expect("must not hang")
.unwrap();
assert_eq!(reply, "spawned");
for _ in 0..200 {
tokio::task::yield_now().await;
}
let reply = tokio::time::timeout(std::time::Duration::from_secs(10), agent.send("check"))
.await
.expect("must not hang")
.unwrap();
assert_eq!(reply, "poll complete");
}
struct SpawnOnceProvider {
args: serde_json::Value,
calls: AtomicUsize,
}
#[async_trait]
impl Provider for SpawnOnceProvider {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
match self.calls.fetch_add(1, Ordering::SeqCst) {
0 => Ok((
assistant_with_calls(vec![tool_call(
"call_1",
"spawn_subagent",
self.args.clone(),
)]),
Usage::default(),
)),
1 => {
let content = last_tool_content(req);
Ok((ChatMessage::assistant(content), Usage::default()))
}
n => panic!("unexpected call {n}"),
}
}
}
#[tokio::test]
async fn background_spawn_without_a_configured_policy_is_refused_c6() {
let config = Config::builder()
.subagents_enabled(true)
.subagents_background(true)
.build();
let mut agent = Agent::with_provider(
config,
Box::new(SpawnOnceProvider {
args: serde_json::json!({"task": "x", "background": true}),
calls: AtomicUsize::new(0),
}),
);
let reply = agent.send("start").await.unwrap();
assert!(
reply.contains("SubagentBackgroundPolicyMissing") || reply.contains("background_prompts"),
"reply: {reply}"
);
}
#[tokio::test]
async fn background_spawn_with_background_module_off_is_refused() {
let config = Config::builder().subagents_enabled(true).build();
let mut agent = Agent::with_provider(
config,
Box::new(SpawnOnceProvider {
args: serde_json::json!({"task": "x", "background": true}),
calls: AtomicUsize::new(0),
}),
);
let reply = agent.send("start").await.unwrap();
assert!(
reply.contains("capabilities.subagents.background"),
"reply: {reply}"
);
}
struct C6PolicyProvider {
calls: AtomicUsize,
captured_id: Mutex<Option<String>>,
}
#[async_trait]
impl Provider for C6PolicyProvider {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
match self.calls.fetch_add(1, Ordering::SeqCst) {
0 => Ok((
assistant_with_calls(vec![tool_call(
"call_1",
"spawn_subagent",
serde_json::json!({"task": "try bash", "background": true}),
)]),
Usage::default(),
)),
1 => {
let content = last_tool_content(req);
let v: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(v["status"], "spawned");
*self.captured_id.lock().unwrap() =
Some(v["subagent_id"].as_str().unwrap().to_string());
Ok((ChatMessage::assistant("spawn kicked off"), Usage::default()))
}
2 => Ok((
assistant_with_calls(vec![tool_call(
"call_bash",
"bash",
serde_json::json!({"command": "echo hi"}),
)]),
Usage::default(),
)),
3 => {
let content = last_tool_content(req);
assert!(
content.contains("not approved"),
"an Ask-tier bash call under a background child must fail closed \
(never an interactive prompt it can't answer): {content}"
);
Ok((ChatMessage::assistant(content), Usage::default()))
}
n => panic!("unexpected call {n}"),
}
}
}
fn c6_test_config(policy: BackgroundPromptsPolicy) -> Config {
let mut config = Config::builder()
.subagents_enabled(true)
.subagents_background(true)
.subagents_background_prompts(policy)
.build();
config.permissions_enabled = true;
config.permissions_ask_patterns = vec!["bash".to_string()];
config
}
#[tokio::test]
async fn background_auto_policy_denies_without_ever_hanging() {
let config = c6_test_config(BackgroundPromptsPolicy::AutoPolicy);
let mut agent = Agent::with_provider(
config,
Box::new(C6PolicyProvider {
calls: AtomicUsize::new(0),
captured_id: Mutex::new(None),
}),
);
let reply = tokio::time::timeout(std::time::Duration::from_secs(10), agent.send("start"))
.await
.expect("must not hang")
.unwrap();
assert_eq!(reply, "spawn kicked off");
for _ in 0..200 {
tokio::task::yield_now().await;
}
assert!(agent.pending_child_approvals().is_empty());
}
#[tokio::test]
async fn background_parent_policy_queues_the_denied_request_without_hanging() {
let config = c6_test_config(BackgroundPromptsPolicy::Parent);
let mut agent = Agent::with_provider(
config,
Box::new(C6PolicyProvider {
calls: AtomicUsize::new(0),
captured_id: Mutex::new(None),
}),
);
let reply = tokio::time::timeout(std::time::Duration::from_secs(10), agent.send("start"))
.await
.expect("must not hang")
.unwrap();
assert_eq!(reply, "spawn kicked off");
for _ in 0..200 {
tokio::task::yield_now().await;
}
let pending = agent.pending_child_approvals();
assert_eq!(
pending.len(),
1,
"the child's bash call must be queued exactly once"
);
assert_eq!(pending[0].tool, "bash");
let expected_id = agent
.pending_child_approvals()
.first()
.map(|q| q.child_agent_id.clone());
assert!(expected_id.is_some());
}
struct MonotonicPostureProvider {
calls: AtomicUsize,
}
#[async_trait]
impl Provider for MonotonicPostureProvider {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
match self.calls.fetch_add(1, Ordering::SeqCst) {
0 => Ok((
assistant_with_calls(vec![tool_call(
"call_1",
"spawn_subagent",
serde_json::json!({"task": "try to escalate"}),
)]),
Usage::default(),
)),
1 => {
assert!(
!req.tools.iter().any(|t| t.name == "bash"),
"child must not regain a tool its parent disabled: {:?}",
req.tools.iter().map(|t| &t.name).collect::<Vec<_>>()
);
Ok((
assistant_with_calls(vec![tool_call(
"call_write",
"write_file",
serde_json::json!({"path": "escape.txt", "content": "pwn"}),
)]),
Usage::default(),
))
}
2 => {
let content = last_tool_content(req);
assert!(
content.contains("read-only") || content.contains("read only"),
"child must inherit the parent's ReadOnly sandbox, never widen it: {content}"
);
Ok((
ChatMessage::assistant("could not escalate"),
Usage::default(),
))
}
3 => {
assert_eq!(last_tool_content(req), "could not escalate");
Ok((
ChatMessage::assistant("parent confirms containment"),
Usage::default(),
))
}
n => panic!("unexpected call {n}"),
}
}
}
#[tokio::test]
async fn monotonic_posture_child_cannot_escalate_sandbox_or_tools() {
let config = Config::builder()
.subagents_enabled(true)
.sandbox(SandboxPolicy::ReadOnly)
.disable_tool("bash")
.build();
let mut agent = Agent::with_provider(
config,
Box::new(MonotonicPostureProvider {
calls: AtomicUsize::new(0),
}),
);
let reply = agent.send("start").await.unwrap();
assert_eq!(reply, "parent confirms containment");
}
struct DropAbortProvider {
calls: AtomicUsize,
child_turns: Arc<AtomicUsize>,
}
#[async_trait]
impl Provider for DropAbortProvider {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
if req.messages[0].content.as_deref() == Some("LOOP_FOREVER") {
self.child_turns.fetch_add(1, Ordering::SeqCst);
tokio::task::yield_now().await;
return Ok((
assistant_with_calls(vec![tool_call(
"loop_call",
"definitely_not_a_real_tool",
serde_json::json!({}),
)]),
Usage::default(),
));
}
match self.calls.fetch_add(1, Ordering::SeqCst) {
0 => Ok((
assistant_with_calls(vec![tool_call(
"call_1",
"spawn_subagent",
serde_json::json!({
"task": "loop forever",
"system_prompt": "LOOP_FOREVER",
"background": true
}),
)]),
Usage::default(),
)),
1 => Ok((ChatMessage::assistant("spawned ok"), Usage::default())),
n => panic!("unexpected parent call {n}"),
}
}
}
async fn yield_many(n: usize) {
for _ in 0..n {
tokio::task::yield_now().await;
}
}
#[tokio::test]
async fn dropping_the_parent_aborts_a_still_running_background_child() {
let child_turns = Arc::new(AtomicUsize::new(0));
let config = Config::builder()
.subagents_enabled(true)
.subagents_background(true)
.subagents_background_prompts(BackgroundPromptsPolicy::AutoPolicy)
.max_iterations(1_000_000)
.build();
let mut agent = Agent::with_provider(
config,
Box::new(DropAbortProvider {
calls: AtomicUsize::new(0),
child_turns: child_turns.clone(),
}),
);
let reply = tokio::time::timeout(std::time::Duration::from_secs(10), agent.send("start"))
.await
.expect("must not hang")
.unwrap();
assert_eq!(reply, "spawned ok");
let before_drop = tokio::time::timeout(std::time::Duration::from_secs(10), async {
loop {
yield_many(50).await;
let n = child_turns.load(Ordering::SeqCst);
if n > 0 {
return n;
}
}
})
.await
.expect("the background child must make progress before the parent drops");
drop(agent);
let mut last = before_drop;
for _ in 0..20 {
yield_many(50).await;
let n = child_turns.load(Ordering::SeqCst);
assert_eq!(
n, last,
"an aborted background child must not issue any further provider \
turns after its parent is dropped"
);
last = n;
}
}
struct MaxTokensInheritProvider {
calls: AtomicUsize,
}
#[async_trait]
impl Provider for MaxTokensInheritProvider {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
match self.calls.fetch_add(1, Ordering::SeqCst) {
0 => Ok((
assistant_with_calls(vec![tool_call(
"call_1",
"spawn_subagent",
serde_json::json!({"task": "go"}),
)]),
Usage::default(),
)),
1 => {
assert_eq!(
req.max_tokens,
Some(777),
"the child's own request must carry the parent's max_tokens verbatim"
);
Ok((ChatMessage::assistant("child done"), Usage::default()))
}
2 => {
assert_eq!(last_tool_content(req), "child done");
Ok((ChatMessage::assistant("parent done"), Usage::default()))
}
n => panic!("unexpected call {n}"),
}
}
}
#[tokio::test]
async fn max_tokens_is_inherited_by_the_child() {
let config = Config::builder()
.subagents_enabled(true)
.max_tokens(777)
.build();
let mut agent = Agent::with_provider(
config,
Box::new(MaxTokensInheritProvider {
calls: AtomicUsize::new(0),
}),
);
let reply = agent.send("start").await.unwrap();
assert_eq!(reply, "parent done");
}
struct MaxTotalOutputTokensInheritProvider {
calls: AtomicUsize,
child_turns: Arc<AtomicUsize>,
}
#[async_trait]
impl Provider for MaxTotalOutputTokensInheritProvider {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
if req.messages[0].content.as_deref() == Some("BUDGET_CHILD") {
self.child_turns.fetch_add(1, Ordering::SeqCst);
return Ok((
assistant_with_calls(vec![tool_call(
"child_call",
"definitely_not_a_real_tool",
serde_json::json!({}),
)]),
Usage {
completion_tokens: 5,
..Usage::default()
},
));
}
match self.calls.fetch_add(1, Ordering::SeqCst) {
0 => Ok((
assistant_with_calls(vec![tool_call(
"call_1",
"spawn_subagent",
serde_json::json!({"task": "go", "system_prompt": "BUDGET_CHILD"}),
)]),
Usage::default(),
)),
1 => Ok((ChatMessage::assistant("parent done"), Usage::default())),
n => panic!("unexpected parent call {n}"),
}
}
}
#[tokio::test]
async fn max_total_output_tokens_is_inherited_by_the_child() {
let child_turns = Arc::new(AtomicUsize::new(0));
let config = Config::builder()
.subagents_enabled(true)
.max_total_output_tokens(1)
.build();
let mut agent = Agent::with_provider(
config,
Box::new(MaxTotalOutputTokensInheritProvider {
calls: AtomicUsize::new(0),
child_turns: child_turns.clone(),
}),
);
let reply = agent.send("start").await.unwrap();
assert_eq!(reply, "parent done");
assert_eq!(
child_turns.load(Ordering::SeqCst),
1,
"the child must inherit the parent's max_total_output_tokens and stop after one turn"
);
}
struct MaxToolOutputBytesInheritProvider {
calls: AtomicUsize,
}
#[async_trait]
impl Provider for MaxToolOutputBytesInheritProvider {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
if req.messages[0].content.as_deref() == Some("BASH_CHILD") {
if req.messages.len() == 2 {
return Ok((
assistant_with_calls(vec![tool_call(
"bash_call",
"bash",
serde_json::json!({"command": "yes x | head -c 300"}),
)]),
Usage::default(),
));
}
let content = last_tool_content(req);
assert!(
content.contains("truncated"),
"the child must inherit the parent's max_tool_output_bytes cap: {content}"
);
assert!(
content.len() < 300,
"an uncapped child would carry the full ~300-byte echo: {content}"
);
return Ok((ChatMessage::assistant("child done"), Usage::default()));
}
match self.calls.fetch_add(1, Ordering::SeqCst) {
0 => Ok((
assistant_with_calls(vec![tool_call(
"call_1",
"spawn_subagent",
serde_json::json!({"task": "go", "system_prompt": "BASH_CHILD"}),
)]),
Usage::default(),
)),
1 => {
assert_eq!(last_tool_content(req), "child done");
Ok((ChatMessage::assistant("parent done"), Usage::default()))
}
n => panic!("unexpected parent call {n}"),
}
}
}
#[tokio::test]
async fn max_tool_output_bytes_is_inherited_by_the_child() {
let config = Config::builder()
.subagents_enabled(true)
.max_tool_output_bytes(50)
.build();
let mut agent = Agent::with_provider(
config,
Box::new(MaxToolOutputBytesInheritProvider {
calls: AtomicUsize::new(0),
}),
);
let reply = agent.send("start").await.unwrap();
assert_eq!(reply, "parent done");
}
struct DoomLoopInheritProvider {
calls: AtomicUsize,
}
#[async_trait]
impl Provider for DoomLoopInheritProvider {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
if req.messages[0].content.as_deref() == Some("DOOM_CHILD") {
return match req.messages.len() {
2 => Ok((
assistant_with_calls(vec![tool_call(
"call_a",
"bash",
serde_json::json!({"command": "echo hi"}),
)]),
Usage::default(),
)),
4 => {
Ok((
assistant_with_calls(vec![tool_call(
"call_b",
"bash",
serde_json::json!({"command": "echo hi"}),
)]),
Usage::default(),
))
}
_ => {
let content = last_tool_content(req);
assert!(
content.contains("doom-loop breaker"),
"the child must inherit the parent's doom_loop_threshold: {content}"
);
Ok((ChatMessage::assistant("child done"), Usage::default()))
}
};
}
match self.calls.fetch_add(1, Ordering::SeqCst) {
0 => Ok((
assistant_with_calls(vec![tool_call(
"call_1",
"spawn_subagent",
serde_json::json!({"task": "go", "system_prompt": "DOOM_CHILD"}),
)]),
Usage::default(),
)),
1 => {
assert_eq!(last_tool_content(req), "child done");
Ok((ChatMessage::assistant("parent done"), Usage::default()))
}
n => panic!("unexpected parent call {n}"),
}
}
}
#[tokio::test]
async fn doom_loop_threshold_is_inherited_by_the_child() {
let config = Config::builder()
.subagents_enabled(true)
.doom_loop_threshold(2)
.build();
let mut agent = Agent::with_provider(
config,
Box::new(DoomLoopInheritProvider {
calls: AtomicUsize::new(0),
}),
);
let reply = agent.send("start").await.unwrap();
assert_eq!(reply, "parent done");
}
struct EditFileRequireReadInheritProvider {
calls: AtomicUsize,
}
#[async_trait]
impl Provider for EditFileRequireReadInheritProvider {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
if req.messages[0].content.as_deref() == Some("EDIT_CHILD") {
if req.messages.len() == 2 {
return Ok((
assistant_with_calls(vec![tool_call(
"edit_call",
"edit_file",
serde_json::json!({
"path": "never-read.txt",
"old_string": "hello",
"new_string": "world",
}),
)]),
Usage::default(),
));
}
let content = last_tool_content(req);
assert!(
content.contains("must be read with"),
"the child must inherit the parent's edit_file_require_read_before_edit: {content}"
);
return Ok((ChatMessage::assistant("child done"), Usage::default()));
}
match self.calls.fetch_add(1, Ordering::SeqCst) {
0 => Ok((
assistant_with_calls(vec![tool_call(
"call_1",
"spawn_subagent",
serde_json::json!({"task": "go", "system_prompt": "EDIT_CHILD"}),
)]),
Usage::default(),
)),
1 => {
assert_eq!(last_tool_content(req), "child done");
Ok((ChatMessage::assistant("parent done"), Usage::default()))
}
n => panic!("unexpected parent call {n}"),
}
}
}
#[tokio::test]
async fn edit_file_require_read_before_edit_is_inherited_by_the_child() {
let config = Config::builder()
.subagents_enabled(true)
.edit_file_require_read_before_edit(true)
.build();
let mut agent = Agent::with_provider(
config,
Box::new(EditFileRequireReadInheritProvider {
calls: AtomicUsize::new(0),
}),
);
let reply = agent.send("start").await.unwrap();
assert_eq!(reply, "parent done");
}
struct SubagentStatusWhenDisabledProvider {
calls: AtomicUsize,
}
#[async_trait]
impl Provider for SubagentStatusWhenDisabledProvider {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode::Result<(ChatMessage, Usage)> {
match self.calls.fetch_add(1, Ordering::SeqCst) {
0 => Ok((
assistant_with_calls(vec![tool_call(
"call_1",
"subagent_status",
serde_json::json!({"subagent_id": "agent-bogus"}),
)]),
Usage::default(),
)),
1 => {
let content = last_tool_content(req);
assert_eq!(
content, "Error: model requested unknown tool: subagent_status",
"must be byte-identical to spawn_subagent's own disabled-path error text"
);
assert!(
!content.contains("unknown subagent id"),
"a disabled `subagent_status` call must not fall through to \
`Error::SubagentNotFound` — that would wrongly imply subagents \
are on but this particular id is bogus: {content}"
);
Ok((ChatMessage::assistant("handled"), Usage::default()))
}
n => panic!("unexpected call {n}"),
}
}
}
#[tokio::test]
async fn subagent_status_when_disabled_returns_unknown_tool_not_unknown_subagent_id() {
let config = Config::builder().build();
let mut agent = Agent::with_provider(
config,
Box::new(SubagentStatusWhenDisabledProvider {
calls: AtomicUsize::new(0),
}),
);
let reply = agent.send("start").await.unwrap();
assert_eq!(reply, "handled");
}
#[tokio::test]
async fn native_spawned_child_transcript_round_trips_with_the_native_source_not_claude_code() {
let config = Config::builder().subagents_enabled(true).build();
let mut agent = Agent::with_provider(
config,
Box::new(ForegroundJoinProvider {
calls: AtomicUsize::new(0),
}),
);
let (store, dir) = temp_store();
store.save("parent-sess-native", "t", "[]").unwrap();
agent.set_subagent_store(store.clone(), "parent-sess-native");
let reply = agent.send("start").await.unwrap();
assert_eq!(reply, "parent done");
let ids = store.list_subagent_ids("parent-sess-native").unwrap();
assert_eq!(ids.len(), 1, "exactly one child spawned");
let child_id = &ids[0];
let sidecar = store
.load_subagent_transcript("parent-sess-native", child_id)
.unwrap()
.expect("transcript persisted");
let header_line = sidecar.lines().next().expect("at least a header line");
assert!(
header_line.contains("\"source\":\"native\""),
"header: {header_line}"
);
assert!(
!header_line.contains("\"source\":\"claude_code\""),
"a native spawn must never be labeled claude_code on disk: {header_line}"
);
let parsed = Session::from_native_str(&sidecar).unwrap();
assert_eq!(parsed.meta.source, SessionSource::Native);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn subagent_error_variants_render_the_documented_text() {
let e = Error::SubagentDepthExceeded {
max_depth: 2,
attempted_depth: 3,
};
assert!(e.to_string().contains("max_depth=2"));
assert!(e.to_string().contains("depth 3"));
let e = Error::SubagentConcurrencyExceeded { max_concurrent: 4 };
assert!(e.to_string().contains("4 subagent(s) already running"));
let e = Error::SubagentBackgroundPolicyMissing;
assert!(e.to_string().contains("background_prompts"));
let e = Error::SubagentDefinitionNotFound("bogus".to_string());
assert!(e
.to_string()
.contains("unknown subagent agent_type `bogus`"));
let e = Error::SubagentNotFound("agent-x".to_string());
assert!(e.to_string().contains("unknown subagent id `agent-x`"));
}