unified-agent-api-opencode 0.2.3

Async wrapper around the OpenCode CLI for canonical run --format json flows
Documentation
use std::{
    ffi::OsString,
    path::{Path, PathBuf},
    process::ExitStatus,
};

use serde_json::{Map, Value};

use crate::OpencodeError;

#[derive(Debug, Clone, Eq, PartialEq)]
pub struct OpencodeRunRequest {
    prompt: String,
    model: Option<String>,
    session: Option<String>,
    continue_session: bool,
    fork: bool,
    working_dir: Option<PathBuf>,
}

impl OpencodeRunRequest {
    pub fn new(prompt: impl Into<String>) -> Self {
        Self {
            prompt: prompt.into(),
            model: None,
            session: None,
            continue_session: false,
            fork: false,
            working_dir: None,
        }
    }

    pub fn model(mut self, model: impl Into<String>) -> Self {
        self.model = Some(model.into());
        self
    }

    pub fn session(mut self, session_id: impl Into<String>) -> Self {
        self.session = Some(session_id.into());
        self
    }

    pub fn continue_session(mut self, value: bool) -> Self {
        self.continue_session = value;
        self
    }

    pub fn fork(mut self, value: bool) -> Self {
        self.fork = value;
        self
    }

    pub fn working_dir(mut self, path: impl Into<PathBuf>) -> Self {
        self.working_dir = Some(path.into());
        self
    }

    pub fn prompt(&self) -> &str {
        &self.prompt
    }

    pub fn model_name(&self) -> Option<&str> {
        self.model.as_deref()
    }

    pub fn session_id(&self) -> Option<&str> {
        self.session.as_deref()
    }

    pub fn continue_requested(&self) -> bool {
        self.continue_session
    }

    pub fn fork_requested(&self) -> bool {
        self.fork
    }

    pub fn working_directory(&self) -> Option<&Path> {
        self.working_dir.as_deref()
    }

    pub(crate) fn argv(&self) -> Result<Vec<OsString>, OpencodeError> {
        if self.prompt.trim().is_empty() {
            return Err(OpencodeError::InvalidRequest(
                "prompt must not be empty".to_string(),
            ));
        }

        let mut argv = vec![
            OsString::from("run"),
            OsString::from("--format"),
            OsString::from("json"),
        ];

        if let Some(model) = normalize_non_empty(self.model.as_deref()) {
            argv.push(OsString::from("--model"));
            argv.push(OsString::from(model));
        }

        if let Some(session) = normalize_non_empty(self.session.as_deref()) {
            argv.push(OsString::from("--session"));
            argv.push(OsString::from(session));
        }

        if self.continue_session {
            argv.push(OsString::from("--continue"));
        }

        if self.fork {
            argv.push(OsString::from("--fork"));
        }

        if let Some(path) = &self.working_dir {
            argv.push(OsString::from("--dir"));
            argv.push(path.as_os_str().to_os_string());
        }

        argv.push(OsString::from(self.prompt.as_str()));
        Ok(argv)
    }
}

fn normalize_non_empty(value: Option<&str>) -> Option<String> {
    value
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
}

#[derive(Debug, Clone, Eq, PartialEq)]
pub struct OpencodeRunCompletion {
    pub status: ExitStatus,
    pub final_text: Option<String>,
}

#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum OpencodeRunJsonErrorCode {
    JsonParse,
    TypedParse,
}

#[derive(Debug, Clone, thiserror::Error)]
#[error("{message}")]
pub struct OpencodeRunJsonParseError {
    pub code: OpencodeRunJsonErrorCode,
    pub message: String,
    pub details: String,
}

impl OpencodeRunJsonParseError {
    fn new(code: OpencodeRunJsonErrorCode, message: String) -> Self {
        Self {
            code,
            details: message.clone(),
            message,
        }
    }
}

#[derive(Debug, Clone)]
pub enum OpencodeRunJsonEvent {
    StepStart {
        session_id: Option<String>,
        raw: Value,
    },
    Text {
        session_id: Option<String>,
        text: String,
        raw: Value,
    },
    StepFinish {
        session_id: Option<String>,
        raw: Value,
    },
    Unknown {
        event_type: String,
        session_id: Option<String>,
        raw: Value,
    },
    TerminalError {
        message: String,
        raw: Value,
    },
}

impl OpencodeRunJsonEvent {
    pub fn raw(&self) -> &Value {
        match self {
            OpencodeRunJsonEvent::StepStart { raw, .. } => raw,
            OpencodeRunJsonEvent::Text { raw, .. } => raw,
            OpencodeRunJsonEvent::StepFinish { raw, .. } => raw,
            OpencodeRunJsonEvent::Unknown { raw, .. } => raw,
            OpencodeRunJsonEvent::TerminalError { raw, .. } => raw,
        }
    }

