use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use supercode_harness::configfile::{resolve, ResolveOptions};
use supercode_harness::{
Agent, ChatMessage, ChatRequest, Config, FunctionCall, LifecycleEvent, Provider, Role,
ToolCall, Usage,
};
fn preset_config(name: &str) -> Config {
let top = format!("extends = \"{name}\"\n");
let resolved = resolve(&top, None, &ResolveOptions { strict: true })
.unwrap_or_else(|e| panic!("preset `{name}` failed to resolve: {e}"));
let mut config = resolved.config;
config.api_key = Some("test-key".to_string());
config.base_url = "http://127.0.0.1:1".to_string();
if config.model.is_empty() {
config.model = "openai/gpt-5-codex".to_string();
}
config
}
struct SpawnOnce {
parent_requests: AtomicUsize,
}
#[async_trait]
impl Provider for SpawnOnce {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode_harness::Result<(ChatMessage, Usage)> {
let is_child = req.messages.iter().any(|m| {
m.role == Role::User && m.content.as_deref().is_some_and(|c| c.contains("CHILD:"))
});
if is_child {
return Ok((ChatMessage::assistant("child: three"), Usage::default()));
}
let has_tool_result = req.messages.iter().any(|m| m.role == Role::Tool);
if has_tool_result || self.parent_requests.fetch_add(1, Ordering::SeqCst) > 0 {
return Ok((ChatMessage::assistant("parent: done"), Usage::default()));
}
let mut msg = ChatMessage::assistant("");
msg.tool_calls = Some(vec![ToolCall {
id: "call_spawn".into(),
kind: "function".into(),
function: FunctionCall {
name: "spawn_subagent".into(),
arguments:
serde_json::json!({"task": "CHILD: count to three", "background": false})
.to_string(),
},
}]);
Ok((msg, Usage::default()))
}
}
#[tokio::test]
async fn a_validated_spawn_is_bracketed_by_start_and_stop_under_both_presets() {
for preset in ["cc-parity", "cx-parity"] {
let seen: Arc<Mutex<Vec<LifecycleEvent>>> = Arc::new(Mutex::new(Vec::new()));
let mut config = preset_config(preset);
let sink = seen.clone();
config.lifecycle_hook = Some(Box::new(move |event| {
sink.lock().unwrap().push(event.clone());
}));
let mut agent = Agent::with_provider(
config,
Box::new(SpawnOnce {
parent_requests: AtomicUsize::new(0),
}),
);
agent.send("please delegate").await.unwrap();
let seen = seen.lock().unwrap();
let starts: Vec<&LifecycleEvent> = seen
.iter()
.filter(|e| matches!(e, LifecycleEvent::SubagentStart { .. }))
.collect();
let stops: Vec<&LifecycleEvent> = seen
.iter()
.filter(|e| matches!(e, LifecycleEvent::SubagentStop { .. }))
.collect();
assert_eq!(starts.len(), 1, "{preset}: one start — {seen:?}");
assert_eq!(stops.len(), 1, "{preset}: one stop — {seen:?}");
assert!(
matches!(starts[0], LifecycleEvent::SubagentStart { task } if task == "CHILD: count to three"),
"{preset}: {:?}",
starts[0]
);
match stops[0] {
LifecycleEvent::SubagentStop {
task,
is_error,
output_len,
} => {
assert_eq!(task, "CHILD: count to three", "{preset}");
assert!(!is_error, "{preset}: the child answered, so no error");
assert!(*output_len > 0, "{preset}");
}
other => panic!("{preset}: {other:?}"),
}
let start_at = seen
.iter()
.position(|e| matches!(e, LifecycleEvent::SubagentStart { .. }))
.unwrap();
let stop_at = seen
.iter()
.position(|e| matches!(e, LifecycleEvent::SubagentStop { .. }))
.unwrap();
assert!(start_at < stop_at, "{preset}: start precedes stop");
}
}
#[tokio::test]
async fn a_refused_spawn_fires_no_lifecycle_event() {
struct EmptyTask(AtomicUsize);
#[async_trait]
impl Provider for EmptyTask {
async fn complete(
&self,
req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> supercode_harness::Result<(ChatMessage, Usage)> {
let has_tool_result = req.messages.iter().any(|m| m.role == Role::Tool);
if has_tool_result || self.0.fetch_add(1, Ordering::SeqCst) > 0 {
return Ok((ChatMessage::assistant("parent: done"), Usage::default()));
}
let mut msg = ChatMessage::assistant("");
msg.tool_calls = Some(vec![ToolCall {
id: "call_spawn".into(),
kind: "function".into(),
function: FunctionCall {
name: "spawn_subagent".into(),
arguments: serde_json::json!({"task": ""}).to_string(),
},
}]);
Ok((msg, Usage::default()))
}
}
let seen: Arc<Mutex<Vec<LifecycleEvent>>> = Arc::new(Mutex::new(Vec::new()));
let mut config = preset_config("cc-parity");
let sink = seen.clone();
config.lifecycle_hook = Some(Box::new(move |event| {
sink.lock().unwrap().push(event.clone());
}));
let mut agent = Agent::with_provider(config, Box::new(EmptyTask(AtomicUsize::new(0))));
agent.send("please delegate").await.unwrap();
assert!(
seen.lock().unwrap().is_empty(),
"{:?}",
seen.lock().unwrap()
);
}