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-23 (`--trace` observability flag +
//! `run --output-format stream-json` NDJSON streaming output):
//!
//! - dev/01: `supercode --trace run ...` logs tool inputs/outputs and token
//!   usage to stderr; without it, stderr stays clean.
//! - dev/02: `supercode run --output-format stream-json ...` emits
//!   newline-delimited JSON events while streaming (each line is valid
//!   JSON).
//! - dev/03: stdout for stream-json contains only the NDJSON event stream
//!   (pipeable to `jq -c`).
//!
//! Same idiom as `spinner_cli.rs`/`quiet_cli.rs`/`token_counter_cli.rs`: the
//! real, built `supercode` binary against a real local HTTP/SSE stub — the
//! CLI, event sink, and `--trace`/`--output-format` gating are all the
//! genuine, unmodified binary, not a reimplementation. Unlike those single
//! round-trip stubs, `spawn_tool_round_trip_stub` below drives TWO model
//! round-trips (a tool call, then the final answer) over two separate
//! accepted connections, so the NDJSON/`--trace` output can be checked for
//! tool-call events too, not just text deltas.

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-ux23-trace-{tag}-{}-{nanos}",
        std::process::id()
    ));
    std::fs::create_dir_all(&dir).unwrap();
    dir
}

/// Drain a request fully before responding — a fixed-size single `read()`
/// can leave bytes sitting in the kernel receive queue (this workspace's
/// request bodies, system prompt + every built-in tool schema, run several
/// KB), and closing on unread inbound data sends a TCP RST instead of a
/// graceful FIN. Same idiom as `token_counter_cli.rs::spawn_delayed_multi_chunk_sse_stub`.
fn drain_request(sock: &mut std::net::TcpStream) {
    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");
}

fn write_sse(sock: &mut std::net::TcpStream, sse: &str) {
    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
    );
    sock.write_all(resp.as_bytes())
        .expect("write stub response");
    sock.flush().ok();
}

/// A local HTTP server that answers exactly two round-trips:
///
/// 1. A `list_dir` tool call (empty args — defaults to the working
///    directory), with a `usage` object on the final chunk.
/// 2. After the client runs the tool locally and sends the result back, a
///    plain-text final answer ("done"), also with a `usage` object.
///
/// Mirrors the exact SSE tool-call delta framing
/// `crates/harness/src/provider.rs::provider::tests::streaming_assembles_tool_calls_and_usage_across_deltas`
/// already exercises at the unit level, just driven over a real socket so
/// the genuine CLI binary (not a mock `Provider`) produces the events.
fn spawn_tool_round_trip_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 || {
        // Round-trip 1: the model asks for `list_dir`.
        let (mut sock, _) = listener.accept().expect("accept round-trip 1");
        drain_request(&mut sock);
        let sse = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"list_dir\",\"arguments\":\"{}\"}}]}}]}\n\n\
                   data: {\"choices\":[{\"delta\":{}}],\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":3,\"total_tokens\":14}}\n\n\
                   data: [DONE]\n\n";
        write_sse(&mut sock, sse);
        drop(sock);

        // Round-trip 2: after the tool result comes back in the next
        // request, the model gives its final answer.
        let (mut sock, _) = listener.accept().expect("accept round-trip 2");
        drain_request(&mut sock);
        let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"done\"}}]}\n\n\
                   data: {\"choices\":[{\"delta\":{}}],\"usage\":{\"prompt_tokens\":25,\"completion_tokens\":1,\"total_tokens\":26}}\n\n\
                   data: [DONE]\n\n";
        write_sse(&mut sock, sse);
    });
    (addr, handle)
}

fn run_piped(home: &Path, cwd: &Path, base_url: &str, extra: &[&str]) -> Output {
    // `--quiet` (the safety banner) and disallowing `bash`/`shell` (the
    // shell-sandbox-unenforceable warning, UX-38: deliberately NOT quiet-
    // gated — see `build_config`) strip every OTHER stderr line this
    // process would otherwise print on this platform/sandbox default,
    // regardless of `--output-format`/`--trace`, so this test's stderr
    // assertions are about stream-json/`--trace` specifically rather than
    // incidentally tripping over unrelated, pre-existing chrome. Neither
    // tool is used by this test's `list_dir` round trip.
    let mut args = vec![
        "--api-key",
        "x",
        "--base-url",
        base_url,
        "--quiet",
        "--disallow-tool",
        "bash",
        "--disallow-tool",
        "shell",
        "--cwd",
    ];
    let cwd_str = cwd.to_str().unwrap();
    args.push(cwd_str);
    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) on both streams — the shape `run ... | jq -c`
        // actually has.
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("failed to spawn the supercode binary")
        .wait_with_output()
        .expect("child process failed")
}

