supercode-harness 0.4.2

The optional native Supercode agent and tool harness
Documentation
//! Opt-in real-executable proof for Claude Code stream-json interruption.
//!
//! ```text
//! SUPERCODE_LIVE_CLAUDE_INTERRUPT=1 cargo test -p supercode-core --test claude_interrupt_live -- --ignored --nocapture
//! ```

#![cfg(unix)]

use std::path::PathBuf;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use supercode_harness::{
    ClaudeCodeRuntimeBackend, HarnessEvent, RuntimeBackend, RuntimeConnection, RuntimeEndpoint,
    RuntimeInput, RuntimeStartRequest,
};

#[tokio::test]
#[ignore = "starts a real Claude Code runtime and spends model tokens; set SUPERCODE_LIVE_CLAUDE_INTERRUPT=1"]
async fn interrupt_ends_the_turn_and_the_runtime_accepts_a_follow_up() {
    if std::env::var("SUPERCODE_LIVE_CLAUDE_INTERRUPT").is_err() {
        panic!(
            "SUPERCODE_LIVE_CLAUDE_INTERRUPT not set — this acceptance test starts a real \
             Claude Code runtime and spends model tokens"
        );
    }

    let run = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let workspace = ScratchWorkspace(std::env::temp_dir().join(format!(
        "supercode-claude-interrupt-{}-{run}",
        std::process::id()
    )));
    std::fs::create_dir_all(&workspace.0).unwrap();

    let backend = ClaudeCodeRuntimeBackend::new();
    assert!(backend.capabilities().interrupt);
    let mut runtime = backend
        .start(RuntimeStartRequest {
            cwd: workspace.0.clone(),
            launch: None,
        })
        .await
        .unwrap();
    let runtime_id = runtime.handle().runtime_id.clone();
    let pid = match runtime.handle().endpoint {
        RuntimeEndpoint::LocalProcess { pid: Some(pid), .. } => pid,
        ref endpoint => panic!("Claude Code did not expose a child pid: {endpoint:?}"),
    };
    eprintln!("started Claude Code runtime {runtime_id} as pid {pid}");

    runtime
        .send_input(RuntimeInput {
            text: "Use the Bash tool to run `sleep 30` and wait for it to finish before replying."
                .into(),
        })
        .await
        .unwrap();

    let tool_deadline = tokio::time::Instant::now() + Duration::from_secs(45);
    loop {
        let event = next_before(&mut *runtime, tool_deadline, "the slow Bash tool to start").await;
        if starts_bash_tool(&event) {
            break;
        }
    }

    let interrupt_started = Instant::now();
    tokio::time::timeout(Duration::from_secs(15), runtime.interrupt())
        .await
        .expect("interrupt call exceeded its public bound")
        .expect("Claude Code rejected the interrupt");
    let acknowledged_after = interrupt_started.elapsed();

    let result_deadline = tokio::time::Instant::now() + Duration::from_secs(5);
    let mut interrupted_marker = false;
    let interrupted_result = loop {
        let event = next_before(
            &mut *runtime,
            result_deadline,
            "the interrupted turn to end",
        )
        .await;
        interrupted_marker |= event.payload.to_string().contains("interrupted by user");
        if event.kind == "result" {
            break event.payload;
        }
    };
    let turn_ended_after = interrupt_started.elapsed();
    assert_eq!(
        interrupted_result["is_error"], true,
        "the interrupted turn must not report success: {interrupted_result}"
    );
    assert!(
        interrupted_marker || interrupted_result.to_string().contains("interrupt"),
        "Claude Code ended the turn without an interruption marker: {interrupted_result}"
    );
    assert!(
        turn_ended_after < Duration::from_secs(5),
        "the interrupted turn took {turn_ended_after:?} to end"
    );
    eprintln!(
        "interrupt acknowledged after {acknowledged_after:?}; turn ended after {turn_ended_after:?}"
    );

    let nonce = format!("FOLLOWUP-OK-{run}");
    runtime
        .send_input(RuntimeInput {
            text: format!("Reply with exactly: {nonce}"),
        })
        .await
        .unwrap();
    let follow_up_deadline = tokio::time::Instant::now() + Duration::from_secs(45);
    let mut saw_nonce = false;
    loop {
        let event = next_before(
            &mut *runtime,
            follow_up_deadline,
            "the follow-up turn to complete",
        )
        .await;
        saw_nonce |= event.payload.to_string().contains(&nonce);
        if event.kind == "result" {
            assert_eq!(
                event.payload["is_error"], false,
                "the follow-up turn failed: {}",
                event.payload
            );
            break;
        }
    }
    assert!(
        saw_nonce,
        "the surviving runtime never emitted the follow-up nonce"
    );

    runtime.close().await.unwrap();
    remove_stale_registry_record(pid);
}

async fn next_before(
    runtime: &mut dyn RuntimeConnection,
    deadline: tokio::time::Instant,
    phase: &str,
) -> HarnessEvent {
    let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
    assert!(!remaining.is_zero(), "timed out waiting for {phase}");
    tokio::time::timeout(remaining, runtime.next_event())
        .await
        .unwrap_or_else(|_| panic!("timed out waiting for {phase}"))
        .unwrap_or_else(|error| panic!("Claude Code failed while waiting for {phase}: {error}"))
        .unwrap_or_else(|| panic!("Claude Code exited while waiting for {phase}"))
}

fn starts_bash_tool(event: &HarnessEvent) -> bool {
    event
        .payload
        .pointer("/message/content")
        .and_then(serde_json::Value::as_array)
        .is_some_and(|blocks| {
            blocks.iter().any(|block| {
                block.get("type").and_then(serde_json::Value::as_str) == Some("tool_use")
                    && block.get("name").and_then(serde_json::Value::as_str) == Some("Bash")
            })
        })
        || (event.kind == "system"
            && event
                .payload
                .get("subtype")
                .and_then(serde_json::Value::as_str)
                == Some("task_started"))
}

fn remove_stale_registry_record(pid: u32) {
    let Some(claude_home) = std::env::var_os("HOME").map(PathBuf::from) else {
        return;
    };
    let record = claude_home
        .join(".claude/sessions")
        .join(format!("{pid}.json"));
    if let Err(error) = std::fs::remove_file(&record) {
        assert_eq!(
            error.kind(),
            std::io::ErrorKind::NotFound,
            "could not remove the test's stale Claude registry record {}: {error}",
            record.display()
        );
    }
}

struct ScratchWorkspace(PathBuf);

impl Drop for ScratchWorkspace {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.0);
    }
}