chimera-opencode 0.2.0

OpenCode/OpenAI-compatible REST backend for the chimera AI agent SDK
Documentation
use chimera_core::{AgentError, AgentEvent, Backend, Input, Session, SessionConfig, TurnOptions};
use chimera_opencode::{OpenCodeBackend, OpenCodeConfig, OpenCodeProvider};

/// Spin up a mock HTTP server that accepts one request and returns a canned response.
async fn mock_server(response_body: &str) -> (tokio::task::JoinHandle<()>, String) {
    use tokio::io::{AsyncReadExt, AsyncWriteExt};
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    let body = response_body.to_string();
    let handle = tokio::spawn(async move {
        let (mut socket, _) = listener.accept().await.unwrap();
        let mut buf = vec![0u8; 16384];
        let _ = socket.read(&mut buf).await.unwrap();
        let response = format!(
            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
            body.len(),
            body
        );
        socket.write_all(response.as_bytes()).await.unwrap();
    });
    let url = format!("http://{}", addr);
    (handle, url)
}

/// Spin up a mock server that returns an SSE stream.
async fn mock_sse_server(sse_body: &str) -> (tokio::task::JoinHandle<()>, String) {
    use tokio::io::{AsyncReadExt, AsyncWriteExt};
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    let body = sse_body.to_string();
    let handle = tokio::spawn(async move {
        let (mut socket, _) = listener.accept().await.unwrap();
        let mut buf = vec![0u8; 16384];
        let _ = socket.read(&mut buf).await.unwrap();
        let response = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n";
        socket.write_all(response.as_bytes()).await.unwrap();
        // Write SSE data as chunked encoding
        for line in body.lines() {
            let chunk = format!("{}\r\n", line);
            let hex_len = format!("{:x}\r\n", chunk.len());
            socket.write_all(hex_len.as_bytes()).await.unwrap();
            socket.write_all(chunk.as_bytes()).await.unwrap();
            socket.write_all(b"\r\n").await.unwrap();
        }
        // Final chunk
        socket.write_all(b"0\r\n\r\n").await.unwrap();
    });
    let url = format!("http://{}", addr);
    (handle, url)
}

fn direct_provider(url: String) -> OpenCodeProvider {
    OpenCodeProvider::Direct { base_url: url }
}

#[tokio::test]
async fn opencode_turn_success_non_streaming() {
    let response_json = r#"{
        "id": "chatcmpl-test",
        "choices": [{"message": {"role": "assistant", "content": "Hello from opencode"}, "finish_reason": "stop"}],
        "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
    }"#;
    let (_handle, url) = mock_server(response_json).await;

    let backend = OpenCodeBackend::builder().api_key("test-key").build();
    let config = SessionConfig::builder()
        .backend(
            OpenCodeConfig::builder()
                .provider(direct_provider(url))
                .stream(false)
                .build(),
        )
        .build();
    let mut session = backend.session(config).await.unwrap();

    let output = session
        .turn(Input::Text("Say hello".into()), TurnOptions::default())
        .await
        .unwrap();

    assert_eq!(output.response.as_deref(), Some("Hello from opencode"));
    assert!(
        output
            .events
            .iter()
            .any(|e| matches!(e, AgentEvent::TurnCompleted { .. }))
    );
    assert_eq!(output.usage.as_ref().unwrap().input_tokens, Some(10));
}

#[tokio::test]
async fn opencode_turn_success_streaming() {
    let sse_body = "\
data: {\"id\":\"cmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\
\n\
data: {\"id\":\"cmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" world\"},\"finish_reason\":null}]}\n\
\n\
data: {\"id\":\"cmpl-1\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}\n\
\n\
data: [DONE]\n";

    let (_handle, url) = mock_sse_server(sse_body).await;

    let backend = OpenCodeBackend::builder().api_key("test-key").build();
    let config = SessionConfig::builder()
        .backend(
            OpenCodeConfig::builder()
                .provider(direct_provider(url))
                .stream(true)
                .build(),
        )
        .build();
    let mut session = backend.session(config).await.unwrap();

    let output = session
        .turn(Input::Text("hello".into()), TurnOptions::default())
        .await
        .unwrap();

    assert_eq!(output.response.as_deref(), Some("Hello world"));
    let deltas: Vec<_> = output
        .events
        .iter()
        .filter(|e| matches!(e, AgentEvent::TextDelta { .. }))
        .collect();
    assert_eq!(deltas.len(), 2);
}

