unified-agent-api-opencode 0.3.5

Async wrapper around the OpenCode CLI for canonical run --format json flows
Documentation
#[cfg(unix)]
mod unix {
    use std::{fs, process::Command, time::Duration};

    use futures_util::StreamExt;
    use opencode::{OpencodeClient, OpencodeError, OpencodeRunJsonEvent, OpencodeRunRequest};
    use tempfile::TempDir;
    use tokio::time;

    const STEP_START: &str = include_str!("fixtures/run_json/v1/step_start.jsonl");
    const TEXT: &str = include_str!("fixtures/run_json/v1/text.jsonl");

    fn first_nonempty_line(text: &str) -> &str {
        text.lines()
            .find(|line| !line.chars().all(|ch| ch.is_whitespace()))
            .expect("fixture contains a non-empty line")
    }

    fn pid_exists(pid: i32) -> bool {
        Command::new("kill")
            .arg("-0")
            .arg(pid.to_string())
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .map(|status| status.success())
            .unwrap_or(false)
    }

    async fn assert_pid_gone(pid: i32) {
        let deadline = time::Instant::now() + Duration::from_millis(500);
        loop {
            if !pid_exists(pid) {
                return;
            }

            if time::Instant::now() >= deadline {
                panic!("expected pid {pid} to be gone, but it still exists");
            }

            time::sleep(Duration::from_millis(25)).await;
        }
    }

    #[tokio::test]
    async fn run_json_timeout_after_stdout_eof_reaps_child() {
        use std::os::unix::fs::PermissionsExt;

        let dir = TempDir::new().expect("temp dir");
        let pid_file = dir.path().join("pid.txt");
        let script_path = dir.path().join("fake-opencode");
        let step_start = first_nonempty_line(STEP_START);
        let text = first_nonempty_line(TEXT);

        let script = format!(
            r#"#!/bin/sh
set -eu
: "${{PID_FILE:?missing PID_FILE}}"
echo $$ > "$PID_FILE"
printf '%s\n' '{step_start}'
printf '%s\n' '{text}'
exec 1>&-
exec sleep 1000000
"#
        );

        fs::write(&script_path, script).expect("write script");
        let mut perms = fs::metadata(&script_path).expect("metadata").permissions();
        perms.set_mode(0o755);
        fs::set_permissions(&script_path, perms).expect("chmod");

        let client = OpencodeClient::builder()
            .binary(&script_path)
            .env("PID_FILE", pid_file.to_string_lossy().to_string())
            .timeout(Duration::from_millis(300))
            .build();

        let handle = client
            .run_json(OpencodeRunRequest::new("Reply with OK."))
            .await
            .expect("spawn run-json handle");
        let mut events = handle.events;

        let first = tokio::time::timeout(Duration::from_secs(1), events.next())
            .await
            .expect("first event timeout")
            .expect("stream open")
            .expect("event parses");
        assert!(matches!(first, OpencodeRunJsonEvent::StepStart { .. }));

        let second = tokio::time::timeout(Duration::from_secs(1), events.next())
            .await
            .expect("second event timeout")
            .expect("stream open")
            .expect("event parses");
        match second {
            OpencodeRunJsonEvent::Text { text, .. } => assert_eq!(text, "OK"),
            other => panic!("expected text event, got {other:?}"),
        }

        tokio::time::timeout(Duration::from_secs(1), async {
            while events.next().await.is_some() {}
        })
        .await
        .expect("expected event stream to close after stdout EOF");

        let err = tokio::time::timeout(Duration::from_secs(2), handle.completion)
            .await
            .expect("completion should resolve after timeout triggers")
            .expect_err("completion should surface timeout");
        assert!(
            matches!(err, OpencodeError::Timeout { .. }),
            "expected timeout, got: {err:?}"
        );

        let pid: i32 = {
            let deadline = time::Instant::now() + Duration::from_secs(1);
            loop {
                match fs::read_to_string(&pid_file) {
                    Ok(contents) => break contents,
                    Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
                        if time::Instant::now() >= deadline {
                            panic!("pid file was not created before timeout");
                        }
                        time::sleep(Duration::from_millis(25)).await;
                    }
                    Err(err) => panic!("failed to read pid file: {err}"),
                }
            }
        }
        .trim()
        .parse()
        .expect("pid parse");

        assert_pid_gone(pid).await;
    }
}