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-39 (live streaming token counter in the
//! status line), the byte-cleanliness half of its AC:
//!
//! - dev/02: "The counter is TTY-gated and suppressed under --quiet /
//!   non-tty (stdout stays clean)."
//!
//! Same idiom as UX-15's `spinner_cli.rs` and UX-29's `soft_interrupt_cli.rs`
//! (raw `TcpListener` + hand-framed HTTP/SSE, piped stdio so `IsTerminal` is
//! false on both streams — the same shape as `run ... | cat` / CI). Unlike
//! `spinner_cli.rs`'s stub (one delayed response, two deltas), this one
//! streams MANY small chunked deltas with a real delay between each —
//! exactly the shape that would give a gating bug the most opportunities to
//! leak a `\r`/counter redraw, since `render_counter` fires once per
//! `TextDelta` (throttled) rather than once per turn.
//!
//! The interactive-tty case (the counter actually appearing on a real
//! terminal, live-updating across several redraws, and being cleared
//! cleanly at turn end and on SIGINT) is not exercised here — capturing a
//! real pty from a `cargo test` process is out of scope for an automated,
//! dependency-free test (same call `spinner_cli.rs` and `soft_interrupt_cli`
//! make); it was verified manually with a `pty.fork()`-based driver against
//! this same multi-chunk delayed stub (see the UX-39 tracker / build
//! report for the captured transcript).

use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
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-ux39-tokencounter-{tag}-{}-{nanos}",
        std::process::id()
    ));
    std::fs::create_dir_all(&dir).unwrap();
    dir
}

/// Delay between EACH of several small streamed chunks — chosen well past
/// the spinner's ~200ms start-delay and the counter's own 120ms redraw
/// throttle, so a gating bug would have many separate opportunities (one
/// per chunk) to leak counter bytes onto a piped stream, not just one.
const CHUNK_DELAY: Duration = Duration::from_millis(150);

/// A one-shot local HTTP server that accepts one connection, drains the
/// request, then streams several separately-delayed, separately-chunked SSE
/// content deltas — mirroring the exact framing
/// `crates/harness/src/provider.rs`'s `OpenAiProvider` parses, just spread
/// across more (and more slowly arriving) frames than `spinner_cli.rs`'s
/// stub uses.
fn spawn_delayed_multi_chunk_sse_stub() -> (std::net::SocketAddr, std::thread::JoinHandle<()>) {
    let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub listener");
    let addr = listener.local_addr().unwrap();
    let handle = std::thread::spawn(move || {
        let (mut sock, _) = listener.accept().expect("accept one connection");
        // Fully drain the request before responding. `supercode`'s request
        // body (system prompt + every built-in tool's JSON schema) is
        // several KB — comfortably more than a single `read()` call is
        // guaranteed to return — so a single fixed-size read (as a smaller
        // stub could get away with) can leave bytes sitting unread in the
        // kernel's receive queue. Closing a socket with unread inbound data
        // still queued makes the OS send a TCP RST instead of a graceful
        // FIN, which the client then surfaces as "connection reset by
        // peer" while it's midway through reading the (separate) response
        // stream — nothing to do with this test's actual subject, but easy
        // to trip over with a stub this test deliberately keeps open much
        // longer (several chunked, separately-delayed writes) than
        // `spinner_cli.rs`'s single-shot one. Read with a short idle
        // timeout, looping until the request goes quiet, rather than
        // guessing a single buffer size.
        sock.set_read_timeout(Some(Duration::from_millis(200)))
            .expect("set read timeout");
        let mut buf = [0u8; 65536];
        loop {
            match sock.read(&mut buf) {
                Ok(0) => break,
                Ok(_) => continue,
                Err(e)
                    if e.kind() == std::io::ErrorKind::WouldBlock
                        || e.kind() == std::io::ErrorKind::TimedOut =>
                {
                    break
                }
                Err(_) => break,
            }
        }
        sock.set_read_timeout(None).expect("clear read timeout");

        sock.write_all(
            b"HTTP/1.1 200 OK\r\n\
              Content-Type: text/event-stream\r\n\
              Transfer-Encoding: chunked\r\n\
              Connection: close\r\n\r\n",
        )
        .expect("write stub headers");

        let words = [
            "This ",
            "is ",
            "a ",
            "long ",
            "streamed ",
            "reply ",
            "made ",
            "of ",
            "many ",
            "small ",
            "delayed ",
            "chunks ",
            "to ",
            "stress ",
            "the ",
            "counter's ",
            "own ",
            "gating.",
        ];
        for w in words {
            std::thread::sleep(CHUNK_DELAY);
            let data = format!(r#"data: {{"choices":[{{"delta":{{"content":"{w}"}}}}]}}"#);
            let payload = format!("{data}\n\n");
            let framed = format!("{:x}\r\n{payload}\r\n", payload.len());
            sock.write_all(framed.as_bytes()).expect("write stub chunk");
        }
        std::thread::sleep(CHUNK_DELAY);
        let done = "data: [DONE]\n\n";
        let framed = format!("{:x}\r\n{done}\r\n", done.len());
        sock.write_all(framed.as_bytes()).expect("write [DONE]");
        sock.write_all(b"0\r\n\r\n").expect("write terminal chunk");
        sock.flush().ok();
    });
    (addr, handle)
}

fn run_piped(home: &Path, base_url: &str, extra: &[&str]) -> Output {
    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)
        // Deliberately NOT a pty: the "piped" shape (`run ... | cat`, CI).
        // `IsTerminal` on a piped `Stdio` is false on every platform this
        // workspace targets — this is the `enabled=false` gate for BOTH the
        // spinner and (this ticket's) counter.
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("failed to spawn the supercode binary")
        .wait_with_output()
        .expect("child process failed")
}

