malvin 0.2.4

Non-interactive research and coding agent
use crate::acp::AgentError;
use crate::bridge_sdk::BridgeSession;

pub(crate) async fn codex_initialize(session: &BridgeSession) -> Result<(), AgentError> {
    let response = request(
        session,
        "initialize",
        serde_json::json!({
            "clientInfo": {
                "name": "malvin",
                "title": "Malvin",
                "version": env!("CARGO_PKG_VERSION")
            },
            "capabilities": {
                "experimentalApi": true
            }
        }),
    )
    .await?;
    if response.get("error").is_some() {
        return Err(response_error("codex initialize", &response));
    }
    write(
        session,
        &serde_json::json!({"method":"initialized","params":{}}),
    )
    .await
}

pub(crate) async fn codex_start_thread(
    session: &BridgeSession,
    model: &str,
    cwd: &std::path::Path,
) -> Result<(), AgentError> {
    let response = request(
        session,
        "thread/start",
        resolved_thread_start_params(model, cwd, session.service.as_deref())?,
    )
    .await?;
    if response.get("error").is_some() {
        return Err(response_error("codex thread/start", &response));
    }
    let id = response
        .pointer("/result/thread/id")
        .and_then(|v| v.as_str())
        .ok_or_else(|| AgentError("codex thread/start response missing thread id".into()))?;
    *session
        .agent_id
        .lock()
        .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(id.to_owned());
    Ok(())
}

pub(crate) async fn request(
    session: &BridgeSession,
    method: &str,
    params: serde_json::Value,
) -> Result<serde_json::Value, AgentError> {
    let id = super::session_io::next_id();
    write(
        session,
        &serde_json::json!({"method": method, "id": id, "params": params}),
    )
    .await?;
    let mut turn = crate::bridge_sdk::DrainIdleTurn::new();
    loop {
        turn.check_max_deadline(crate::bridge_sdk::DrainIdleLabels {
            prefix: crate::acp::DRAIN_IDLE_PREFIX_CODEX,
            waiting_for: "rpc reply",
        })?;
        let value = super::session_io::read_json_waiting(session, "rpc reply", &mut turn).await?;
        if value.get("id").and_then(serde_json::Value::as_u64) == Some(id) {
            return Ok(value);
        }
        turn.check_max_deadline(crate::bridge_sdk::DrainIdleLabels {
            prefix: crate::acp::DRAIN_IDLE_PREFIX_CODEX,
            waiting_for: "rpc reply",
        })?;
    }
}

fn resolved_thread_start_params(
    model: &str,
    cwd: &std::path::Path,
    service: Option<&str>,
) -> Result<serde_json::Value, AgentError> {
    let model = super::discover::resolve_codex_model(model).map_err(AgentError)?;
    let sandbox = if super::session_process::codex_uses_outer_sandbox() {
        "danger-full-access"
    } else {
        "workspace-write"
    };
    Ok(thread_start_params(model, cwd, sandbox, service))
}

fn thread_start_params(
    model: String,
    cwd: &std::path::Path,
    sandbox: &str,
    service: Option<&str>,
) -> serde_json::Value {
    let mut params = serde_json::json!({
        "model": model,
        "cwd": cwd,
        "approvalPolicy": "never",
        "sandbox": sandbox,
        "ephemeral": true
    });
    if let Some(service) = service {
        params["serviceTier"] = serde_json::Value::String(service.to_owned());
    }
    params
}

pub(crate) fn response_error(context: &str, response: &serde_json::Value) -> AgentError {
    AgentError(format!(
        "{context}: {}",
        response.get("error").unwrap_or(response)
    ))
}

async fn write(session: &BridgeSession, value: &serde_json::Value) -> Result<(), AgentError> {
    super::session_io::write_json(session, value).await
}

#[cfg(test)]
mod tests {
    use super::thread_start_params;
    use std::path::Path;

    #[test]
    fn thread_start_includes_optional_service() {
        let with_service = thread_start_params(
            "gpt-5.6-sol".into(),
            Path::new("/work"),
            "workspace-write",
            Some("priority"),
        );
        assert_eq!(with_service["serviceTier"], "priority");
        assert_eq!(with_service["model"], "gpt-5.6-sol");
        let bare = thread_start_params(
            "gpt-5.6-sol".into(),
            Path::new("/work"),
            "workspace-write",
            None,
        );
        assert!(bare.get("serviceTier").is_none());
    }
}