/// dev/02 + dev/03: `run --output-format stream-json` against the two-round-
/// trip stub emits ONLY valid, per-line-parseable NDJSON on stdout — every
/// line parses standalone (the exact shape `jq -c`/`json.loads` per line
/// requires) — covering text deltas, both tool-call events, a usage event,
/// and a final result line; stderr carries zero bytes (no `--trace`, so no
/// chrome at all on this path, same byte-cleanliness guarantee `json` mode
/// already has).
#[test]
fn stream_json_emits_valid_ndjson_with_tool_and_usage_events() {
    let (addr, server) = spawn_tool_round_trip_stub();
    let home = fresh_home("ndjson");
    let cwd = fresh_home("ndjson-cwd");
    std::fs::write(cwd.join("a.txt"), b"hi").unwrap();

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

    assert!(
        out.status.success(),
        "run --output-format stream-json 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).into_owned();
    let lines: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect();
    assert!(
        !lines.is_empty(),
        "expected at least one NDJSON line, got empty stdout"
    );

    let mut types = std::collections::HashSet::new();
    for line in &lines {
        let value: serde_json::Value = serde_json::from_str(line)
            .unwrap_or_else(|e| panic!("line is not valid JSON: {e}\nline: {line}"));
        let ty = value
            .get("type")
            .and_then(|v| v.as_str())
            .unwrap_or_else(|| panic!("line missing a `type` field: {line}"))
            .to_string();
        types.insert(ty);
    }

    for expected in [
        "text_delta",
        "tool_call_started",
        "tool_call_completed",
        "turn_completed",
        "usage",
        "result",
    ] {
        assert!(
            types.contains(expected),
            "expected an event of type `{expected}` in the NDJSON stream; saw types: {types:?}\nfull stdout:\n{stdout}"
        );
    }

    // dev/03: stdout carries ONLY the NDJSON stream — every non-blank line
    // parsed above, and (belt-and-suspenders) the raw bytes never contain
    // spinner/chrome markers a human-facing renderer would have left.
    assert!(
        !stdout.contains('\u{1b}'),
        "stream-json stdout must never carry a raw ESC byte (spinner/color chrome); raw: {stdout:?}"
    );

    // No `--trace`: stderr must stay completely clean on this path, exactly
    // like `--output-format json` already guarantees today.
    assert!(
        out.stderr.is_empty(),
        "stream-json stderr must be empty without --trace; got: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    // The final line is the assembled result, and it is itself one more
    // valid NDJSON line (not a bare/differently-shaped tail).
    let last: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap();
    assert_eq!(last["type"], "result");
    assert!(
        last["result"].as_str().unwrap_or("").contains("done"),
        "expected the final result line to carry the assembled reply; got: {last}"
    );

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

/// dev/01: `--trace` logs tool inputs/outputs and token usage to stderr —
/// checked against the SAME two-round-trip stub/prompt, but with plain text
/// output (`--trace` composes with any `--output-format`, not just
/// stream-json). The companion assertion (no `--trace`) proves stderr stays
/// trace-line-free by default, not just quieter.
#[test]
fn trace_flag_logs_tool_io_and_usage_to_stderr_and_is_silent_without_it() {
    let (addr, server) = spawn_tool_round_trip_stub();
    let home = fresh_home("trace-on");
    let cwd = fresh_home("trace-on-cwd");
    std::fs::write(cwd.join("a.txt"), b"hi").unwrap();

    let out = run_piped(
        &home,
        &cwd,
        &format!("http://{addr}"),
        &["--trace", "run", "list files"],
    );
    assert!(
        out.status.success(),
        "--trace run failed: status={:?} stdout={} stderr={}",
        out.status,
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
    assert!(
        stderr.contains("[trace ") && stderr.contains("tool_call") && stderr.contains("list_dir"),
        "expected a [trace ...] tool_call line naming list_dir on stderr; got: {stderr}"
    );
    assert!(
        stderr.contains("tool_result"),
        "expected a [trace ...] tool_result line on stderr; got: {stderr}"
    );
    assert!(
        stderr.contains("usage") && stderr.contains("prompt_tokens="),
        "expected a [trace ...] usage line on stderr; got: {stderr}"
    );
    // The trace lines never leaked onto stdout — the model's actual answer
    // is the only thing there.
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        !stdout.contains("[trace "),
        "trace diagnostics must never appear on stdout; got: {stdout}"
    );

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

    // Companion run: the identical command WITHOUT --trace shows none of
    // this — stderr stays exactly as clean as it always was (dev/01's
    // "without it, stderr stays clean").
    let (addr2, server2) = spawn_tool_round_trip_stub();
    let home2 = fresh_home("trace-off");
    let cwd2 = fresh_home("trace-off-cwd");
    std::fs::write(cwd2.join("a.txt"), b"hi").unwrap();
    let out2 = run_piped(
        &home2,
        &cwd2,
        &format!("http://{addr2}"),
        &["run", "list files"],
    );
    assert!(out2.status.success());
    let stderr2 = String::from_utf8_lossy(&out2.stderr);
    assert!(
        !stderr2.contains("[trace "),
        "no --trace flag: stderr must carry zero trace lines; got: {stderr2}"
    );
    server2.join().expect("stub server thread panicked");
}