supercode-cli 0.4.6

supercode — a lightweight, fully-customizable AI coding agent CLI in Rust. Any model via OpenRouter; natively continues Claude Code and Codex sessions.
//! CLI-level acceptance test for UX-29 dev/02 (Ctrl-C/SIGINT hard-cancels an
//! in-flight turn cleanly, session left consistent) — the real-CLI proof
//! this ticket's bar asks for: spawn the built `supercode` binary against a
//! real local HTTP/SSE stub that deliberately delays its response, send a
//! genuine `SIGINT` to the CHILD PROCESS once we know the request has left
//! the wire (not a fixed sleep-and-hope), and assert:
//!
//!   - the process exits with the conventional 130 (128 + SIGINT), not a
//!     raw kill/panic/hang;
//!   - stderr carries a clean, single-line interrupt notice and NOT a
//!     spinner frame, a stray ANSI escape, or a panic backtrace;
//!   - stdout carries nothing from the (never-delivered) reply;
//!   - and, the load-bearing property, THE PRIOR SESSION ON DISK IS
//!     UNTOUCHED byte-for-byte — reading it back afterward still parses and
//!     a subsequent `--continue` reports the exact same message count, so
//!     nothing was corrupted or half-written by the cancelled turn.
//!
//! Follows the same raw `TcpListener` + hand-framed SSE idiom as UX-15's
//! `spinner_cli.rs` (and `supercode-core`'s own provider tests), extended
//! with a signal so the test can send `SIGINT` only once the child has
//! actually issued the request — never a fixed timing guess.

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
}

/// A one-shot local HTTP server that: accepts one connection, reads the
/// request (notifying `notify` the instant it has read at least one byte —
/// proof the client actually sent the request, i.e. the turn is genuinely
/// "in flight"), then sleeps `delay` before writing back a hand-framed
/// `text/event-stream` response. `delay` is chosen long enough that the
/// test always has time to deliver `SIGINT` well before any reply arrives.
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(()); // the request genuinely left the client.
        }

        std::thread::sleep(delay);

        // Only reachable if the test somehow failed to interrupt in time;
        // still a well-formed response so a broken test fails legibly
        // rather than hanging.
        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)
}

/// A stub that responds immediately (used for the "before"/"after" turns
/// that must succeed normally).
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)
        // Piped (non-tty) stdio: SIGINT is delivered directly to the child's
        // pid below, not via a terminal's ISIG — this test proves the
        // `tokio::signal::ctrl_c()` handling path, not rustyline's separate
        // idle-prompt raw-mode interception. That other path has NO automated
        // coverage in this file (this file has exactly one `#[test]`, the one
        // below) — it was verified only manually, with a real pty — see the
        // UX-29 tracker note.
        .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")
}

/// Send a genuine `SIGINT` to `pid` — via the `kill` utility rather than a
/// signals crate dependency (none of this workspace's crates pull one in;
/// `tokio`'s own `signal` feature, already enabled via `features = ["full"]`
/// in the workspace `Cargo.toml`, is what the CLI itself uses to receive
/// it).
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()))
}

/// The most recently created `*.jsonl` session file's stem, under `home`'s
/// session store — used to name-address the file the "before"/"after" turns
/// wrote, without needing to scrape it out of the CLI's own stderr banner.
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
}

/// Real-CLI proof (UX-29 dev/02): SIGINT mid-stream on a one-shot `run`
/// cancels cleanly (exit 130, clean stderr, no stdout residue) — and,
/// critically, an EARLIER successfully-persisted session that the
/// interrupted turn was `--continue`-ing is left byte-for-byte untouched,
/// so a subsequent turn against it still loads exactly the pre-interrupt
/// history.
#[test]
fn sigint_mid_stream_cancels_cleanly_and_leaves_the_prior_session_intact() {
    let home = fresh_home("run-continue");

    // ---- Turn 1: establish a real, persisted session (fast stub) --------
    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"
    );
    // Sanity: `persisted_view` persists the full `agent.history()` verbatim
    // outside reduced mode (see `main.rs::persisted_view`), which includes
    // this agent's own system prompt at index 0 — so exactly 3 lines:
    // system + user + assistant.
    assert_eq!(
        before.lines().filter(|l| !l.trim().is_empty()).count(),
        3,
        "turn 1 should persist system + user + assistant, got: {before:?}"
    );

    // ---- Turn 2: --continue against a DELAYED stub, interrupted mid-flight
    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();

    // Block until the stub has actually read bytes off the wire — i.e. the
    // request for turn 2 was genuinely issued — before signaling, so this
    // is a real mid-stream interrupt, never a timing guess.
    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);
    // The stub thread is still asleep (mid-`delay`) or already exited after
    // a failed write to the now-closed socket; either way, don't block the
    // test on it — the assertions above/below are what matter. Detach it.
    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}"
    );
    // Piped (non-tty) stdio ⇒ color/spinner are both gated off (UX-15/UX-17)
    // ⇒ zero raw ESC bytes anywhere, same bar `spinner_cli.rs` holds `run`
    // to normally — a hard-cancel must not leave stray cursor/color codes.
    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
    );

    // ---- The load-bearing assertion: turn 1's session file is BYTE-FOR-BYTE
    // untouched by the cancelled turn 2 (UX-29 dev/02's session-consistency
    // guarantee — nothing new is ever persisted for a hard-cancelled turn).
    let after = read_session_file(&home, &session_name);
    assert_eq!(
        before, after,
        "the pre-turn session file must be untouched by a cancelled turn"
    );

    // ---- Turn 3: a normal --continue against a fresh fast stub must see
    // EXACTLY the same 2-message history turn 1 left — proof the session is
    // still valid/loadable and nothing from the cancelled turn 2 leaked in
    // (no dangling half-turn, no corruption).
    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");

    // And turn 3 itself now correctly appended its own exchange on top:
    // 3 (turn 1) + user3 + assistant3 = 5 lines.
    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:?}"
    );
}