use super::{
json_error_message, json_str, AgentError, AgentRunner, ParsedResponse, PermissionRules,
StreamEvent,
};
use crate::parser;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
static FREE_MODELS_CACHE: OnceLock<Mutex<(i64, Vec<String>)>> = OnceLock::new();
static FREE_MODELS_REFRESH_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
static ALL_MODELS_CACHE: OnceLock<Mutex<(i64, Vec<String>)>> = OnceLock::new();
static ALL_MODELS_REFRESH_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
#[cfg(test)]
pub(crate) fn reset_model_caches_for_test() {
if let Some(cache) = FREE_MODELS_CACHE.get() {
if let Ok(mut guard) = cache.lock() {
*guard = (0, Vec::new());
}
}
if let Some(cache) = ALL_MODELS_CACHE.get() {
if let Ok(mut guard) = cache.lock() {
*guard = (0, Vec::new());
}
}
FREE_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
ALL_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
}
fn update_discovered_models_cache(
cache: &Mutex<(i64, Vec<String>)>,
discovered: Vec<String>,
cache_name: &'static str,
empty_reason: &'static str,
) {
let now = chrono::Utc::now().timestamp();
let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
if discovered.is_empty() && !guard.1.is_empty() {
tracing::warn!(
cache = cache_name,
cached_models = guard.1.len(),
reason = empty_reason,
"opencode model discovery returned empty; preserving previous cache contents"
);
guard.0 = now;
return;
}
tracing::debug!(
cache = cache_name,
discovered_models = discovered.len(),
"opencode model discovery refreshed cache"
);
*guard = (now, discovered);
}
pub struct OpenCodeRunner;
pub(crate) fn extract_ndjson_text(events: &[serde_json::Value]) -> Option<String> {
let mut texts = Vec::new();
for event in events {
let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
if event_type == "text" || event_type == "message" || event_type == "assistant" {
if let Some(part) = event.get("part") {
texts.extend(extract_text_from_part(part));
}
if let Some(text) = event.get("text").and_then(|v| v.as_str()) {
texts.push(text.to_string());
}
if let Some(message) = event.get("message") {
if let Some(items) = message.get("content").and_then(|v| v.as_array()) {
for item in items {
let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
if item_type == "text" {
if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
texts.push(text.to_string());
}
}
}
}
}
continue;
}
if let Some(part) = event.get("part") {
let part_type = part.get("type").and_then(|v| v.as_str()).unwrap_or("");
if part_type == "text" || part_type == "output_text" || part_type == "message" {
texts.extend(extract_text_from_part(part));
}
}
}
if texts.is_empty() {
for event in events.iter().rev() {
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
if event_type == "system" {
continue;
}
}
if let Some(result) = event.get("result") {
if let Some(s) = result.as_str() {
return Some(s.to_string());
}
if result.is_object() || result.is_array() {
return Some(result.to_string());
}
}
if let Some(text) = event.get("text").and_then(|v| v.as_str()) {
if !text.trim().is_empty() {
return Some(text.to_string());
}
}
}
return None;
}
let full = texts.join("");
if let Some(json) = extract_json_object(&full) {
if crate::parser::parse(&json).is_ok() {
return Some(json);
}
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&json) {
if let Some(obj) = val.as_object() {
if obj.contains_key("executor")
|| obj.contains_key("status")
|| obj.contains_key("complexity")
|| obj.contains_key("decision")
{
return Some(json);
}
}
}
}
for text in texts.iter().rev() {
let trimmed = text.trim();
if let Some(json) = extract_json_object(trimmed) {
if crate::parser::parse(&json).is_ok() {
return Some(json);
}
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&json) {
if let Some(obj) = val.as_object() {
if obj.contains_key("executor") || obj.contains_key("status") {
return Some(json);
}
}
}
}
if trimmed.contains('{') {
if crate::parser::parse(trimmed).is_ok() {
return Some(trimmed.to_string());
}
if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed) {
if let Some(obj) = val.as_object() {
if obj.contains_key("executor")
|| obj.contains_key("status")
|| obj.contains_key("complexity")
{
return Some(trimmed.to_string());
}
}
}
}
}
Some(full)
}
fn extract_text_from_part(part: &serde_json::Value) -> Vec<String> {
let mut out = Vec::new();
if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
out.push(text.to_string());
}
if let Some(items) = part.get("content").and_then(|v| v.as_array()) {
for item in items {
let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
if item_type == "text" {
if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
out.push(text.to_string());
}
}
}
}
out
}
fn extract_json_object(text: &str) -> Option<String> {
let trimmed = text.trim();
if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
return Some(trimmed.to_string());
}
let start = trimmed.find('{')?;
let end = trimmed.rfind('}')?;
if end <= start {
return None;
}
let candidate = trimmed[start..=end].trim();
serde_json::from_str::<serde_json::Value>(candidate)
.ok()
.map(|_| candidate.to_string())
}
pub(crate) fn extract_router_text(raw: &str) -> Option<String> {
let events = super::parse_ndjson(raw.trim());
if events.is_empty() {
return None;
}
extract_ndjson_text(&events)
}
#[cfg(test)]
mod router_text_tests {
use super::*;
#[test]
fn extract_router_text_supports_message_with_content_array() {
let raw = r#"{"type":"step_start","timestamp":1}
{"type":"message","timestamp":2,"part":{"type":"message","content":[{"type":"text","text":"```json\n{\"executor\":\"opencode\",\"complexity\":\"medium\",\"reason\":\"ok\"}\n```"}]}}
{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
let text = extract_router_text(raw).expect("should extract text");
assert!(
text.contains("executor"),
"extracted text should include JSON"
);
}
#[test]
fn extract_router_text_ignores_leading_system_init() {
let raw = r#"{"type":"system","subtype":"init","cwd":"/"}
{"type":"text","timestamp":2,"text":"{\"executor\":\"opencode\",\"complexity\":\"medium\",\"reason\":\"ndjson\"}"}
{"type":"step_finish","timestamp":3}"#;
let text = extract_router_text(raw).expect("should extract text even with leading init");
let parsed: serde_json::Value =
serde_json::from_str(&text).expect("extracted text must be JSON");
assert_eq!(parsed["executor"], "opencode");
assert_eq!(parsed["complexity"], "medium");
}
}
impl OpenCodeRunner {
pub fn new() -> Self {
Self
}
pub(crate) fn split_model_variant(s: &str) -> (&str, Option<&str>) {
if let Some(pos) = s.rfind('@') {
let base = &s[..pos];
let variant = &s[pos + 1..];
(base, Some(variant))
} else {
(s, None)
}
}
fn extract_text_from_events(&self, events: &[serde_json::Value]) -> Option<String> {
extract_ndjson_text(events)
}
fn extract_tokens(&self, events: &[serde_json::Value]) -> (Option<u64>, Option<u64>) {
for event in events.iter().rev() {
let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
if event_type == "step_finish" {
if let Some(part) = event.get("part") {
if let Some(tokens) = part.get("tokens").and_then(|v| v.as_object()) {
let input = tokens.get("input").and_then(|v| v.as_u64());
let output = tokens.get("output").and_then(|v| v.as_u64());
return (input, output);
}
}
}
}
(None, None)
}
fn detect_error(&self, events: &[serde_json::Value]) -> Option<AgentError> {
super::patterns::detect_ndjson_error(
events,
|event| {
let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
match event_type {
"error" => Some(opencode_error_text(event)),
"step_finish" => {
let part = event.get("part")?;
let reason = part.get("reason").and_then(|v| v.as_str()).unwrap_or("");
if reason == "error" || reason == "failed" {
Some(
part.get("error")
.map(error_value_text)
.unwrap_or_else(|| "step failed".to_string()),
)
} else {
None
}
}
_ => None,
}
},
classify_opencode_message,
)
}
}
fn error_value_text(v: &serde_json::Value) -> String {
if let Some(s) = v.as_str() {
return s.to_string();
}
v.get("data")
.and_then(|d| d.get("message").or(Some(d)))
.and_then(|m| m.as_str())
.or_else(|| v.get("message").and_then(|m| m.as_str()))
.or_else(|| v.get("name").and_then(|n| n.as_str()))
.map(str::to_string)
.unwrap_or_else(|| v.to_string())
}
fn opencode_error_text(event: &serde_json::Value) -> String {
if let Some(s) = event.get("message").and_then(|v| v.as_str()) {
return s.to_string();
}
match event.get("error") {
Some(e) => {
let text = error_value_text(e);
if e.is_object() && text == *e {
tracing::warn!(event = %event, "unrecognized opencode error event shape");
}
text
}
None => {
tracing::warn!(event = %event, "unrecognized opencode error event shape");
event.to_string()
}
}
}
impl AgentRunner for OpenCodeRunner {
#[cfg(test)]
fn name(&self) -> &str {
"opencode"
}
fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent> {
stream_events(event)
}
fn router_transcript(&self) -> super::RouterTranscript {
super::RouterTranscript::OpenCode
}
fn extract_text(&self, raw: &str) -> Result<String, AgentError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(String::new());
}
let events = super::parse_ndjson(trimmed);
if events.is_empty() {
return Ok(trimmed.to_string());
}
if let Some(err) = self.detect_error(&events) {
return Err(err);
}
Ok(extract_ndjson_text(&events).unwrap_or_else(|| trimmed.to_string()))
}
fn build_command(
&self,
model: Option<&str>,
timeout_cmd: &str,
sys_file: &str,
msg_file: &str,
permissions: &PermissionRules,
) -> String {
let (model_flag, variant_flag) = if let Some(m) = model {
let (base, variant) = Self::split_model_variant(m);
let mf = format!("--model {}", super::shell_single_quote(base));
let vf = variant.map(|v| format!("--variant {}", super::shell_single_quote(v)));
(mf, vf)
} else {
(String::new(), None)
};
let skip_perms = if permissions.autonomous {
"--dangerously-skip-permissions "
} else {
""
};
let variant_part = variant_flag.unwrap_or_default();
format!(
r#"{timeout_cmd} opencode run {model_flag} {variant_part} {skip_perms}\
--format json - < <(cat "{sys_file}" "{msg_file}")"#,
sys_file = sys_file,
msg_file = msg_file,
timeout_cmd = timeout_cmd,
model_flag = model_flag,
variant_part = variant_part,
skip_perms = skip_perms,
)
}
fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(AgentError::Unknown {
exit_code: 0,
message: "empty-output-exit0: opencode returned exit 0 with empty stdout"
.to_string(),
});
}
let events = super::parse_ndjson(trimmed);
if events.is_empty() {
if let Ok(resp) = parser::parse(trimmed) {
return Ok(ParsedResponse {
response: resp,
input_tokens: None,
output_tokens: None,
duration_ms: None,
});
}
return Err(AgentError::InvalidResponse {
raw: trimmed.to_string(),
});
}
if let Some(err) = self.detect_error(&events) {
return Err(err);
}
let (input_tokens, output_tokens) = self.extract_tokens(&events);
let text = match self.extract_text_from_events(&events) {
Some(t) => t,
None => {
let response = synthesize_response_from_events(&events);
return Ok(ParsedResponse {
response,
input_tokens,
output_tokens,
duration_ms: None,
});
}
};
let response = match parser::parse(&text) {
Ok(r) => r,
Err(_) => {
super::synthesize_response_from_text(&text)
.ok_or_else(|| AgentError::InvalidResponse { raw: text.clone() })?
}
};
Ok(ParsedResponse {
response,
input_tokens,
output_tokens,
duration_ms: None,
})
}
fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
let events = super::parse_ndjson(stdout);
if let Some(err) = self.detect_error(&events) {
return err;
}
let combined = format!("{stdout}\n{stderr}");
super::patterns::classify_from_text(exit_code, &combined)
}
fn free_models(&self) -> Vec<String> {
discover_free_opencode_models()
}
fn available_models(&self) -> Vec<String> {
vec![
"anthropic/claude-sonnet-4-20250514".to_string(),
"openai/gpt-4.1".to_string(),
]
}
fn router_command(
&self,
prompt: &str,
model: Option<&str>,
) -> anyhow::Result<tokio::process::Command> {
let mut cmd = tokio::process::Command::new("opencode");
cmd.arg("run")
.arg("--format")
.arg("json")
.arg("--dangerously-skip-permissions");
if let Some(m) = model {
let (base, variant) = Self::split_model_variant(m);
cmd.arg("--model").arg(base);
if let Some(v) = variant {
cmd.arg("--variant").arg(v);
}
}
cmd.arg(prompt);
Ok(cmd)
}
}
fn classify_opencode_message(message: &str) -> AgentError {
let lower = message.to_lowercase();
if let Some(e) = super::patterns::detect_rate_limit(message) {
return e;
}
if let Some(e) = super::patterns::detect_context_overflow(message) {
return e;
}
if let Some(e) = super::patterns::detect_auth_error(message) {
return e;
}
if let Some(e) = super::patterns::detect_permission_denied(message) {
return e;
}
let is_model_unavailable = (lower.contains("model")
&& (lower.contains("not found")
|| lower.contains("not supported")
|| lower.contains("deprecated")
|| lower.contains("unavailable for free")
|| lower.contains("not available in your country")
|| lower.contains("not available in your region")))
|| lower.contains("no endpoints found");
if is_model_unavailable {
let model = if lower.contains("no endpoints found") {
message
.split(" for ")
.nth(1)
.map(|s| s.trim_end_matches('.').to_string())
.unwrap_or_default()
} else {
let from_colon = message
.split(": ")
.nth(1)
.map(|s| {
s.split(". Did you mean")
.next()
.unwrap_or(s)
.trim_end_matches('.')
.to_string()
})
.filter(|s| !s.is_empty());
let from_transition = if lower.contains("deprecated") {
message
.split("Transition to ")
.nth(1)
.and_then(|s| s.split(" for ").next())
.map(|s| s.trim_end_matches('.').to_string())
.filter(|s| !s.is_empty())
} else {
None
};
let from_slug_suggestion = if lower.contains("unavailable for free") {
message
.split("use this slug instead: ")
.nth(1)
.map(|s| s.trim_end_matches('.').trim().to_string())
.filter(|s| !s.is_empty())
} else {
None
};
from_transition
.or(from_slug_suggestion)
.or(from_colon)
.unwrap_or_default()
};
return AgentError::ModelUnavailable {
message: message.to_string(),
model,
};
}
if lower.contains("provider returned error") {
return AgentError::RateLimit {
message: message.to_string(),
};
}
if let Some(e) = super::patterns::detect_network_error(message) {
return e;
}
AgentError::AgentFailed {
message: message.to_string(),
}
}
pub fn discover_free_opencode_models() -> Vec<String> {
let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
let now = chrono::Utc::now().timestamp();
{
let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
let (ts, models) = &*guard;
if *ts != 0 && now.saturating_sub(*ts) < 3600 {
return models.clone();
}
}
if !FREE_MODELS_REFRESH_IN_PROGRESS.swap(true, Ordering::AcqRel) {
let refresh = async {
let _guard = scopeguard::guard((), |_| {
FREE_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
});
let (models, empty_reason) = run_opencode_models_discovery_async().await;
let raw_count = models.len();
let discovered = models
.into_iter()
.filter(|m| m.to_lowercase().contains("free"))
.collect::<Vec<_>>();
let empty_reason = if discovered.is_empty() && raw_count > 0 {
"no_free_models_in_catalog"
} else {
empty_reason
};
let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
update_discovered_models_cache(cache, discovered, "free", empty_reason);
};
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(refresh);
} else {
std::thread::spawn(move || {
if let Ok(rt) = tokio::runtime::Runtime::new() {
rt.block_on(refresh);
}
});
}
}
let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
guard.1.clone()
}
pub fn discover_opencode_models() -> Vec<String> {
let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
let now = chrono::Utc::now().timestamp();
{
let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
let (ts, models) = &*guard;
if *ts != 0 && now.saturating_sub(*ts) < 3600 {
return models.clone();
}
}
if !ALL_MODELS_REFRESH_IN_PROGRESS.swap(true, Ordering::AcqRel) {
let refresh = async {
let _guard = scopeguard::guard((), |_| {
ALL_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
});
let (discovered, empty_reason) = run_opencode_models_discovery_async().await;
let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
update_discovered_models_cache(cache, discovered, "all", empty_reason);
};
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(refresh);
} else {
std::thread::spawn(move || {
if let Ok(rt) = tokio::runtime::Runtime::new() {
rt.block_on(refresh);
}
});
}
}
let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
guard.1.clone()
}
pub fn prime_free_model_cache() {
let discovered_all = std::thread::spawn(|| {
if let Ok(rt) = tokio::runtime::Runtime::new() {
rt.block_on(run_opencode_models_discovery_async()).0
} else {
vec![]
}
})
.join()
.unwrap_or_default();
let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
let now = chrono::Utc::now().timestamp();
if let Ok(mut guard) = cache.lock() {
*guard = (
now,
discovered_all
.iter()
.filter(|m| m.to_lowercase().contains("free"))
.cloned()
.collect(),
);
}
let all_cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
if let Ok(mut guard) = all_cache.lock() {
*guard = (now, discovered_all);
}
}
async fn run_opencode_models_discovery_async() -> (Vec<String>, &'static str) {
if !crate::cmd_cache::command_exists("opencode") {
tracing::warn!("opencode not in PATH — skipping model discovery");
return (vec![], "command_not_found");
}
let mut child = match tokio::process::Command::new("opencode")
.args(["models"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "failed to spawn opencode models");
return (vec![], "spawn_failed");
}
};
let timeout = std::time::Duration::from_secs(30);
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => {
let stdout = match child.stdout.take() {
Some(mut s) => {
let mut buf = String::new();
use tokio::io::AsyncReadExt;
let _ = s.read_to_string(&mut buf).await.ok();
buf
}
None => String::new(),
};
let models: Vec<String> = stdout
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
let reason = if models.is_empty() {
"empty_output"
} else {
"ok"
};
return (models, reason);
}
Ok(Some(status)) => {
let stderr = match child.stderr.take() {
Some(mut s) => {
let mut buf = String::new();
use tokio::io::AsyncReadExt;
let _ = s.read_to_string(&mut buf).await.ok();
buf
}
None => String::new(),
};
let stderr_trimmed = stderr.trim();
let stderr_display = if stderr_trimmed.len() > 500 {
format!("{}...", &stderr_trimmed[..500])
} else {
stderr_trimmed.to_string()
};
tracing::warn!(?status, stderr = %stderr_display, "opencode models command failed");
return (vec![], "exit_status_failure");
}
Ok(None) => {
if start.elapsed() > timeout {
tracing::warn!("opencode models timed out after 30s, killing process");
let _ = child.kill().await;
let _ = child.wait().await;
return (vec![], "timeout");
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
Err(e) => {
tracing::warn!(error = %e, "failed to wait on opencode models");
let _ = child.kill().await;
return (vec![], "wait_failed");
}
}
}
}
fn synthesize_response_from_events(events: &[serde_json::Value]) -> crate::parser::AgentResponse {
use std::collections::BTreeMap;
let mut counts: BTreeMap<String, u32> = BTreeMap::new();
let mut tool_names: Vec<String> = Vec::new();
let mut step_finish_reason: Option<String> = None;
for event in events {
let event_type = event
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
*counts.entry(event_type.to_string()).or_insert(0) += 1;
if event_type == "step_finish" {
if let Some(reason) = event
.get("part")
.and_then(|p| p.get("reason"))
.and_then(|r| r.as_str())
{
step_finish_reason = Some(reason.to_string());
}
}
if event_type == "tool_use" {
if let Some(name) = event
.get("name")
.and_then(|v| v.as_str())
.or_else(|| event.get("tool").and_then(|v| v.as_str()))
.or_else(|| {
event
.get("part")
.and_then(|p| p.get("name"))
.and_then(|v| v.as_str())
})
.or_else(|| {
event
.get("tool")
.and_then(|t| t.get("name"))
.and_then(|v| v.as_str())
})
{
tool_names.push(name.to_string());
}
}
}
let total_events = events.len();
let reason = step_finish_reason.as_deref().unwrap_or("unknown");
let tool_summary = if tool_names.is_empty() {
String::new()
} else {
let mut tool_counts: BTreeMap<String, u32> = BTreeMap::new();
for n in &tool_names {
*tool_counts.entry(n.clone()).or_insert(0) += 1;
}
let parts: Vec<String> = tool_counts
.iter()
.map(|(k, v)| {
if *v > 1 {
format!("{k}×{v}")
} else {
k.clone()
}
})
.collect();
format!(" (tools: {})", parts.join(", "))
};
let summary = format!(
"opencode emitted {total_events} event(s) with no text output{tool_summary}; \
step_finish reason: {reason}"
);
crate::parser::AgentResponse {
status: "needs_review".to_string(),
summary,
error: Some("agent emitted no text response — only tool/step events".to_string()),
..Default::default()
}
}
pub fn find_opencode_result(ndjson: &str) -> Option<super::AgentResult> {
let events = super::parse_ndjson(ndjson.trim());
if events.is_empty() {
return None;
}
let is_error;
let error_text;
let runner = OpenCodeRunner::new();
if let Some(err) = runner.detect_error(&events) {
is_error = true;
error_text = Some(err.to_string());
} else {
is_error = false;
error_text = None;
}
let result_text = if is_error {
error_text.unwrap_or_default()
} else {
extract_ndjson_text(&events).unwrap_or_default()
};
let (input_tokens, output_tokens) = runner.extract_tokens(&events);
let last_step_finish_reason = events
.iter()
.rev()
.find(|event| event.get("type").and_then(|v| v.as_str()) == Some("step_finish"))
.and_then(|event| {
event
.get("part")?
.get("reason")?
.as_str()
.map(str::to_string)
});
let truncated_by_length = match last_step_finish_reason.as_deref() {
Some("length") => true,
Some("unknown") => output_tokens == Some(0),
_ => false,
};
if result_text.is_empty() && !is_error && !truncated_by_length {
return None;
}
let cost_usd = events.iter().rev().find_map(|event| {
if event.get("type").and_then(|v| v.as_str()) == Some("step_finish") {
event
.get("part")
.and_then(|p| p.get("cost"))
.and_then(|c| c.as_f64())
.filter(|&c| c > 0.0)
} else {
None
}
});
Some(super::AgentResult {
is_error,
result_text,
input_tokens,
output_tokens,
cost_usd,
duration_ms: None,
truncated_by_length,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn runner() -> OpenCodeRunner {
OpenCodeRunner::new()
}
#[test]
fn parse_opencode_ndjson_success() {
let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start","snapshot":"..."}}
{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"status\":\"done\",\"summary\":\"hello\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}}
{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":17512,"input":17509,"output":3}}}"#;
let parsed = runner().parse_response(raw).unwrap();
assert_eq!(parsed.response.status, "done");
assert_eq!(parsed.response.summary, "hello");
assert_eq!(parsed.input_tokens, Some(17509));
assert_eq!(parsed.output_tokens, Some(3));
}
#[test]
fn parse_opencode_ndjson_direct_text_format() {
let raw = r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}
{"type":"text","timestamp":1001,"sessionID":"ses_abc","text":"{\"status\":\"done\",\"summary\":\"fixed\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}
{"type":"step_finish","timestamp":1002,"sessionID":"ses_abc","part":{"type":"step-finish","reason":"stop","tokens":{"input":200,"output":10}}}"#;
let parsed = runner().parse_response(raw).unwrap();
assert_eq!(parsed.response.status, "done");
assert_eq!(parsed.response.summary, "fixed");
assert_eq!(parsed.input_tokens, Some(200));
assert_eq!(parsed.output_tokens, Some(10));
}
#[test]
fn parse_opencode_concatenated_text() {
let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
{"type":"text","timestamp":1001,"part":{"type":"text","text":"Working on "}}
{"type":"text","timestamp":1002,"part":{"type":"text","text":"the task. "}}
{"type":"text","timestamp":1003,"part":{"type":"text","text":"{\"status\":\"done\",\"summary\":\"finished\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}}
{"type":"step_finish","timestamp":1004,"part":{"type":"step-finish","reason":"stop","tokens":{"input":100,"output":50}}}"#;
let parsed = runner().parse_response(raw).unwrap();
assert_eq!(parsed.response.status, "done");
assert_eq!(parsed.input_tokens, Some(100));
assert_eq!(parsed.output_tokens, Some(50));
}
#[test]
fn extract_router_text_prefers_json_payload() {
let raw = r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"Thinking..."}}
{"type":"text","timestamp":1002,"part":{"type":"text","text":"{\"executor\":\"claude\",\"complexity\":\"medium\",\"reason\":\"fit\"}"}}"#;
let text = extract_router_text(raw).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(parsed["executor"], "claude");
}
#[test]
fn parse_opencode_embedded_json_fragment_without_executor_is_ignored() {
let raw = r#"{"type":"step_start","timestamp":1}
{"type":"text","timestamp":2,"part":{"type":"text","text":"Here is a debug trace: {\"trace\":{\"duration\":123,\"id\":\"abc\"}}"}}
{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
let err = runner().parse_response(raw).unwrap_err();
match err {
AgentError::InvalidResponse { raw: _ } => {
}
other => panic!("expected InvalidResponse, got: {other:?}"),
}
}
#[test]
fn parse_opencode_error_event() {
let raw = r#"{"type":"error","message":"rate limit exceeded for this model"}"#;
let err = runner().parse_response(raw).unwrap_err();
assert!(matches!(err, AgentError::RateLimit { .. }));
}
#[test]
fn parse_opencode_tool_only_stream_synthesizes_needs_review() {
let raw = r#"{"type":"step_start","timestamp":1,"part":{"type":"step-start"}}
{"type":"tool_use","timestamp":2,"name":"bash","input":{"cmd":"ls"}}
{"type":"tool_use","timestamp":3,"name":"read","input":{"path":"src/main.rs"}}
{"type":"tool_use","timestamp":4,"name":"bash","input":{"cmd":"cargo check"}}
{"type":"step_finish","timestamp":5,"part":{"type":"step-finish","reason":"stop","tokens":{"input":500,"output":0}}}"#;
let parsed = runner()
.parse_response(raw)
.expect("tool-only NDJSON must synthesize a response, not error");
assert_eq!(parsed.response.status, "needs_review");
assert!(
!parsed.response.summary.contains("\"type\":\"step_start\""),
"summary must not contain raw NDJSON; got: {}",
parsed.response.summary
);
assert!(
parsed.response.summary.contains("bash") && parsed.response.summary.contains("read"),
"summary should mention tools used; got: {}",
parsed.response.summary
);
assert!(
parsed.response.summary.contains("stop"),
"summary should mention step_finish reason; got: {}",
parsed.response.summary
);
assert_eq!(parsed.input_tokens, Some(500));
assert_eq!(parsed.output_tokens, Some(0));
}
#[test]
fn parse_opencode_plain_prose_completion_synthesizes_response() {
let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
{"type":"text","timestamp":1001,"part":{"type":"text","text":"Fixed the typo and corrected the deadline reference in the review."}}
{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","tokens":{"input":300,"output":40}}}"#;
let parsed = runner()
.parse_response(raw)
.expect("plain-prose completion text must synthesize successfully");
assert_eq!(parsed.response.status, "done");
assert_eq!(parsed.input_tokens, Some(300));
assert_eq!(parsed.output_tokens, Some(40));
}
#[test]
fn parse_opencode_step_finish_error() {
let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
{"type":"step_finish","timestamp":1001,"part":{"type":"step-finish","reason":"error","error":"context length exceeded"}}"#;
let err = runner().parse_response(raw).unwrap_err();
assert!(matches!(err, AgentError::ContextOverflow { .. }));
}
#[test]
fn parse_opencode_empty_returns_silent_failure() {
let err = runner().parse_response("").unwrap_err();
assert!(matches!(err, AgentError::Unknown { exit_code: 0, .. }));
}
#[test]
fn extract_tokens_from_step_finish() {
let r = runner();
let events: Vec<serde_json::Value> = vec![
serde_json::json!({"type":"step_finish","part":{"type":"step-finish","tokens":{"input":1000,"output":500,"total":1500}}}),
];
let (input, output) = r.extract_tokens(&events);
assert_eq!(input, Some(1000));
assert_eq!(output, Some(500));
}
#[test]
fn classify_opencode_rate_limit() {
let err = classify_opencode_message("429 Too Many Requests");
assert!(matches!(err, AgentError::RateLimit { .. }));
}
#[test]
fn classify_opencode_model_not_found() {
let err = classify_opencode_message("model not found: gpt-5");
assert!(matches!(err, AgentError::ModelUnavailable { .. }));
}
#[test]
fn classify_opencode_model_not_found_with_suggestion() {
let err = classify_opencode_message(
"Model not found: github-copilot/gemini-3.1-pro. Did you mean: gemini-3.1-pro-preview?",
);
match err {
AgentError::ModelUnavailable { model, message } => {
assert_eq!(model, "github-copilot/gemini-3.1-pro");
assert!(message.contains("Did you mean"));
}
other => panic!("expected ModelUnavailable, got: {other:?}"),
}
}
#[test]
fn classify_opencode_model_deprecated() {
let err = classify_opencode_message(
"The free model has been deprecated. Transition to qwen/qwen3.6-plus for continued paid access.",
);
match err {
AgentError::ModelUnavailable { model, .. } => {
assert_eq!(
model, "qwen/qwen3.6-plus",
"expected model extracted from 'Transition to X', got: {model:?}"
);
}
other => panic!("expected ModelUnavailable, got: {other:?}"),
}
}
#[test]
fn classify_opencode_model_unavailable_for_free() {
let err = classify_opencode_message(
"network error: Error from provider (Console): Upstream request failed: [404] This model is unavailable for free. The paid version is available now - use this slug instead: inclusionai/ling-3.0-flash",
);
match err {
AgentError::ModelUnavailable { model, .. } => {
assert_eq!(
model, "inclusionai/ling-3.0-flash",
"expected model extracted from 'use this slug instead: X', got: {model:?}"
);
}
other => panic!("expected ModelUnavailable, got: {other:?}"),
}
}
#[test]
fn classify_opencode_model_not_available_in_country() {
let err =
classify_opencode_message("agent failed: This model is not available in your country.");
match err {
AgentError::ModelUnavailable { .. } => {}
other => panic!("expected ModelUnavailable, got: {other:?}"),
}
}
#[test]
fn classify_opencode_no_endpoints_found() {
let err = classify_opencode_message("No endpoints found for qwen/qwen3.6-plus:free.");
match err {
AgentError::ModelUnavailable { model, .. } => {
assert_eq!(model, "qwen/qwen3.6-plus:free");
}
other => panic!("expected ModelUnavailable, got: {other:?}"),
}
}
#[test]
fn build_command_opencode() {
let r = runner();
let perms = PermissionRules::default();
let cmd = r.build_command(
Some("anthropic/claude-sonnet-4-20250514"),
"timeout 1800",
"/tmp/sys.txt",
"/tmp/msg.txt",
&perms,
);
assert!(cmd.contains("opencode run"));
assert!(cmd.contains("--model 'anthropic/claude-sonnet-4-20250514'"));
assert!(cmd.contains("--format json"));
assert!(
cmd.contains("--dangerously-skip-permissions"),
"expected --dangerously-skip-permissions, got: {cmd}"
);
assert!(
!cmd.contains("opencode.json"),
"should not write a config file, got: {cmd}"
);
assert!(
!cmd.contains("XDG_CONFIG_HOME"),
"should not override XDG_CONFIG_HOME, got: {cmd}"
);
assert!(
!cmd.contains(".orch-opencode"),
"should not reference .orch-opencode, got: {cmd}"
);
assert!(
!cmd.trim_start().starts_with("cat "),
"opencode command must not start with a `cat |` pipe stage, got: {cmd}"
);
assert!(
cmd.contains("< <(cat \"/tmp/sys.txt\" \"/tmp/msg.txt\")"),
"sys/msg files must be fed via process substitution, got: {cmd}"
);
}
#[test]
fn build_command_opencode_supervised_no_skip_flag() {
let r = runner();
let perms = PermissionRules {
autonomous: false,
..PermissionRules::default()
};
let cmd = r.build_command(
Some("anthropic/claude-sonnet-4-20250514"),
"timeout 1800",
"/tmp/sys.txt",
"/tmp/msg.txt",
&perms,
);
assert!(
!cmd.contains("--dangerously-skip-permissions"),
"supervised mode must not pass --dangerously-skip-permissions, got: {cmd}"
);
}
#[test]
fn split_model_variant_no_at() {
let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5");
assert_eq!(base, "openai/gpt-5.5");
assert!(variant.is_none());
}
#[test]
fn split_model_variant_with_variant() {
let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5@xhigh");
assert_eq!(base, "openai/gpt-5.5");
assert_eq!(variant, Some("xhigh"));
}
#[test]
fn split_model_variant_trailing_at_empty() {
let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5@");
assert_eq!(base, "openai/gpt-5.5");
assert_eq!(variant, Some(""));
}
#[test]
fn split_model_variant_multiple_ats_last_wins() {
let (base, variant) = OpenCodeRunner::split_model_variant("a@b@c@v");
assert_eq!(base, "a@b@c");
assert_eq!(variant, Some("v"));
}
#[test]
fn build_command_opencode_with_variant() {
let r = runner();
let perms = PermissionRules::default();
let cmd = r.build_command(
Some("openai/gpt-5.5@high"),
"timeout 1800",
"/tmp/sys.txt",
"/tmp/msg.txt",
&perms,
);
assert!(cmd.contains("--model 'openai/gpt-5.5'"));
assert!(cmd.contains("--variant 'high'"));
}
#[test]
fn classify_opencode_permission_denied() {
let err = classify_opencode_message("user rejected permission for tool bash: cargo test");
assert!(
matches!(err, AgentError::PermissionDenied { .. }),
"expected PermissionDenied, got: {err:?}"
);
}
#[test]
fn classify_opencode_provider_returned_error() {
let err = classify_opencode_message("Provider returned error");
assert!(
matches!(err, AgentError::RateLimit { .. }),
"expected RateLimit, got: {err:?}"
);
let err_with_detail =
classify_opencode_message("Provider returned error: upstream connection timed out");
assert!(
matches!(err_with_detail, AgentError::RateLimit { .. }),
"expected RateLimit, got: {err_with_detail:?}"
);
}
#[test]
fn classify_opencode_upstream_idle_timeout() {
let err = classify_opencode_message("Upstream idle timeout exceeded");
assert!(
matches!(err, AgentError::NetworkError { .. }),
"expected NetworkError, got: {err:?}"
);
}
#[test]
fn free_models_discovery_returns_vec() {
let models = discover_free_opencode_models();
assert!(models.len() < 1000, "sanity check");
}
#[test]
fn empty_free_discovery_preserves_existing_cache_contents() {
reset_model_caches_for_test();
let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
{
let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
*guard = (123, vec!["opencode/model-free".to_string()]);
}
update_discovered_models_cache(cache, Vec::new(), "free", "empty_output");
let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
assert!(guard.0 > 123);
assert_eq!(guard.1, vec!["opencode/model-free".to_string()]);
}
#[test]
fn empty_all_models_discovery_preserves_existing_cache_contents() {
reset_model_caches_for_test();
let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
{
let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
*guard = (
456,
vec![
"opencode/model-free".to_string(),
"opencode/model-paid".to_string(),
],
);
}
update_discovered_models_cache(cache, Vec::new(), "all", "exit_status_failure");
let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
assert!(guard.0 > 456);
assert_eq!(
guard.1,
vec![
"opencode/model-free".to_string(),
"opencode/model-paid".to_string(),
]
);
}
#[test]
fn empty_discovery_populates_empty_cache() {
reset_model_caches_for_test();
let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
update_discovered_models_cache(cache, Vec::new(), "free", "command_not_found");
let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
assert!(guard.0 > 0);
assert!(guard.1.is_empty());
}
#[test]
fn fixture_opencode_success() {
let raw = include_str!("../../../../tests/fixtures/opencode_success.jsonl");
let parsed = runner().parse_response(raw).unwrap();
assert_eq!(parsed.response.status, "done");
assert!(parsed.response.summary.contains("Fixed"));
assert_eq!(parsed.response.accomplished.len(), 2);
assert_eq!(parsed.input_tokens, Some(17500));
assert_eq!(parsed.output_tokens, Some(500));
}
#[test]
fn fixture_opencode_permission_denied() {
let raw = include_str!("../../../../tests/fixtures/opencode_permission_denied.jsonl");
let err = runner().parse_response(raw).unwrap_err();
assert!(
matches!(err, AgentError::PermissionDenied { .. }),
"expected PermissionDenied, got: {err:?}"
);
}
#[test]
fn fixture_opencode_rate_limit() {
let raw = include_str!("../../../../tests/fixtures/opencode_rate_limit.jsonl");
let err = runner().parse_response(raw).unwrap_err();
assert!(
matches!(err, AgentError::RateLimit { .. }),
"expected RateLimit, got: {err:?}"
);
}
#[test]
fn fixture_opencode_model_not_found() {
let raw = include_str!("../../../../tests/fixtures/opencode_model_not_found.jsonl");
let err = runner().parse_response(raw).unwrap_err();
assert!(
matches!(err, AgentError::ModelUnavailable { .. }),
"expected ModelUnavailable, got: {err:?}"
);
if let AgentError::ModelUnavailable { model, .. } = &err {
assert_eq!(model, "anthropic/claude-sonnet-4-6");
}
}
#[test]
fn fixture_opencode_provider_returned_error() {
let raw = include_str!("../../../../tests/fixtures/opencode_provider_returned_error.jsonl");
let err = runner().parse_response(raw).unwrap_err();
assert!(
matches!(err, AgentError::RateLimit { .. }),
"expected RateLimit, got: {err:?}"
);
}
#[test]
fn extract_text_returns_text_events() {
let raw = concat!(
r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
"\n",
r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
"\n",
r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":100,"input":90,"output":10}}}"#,
);
let text = runner().extract_text(raw).unwrap();
assert_eq!(
text,
r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#
);
}
#[test]
fn extract_text_direct_text_event_format() {
let raw = concat!(
r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
"\n",
r#"{"type":"text","timestamp":1001,"sessionID":"ses_abc","text":"Here is my review:\n\n```json\n{\"decision\":\"request_changes\",\"notes\":\"Fix the bug\",\"test_results\":\"fail\",\"issues\":[]}\n```\n"}"#,
"\n",
r#"{"type":"step_finish","timestamp":1002,"sessionID":"ses_abc"}"#,
);
let text = runner().extract_text(raw).unwrap();
assert!(
text.contains("request_changes"),
"expected review JSON, got: {text}"
);
}
#[test]
fn extract_text_rate_limit_propagates() {
let raw = concat!(
r#"{"type":"step_start","timestamp":1000}"#,
"\n",
r#"{"type":"error","message":"rate limit exceeded: 429 Too Many Requests"}"#,
);
let err = runner().extract_text(raw).unwrap_err();
assert!(
matches!(err, AgentError::RateLimit { .. }),
"expected RateLimit, got: {err:?}"
);
}
#[test]
fn extract_text_auth_error_propagates() {
let raw = concat!(
r#"{"type":"step_start","timestamp":1000}"#,
"\n",
r#"{"type":"error","message":"401 Unauthorized: invalid api key"}"#,
);
let err = runner().extract_text(raw).unwrap_err();
assert!(
matches!(err, AgentError::Auth { .. }),
"expected Auth, got: {err:?}"
);
}
#[test]
fn extract_text_empty_input_returns_empty_ok() {
let text = runner().extract_text("").unwrap();
assert!(text.is_empty());
}
#[test]
fn find_opencode_result_success_with_tokens() {
let ndjson = concat!(
r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
"\n",
r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\"}"}}"#,
"\n",
r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0.05,"tokens":{"input":5000,"output":200}}}"#,
);
let result = find_opencode_result(ndjson).expect("should find result");
assert!(!result.is_error);
assert!(result.result_text.contains("approve"));
assert_eq!(result.input_tokens, Some(5000));
assert_eq!(result.output_tokens, Some(200));
assert_eq!(result.cost_usd, Some(0.05));
assert!(!result.truncated_by_length);
}
#[test]
fn find_opencode_result_error_event() {
let ndjson = r#"{"type":"error","message":"rate limit exceeded: 429"}"#;
let result = find_opencode_result(ndjson).expect("should find error result");
assert!(result.is_error);
assert!(result.result_text.contains("rate limit"));
assert!(!result.truncated_by_length);
}
#[test]
fn find_opencode_result_length_truncation() {
let ndjson = concat!(
r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
"\n",
r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":"}}"#,
"\n",
r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"length","tokens":{"input":5485,"output":759,"reasoning":31241}}}"#,
);
let result = find_opencode_result(ndjson).expect("should find result");
assert!(result.truncated_by_length);
assert!(!result.is_error, "length truncation is not a hard error");
}
#[test]
fn find_opencode_result_zero_output_length_truncation() {
let ndjson = concat!(
r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
"\n",
r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"length","tokens":{"total":71482,"input":39482,"output":0,"reasoning":40000}}}"#,
);
let result = find_opencode_result(ndjson).expect("should find result, not None");
assert!(result.truncated_by_length);
assert!(!result.is_error);
assert!(result.result_text.is_empty());
}
#[test]
fn find_opencode_result_zero_output_unknown_reason_truncation() {
let ndjson = concat!(
r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
"\n",
r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"unknown","tokens":{"input":0,"output":0,"reasoning":0}}}"#,
);
let result = find_opencode_result(ndjson).expect("should find result, not None");
assert!(result.truncated_by_length);
assert!(!result.is_error);
assert!(result.result_text.is_empty());
}
#[test]
fn find_opencode_result_unknown_reason_with_output_not_truncated() {
let ndjson = concat!(
r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
"\n",
r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"partial answer"}}"#,
"\n",
r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"unknown","tokens":{"input":100,"output":50,"reasoning":0}}}"#,
);
let result = find_opencode_result(ndjson).expect("should find result");
assert!(!result.truncated_by_length);
assert!(!result.is_error);
}
#[test]
fn find_opencode_result_empty_returns_none() {
assert!(find_opencode_result("").is_none());
assert!(find_opencode_result(" ").is_none());
}
#[test]
fn find_opencode_result_direct_text_format() {
let ndjson = concat!(
r#"{"type":"step_start","timestamp":1000}"#,
"\n",
r#"{"type":"text","timestamp":1001,"text":"Review complete. All tests passed."}"#,
"\n",
r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","tokens":{"input":100,"output":10}}}"#,
);
let result = find_opencode_result(ndjson).expect("should find result");
assert!(!result.is_error);
assert!(result.result_text.contains("All tests passed"));
}
#[test]
fn find_opencode_result_plain_text_returns_none() {
assert!(find_opencode_result("just some plain text").is_none());
}
}
#[cfg(test)]
mod error_text_tests {
use super::*;
use serde_json::json;
#[test]
fn unrecognized_error_shape_keeps_raw_json() {
let e = json!({"type":"error","error":{"code":429,"detail":"slow down"}});
assert!(opencode_error_text(&e).contains("slow down"));
let e = json!({"type":"error"});
assert!(opencode_error_text(&e).contains("\"type\""));
}
#[test]
fn known_shapes_still_extract() {
assert_eq!(opencode_error_text(&json!({"message":"a"})), "a");
assert_eq!(opencode_error_text(&json!({"error":"b"})), "b");
assert_eq!(opencode_error_text(&json!({"error":{"message":"c"}})), "c");
assert_eq!(
opencode_error_text(&json!({"error":{"name":"N","data":{"message":"d"}}})),
"d"
);
}
}
fn stream_events(v: &serde_json::Value) -> Vec<StreamEvent> {
let text = || {
v.pointer("/part/text")
.or_else(|| v.get("text"))
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.to_string()
};
match json_str(v, "/type") {
"text" => vec![StreamEvent::Text(text())],
"reasoning" => vec![StreamEvent::Thinking(text())],
"tool_use" => {
let part = &v["part"];
let state = &part["state"];
let id = json_str(part, "/callID").to_string();
let name = json_str(part, "/tool").to_string();
let mut out = vec![StreamEvent::ToolCall {
id: id.clone(),
name: name.clone(),
input: state["input"].clone(),
}];
let result = match json_str(state, "/status") {
"completed" => Some((json_str(state, "/output").to_string(), false)),
"error" => Some((json_error_message(state), true)),
_ => None,
};
if let Some((output, is_error)) = result {
out.push(StreamEvent::ToolResult {
id,
name,
output,
is_error,
});
}
out
}
"step_finish" if json_str(v, "/part/reason") == "stop" => {
vec![StreamEvent::Done(String::new())]
}
"error" => vec![StreamEvent::Error(json_error_message(v))],
_ => vec![],
}
}
#[cfg(test)]
mod stream_tests {
use super::*;
use serde_json::json;
fn events(line: &str) -> Vec<StreamEvent> {
OpenCodeRunner::new().stream_events(&serde_json::from_str(line).unwrap())
}
#[test]
fn text_in_part_or_top_level() {
assert_eq!(
events(r#"{"type":"text","part":{"type":"text","text":"Reviewing."}}"#),
vec![StreamEvent::Text("Reviewing.".into())]
);
assert_eq!(
events(r#"{"type":"text","text":"Plain."}"#),
vec![StreamEvent::Text("Plain.".into())]
);
}
#[test]
fn completed_tool_has_call_and_result() {
let line = r#"{"type":"tool_use","part":{"type":"tool","tool":"read","callID":"c1","state":{"status":"completed","input":{"filePath":"src/lib.rs"},"output":"a\nb"}}}"#;
assert_eq!(
events(line),
vec![
StreamEvent::ToolCall {
id: "c1".into(),
name: "read".into(),
input: json!({"filePath": "src/lib.rs"}),
},
StreamEvent::ToolResult {
id: "c1".into(),
name: "read".into(),
output: "a\nb".into(),
is_error: false,
},
]
);
}
#[test]
fn failed_tool_result_is_error() {
let line = r#"{"type":"tool_use","part":{"tool":"bash","callID":"c2","state":{"status":"error","input":{"command":"x"},"error":"not found"}}}"#;
assert_eq!(
events(line)[1],
StreamEvent::ToolResult {
id: "c2".into(),
name: "bash".into(),
output: "not found".into(),
is_error: true,
}
);
}
#[test]
fn only_final_step_is_done() {
assert!(events(r#"{"type":"step_finish","part":{"reason":"tool-calls"}}"#).is_empty());
assert_eq!(
events(r#"{"type":"step_finish","part":{"reason":"stop"}}"#),
vec![StreamEvent::Done(String::new())]
);
assert!(events(r#"{"type":"step_start"}"#).is_empty());
}
#[test]
fn error_event() {
let line = r#"{"type":"error","error":{"name":"UnknownError","data":{"message":"Provider returned error"}}}"#;
assert_eq!(
events(line),
vec![StreamEvent::Error("Provider returned error".into())]
);
}
}