supercode-harness 0.4.10

The optional native Supercode agent and tool harness
Documentation
//! P5-2 (COMPOSABLE-HARNESS-DESIGN.md §2.2 C2 "connect invalidates cache
//! prefix"; §2 module 25 `cache` is the referee): the churn signal an MCP
//! connect emits, and the runtime cache-established reset a post-first-turn
//! `Agent::register_tool` call now performs.

use std::path::{Path, PathBuf};

use async_trait::async_trait;
use supercode_harness::mcp::cache_churn_notice;
use supercode_harness::session::Session;
use supercode_harness::tools::{Tool, ToolContext};
use supercode_harness::{Agent, CachePlan, ChatMessage, ChatRequest, Config, Provider, Usage};

#[test]
fn cache_churn_notice_is_a_pure_function_naming_server_and_c2() {
    let msg = cache_churn_notice("github", 7);
    assert!(msg.contains("github"));
    assert!(msg.contains('7'));
    assert!(msg.contains("C2"));
}

fn fixture(name: &str) -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests/fixtures")
        .join(name)
}

fn load_codex() -> Session {
    Session::from_codex(fixture("codex_session.jsonl")).unwrap()
}

struct PlainAnswerProvider;

#[async_trait]
impl Provider for PlainAnswerProvider {
    async fn complete(
        &self,
        _req: &ChatRequest,
        _on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
    ) -> supercode_harness::Result<(ChatMessage, Usage)> {
        Ok((ChatMessage::assistant("ok"), Usage::default()))
    }
}

struct NoopTool;

#[async_trait]
impl Tool for NoopTool {
    fn name(&self) -> &str {
        "noop_extra_tool"
    }
    fn description(&self) -> &str {
        "does nothing"
    }
    fn parameters(&self) -> serde_json::Value {
        serde_json::json!({"type": "object", "properties": {}})
    }
    async fn execute(
        &self,
        _args: serde_json::Value,
        _ctx: &ToolContext,
    ) -> supercode_harness::Result<String> {
        Ok(String::new())
    }
}

#[tokio::test]
async fn registering_a_tool_before_any_turn_leaves_cache_established_false() {
    let config = Config::builder().model("test-model").build();
    let mut agent = Agent::with_provider(config, Box::new(PlainAnswerProvider));
    assert!(!agent.cache_established());
    // No turn has run yet — the ordinary startup-time `attach_mcp` case —
    // registering here must not be treated as a churn event (nothing was
    // ever warm to invalidate); still false afterward, unremarkably.
    agent.register_tool(NoopTool);
    assert!(!agent.cache_established());
    assert!(!agent.request_issued());
}

/// The real C2 case: a cache entry is genuinely warm (an
/// `ImportedPrefix`-annotated turn already ran), THEN a tool gets
/// registered (the shape a hypothetical live/mid-session MCP connect would
/// take) — `cache_established` must flip back to `false`, matching C2's
/// "connect invalidates cache prefix" for the prefix-churn class generally
/// (register_tool's fix is not MCP-specific, but this is the scenario P5-2
/// names).
#[tokio::test]
async fn registering_a_tool_after_the_cache_is_established_invalidates_it() {
    let config = Config::builder()
        .model("test-model")
        .cache_plan(CachePlan::ImportedPrefix)
        .build();
    let mut agent = Agent::with_provider(config, Box::new(PlainAnswerProvider));
    agent.load_session(load_codex());

    agent.send("continue where we left off").await.unwrap();
    assert!(
        agent.cache_established(),
        "sanity: the first ImportedPrefix-annotated turn must establish the cache"
    );

    agent.register_tool(NoopTool);
    assert!(
        !agent.cache_established(),
        "registering a tool after the cache was warm must invalidate it (C2)"
    );
}