supercode-harness 0.4.20

The optional native Supercode agent and tool harness
Documentation
//! BP-11 (`.volter/tracker/markdown/BP-11.md`), row
//! `lifecycle-hooks-config-registered`: the subagent boundary is observable
//! through `Config::lifecycle_hook` — `subagent_start` fires once a
//! `spawn_subagent` call has passed validation and `subagent_stop` when its
//! result is back — proven as EXECUTED BEHAVIOUR over the RESOLVED
//! `cc-parity` / `cx-parity` presets (the compaction half lives beside the
//! compaction code in `agent.rs`'s own tests).

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
}

/// Parent and child share this provider (as in production). The parent's
/// first turn asks for a foreground child; the child's turn (its request
/// carries the task as its user message) answers plainly; the parent's turn
/// after the tool result answers plainly.
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");
    }
}

/// A spawn the runner refuses (empty task) fires neither event: the hooks
/// describe children that actually ran, not attempts.
#[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()
    );
}