use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::sync::mpsc;
use std::time::Duration;
fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_supercode"))
}
fn fresh_home(tag: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!(
"supercode-ux29-softint-{tag}-{}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn spawn_delayed_sse_stub(
delay: Duration,
) -> (
std::net::SocketAddr,
std::thread::JoinHandle<()>,
mpsc::Receiver<()>,
) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub listener");
let addr = listener.local_addr().unwrap();
let (tx, rx) = mpsc::channel();
let handle = std::thread::spawn(move || {
let (mut sock, _) = listener.accept().expect("accept one connection");
let mut buf = [0u8; 4096];
let n = sock.read(&mut buf).unwrap_or(0);
if n > 0 {
let _ = tx.send(()); }
std::thread::sleep(delay);
let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n\
data: {\"choices\":[{\"delta\":{\"content\":\"lo there\"}}]}\n\n\
data: [DONE]\n\n";
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
sse.len(),
sse
);
let _ = sock.write_all(resp.as_bytes());
let _ = sock.flush();
});
(addr, handle, rx)
}
fn spawn_fast_sse_stub() -> (std::net::SocketAddr, std::thread::JoinHandle<()>) {
let (addr, handle, _rx) = spawn_delayed_sse_stub(Duration::from_millis(0));
(addr, handle)
}
fn spawn_piped(home: &Path, base_url: &str, extra: &[&str]) -> Child {
let mut args = vec!["--api-key", "x", "--base-url", base_url];
args.extend_from_slice(extra);
Command::new(bin())
.env("SUPERCODE_HOME", home)
.env_remove("OPENROUTER_API_KEY")
.env_remove("OPENAI_API_KEY")
.env_remove("ANTHROPIC_API_KEY")
.env_remove("NO_COLOR")
.env_remove("SUPERCODE_QUIET")
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn the supercode binary")
}
fn run_to_completion(child: Child) -> Output {
child.wait_with_output().expect("child process failed")
}
fn send_sigint(pid: u32) {
let status = Command::new("kill")
.args(["-INT", &pid.to_string()])
.status()
.expect("failed to invoke kill(1)");
assert!(status.success(), "kill -INT {pid} failed: {status:?}");
}
fn read_session_file(home: &Path, name: &str) -> String {
let path = home.join("sessions").join(format!("{name}.jsonl"));
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("reading session file {}: {e}", path.display()))
}
fn newest_session_name(home: &Path) -> String {
let dir = home.join("sessions");
let mut names: Vec<(std::time::SystemTime, String)> = std::fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("reading {}: {e}", dir.display()))
.flatten()
.filter_map(|entry| {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) == Some("jsonl")
&& !p.to_string_lossy().ends_with(".sidecar.jsonl")
{
let mtime = entry.metadata().ok()?.modified().ok()?;
Some((mtime, p.file_stem()?.to_string_lossy().into_owned()))
} else {
None
}
})
.collect();
names.sort_by_key(|(t, _)| *t);
names
.pop()
.unwrap_or_else(|| panic!("no session *.jsonl found under {}", dir.display()))
.1
}
#[test]
fn sigint_mid_stream_cancels_cleanly_and_leaves_the_prior_session_intact() {
let home = fresh_home("run-continue");
let (addr1, server1) = spawn_fast_sse_stub();
let out1 = run_to_completion(spawn_piped(
&home,
&format!("http://{addr1}"),
&["run", "first message"],
));
assert!(
out1.status.success(),
"turn 1 (establishing the session) failed: status={:?} stderr={}",
out1.status,
String::from_utf8_lossy(&out1.stderr)
);
server1.join().expect("fast stub thread panicked");
let session_name = newest_session_name(&home);
let before = read_session_file(&home, &session_name);
assert!(
!before.trim().is_empty(),
"turn 1 should have persisted a non-empty session"
);
assert_eq!(
before.lines().filter(|l| !l.trim().is_empty()).count(),
3,
"turn 1 should persist system + user + assistant, got: {before:?}"
);
let delay = Duration::from_secs(3);
let (addr2, server2, request_seen) = spawn_delayed_sse_stub(delay);
let child2 = spawn_piped(
&home,
&format!("http://{addr2}"),
&["run", "--continue", "second message (never lands)"],
);
let pid2 = child2.id();
request_seen
.recv_timeout(Duration::from_secs(10))
.expect("stub never saw the turn-2 request arrive");
send_sigint(pid2);
let out2 = run_to_completion(child2);
drop(server2);
assert_eq!(
out2.status.code(),
Some(130),
"SIGINT mid-stream must exit 130 (128+SIGINT): status={:?} stdout={} stderr={}",
out2.status,
String::from_utf8_lossy(&out2.stdout),
String::from_utf8_lossy(&out2.stderr)
);
let stderr2 = String::from_utf8_lossy(&out2.stderr);
assert!(
stderr2.contains("interrupted"),
"stderr should carry a clean interrupt notice, got: {stderr2}"
);
assert!(
!stderr2.to_lowercase().contains("panic"),
"no panic/backtrace expected on the interrupt path, got: {stderr2}"
);
assert!(
!out2.stderr.contains(&0x1b),
"stderr must carry no raw ANSI escapes when piped, raw: {:?}",
out2.stderr
);
let stdout2 = String::from_utf8_lossy(&out2.stdout);
assert!(
!stdout2.contains("hello there") && !stdout2.contains("hel"),
"no reply text should ever reach stdout — the stub's response was cancelled before delivery, got: {stdout2}"
);
assert!(
!out2.stdout.contains(&0x1b),
"stdout must carry no raw ANSI escapes when piped, raw: {:?}",
out2.stdout
);
let after = read_session_file(&home, &session_name);
assert_eq!(
before, after,
"the pre-turn session file must be untouched by a cancelled turn"
);
let (addr3, server3) = spawn_fast_sse_stub();
let out3 = run_to_completion(spawn_piped(
&home,
&format!("http://{addr3}"),
&["run", "--continue", "third message"],
));
assert!(
out3.status.success(),
"turn 3 (post-interrupt continuation) failed: status={:?} stderr={}",
out3.status,
String::from_utf8_lossy(&out3.stderr)
);
let stderr3 = String::from_utf8_lossy(&out3.stderr);
assert!(
stderr3.contains("Continuing session (3 messages)"),
"turn 3 must resume from EXACTLY turn 1's 3-message history (system + user + \
assistant) — any other count means the cancelled turn 2 left residue; got: {stderr3}"
);
server3.join().expect("turn-3 fast stub thread panicked");
let final_transcript = read_session_file(&home, &session_name);
assert_eq!(
final_transcript
.lines()
.filter(|l| !l.trim().is_empty())
.count(),
5,
"turn 3 should append its own user+assistant pair on top of turn 1's, got: {final_transcript:?}"
);
}