#[tokio::test]
async fn opencode_session_id_set_from_response() {
    let response_json = r#"{
        "id": "chatcmpl-session-42",
        "choices": [{"message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
        "usage": null
    }"#;
    let (_handle, url) = mock_server(response_json).await;

    let backend = OpenCodeBackend::builder().api_key("key").build();
    let config = SessionConfig::builder()
        .backend(
            OpenCodeConfig::builder()
                .provider(direct_provider(url))
                .stream(false)
                .build(),
        )
        .build();
    let mut session = backend.session(config).await.unwrap();
    assert!(session.session_id().is_none());

    session
        .turn(Input::Text("hi".into()), TurnOptions::default())
        .await
        .unwrap();

    assert_eq!(session.session_id(), Some("chatcmpl-session-42"));
}

#[tokio::test]
async fn opencode_turn_http_error_surfaces_turn_failed() {
    use tokio::io::{AsyncReadExt, AsyncWriteExt};
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    let url = format!("http://{}", addr);

    // Return 400 (non-retryable client error)
    let _handle = tokio::spawn(async move {
        let (mut socket, _) = listener.accept().await.unwrap();
        let mut buf = vec![0u8; 16384];
        let _ = socket.read(&mut buf).await.unwrap();
        let body = r#"{"error":"bad request"}"#;
        let response = format!(
            "HTTP/1.1 400 Bad Request\r\nContent-Length: {}\r\n\r\n{}",
            body.len(),
            body
        );
        socket.write_all(response.as_bytes()).await.unwrap();
    });

    let backend = OpenCodeBackend::builder().api_key("key").build();
    let config = SessionConfig::builder()
        .backend(
            OpenCodeConfig::builder()
                .provider(direct_provider(url))
                .stream(false)
                .build(),
        )
        .build();
    let mut session = backend.session(config).await.unwrap();

    let err = session
        .turn(Input::Text("hi".into()), TurnOptions::default())
        .await
        .expect_err("expected turn failure");

    match err {
        AgentError::TurnFailed { message } => {
            assert!(
                message.contains("400"),
                "expected 400 in message: {message}"
            );
        }
        _ => panic!("expected TurnFailed, got {err:?}"),
    }
}

#[tokio::test]
async fn opencode_turn_timeout() {
    use tokio::io::AsyncReadExt;
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    let url = format!("http://{}", addr);

    // Accept connection but never respond
    let _handle = tokio::spawn(async move {
        let (mut socket, _) = listener.accept().await.unwrap();
        let mut buf = vec![0u8; 16384];
        let _ = socket.read(&mut buf).await.unwrap();
        tokio::time::sleep(std::time::Duration::from_secs(30)).await;
        drop(socket);
    });

    let backend = OpenCodeBackend::builder().api_key("key").build();
    let config = SessionConfig::builder()
        .backend(
            OpenCodeConfig::builder()
                .provider(direct_provider(url))
                .stream(false)
                .timeout_secs(30)
                .build(),
        )
        .build();
    let mut session = backend.session(config).await.unwrap();

    let options = TurnOptions {
        timeout: Some(std::time::Duration::from_millis(100)),
        ..Default::default()
    };

    let err = session
        .turn(Input::Text("hi".into()), options)
        .await
        .expect_err("expected timeout");

    match err {
        AgentError::Timeout { duration } => {
            assert_eq!(duration, std::time::Duration::from_millis(100));
        }
        _ => panic!("expected Timeout, got {err:?}"),
    }
}

#[tokio::test]
async fn opencode_turn_with_output_schema_passes_response_format() {
    use tokio::io::{AsyncReadExt, AsyncWriteExt};
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    let url = format!("http://{}", addr);

    let _handle = tokio::spawn(async move {
        let (mut socket, _) = listener.accept().await.unwrap();
        let mut buf = vec![0u8; 16384];
        let n = socket.read(&mut buf).await.unwrap();
        let request = String::from_utf8_lossy(&buf[..n]);

        assert!(
            request.contains("\"response_format\":{\"type\":\"json_schema\""),
            "expected response_format in request body: {request}"
        );

        let body = r#"{
            "id": "chatcmpl-schema",
            "choices": [{"message": {"role": "assistant", "content": "{\"ok\":true}"}, "finish_reason": "stop"}],
            "usage": null
        }"#;
        let response = format!(
            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
            body.len(),
            body
        );
        socket.write_all(response.as_bytes()).await.unwrap();
    });

    let backend = OpenCodeBackend::builder().api_key("test-key").build();
    let config = SessionConfig::builder()
        .backend(
            OpenCodeConfig::builder()
                .provider(direct_provider(url))
                .stream(false)
                .build(),
        )
        .build();
    let mut session = backend.session(config).await.unwrap();
    let output = session
        .turn(
            Input::Text("return json".into()),
            TurnOptions {
                output_schema: Some(serde_json::json!({
                    "type": "object",
                    "properties": {"ok": {"type": "boolean"}},
                    "required": ["ok"]
                })),
                ..Default::default()
            },
        )
        .await
        .unwrap();

    assert_eq!(output.response.as_deref(), Some(r#"{"ok":true}"#));
}

#[tokio::test]
async fn opencode_interrupt_integration_returns_interrupted() {
    use tokio::io::AsyncReadExt;
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    let url = format!("http://{}", addr);

    let _handle = tokio::spawn(async move {
        let (mut socket, _) = listener.accept().await.unwrap();
        let mut buf = vec![0u8; 16384];
        let _ = socket.read(&mut buf).await.unwrap();
        tokio::time::sleep(std::time::Duration::from_secs(30)).await;
        drop(socket);
    });

    let backend = OpenCodeBackend::builder().api_key("key").build();
    let config = SessionConfig::builder()
        .backend(
            OpenCodeConfig::builder()
                .provider(direct_provider(url))
                .stream(false)
                .build(),
        )
        .build();
    let mut session = backend.session(config).await.unwrap();

    let stream = session
        .turn_stream(Input::Text("interrupt me".into()), TurnOptions::default())
        .await
        .expect("turn_stream");
    tokio::time::sleep(std::time::Duration::from_millis(50)).await;
    session.interrupt().await.expect("interrupt");

    let err = stream
        .collect_turn()
        .await
        .expect_err("expected interrupted");
    assert!(matches!(err, AgentError::Interrupted));
}

#[tokio::test]
async fn opencode_live_smoke_real_api() {
    if std::env::var("APPS_LLM_RUN_LIVE").as_deref() != Ok("1") {
        return;
    }
    let api_key = match std::env::var("OPENCODE_ZEN_KEY")
        .ok()
        .or_else(|| std::env::var("OPENCODE_API_KEY").ok())
    {
        Some(k) => k,
        None => return,
    };

    let backend = OpenCodeBackend::builder().api_key(api_key).build();
    let config = SessionConfig::builder()
        .model("glm-5")
        .backend(OpenCodeConfig::builder().stream(false).build())
        .build();
    let mut session = backend.session(config).await.unwrap();

    let output = session
        .turn(
            Input::Text("Reply with exactly: live-opencode-ok".into()),
            TurnOptions::default(),
        )
        .await
        .unwrap();

    let response = output.response.unwrap_or_default();
    assert!(
        response.to_lowercase().contains("live-opencode-ok"),
        "unexpected response: {response}"
    );
}