    pub fn event_type(&self) -> &str {
        match self {
            OpencodeRunJsonEvent::StepStart { .. } => "step_start",
            OpencodeRunJsonEvent::Text { .. } => "text",
            OpencodeRunJsonEvent::StepFinish { .. } => "step_finish",
            OpencodeRunJsonEvent::Unknown { event_type, .. } => event_type.as_str(),
            OpencodeRunJsonEvent::TerminalError { .. } => "terminal_error",
        }
    }

    pub fn session_id(&self) -> Option<&str> {
        match self {
            OpencodeRunJsonEvent::StepStart { session_id, .. } => session_id.as_deref(),
            OpencodeRunJsonEvent::Text { session_id, .. } => session_id.as_deref(),
            OpencodeRunJsonEvent::StepFinish { session_id, .. } => session_id.as_deref(),
            OpencodeRunJsonEvent::Unknown { session_id, .. } => session_id.as_deref(),
            OpencodeRunJsonEvent::TerminalError { .. } => None,
        }
    }
}

#[derive(Debug, Clone, Default)]
pub struct OpencodeRunJsonParser {
    last_session_id: Option<String>,
}

impl OpencodeRunJsonParser {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn reset(&mut self) {
        self.last_session_id = None;
    }

    pub fn parse_line(
        &mut self,
        line: &str,
    ) -> Result<Option<OpencodeRunJsonEvent>, OpencodeRunJsonParseError> {
        let line = line.strip_suffix('\r').unwrap_or(line);
        if line.chars().all(|ch| ch.is_whitespace()) {
            return Ok(None);
        }

        let value: Value = serde_json::from_str(line).map_err(|err| {
            OpencodeRunJsonParseError::new(
                OpencodeRunJsonErrorCode::JsonParse,
                format!("invalid JSON: {err}"),
            )
        })?;

        self.parse_json(&value)
    }

    pub fn parse_json(
        &mut self,
        value: &Value,
    ) -> Result<Option<OpencodeRunJsonEvent>, OpencodeRunJsonParseError> {
        let obj = value.as_object().ok_or_else(|| {
            OpencodeRunJsonParseError::new(
                OpencodeRunJsonErrorCode::TypedParse,
                "expected JSON object".to_string(),
            )
        })?;

        let event_type = get_required_str(obj, "type").map_err(|message| {
            OpencodeRunJsonParseError::new(OpencodeRunJsonErrorCode::TypedParse, message)
        })?;

        let session_id = get_optional_session_id(obj).or_else(|| self.last_session_id.clone());

        let event = match event_type.as_str() {
            "step_start" => OpencodeRunJsonEvent::StepStart {
                session_id,
                raw: value.clone(),
            },
            "text" => {
                let text = get_required_str(obj, "text").map_err(|message| {
                    OpencodeRunJsonParseError::new(OpencodeRunJsonErrorCode::TypedParse, message)
                })?;
                OpencodeRunJsonEvent::Text {
                    session_id,
                    text,
                    raw: value.clone(),
                }
            }
            "step_finish" => OpencodeRunJsonEvent::StepFinish {
                session_id,
                raw: value.clone(),
            },
            _ => OpencodeRunJsonEvent::Unknown {
                event_type,
                session_id,
                raw: value.clone(),
            },
        };

        if let Some(session_id) = event.session_id() {
            self.last_session_id = Some(session_id.to_string());
        }

        Ok(Some(event))
    }
}

#[derive(Debug, Clone, Eq, PartialEq)]
pub struct OpencodeRunJsonLine {
    pub line_number: usize,
    pub raw: String,
}

#[derive(Debug, Clone)]
pub enum OpencodeRunJsonLineOutcome {
    Ok {
        line: OpencodeRunJsonLine,
        event: OpencodeRunJsonEvent,
    },
    Err {
        line: OpencodeRunJsonLine,
        error: OpencodeRunJsonParseError,
    },
}

pub fn parse_run_json_lines(input: &str) -> Vec<OpencodeRunJsonLineOutcome> {
    let mut parser = OpencodeRunJsonParser::new();
    let mut outcomes = Vec::new();

    for (index, raw) in input.lines().enumerate() {
        let line = OpencodeRunJsonLine {
            line_number: index + 1,
            raw: raw.to_string(),
        };

        match parser.parse_line(raw) {
            Ok(Some(event)) => outcomes.push(OpencodeRunJsonLineOutcome::Ok { line, event }),
            Ok(None) => {}
            Err(error) => outcomes.push(OpencodeRunJsonLineOutcome::Err { line, error }),
        }
    }

    outcomes
}

fn get_required_str(obj: &Map<String, Value>, key: &str) -> Result<String, String> {
    obj.get(key)
        .and_then(Value::as_str)
        .map(ToOwned::to_owned)
        .ok_or_else(|| format!("expected string field `{key}`"))
}

fn get_optional_session_id(obj: &Map<String, Value>) -> Option<String> {
    ["session_id", "sessionId"]
        .into_iter()
        .find_map(|key| obj.get(key).and_then(Value::as_str))
        .map(ToOwned::to_owned)
}