use chimera_core::{AgentError, AgentEvent, Backend, Input, Session, SessionConfig, TurnOptions};
use chimera_opencode::{OpenCodeBackend, OpenCodeConfig, OpenCodeProvider};
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)
}
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();
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();
}
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);
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);
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}"
);
}