newt-agent 0.7.1

Newt-Agent — small, fast, local-first agentic coder (vi to Hermes's emacs)
//! Regression: `newt worker` and `newt mcp` must NEVER write
//! non-protocol bytes to stdout. Every non-empty line of stdout must
//! parse as JSON-RPC.
//!
//! This protects the structural fix in
//! `newt_cli::stdio_guard::redirect_stdout_to_stderr` and the
//! `with_writer(std::io::stderr)` configuration on the tracing
//! subscriber. If either regresses (someone removes the redirect, a
//! dep adds a `println!` that the redirect was masking, etc.) this
//! test fails.

#![cfg(unix)]

use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;

use tokio::io::AsyncWriteExt;
use tokio::process::Command;

/// Initialize request — valid for both ACP (`newt worker`) and MCP
/// (`newt mcp`). Both protocols use newline-delimited JSON-RPC 2.0
/// and accept `initialize` with an empty params object.
fn init_request() -> serde_json::Value {
    serde_json::json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "initialize",
        "params": {},
    })
}

#[tokio::test]
async fn worker_stdout_is_pure_json_rpc() {
    let bin = locate_newt_bin();
    spawn_and_assert_pure(&bin, &["worker"]).await;
}

#[tokio::test]
async fn mcp_stdout_is_pure_json_rpc() {
    let bin = locate_newt_bin();
    spawn_and_assert_pure(&bin, &["mcp"]).await;
}

/// Common driver: spawn the binary with the given args, send a single
/// initialize, close stdin, collect stdout, assert every non-empty
/// line parses as JSON.
async fn spawn_and_assert_pure(bin: &PathBuf, args: &[&str]) {
    let mut cmd = Command::new(bin);
    cmd.args(args)
        // OLLAMA_HOST is set to an unreachable address. With the
        // verbatim contract, discover() doesn't probe — so the
        // worker starts cleanly. `initialize` doesn't touch Ollama,
        // so we get a clean response. (If the test sent a `prompt`
        // we'd see an Ollama error on stderr; that's fine — we only
        // assert stdout purity.)
        .env("OLLAMA_HOST", "http://127.0.0.1:1")
        // Crank up tracing on purpose — we want to PROVE that tracing
        // output stays on stderr even when the dep tree is chatty.
        .env("RUST_LOG", "debug")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .kill_on_drop(true);

    let mut child = cmd.spawn().expect("spawn newt");

    {
        let mut stdin = child.stdin.take().expect("take stdin");
        let line = format!(
            "{}\n",
            serde_json::to_string(&init_request()).expect("serialize init")
        );
        stdin.write_all(line.as_bytes()).await.expect("write init");
        // Dropping stdin closes it — the server's readline loop exits
        // and the process terminates.
    }

    let output = tokio::time::timeout(Duration::from_secs(10), child.wait_with_output())
        .await
        .expect("worker timed out")
        .expect("collect worker output");

    let stdout = String::from_utf8_lossy(&output.stdout);

    // Every non-empty line of stdout must be valid JSON.
    let mut saw_response = false;
    for (idx, line) in stdout.lines().enumerate() {
        if line.trim().is_empty() {
            continue;
        }
        let parsed: Result<serde_json::Value, _> = serde_json::from_str(line);
        assert!(
            parsed.is_ok(),
            "stdout line {} is not valid JSON ({} args={:?})\n  line: {:?}\n\nFull stdout:\n{}\n\nFull stderr:\n{}",
            idx + 1,
            bin.display(),
            args,
            line,
            stdout,
            String::from_utf8_lossy(&output.stderr),
        );
        let v = parsed.unwrap();
        assert_eq!(
            v.get("jsonrpc").and_then(|j| j.as_str()),
            Some("2.0"),
            "every stdout line must be a JSON-RPC 2.0 frame: {line}"
        );
        saw_response = true;
    }

    assert!(
        saw_response,
        "expected at least one JSON-RPC response on stdout (got none)\n\nFull stdout:\n{}\n\nFull stderr:\n{}",
        stdout,
        String::from_utf8_lossy(&output.stderr),
    );
}

/// The cargo target directory, resolved the way cargo itself resolves it: the
/// `CARGO_TARGET_DIR` env var, then a workspace or user `config.toml`'s `[build]
/// target-dir`, then the default. This makes the test robust to a target-dir set
/// in `~/.cargo/config.toml` (newt-agent#64), which a plain
/// `workspace_root/target` guess misses (failing with a confusing `NotFound`).
fn cargo_target_dir() -> Option<PathBuf> {
    cargo_metadata::MetadataCommand::new()
        .exec()
        .ok()
        .map(|m| m.target_directory.into_std_path_buf())
}

/// Locator strategy (first hit wins):
/// 1. `$CARGO_TARGET_DIR` (set by `cargo llvm-cov`)
/// 2. the cargo-resolved target dir (honors `~/.cargo/config.toml`, newt-agent#64)
/// 3. `<manifest>/../target/{debug,release}/newt`
/// 4. `<manifest>/../target/llvm-cov-target/{debug,release}/newt`
fn locate_newt_bin() -> PathBuf {
    let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let workspace_root = manifest.parent().expect("manifest dir has parent");

    let mut target_dirs: Vec<PathBuf> = Vec::new();
    if let Some(tdir) = std::env::var_os("CARGO_TARGET_DIR") {
        target_dirs.push(PathBuf::from(tdir));
    }
    // Honor `[build] target-dir` from cargo config (newt-agent#64).
    if let Some(tdir) = cargo_target_dir() {
        target_dirs.push(tdir);
    }
    target_dirs.push(workspace_root.join("target"));
    target_dirs.push(workspace_root.join("target").join("llvm-cov-target"));

    for tdir in &target_dirs {
        for profile in ["debug", "release"] {
            let candidate = tdir.join(profile).join("newt");
            if candidate.exists() {
                return candidate;
            }
        }
    }
    // Best-effort build — `cargo test` for newt-cli usually builds
    // the `newt` binary as a sibling artifact, but llvm-cov runs in
    // an isolated target dir.
    let _ = std::process::Command::new(env!("CARGO"))
        .args(["build", "--bin", "newt"])
        .output();

    // Final fallback: the cargo-resolved target dir (newt-agent#64), else the
    // conventional path.
    cargo_target_dir()
        .unwrap_or_else(|| workspace_root.join("target"))
        .join("debug")
        .join("newt")
}