/// Bytes that would only ever appear if the counter (or the spinner it
/// coexists with) actually rendered: a raw ESC byte at all (this binary has
/// no other reason to emit one on a piped, `NO_COLOR`-equivalent path), the
/// counter's own literal label text, or the spinner's.
fn contains_status_line_bytes(raw: &[u8]) -> bool {
    if raw.contains(&0x1b) {
        return true; // ESC — CSI clear-line / erase-to-EOL / any escape.
    }
    let text = String::from_utf8_lossy(raw);
    text.contains("this turn:")
        || text.contains("session:")
        || text.contains("Thinking…")
        || text.contains("Working…")
}

const EXPECTED_REPLY: &str =
    "This is a long streamed reply made of many small delayed chunks to stress the counter's own gating.";

/// The CLI word-wraps rendered Markdown at the terminal width (`wrap_width`
/// in `main.rs`), so the reassembled reply can land on stdout with a `\n`
/// wherever a wrap boundary fell — a rendering detail unrelated to what
/// this test actually checks (that every streamed chunk arrived, in order,
/// byte-for-byte, uninterleaved with any status-line write). Collapse
/// whitespace before comparing so the assertion is robust to wrap width.
fn normalize_whitespace(s: &str) -> String {
    s.split_whitespace().collect::<Vec<_>>().join(" ")
}

#[test]
fn piped_multi_chunk_stream_emits_zero_counter_bytes_and_reply_is_intact() {
    let (addr, server) = spawn_delayed_multi_chunk_sse_stub();
    let home = fresh_home("piped-clean");

    let out = run_piped(&home, &format!("http://{addr}"), &["run", "say hi"]);

    assert!(
        out.status.success(),
        "run failed: status={:?} stdout={} stderr={}",
        out.status,
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );

    // The load-bearing "reply is intact" half of the bar: every one of the
    // 18 separately-streamed, separately-delayed chunks landed on stdout,
    // uninterleaved with any status-line byte, and reassembled exactly.
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        normalize_whitespace(&stdout).contains(EXPECTED_REPLY),
        "expected the fully-reassembled streamed reply on stdout, got: {stdout}"
    );

    assert!(
        !contains_status_line_bytes(&out.stdout),
        "stdout must never carry counter/spinner bytes; raw: {:?}",
        out.stdout
    );
    assert!(
        !contains_status_line_bytes(&out.stderr),
        "piped (non-tty) stderr must carry zero counter/spinner bytes even across many \
         separately-delayed chunks; raw: {:?}",
        out.stderr
    );

    server.join().expect("stub server thread panicked");
}

#[test]
fn piped_json_output_format_emits_zero_counter_bytes() {
    // `--output-format json` never even constructs a `Spinner` (see
    // `run_cmd`) — so it never touches `record_delta`/`render_counter`
    // either. Belt-and-suspenders proof the machine-readable path stays
    // byte-clean regardless of how many chunks/how slowly the provider
    // streams them.
    let (addr, server) = spawn_delayed_multi_chunk_sse_stub();
    let home = fresh_home("piped-json-clean");

    let out = run_piped(
        &home,
        &format!("http://{addr}"),
        &["run", "--output-format", "json", "say hi"],
    );

    assert!(
        out.status.success(),
        "run --output-format json failed: status={:?} stdout={} stderr={}",
        out.status,
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(
        !contains_status_line_bytes(&out.stdout),
        "json stdout must never carry counter/spinner bytes; raw: {:?}",
        out.stdout
    );
    assert!(
        !contains_status_line_bytes(&out.stderr),
        "json-mode stderr must carry zero counter/spinner bytes; raw: {:?}",
        out.stderr
    );

    server.join().expect("stub server thread panicked");
}

#[test]
fn quiet_flag_emits_zero_counter_bytes_even_though_piped_already_would() {
    // Defense in depth: `--quiet` must independently suppress the counter
    // (via the same `effective_quiet`/`should_show_spinner` gate the
    // spinner uses), not merely happen to be silent because this harness
    // always pipes stdio. Uses the SAME multi-chunk stub as the primary
    // test above.
    let (addr, server) = spawn_delayed_multi_chunk_sse_stub();
    let home = fresh_home("quiet-clean");

    let out = run_piped(
        &home,
        &format!("http://{addr}"),
        &["--quiet", "run", "say hi"],
    );

    assert!(
        out.status.success(),
        "run --quiet failed: status={:?} stdout={} stderr={}",
        out.status,
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        normalize_whitespace(&stdout).contains(EXPECTED_REPLY),
        "the reply itself must still stream through under --quiet, got: {stdout}"
    );
    assert!(
        !contains_status_line_bytes(&out.stdout),
        "stdout must never carry counter/spinner bytes under --quiet; raw: {:?}",
        out.stdout
    );
    assert!(
        !contains_status_line_bytes(&out.stderr),
        "--quiet stderr must carry zero counter/spinner bytes; raw: {:?}",
        out.stderr
    );

    server.join().expect("stub server thread panicked");
}