#![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(),
image_urls: Vec::new(),
})
.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}"),
image_urls: Vec::new(),
})
.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);
}
}