1use super::{
28 json_error_message, json_str, AgentError, AgentRunner, ParsedResponse, PermissionRules,
29 StreamEvent,
30};
31use crate::parser;
32
33use std::sync::atomic::{AtomicBool, Ordering};
34use std::sync::{Mutex, OnceLock};
35
36static FREE_MODELS_CACHE: OnceLock<Mutex<(i64, Vec<String>)>> = OnceLock::new();
39static FREE_MODELS_REFRESH_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
40static ALL_MODELS_CACHE: OnceLock<Mutex<(i64, Vec<String>)>> = OnceLock::new();
43static ALL_MODELS_REFRESH_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
44
45#[cfg(test)]
46pub(crate) fn reset_model_caches_for_test() {
47 if let Some(cache) = FREE_MODELS_CACHE.get() {
48 if let Ok(mut guard) = cache.lock() {
49 *guard = (0, Vec::new());
50 }
51 }
52 if let Some(cache) = ALL_MODELS_CACHE.get() {
53 if let Ok(mut guard) = cache.lock() {
54 *guard = (0, Vec::new());
55 }
56 }
57 FREE_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
58 ALL_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
59}
60
61fn update_discovered_models_cache(
62 cache: &Mutex<(i64, Vec<String>)>,
63 discovered: Vec<String>,
64 cache_name: &'static str,
65 empty_reason: &'static str,
66) {
67 let now = chrono::Utc::now().timestamp();
68 let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
69
70 if discovered.is_empty() && !guard.1.is_empty() {
71 tracing::warn!(
72 cache = cache_name,
73 cached_models = guard.1.len(),
74 reason = empty_reason,
75 "opencode model discovery returned empty; preserving previous cache contents"
76 );
77 guard.0 = now;
81 return;
82 }
83
84 tracing::debug!(
85 cache = cache_name,
86 discovered_models = discovered.len(),
87 "opencode model discovery refreshed cache"
88 );
89 *guard = (now, discovered);
90}
91
92pub struct OpenCodeRunner;
94
95pub(crate) fn extract_ndjson_text(events: &[serde_json::Value]) -> Option<String> {
96 let mut texts = Vec::new();
97
98 for event in events {
99 let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
100
101 if event_type == "text" || event_type == "message" || event_type == "assistant" {
107 if let Some(part) = event.get("part") {
108 texts.extend(extract_text_from_part(part));
109 }
110
111 if let Some(text) = event.get("text").and_then(|v| v.as_str()) {
113 texts.push(text.to_string());
114 }
115
116 if let Some(message) = event.get("message") {
118 if let Some(items) = message.get("content").and_then(|v| v.as_array()) {
119 for item in items {
120 let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
121 if item_type == "text" {
122 if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
123 texts.push(text.to_string());
124 }
125 }
126 }
127 }
128 }
129
130 continue;
131 }
132
133 if let Some(part) = event.get("part") {
135 let part_type = part.get("type").and_then(|v| v.as_str()).unwrap_or("");
136 if part_type == "text" || part_type == "output_text" || part_type == "message" {
137 texts.extend(extract_text_from_part(part));
138 }
139 }
140 }
141
142 if texts.is_empty() {
145 for event in events.iter().rev() {
146 if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
147 if event_type == "system" {
149 continue;
150 }
151 }
152
153 if let Some(result) = event.get("result") {
154 if let Some(s) = result.as_str() {
156 return Some(s.to_string());
157 }
158 if result.is_object() || result.is_array() {
159 return Some(result.to_string());
160 }
161 }
162
163 if let Some(text) = event.get("text").and_then(|v| v.as_str()) {
165 if !text.trim().is_empty() {
166 return Some(text.to_string());
167 }
168 }
169 }
170
171 return None;
172 }
173
174 let full = texts.join("");
175 if let Some(json) = extract_json_object(&full) {
176 if crate::parser::parse(&json).is_ok() {
178 return Some(json);
179 }
180
181 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&json) {
184 if let Some(obj) = val.as_object() {
185 if obj.contains_key("executor")
186 || obj.contains_key("status")
187 || obj.contains_key("complexity")
188 || obj.contains_key("decision")
189 {
190 return Some(json);
191 }
192 }
193 }
194 }
196
197 for text in texts.iter().rev() {
198 let trimmed = text.trim();
199 if let Some(json) = extract_json_object(trimmed) {
200 if crate::parser::parse(&json).is_ok() {
204 return Some(json);
205 }
206
207 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&json) {
210 if let Some(obj) = val.as_object() {
211 if obj.contains_key("executor") || obj.contains_key("status") {
212 return Some(json);
213 }
214 }
215 }
216 }
217 if trimmed.contains('{') {
221 if crate::parser::parse(trimmed).is_ok() {
226 return Some(trimmed.to_string());
227 }
228
229 if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed) {
230 if let Some(obj) = val.as_object() {
231 if obj.contains_key("executor")
232 || obj.contains_key("status")
233 || obj.contains_key("complexity")
234 {
235 return Some(trimmed.to_string());
236 }
237 }
238 }
239 }
240 }
241
242 Some(full)
243}
244
245fn extract_text_from_part(part: &serde_json::Value) -> Vec<String> {
246 let mut out = Vec::new();
247
248 if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
249 out.push(text.to_string());
250 }
251
252 if let Some(items) = part.get("content").and_then(|v| v.as_array()) {
254 for item in items {
255 let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
256 if item_type == "text" {
257 if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
258 out.push(text.to_string());
259 }
260 }
261 }
262 }
263
264 out
265}
266
267fn extract_json_object(text: &str) -> Option<String> {
268 let trimmed = text.trim();
269 if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
270 return Some(trimmed.to_string());
271 }
272
273 let start = trimmed.find('{')?;
274 let end = trimmed.rfind('}')?;
275 if end <= start {
276 return None;
277 }
278
279 let candidate = trimmed[start..=end].trim();
280 serde_json::from_str::<serde_json::Value>(candidate)
281 .ok()
282 .map(|_| candidate.to_string())
283}
284
285pub(crate) fn extract_router_text(raw: &str) -> Option<String> {
286 let events = super::parse_ndjson(raw.trim());
287 if events.is_empty() {
288 return None;
289 }
290 extract_ndjson_text(&events)
291}
292
293#[cfg(test)]
294mod router_text_tests {
295 use super::*;
296
297 #[test]
298 fn extract_router_text_supports_message_with_content_array() {
299 let raw = r#"{"type":"step_start","timestamp":1}
301{"type":"message","timestamp":2,"part":{"type":"message","content":[{"type":"text","text":"```json\n{\"executor\":\"opencode\",\"complexity\":\"medium\",\"reason\":\"ok\"}\n```"}]}}
302{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
303 let text = extract_router_text(raw).expect("should extract text");
304 assert!(
305 text.contains("executor"),
306 "extracted text should include JSON"
307 );
308 }
309
310 #[test]
311 fn extract_router_text_ignores_leading_system_init() {
312 let raw = r#"{"type":"system","subtype":"init","cwd":"/"}
316{"type":"text","timestamp":2,"text":"{\"executor\":\"opencode\",\"complexity\":\"medium\",\"reason\":\"ndjson\"}"}
317{"type":"step_finish","timestamp":3}"#;
318
319 let text = extract_router_text(raw).expect("should extract text even with leading init");
320 let parsed: serde_json::Value =
321 serde_json::from_str(&text).expect("extracted text must be JSON");
322 assert_eq!(parsed["executor"], "opencode");
323 assert_eq!(parsed["complexity"], "medium");
324 }
325}
326
327impl OpenCodeRunner {
328 pub fn new() -> Self {
329 Self
330 }
331
332 pub(crate) fn split_model_variant(s: &str) -> (&str, Option<&str>) {
339 if let Some(pos) = s.rfind('@') {
340 let base = &s[..pos];
341 let variant = &s[pos + 1..];
342 (base, Some(variant))
343 } else {
344 (s, None)
345 }
346 }
347
348 fn extract_text_from_events(&self, events: &[serde_json::Value]) -> Option<String> {
359 extract_ndjson_text(events)
360 }
361
362 fn extract_tokens(&self, events: &[serde_json::Value]) -> (Option<u64>, Option<u64>) {
364 for event in events.iter().rev() {
365 let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
366
367 if event_type == "step_finish" {
368 if let Some(part) = event.get("part") {
369 if let Some(tokens) = part.get("tokens").and_then(|v| v.as_object()) {
370 let input = tokens.get("input").and_then(|v| v.as_u64());
371 let output = tokens.get("output").and_then(|v| v.as_u64());
372 return (input, output);
373 }
374 }
375 }
376 }
377
378 (None, None)
379 }
380
381 fn detect_error(&self, events: &[serde_json::Value]) -> Option<AgentError> {
383 super::patterns::detect_ndjson_error(
384 events,
385 |event| {
386 let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
387 match event_type {
388 "error" => Some(opencode_error_text(event)),
389 "step_finish" => {
390 let part = event.get("part")?;
391 let reason = part.get("reason").and_then(|v| v.as_str()).unwrap_or("");
392 if reason == "error" || reason == "failed" {
393 Some(
394 part.get("error")
395 .map(error_value_text)
396 .unwrap_or_else(|| "step failed".to_string()),
397 )
398 } else {
399 None
400 }
401 }
402 _ => None,
403 }
404 },
405 classify_opencode_message,
406 )
407 }
408}
409
410fn error_value_text(v: &serde_json::Value) -> String {
412 if let Some(s) = v.as_str() {
413 return s.to_string();
414 }
415 v.get("data")
416 .and_then(|d| d.get("message").or(Some(d)))
417 .and_then(|m| m.as_str())
418 .or_else(|| v.get("message").and_then(|m| m.as_str()))
419 .or_else(|| v.get("name").and_then(|n| n.as_str()))
420 .map(str::to_string)
421 .unwrap_or_else(|| v.to_string())
422}
423
424fn opencode_error_text(event: &serde_json::Value) -> String {
426 if let Some(s) = event.get("message").and_then(|v| v.as_str()) {
427 return s.to_string();
428 }
429 match event.get("error") {
430 Some(e) => {
431 let text = error_value_text(e);
432 if e.is_object() && text == *e {
433 tracing::warn!(event = %event, "unrecognized opencode error event shape");
434 }
435 text
436 }
437 None => {
438 tracing::warn!(event = %event, "unrecognized opencode error event shape");
439 event.to_string()
440 }
441 }
442}
443
444impl AgentRunner for OpenCodeRunner {
445 #[cfg(test)]
446 fn name(&self) -> &str {
447 "opencode"
448 }
449
450 fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent> {
451 stream_events(event)
452 }
453
454 fn router_transcript(&self) -> super::RouterTranscript {
455 super::RouterTranscript::OpenCode
456 }
457
458 fn extract_text(&self, raw: &str) -> Result<String, AgentError> {
459 let trimmed = raw.trim();
460 if trimmed.is_empty() {
461 return Ok(String::new());
462 }
463
464 let events = super::parse_ndjson(trimmed);
465 if events.is_empty() {
466 return Ok(trimmed.to_string());
467 }
468
469 if let Some(err) = self.detect_error(&events) {
471 return Err(err);
472 }
473
474 Ok(extract_ndjson_text(&events).unwrap_or_else(|| trimmed.to_string()))
476 }
477
478 fn build_command(
479 &self,
480 model: Option<&str>,
481 timeout_cmd: &str,
482 sys_file: &str,
483 msg_file: &str,
484 permissions: &PermissionRules,
485 ) -> String {
486 let (model_flag, variant_flag) = if let Some(m) = model {
489 let (base, variant) = Self::split_model_variant(m);
490 let mf = format!("--model {}", super::shell_single_quote(base));
491 let vf = variant.map(|v| format!("--variant {}", super::shell_single_quote(v)));
492 (mf, vf)
493 } else {
494 (String::new(), None)
495 };
496
497 let skip_perms = if permissions.autonomous {
510 "--dangerously-skip-permissions "
511 } else {
512 ""
513 };
514
515 let variant_part = variant_flag.unwrap_or_default();
517 format!(
522 r#"{timeout_cmd} opencode run {model_flag} {variant_part} {skip_perms}\
523 --format json - < <(cat "{sys_file}" "{msg_file}")"#,
524 sys_file = sys_file,
525 msg_file = msg_file,
526 timeout_cmd = timeout_cmd,
527 model_flag = model_flag,
528 variant_part = variant_part,
529 skip_perms = skip_perms,
530 )
531 }
532
533 fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
534 let trimmed = raw.trim();
535 if trimmed.is_empty() {
539 return Err(AgentError::Unknown {
540 exit_code: 0,
541 message: "empty-output-exit0: opencode returned exit 0 with empty stdout"
542 .to_string(),
543 });
544 }
545
546 let events = super::parse_ndjson(trimmed);
547
548 if events.is_empty() {
549 if let Ok(resp) = parser::parse(trimmed) {
551 return Ok(ParsedResponse {
552 response: resp,
553 input_tokens: None,
554 output_tokens: None,
555 duration_ms: None,
556 });
557 }
558 return Err(AgentError::InvalidResponse {
559 raw: trimmed.to_string(),
560 });
561 }
562
563 if let Some(err) = self.detect_error(&events) {
565 return Err(err);
566 }
567
568 let (input_tokens, output_tokens) = self.extract_tokens(&events);
570
571 let text = match self.extract_text_from_events(&events) {
578 Some(t) => t,
579 None => {
580 let response = synthesize_response_from_events(&events);
581 return Ok(ParsedResponse {
582 response,
583 input_tokens,
584 output_tokens,
585 duration_ms: None,
586 });
587 }
588 };
589
590 let response = match parser::parse(&text) {
592 Ok(r) => r,
593 Err(_) => {
594 super::synthesize_response_from_text(&text)
597 .ok_or_else(|| AgentError::InvalidResponse { raw: text.clone() })?
598 }
599 };
600
601 Ok(ParsedResponse {
602 response,
603 input_tokens,
604 output_tokens,
605 duration_ms: None,
606 })
607 }
608
609 fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
610 let events = super::parse_ndjson(stdout);
612 if let Some(err) = self.detect_error(&events) {
613 return err;
614 }
615
616 let combined = format!("{stdout}\n{stderr}");
617 super::patterns::classify_from_text(exit_code, &combined)
618 }
619
620 fn free_models(&self) -> Vec<String> {
621 discover_free_opencode_models()
622 }
623
624 fn available_models(&self) -> Vec<String> {
625 vec![
627 "anthropic/claude-sonnet-4-20250514".to_string(),
628 "openai/gpt-4.1".to_string(),
629 ]
630 }
631
632 fn router_command(
633 &self,
634 prompt: &str,
635 model: Option<&str>,
636 ) -> anyhow::Result<tokio::process::Command> {
637 let mut cmd = tokio::process::Command::new("opencode");
643 cmd.arg("run")
644 .arg("--format")
645 .arg("json")
646 .arg("--dangerously-skip-permissions");
647 if let Some(m) = model {
648 let (base, variant) = Self::split_model_variant(m);
649 cmd.arg("--model").arg(base);
650 if let Some(v) = variant {
651 cmd.arg("--variant").arg(v);
653 }
654 }
655 cmd.arg(prompt);
656 Ok(cmd)
657 }
658}
659
660fn classify_opencode_message(message: &str) -> AgentError {
662 let lower = message.to_lowercase();
663
664 if let Some(e) = super::patterns::detect_rate_limit(message) {
666 return e;
667 }
668
669 if let Some(e) = super::patterns::detect_context_overflow(message) {
671 return e;
672 }
673
674 if let Some(e) = super::patterns::detect_auth_error(message) {
676 return e;
677 }
678
679 if let Some(e) = super::patterns::detect_permission_denied(message) {
681 return e;
682 }
683
684 let is_model_unavailable = (lower.contains("model")
695 && (lower.contains("not found")
696 || lower.contains("not supported")
697 || lower.contains("deprecated")
698 || lower.contains("unavailable for free")
699 || lower.contains("not available in your country")
700 || lower.contains("not available in your region")))
701 || lower.contains("no endpoints found");
702
703 if is_model_unavailable {
704 let model = if lower.contains("no endpoints found") {
707 message
709 .split(" for ")
710 .nth(1)
711 .map(|s| s.trim_end_matches('.').to_string())
712 .unwrap_or_default()
713 } else {
714 let from_colon = message
716 .split(": ")
717 .nth(1)
718 .map(|s| {
719 s.split(". Did you mean")
721 .next()
722 .unwrap_or(s)
723 .trim_end_matches('.')
724 .to_string()
725 })
726 .filter(|s| !s.is_empty());
727
728 let from_transition = if lower.contains("deprecated") {
731 message
732 .split("Transition to ")
733 .nth(1)
734 .and_then(|s| s.split(" for ").next())
735 .map(|s| s.trim_end_matches('.').to_string())
736 .filter(|s| !s.is_empty())
737 } else {
738 None
739 };
740
741 let from_slug_suggestion = if lower.contains("unavailable for free") {
744 message
745 .split("use this slug instead: ")
746 .nth(1)
747 .map(|s| s.trim_end_matches('.').trim().to_string())
748 .filter(|s| !s.is_empty())
749 } else {
750 None
751 };
752
753 from_transition
757 .or(from_slug_suggestion)
758 .or(from_colon)
759 .unwrap_or_default()
760 };
761 return AgentError::ModelUnavailable {
762 message: message.to_string(),
763 model,
764 };
765 }
766
767 if lower.contains("provider returned error") {
772 return AgentError::RateLimit {
773 message: message.to_string(),
774 };
775 }
776
777 if let Some(e) = super::patterns::detect_network_error(message) {
779 return e;
780 }
781
782 AgentError::AgentFailed {
783 message: message.to_string(),
784 }
785}
786
787pub fn discover_free_opencode_models() -> Vec<String> {
796 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
797
798 let now = chrono::Utc::now().timestamp();
799 {
801 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
802 let (ts, models) = &*guard;
803 if *ts != 0 && now.saturating_sub(*ts) < 3600 {
804 return models.clone();
805 }
806 }
807
808 if !FREE_MODELS_REFRESH_IN_PROGRESS.swap(true, Ordering::AcqRel) {
811 let refresh = async {
812 let _guard = scopeguard::guard((), |_| {
813 FREE_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
814 });
815 let (models, empty_reason) = run_opencode_models_discovery_async().await;
816 let raw_count = models.len();
817 let discovered = models
818 .into_iter()
819 .filter(|m| m.to_lowercase().contains("free"))
820 .collect::<Vec<_>>();
821 let empty_reason = if discovered.is_empty() && raw_count > 0 {
822 "no_free_models_in_catalog"
823 } else {
824 empty_reason
825 };
826 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
827 update_discovered_models_cache(cache, discovered, "free", empty_reason);
828 };
829 if let Ok(handle) = tokio::runtime::Handle::try_current() {
830 handle.spawn(refresh);
833 } else {
834 std::thread::spawn(move || {
837 if let Ok(rt) = tokio::runtime::Runtime::new() {
838 rt.block_on(refresh);
839 }
840 });
841 }
842 }
843
844 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
846 guard.1.clone()
847}
848
849pub fn discover_opencode_models() -> Vec<String> {
854 let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
855 let now = chrono::Utc::now().timestamp();
856
857 {
858 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
859 let (ts, models) = &*guard;
860 if *ts != 0 && now.saturating_sub(*ts) < 3600 {
861 return models.clone();
862 }
863 }
864
865 if !ALL_MODELS_REFRESH_IN_PROGRESS.swap(true, Ordering::AcqRel) {
866 let refresh = async {
867 let _guard = scopeguard::guard((), |_| {
868 ALL_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
869 });
870 let (discovered, empty_reason) = run_opencode_models_discovery_async().await;
871 let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
872 update_discovered_models_cache(cache, discovered, "all", empty_reason);
873 };
874 if let Ok(handle) = tokio::runtime::Handle::try_current() {
875 handle.spawn(refresh);
876 } else {
877 std::thread::spawn(move || {
878 if let Ok(rt) = tokio::runtime::Runtime::new() {
879 rt.block_on(refresh);
880 }
881 });
882 }
883 }
884
885 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
886 guard.1.clone()
887}
888
889pub fn prime_free_model_cache() {
897 let discovered_all = std::thread::spawn(|| {
898 if let Ok(rt) = tokio::runtime::Runtime::new() {
899 rt.block_on(run_opencode_models_discovery_async()).0
900 } else {
901 vec![]
902 }
903 })
904 .join()
905 .unwrap_or_default();
906 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
907 let now = chrono::Utc::now().timestamp();
908 if let Ok(mut guard) = cache.lock() {
909 *guard = (
910 now,
911 discovered_all
912 .iter()
913 .filter(|m| m.to_lowercase().contains("free"))
914 .cloned()
915 .collect(),
916 );
917 }
918 let all_cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
919 if let Ok(mut guard) = all_cache.lock() {
920 *guard = (now, discovered_all);
921 }
922}
923
924async fn run_opencode_models_discovery_async() -> (Vec<String>, &'static str) {
933 if !crate::cmd_cache::command_exists("opencode") {
934 tracing::warn!("opencode not in PATH — skipping model discovery");
935 return (vec![], "command_not_found");
936 }
937
938 let mut child = match tokio::process::Command::new("opencode")
939 .args(["models"])
940 .stdout(std::process::Stdio::piped())
941 .stderr(std::process::Stdio::piped())
942 .spawn()
943 {
944 Ok(c) => c,
945 Err(e) => {
946 tracing::warn!(error = %e, "failed to spawn opencode models");
947 return (vec![], "spawn_failed");
948 }
949 };
950
951 let timeout = std::time::Duration::from_secs(30);
952 let start = std::time::Instant::now();
953 loop {
954 match child.try_wait() {
955 Ok(Some(status)) if status.success() => {
956 let stdout = match child.stdout.take() {
958 Some(mut s) => {
959 let mut buf = String::new();
960 use tokio::io::AsyncReadExt;
961 let _ = s.read_to_string(&mut buf).await.ok();
962 buf
963 }
964 None => String::new(),
965 };
966 let models: Vec<String> = stdout
967 .lines()
968 .map(|l| l.trim().to_string())
969 .filter(|l| !l.is_empty())
970 .collect();
971 let reason = if models.is_empty() {
972 "empty_output"
973 } else {
974 "ok"
975 };
976 return (models, reason);
977 }
978 Ok(Some(status)) => {
979 let stderr = match child.stderr.take() {
980 Some(mut s) => {
981 let mut buf = String::new();
982 use tokio::io::AsyncReadExt;
983 let _ = s.read_to_string(&mut buf).await.ok();
984 buf
985 }
986 None => String::new(),
987 };
988 let stderr_trimmed = stderr.trim();
989 let stderr_display = if stderr_trimmed.len() > 500 {
990 format!("{}...", &stderr_trimmed[..500])
991 } else {
992 stderr_trimmed.to_string()
993 };
994 tracing::warn!(?status, stderr = %stderr_display, "opencode models command failed");
995 return (vec![], "exit_status_failure");
996 }
997 Ok(None) => {
998 if start.elapsed() > timeout {
999 tracing::warn!("opencode models timed out after 30s, killing process");
1000 let _ = child.kill().await;
1001 let _ = child.wait().await;
1002 return (vec![], "timeout");
1003 }
1004 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1005 }
1006 Err(e) => {
1007 tracing::warn!(error = %e, "failed to wait on opencode models");
1008 let _ = child.kill().await;
1009 return (vec![], "wait_failed");
1010 }
1011 }
1012 }
1013}
1014
1015fn synthesize_response_from_events(events: &[serde_json::Value]) -> crate::parser::AgentResponse {
1028 use std::collections::BTreeMap;
1029
1030 let mut counts: BTreeMap<String, u32> = BTreeMap::new();
1031 let mut tool_names: Vec<String> = Vec::new();
1032 let mut step_finish_reason: Option<String> = None;
1033
1034 for event in events {
1035 let event_type = event
1036 .get("type")
1037 .and_then(|v| v.as_str())
1038 .unwrap_or("unknown");
1039 *counts.entry(event_type.to_string()).or_insert(0) += 1;
1040
1041 if event_type == "step_finish" {
1042 if let Some(reason) = event
1043 .get("part")
1044 .and_then(|p| p.get("reason"))
1045 .and_then(|r| r.as_str())
1046 {
1047 step_finish_reason = Some(reason.to_string());
1048 }
1049 }
1050
1051 if event_type == "tool_use" {
1053 if let Some(name) = event
1054 .get("name")
1055 .and_then(|v| v.as_str())
1056 .or_else(|| event.get("tool").and_then(|v| v.as_str()))
1057 .or_else(|| {
1058 event
1059 .get("part")
1060 .and_then(|p| p.get("name"))
1061 .and_then(|v| v.as_str())
1062 })
1063 .or_else(|| {
1064 event
1065 .get("tool")
1066 .and_then(|t| t.get("name"))
1067 .and_then(|v| v.as_str())
1068 })
1069 {
1070 tool_names.push(name.to_string());
1071 }
1072 }
1073 }
1074
1075 let total_events = events.len();
1076 let reason = step_finish_reason.as_deref().unwrap_or("unknown");
1077
1078 let tool_summary = if tool_names.is_empty() {
1079 String::new()
1080 } else {
1081 let mut tool_counts: BTreeMap<String, u32> = BTreeMap::new();
1082 for n in &tool_names {
1083 *tool_counts.entry(n.clone()).or_insert(0) += 1;
1084 }
1085 let parts: Vec<String> = tool_counts
1086 .iter()
1087 .map(|(k, v)| {
1088 if *v > 1 {
1089 format!("{k}×{v}")
1090 } else {
1091 k.clone()
1092 }
1093 })
1094 .collect();
1095 format!(" (tools: {})", parts.join(", "))
1096 };
1097
1098 let summary = format!(
1099 "opencode emitted {total_events} event(s) with no text output{tool_summary}; \
1100 step_finish reason: {reason}"
1101 );
1102
1103 crate::parser::AgentResponse {
1104 status: "needs_review".to_string(),
1105 summary,
1106 error: Some("agent emitted no text response — only tool/step events".to_string()),
1107 ..Default::default()
1108 }
1109}
1110
1111pub fn find_opencode_result(ndjson: &str) -> Option<super::AgentResult> {
1119 let events = super::parse_ndjson(ndjson.trim());
1120 if events.is_empty() {
1121 return None;
1122 }
1123
1124 let is_error;
1126 let error_text;
1127 let runner = OpenCodeRunner::new();
1128 if let Some(err) = runner.detect_error(&events) {
1129 is_error = true;
1130 error_text = Some(err.to_string());
1131 } else {
1132 is_error = false;
1133 error_text = None;
1134 }
1135
1136 let result_text = if is_error {
1138 error_text.unwrap_or_default()
1139 } else {
1140 extract_ndjson_text(&events).unwrap_or_default()
1141 };
1142
1143 let (input_tokens, output_tokens) = runner.extract_tokens(&events);
1145
1146 let last_step_finish_reason = events
1155 .iter()
1156 .rev()
1157 .find(|event| event.get("type").and_then(|v| v.as_str()) == Some("step_finish"))
1158 .and_then(|event| {
1159 event
1160 .get("part")?
1161 .get("reason")?
1162 .as_str()
1163 .map(str::to_string)
1164 });
1165 let truncated_by_length = match last_step_finish_reason.as_deref() {
1166 Some("length") => true,
1167 Some("unknown") => output_tokens == Some(0),
1168 _ => false,
1169 };
1170
1171 if result_text.is_empty() && !is_error && !truncated_by_length {
1172 return None;
1173 }
1174
1175 let cost_usd = events.iter().rev().find_map(|event| {
1177 if event.get("type").and_then(|v| v.as_str()) == Some("step_finish") {
1178 event
1179 .get("part")
1180 .and_then(|p| p.get("cost"))
1181 .and_then(|c| c.as_f64())
1182 .filter(|&c| c > 0.0)
1183 } else {
1184 None
1185 }
1186 });
1187
1188 Some(super::AgentResult {
1189 is_error,
1190 result_text,
1191 input_tokens,
1192 output_tokens,
1193 cost_usd,
1194 duration_ms: None,
1195 truncated_by_length,
1196 })
1197}
1198
1199#[cfg(test)]
1200mod tests {
1201 use super::*;
1202
1203 fn runner() -> OpenCodeRunner {
1204 OpenCodeRunner::new()
1205 }
1206
1207 #[test]
1208 fn parse_opencode_ndjson_success() {
1209 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start","snapshot":"..."}}
1210{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"status\":\"done\",\"summary\":\"hello\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}}
1211{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":17512,"input":17509,"output":3}}}"#;
1212
1213 let parsed = runner().parse_response(raw).unwrap();
1214 assert_eq!(parsed.response.status, "done");
1215 assert_eq!(parsed.response.summary, "hello");
1216 assert_eq!(parsed.input_tokens, Some(17509));
1217 assert_eq!(parsed.output_tokens, Some(3));
1218 }
1219
1220 #[test]
1223 fn parse_opencode_ndjson_direct_text_format() {
1224 let raw = r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}
1225{"type":"text","timestamp":1001,"sessionID":"ses_abc","text":"{\"status\":\"done\",\"summary\":\"fixed\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}
1226{"type":"step_finish","timestamp":1002,"sessionID":"ses_abc","part":{"type":"step-finish","reason":"stop","tokens":{"input":200,"output":10}}}"#;
1227
1228 let parsed = runner().parse_response(raw).unwrap();
1229 assert_eq!(parsed.response.status, "done");
1230 assert_eq!(parsed.response.summary, "fixed");
1231 assert_eq!(parsed.input_tokens, Some(200));
1232 assert_eq!(parsed.output_tokens, Some(10));
1233 }
1234
1235 #[test]
1236 fn parse_opencode_concatenated_text() {
1237 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1238{"type":"text","timestamp":1001,"part":{"type":"text","text":"Working on "}}
1239{"type":"text","timestamp":1002,"part":{"type":"text","text":"the task. "}}
1240{"type":"text","timestamp":1003,"part":{"type":"text","text":"{\"status\":\"done\",\"summary\":\"finished\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}}
1241{"type":"step_finish","timestamp":1004,"part":{"type":"step-finish","reason":"stop","tokens":{"input":100,"output":50}}}"#;
1242
1243 let parsed = runner().parse_response(raw).unwrap();
1244 assert_eq!(parsed.response.status, "done");
1245 assert_eq!(parsed.input_tokens, Some(100));
1246 assert_eq!(parsed.output_tokens, Some(50));
1247 }
1248
1249 #[test]
1250 fn extract_router_text_prefers_json_payload() {
1251 let raw = r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"Thinking..."}}
1252{"type":"text","timestamp":1002,"part":{"type":"text","text":"{\"executor\":\"claude\",\"complexity\":\"medium\",\"reason\":\"fit\"}"}}"#;
1253
1254 let text = extract_router_text(raw).unwrap();
1255 let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
1256 assert_eq!(parsed["executor"], "claude");
1257 }
1258
1259 #[test]
1260 fn parse_opencode_embedded_json_fragment_without_executor_is_ignored() {
1261 let raw = r#"{"type":"step_start","timestamp":1}
1266{"type":"text","timestamp":2,"part":{"type":"text","text":"Here is a debug trace: {\"trace\":{\"duration\":123,\"id\":\"abc\"}}"}}
1267{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
1268
1269 let err = runner().parse_response(raw).unwrap_err();
1270 match err {
1271 AgentError::InvalidResponse { raw: _ } => {
1272 }
1274 other => panic!("expected InvalidResponse, got: {other:?}"),
1275 }
1276 }
1277
1278 #[test]
1279 fn parse_opencode_error_event() {
1280 let raw = r#"{"type":"error","message":"rate limit exceeded for this model"}"#;
1281
1282 let err = runner().parse_response(raw).unwrap_err();
1283 assert!(matches!(err, AgentError::RateLimit { .. }));
1284 }
1285
1286 #[test]
1292 fn parse_opencode_tool_only_stream_synthesizes_needs_review() {
1293 let raw = r#"{"type":"step_start","timestamp":1,"part":{"type":"step-start"}}
1294{"type":"tool_use","timestamp":2,"name":"bash","input":{"cmd":"ls"}}
1295{"type":"tool_use","timestamp":3,"name":"read","input":{"path":"src/main.rs"}}
1296{"type":"tool_use","timestamp":4,"name":"bash","input":{"cmd":"cargo check"}}
1297{"type":"step_finish","timestamp":5,"part":{"type":"step-finish","reason":"stop","tokens":{"input":500,"output":0}}}"#;
1298
1299 let parsed = runner()
1300 .parse_response(raw)
1301 .expect("tool-only NDJSON must synthesize a response, not error");
1302 assert_eq!(parsed.response.status, "needs_review");
1303 assert!(
1304 !parsed.response.summary.contains("\"type\":\"step_start\""),
1305 "summary must not contain raw NDJSON; got: {}",
1306 parsed.response.summary
1307 );
1308 assert!(
1309 parsed.response.summary.contains("bash") && parsed.response.summary.contains("read"),
1310 "summary should mention tools used; got: {}",
1311 parsed.response.summary
1312 );
1313 assert!(
1314 parsed.response.summary.contains("stop"),
1315 "summary should mention step_finish reason; got: {}",
1316 parsed.response.summary
1317 );
1318 assert_eq!(parsed.input_tokens, Some(500));
1320 assert_eq!(parsed.output_tokens, Some(0));
1321 }
1322
1323 #[test]
1328 fn parse_opencode_plain_prose_completion_synthesizes_response() {
1329 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1330{"type":"text","timestamp":1001,"part":{"type":"text","text":"Fixed the typo and corrected the deadline reference in the review."}}
1331{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","tokens":{"input":300,"output":40}}}"#;
1332
1333 let parsed = runner()
1334 .parse_response(raw)
1335 .expect("plain-prose completion text must synthesize successfully");
1336 assert_eq!(parsed.response.status, "done");
1337 assert_eq!(parsed.input_tokens, Some(300));
1338 assert_eq!(parsed.output_tokens, Some(40));
1339 }
1340
1341 #[test]
1342 fn parse_opencode_step_finish_error() {
1343 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1344{"type":"step_finish","timestamp":1001,"part":{"type":"step-finish","reason":"error","error":"context length exceeded"}}"#;
1345
1346 let err = runner().parse_response(raw).unwrap_err();
1347 assert!(matches!(err, AgentError::ContextOverflow { .. }));
1348 }
1349
1350 #[test]
1351 fn parse_opencode_empty_returns_silent_failure() {
1352 let err = runner().parse_response("").unwrap_err();
1353 assert!(matches!(err, AgentError::Unknown { exit_code: 0, .. }));
1356 }
1357
1358 #[test]
1359 fn extract_tokens_from_step_finish() {
1360 let r = runner();
1361 let events: Vec<serde_json::Value> = vec![
1362 serde_json::json!({"type":"step_finish","part":{"type":"step-finish","tokens":{"input":1000,"output":500,"total":1500}}}),
1363 ];
1364 let (input, output) = r.extract_tokens(&events);
1365 assert_eq!(input, Some(1000));
1366 assert_eq!(output, Some(500));
1367 }
1368
1369 #[test]
1370 fn classify_opencode_rate_limit() {
1371 let err = classify_opencode_message("429 Too Many Requests");
1372 assert!(matches!(err, AgentError::RateLimit { .. }));
1373 }
1374
1375 #[test]
1376 fn classify_opencode_model_not_found() {
1377 let err = classify_opencode_message("model not found: gpt-5");
1378 assert!(matches!(err, AgentError::ModelUnavailable { .. }));
1379 }
1380
1381 #[test]
1382 fn classify_opencode_model_not_found_with_suggestion() {
1383 let err = classify_opencode_message(
1386 "Model not found: github-copilot/gemini-3.1-pro. Did you mean: gemini-3.1-pro-preview?",
1387 );
1388 match err {
1389 AgentError::ModelUnavailable { model, message } => {
1390 assert_eq!(model, "github-copilot/gemini-3.1-pro");
1391 assert!(message.contains("Did you mean"));
1392 }
1393 other => panic!("expected ModelUnavailable, got: {other:?}"),
1394 }
1395 }
1396
1397 #[test]
1398 fn classify_opencode_model_deprecated() {
1399 let err = classify_opencode_message(
1402 "The free model has been deprecated. Transition to qwen/qwen3.6-plus for continued paid access.",
1403 );
1404 match err {
1405 AgentError::ModelUnavailable { model, .. } => {
1406 assert_eq!(
1407 model, "qwen/qwen3.6-plus",
1408 "expected model extracted from 'Transition to X', got: {model:?}"
1409 );
1410 }
1411 other => panic!("expected ModelUnavailable, got: {other:?}"),
1412 }
1413 }
1414
1415 #[test]
1416 fn classify_opencode_model_unavailable_for_free() {
1417 let err = classify_opencode_message(
1421 "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",
1422 );
1423 match err {
1424 AgentError::ModelUnavailable { model, .. } => {
1425 assert_eq!(
1426 model, "inclusionai/ling-3.0-flash",
1427 "expected model extracted from 'use this slug instead: X', got: {model:?}"
1428 );
1429 }
1430 other => panic!("expected ModelUnavailable, got: {other:?}"),
1431 }
1432 }
1433
1434 #[test]
1435 fn classify_opencode_model_not_available_in_country() {
1436 let err =
1441 classify_opencode_message("agent failed: This model is not available in your country.");
1442 match err {
1443 AgentError::ModelUnavailable { .. } => {}
1444 other => panic!("expected ModelUnavailable, got: {other:?}"),
1445 }
1446 }
1447
1448 #[test]
1449 fn classify_opencode_no_endpoints_found() {
1450 let err = classify_opencode_message("No endpoints found for qwen/qwen3.6-plus:free.");
1452 match err {
1453 AgentError::ModelUnavailable { model, .. } => {
1454 assert_eq!(model, "qwen/qwen3.6-plus:free");
1455 }
1456 other => panic!("expected ModelUnavailable, got: {other:?}"),
1457 }
1458 }
1459
1460 #[test]
1461 fn build_command_opencode() {
1462 let r = runner();
1463 let perms = PermissionRules::default();
1464 let cmd = r.build_command(
1465 Some("anthropic/claude-sonnet-4-20250514"),
1466 "timeout 1800",
1467 "/tmp/sys.txt",
1468 "/tmp/msg.txt",
1469 &perms,
1470 );
1471 assert!(cmd.contains("opencode run"));
1472 assert!(cmd.contains("--model 'anthropic/claude-sonnet-4-20250514'"));
1473 assert!(cmd.contains("--format json"));
1474 assert!(
1477 cmd.contains("--dangerously-skip-permissions"),
1478 "expected --dangerously-skip-permissions, got: {cmd}"
1479 );
1480 assert!(
1481 !cmd.contains("opencode.json"),
1482 "should not write a config file, got: {cmd}"
1483 );
1484 assert!(
1485 !cmd.contains("XDG_CONFIG_HOME"),
1486 "should not override XDG_CONFIG_HOME, got: {cmd}"
1487 );
1488 assert!(
1489 !cmd.contains(".orch-opencode"),
1490 "should not reference .orch-opencode, got: {cmd}"
1491 );
1492 assert!(
1499 !cmd.trim_start().starts_with("cat "),
1500 "opencode command must not start with a `cat |` pipe stage, got: {cmd}"
1501 );
1502 assert!(
1503 cmd.contains("< <(cat \"/tmp/sys.txt\" \"/tmp/msg.txt\")"),
1504 "sys/msg files must be fed via process substitution, got: {cmd}"
1505 );
1506 }
1507
1508 #[test]
1509 fn build_command_opencode_supervised_no_skip_flag() {
1510 let r = runner();
1511 let perms = PermissionRules {
1512 autonomous: false,
1513 ..PermissionRules::default()
1514 };
1515 let cmd = r.build_command(
1516 Some("anthropic/claude-sonnet-4-20250514"),
1517 "timeout 1800",
1518 "/tmp/sys.txt",
1519 "/tmp/msg.txt",
1520 &perms,
1521 );
1522 assert!(
1523 !cmd.contains("--dangerously-skip-permissions"),
1524 "supervised mode must not pass --dangerously-skip-permissions, got: {cmd}"
1525 );
1526 }
1527
1528 #[test]
1529 fn split_model_variant_no_at() {
1530 let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5");
1531 assert_eq!(base, "openai/gpt-5.5");
1532 assert!(variant.is_none());
1533 }
1534
1535 #[test]
1536 fn split_model_variant_with_variant() {
1537 let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5@xhigh");
1538 assert_eq!(base, "openai/gpt-5.5");
1539 assert_eq!(variant, Some("xhigh"));
1540 }
1541
1542 #[test]
1543 fn split_model_variant_trailing_at_empty() {
1544 let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5@");
1545 assert_eq!(base, "openai/gpt-5.5");
1546 assert_eq!(variant, Some(""));
1547 }
1548
1549 #[test]
1550 fn split_model_variant_multiple_ats_last_wins() {
1551 let (base, variant) = OpenCodeRunner::split_model_variant("a@b@c@v");
1552 assert_eq!(base, "a@b@c");
1553 assert_eq!(variant, Some("v"));
1554 }
1555
1556 #[test]
1557 fn build_command_opencode_with_variant() {
1558 let r = runner();
1559 let perms = PermissionRules::default();
1560 let cmd = r.build_command(
1561 Some("openai/gpt-5.5@high"),
1562 "timeout 1800",
1563 "/tmp/sys.txt",
1564 "/tmp/msg.txt",
1565 &perms,
1566 );
1567 assert!(cmd.contains("--model 'openai/gpt-5.5'"));
1568 assert!(cmd.contains("--variant 'high'"));
1569 }
1570
1571 #[test]
1572 fn classify_opencode_permission_denied() {
1573 let err = classify_opencode_message("user rejected permission for tool bash: cargo test");
1574 assert!(
1575 matches!(err, AgentError::PermissionDenied { .. }),
1576 "expected PermissionDenied, got: {err:?}"
1577 );
1578 }
1579
1580 #[test]
1581 fn classify_opencode_provider_returned_error() {
1582 let err = classify_opencode_message("Provider returned error");
1586 assert!(
1587 matches!(err, AgentError::RateLimit { .. }),
1588 "expected RateLimit, got: {err:?}"
1589 );
1590 let err_with_detail =
1591 classify_opencode_message("Provider returned error: upstream connection timed out");
1592 assert!(
1593 matches!(err_with_detail, AgentError::RateLimit { .. }),
1594 "expected RateLimit, got: {err_with_detail:?}"
1595 );
1596 }
1597
1598 #[test]
1599 fn classify_opencode_upstream_idle_timeout() {
1600 let err = classify_opencode_message("Upstream idle timeout exceeded");
1603 assert!(
1604 matches!(err, AgentError::NetworkError { .. }),
1605 "expected NetworkError, got: {err:?}"
1606 );
1607 }
1608
1609 #[test]
1610 fn free_models_discovery_returns_vec() {
1611 let models = discover_free_opencode_models();
1614 assert!(models.len() < 1000, "sanity check");
1617 }
1618
1619 #[test]
1620 fn empty_free_discovery_preserves_existing_cache_contents() {
1621 reset_model_caches_for_test();
1622 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1623 {
1624 let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
1625 *guard = (123, vec!["opencode/model-free".to_string()]);
1626 }
1627
1628 update_discovered_models_cache(cache, Vec::new(), "free", "empty_output");
1629
1630 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
1631 assert!(guard.0 > 123);
1634 assert_eq!(guard.1, vec!["opencode/model-free".to_string()]);
1635 }
1636
1637 #[test]
1638 fn empty_all_models_discovery_preserves_existing_cache_contents() {
1639 reset_model_caches_for_test();
1640 let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1641 {
1642 let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
1643 *guard = (
1644 456,
1645 vec![
1646 "opencode/model-free".to_string(),
1647 "opencode/model-paid".to_string(),
1648 ],
1649 );
1650 }
1651
1652 update_discovered_models_cache(cache, Vec::new(), "all", "exit_status_failure");
1653
1654 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
1655 assert!(guard.0 > 456);
1656 assert_eq!(
1657 guard.1,
1658 vec![
1659 "opencode/model-free".to_string(),
1660 "opencode/model-paid".to_string(),
1661 ]
1662 );
1663 }
1664
1665 #[test]
1666 fn empty_discovery_populates_empty_cache() {
1667 reset_model_caches_for_test();
1668 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1669
1670 update_discovered_models_cache(cache, Vec::new(), "free", "command_not_found");
1671
1672 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
1673 assert!(guard.0 > 0);
1674 assert!(guard.1.is_empty());
1675 }
1676
1677 #[test]
1680 fn fixture_opencode_success() {
1681 let raw = include_str!("../../../../tests/fixtures/opencode_success.jsonl");
1682 let parsed = runner().parse_response(raw).unwrap();
1683 assert_eq!(parsed.response.status, "done");
1684 assert!(parsed.response.summary.contains("Fixed"));
1685 assert_eq!(parsed.response.accomplished.len(), 2);
1686 assert_eq!(parsed.input_tokens, Some(17500));
1687 assert_eq!(parsed.output_tokens, Some(500));
1688 }
1689
1690 #[test]
1694 fn fixture_opencode_permission_denied() {
1695 let raw = include_str!("../../../../tests/fixtures/opencode_permission_denied.jsonl");
1696 let err = runner().parse_response(raw).unwrap_err();
1697 assert!(
1698 matches!(err, AgentError::PermissionDenied { .. }),
1699 "expected PermissionDenied, got: {err:?}"
1700 );
1701 }
1702
1703 #[test]
1705 fn fixture_opencode_rate_limit() {
1706 let raw = include_str!("../../../../tests/fixtures/opencode_rate_limit.jsonl");
1707 let err = runner().parse_response(raw).unwrap_err();
1708 assert!(
1709 matches!(err, AgentError::RateLimit { .. }),
1710 "expected RateLimit, got: {err:?}"
1711 );
1712 }
1713
1714 #[test]
1718 fn fixture_opencode_model_not_found() {
1719 let raw = include_str!("../../../../tests/fixtures/opencode_model_not_found.jsonl");
1720 let err = runner().parse_response(raw).unwrap_err();
1721 assert!(
1722 matches!(err, AgentError::ModelUnavailable { .. }),
1723 "expected ModelUnavailable, got: {err:?}"
1724 );
1725 if let AgentError::ModelUnavailable { model, .. } = &err {
1726 assert_eq!(model, "anthropic/claude-sonnet-4-6");
1727 }
1728 }
1729
1730 #[test]
1733 fn fixture_opencode_provider_returned_error() {
1734 let raw = include_str!("../../../../tests/fixtures/opencode_provider_returned_error.jsonl");
1735 let err = runner().parse_response(raw).unwrap_err();
1736 assert!(
1737 matches!(err, AgentError::RateLimit { .. }),
1738 "expected RateLimit, got: {err:?}"
1739 );
1740 }
1741
1742 #[test]
1746 fn extract_text_returns_text_events() {
1747 let raw = concat!(
1748 r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1749 "\n",
1750 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
1751 "\n",
1752 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":100,"input":90,"output":10}}}"#,
1753 );
1754 let text = runner().extract_text(raw).unwrap();
1755 assert_eq!(
1756 text,
1757 r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#
1758 );
1759 }
1760
1761 #[test]
1763 fn extract_text_direct_text_event_format() {
1764 let raw = concat!(
1765 r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1766 "\n",
1767 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"}"#,
1768 "\n",
1769 r#"{"type":"step_finish","timestamp":1002,"sessionID":"ses_abc"}"#,
1770 );
1771 let text = runner().extract_text(raw).unwrap();
1772 assert!(
1773 text.contains("request_changes"),
1774 "expected review JSON, got: {text}"
1775 );
1776 }
1777
1778 #[test]
1780 fn extract_text_rate_limit_propagates() {
1781 let raw = concat!(
1782 r#"{"type":"step_start","timestamp":1000}"#,
1783 "\n",
1784 r#"{"type":"error","message":"rate limit exceeded: 429 Too Many Requests"}"#,
1785 );
1786 let err = runner().extract_text(raw).unwrap_err();
1787 assert!(
1788 matches!(err, AgentError::RateLimit { .. }),
1789 "expected RateLimit, got: {err:?}"
1790 );
1791 }
1792
1793 #[test]
1795 fn extract_text_auth_error_propagates() {
1796 let raw = concat!(
1797 r#"{"type":"step_start","timestamp":1000}"#,
1798 "\n",
1799 r#"{"type":"error","message":"401 Unauthorized: invalid api key"}"#,
1800 );
1801 let err = runner().extract_text(raw).unwrap_err();
1802 assert!(
1803 matches!(err, AgentError::Auth { .. }),
1804 "expected Auth, got: {err:?}"
1805 );
1806 }
1807
1808 #[test]
1810 fn extract_text_empty_input_returns_empty_ok() {
1811 let text = runner().extract_text("").unwrap();
1812 assert!(text.is_empty());
1813 }
1814
1815 #[test]
1818 fn find_opencode_result_success_with_tokens() {
1819 let ndjson = concat!(
1820 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1821 "\n",
1822 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\"}"}}"#,
1823 "\n",
1824 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0.05,"tokens":{"input":5000,"output":200}}}"#,
1825 );
1826 let result = find_opencode_result(ndjson).expect("should find result");
1827 assert!(!result.is_error);
1828 assert!(result.result_text.contains("approve"));
1829 assert_eq!(result.input_tokens, Some(5000));
1830 assert_eq!(result.output_tokens, Some(200));
1831 assert_eq!(result.cost_usd, Some(0.05));
1832 assert!(!result.truncated_by_length);
1833 }
1834
1835 #[test]
1836 fn find_opencode_result_error_event() {
1837 let ndjson = r#"{"type":"error","message":"rate limit exceeded: 429"}"#;
1838 let result = find_opencode_result(ndjson).expect("should find error result");
1839 assert!(result.is_error);
1840 assert!(result.result_text.contains("rate limit"));
1841 assert!(!result.truncated_by_length);
1842 }
1843
1844 #[test]
1848 fn find_opencode_result_length_truncation() {
1849 let ndjson = concat!(
1850 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1851 "\n",
1852 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":"}}"#,
1853 "\n",
1854 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"length","tokens":{"input":5485,"output":759,"reasoning":31241}}}"#,
1855 );
1856 let result = find_opencode_result(ndjson).expect("should find result");
1857 assert!(result.truncated_by_length);
1858 assert!(!result.is_error, "length truncation is not a hard error");
1859 }
1860
1861 #[test]
1867 fn find_opencode_result_zero_output_length_truncation() {
1868 let ndjson = concat!(
1869 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1870 "\n",
1871 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"length","tokens":{"total":71482,"input":39482,"output":0,"reasoning":40000}}}"#,
1872 );
1873 let result = find_opencode_result(ndjson).expect("should find result, not None");
1874 assert!(result.truncated_by_length);
1875 assert!(!result.is_error);
1876 assert!(result.result_text.is_empty());
1877 }
1878
1879 #[test]
1882 fn find_opencode_result_zero_output_unknown_reason_truncation() {
1883 let ndjson = concat!(
1884 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1885 "\n",
1886 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"unknown","tokens":{"input":0,"output":0,"reasoning":0}}}"#,
1887 );
1888 let result = find_opencode_result(ndjson).expect("should find result, not None");
1889 assert!(result.truncated_by_length);
1890 assert!(!result.is_error);
1891 assert!(result.result_text.is_empty());
1892 }
1893
1894 #[test]
1898 fn find_opencode_result_unknown_reason_with_output_not_truncated() {
1899 let ndjson = concat!(
1900 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1901 "\n",
1902 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"partial answer"}}"#,
1903 "\n",
1904 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"unknown","tokens":{"input":100,"output":50,"reasoning":0}}}"#,
1905 );
1906 let result = find_opencode_result(ndjson).expect("should find result");
1907 assert!(!result.truncated_by_length);
1908 assert!(!result.is_error);
1909 }
1910
1911 #[test]
1912 fn find_opencode_result_empty_returns_none() {
1913 assert!(find_opencode_result("").is_none());
1914 assert!(find_opencode_result(" ").is_none());
1915 }
1916
1917 #[test]
1918 fn find_opencode_result_direct_text_format() {
1919 let ndjson = concat!(
1920 r#"{"type":"step_start","timestamp":1000}"#,
1921 "\n",
1922 r#"{"type":"text","timestamp":1001,"text":"Review complete. All tests passed."}"#,
1923 "\n",
1924 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","tokens":{"input":100,"output":10}}}"#,
1925 );
1926 let result = find_opencode_result(ndjson).expect("should find result");
1927 assert!(!result.is_error);
1928 assert!(result.result_text.contains("All tests passed"));
1929 }
1930
1931 #[test]
1932 fn find_opencode_result_plain_text_returns_none() {
1933 assert!(find_opencode_result("just some plain text").is_none());
1934 }
1935}
1936
1937#[cfg(test)]
1938mod error_text_tests {
1939 use super::*;
1940 use serde_json::json;
1941
1942 #[test]
1943 fn unrecognized_error_shape_keeps_raw_json() {
1944 let e = json!({"type":"error","error":{"code":429,"detail":"slow down"}});
1945 assert!(opencode_error_text(&e).contains("slow down"));
1946 let e = json!({"type":"error"});
1947 assert!(opencode_error_text(&e).contains("\"type\""));
1948 }
1949
1950 #[test]
1951 fn known_shapes_still_extract() {
1952 assert_eq!(opencode_error_text(&json!({"message":"a"})), "a");
1953 assert_eq!(opencode_error_text(&json!({"error":"b"})), "b");
1954 assert_eq!(opencode_error_text(&json!({"error":{"message":"c"}})), "c");
1955 assert_eq!(
1956 opencode_error_text(&json!({"error":{"name":"N","data":{"message":"d"}}})),
1957 "d"
1958 );
1959 }
1960}
1961
1962fn stream_events(v: &serde_json::Value) -> Vec<StreamEvent> {
1964 let text = || {
1965 v.pointer("/part/text")
1966 .or_else(|| v.get("text"))
1967 .and_then(serde_json::Value::as_str)
1968 .unwrap_or("")
1969 .to_string()
1970 };
1971 match json_str(v, "/type") {
1972 "text" => vec![StreamEvent::Text(text())],
1973 "reasoning" => vec![StreamEvent::Thinking(text())],
1974 "tool_use" => {
1975 let part = &v["part"];
1976 let state = &part["state"];
1977 let id = json_str(part, "/callID").to_string();
1978 let name = json_str(part, "/tool").to_string();
1979 let mut out = vec![StreamEvent::ToolCall {
1980 id: id.clone(),
1981 name: name.clone(),
1982 input: state["input"].clone(),
1983 }];
1984 let result = match json_str(state, "/status") {
1985 "completed" => Some((json_str(state, "/output").to_string(), false)),
1986 "error" => Some((json_error_message(state), true)),
1987 _ => None,
1988 };
1989 if let Some((output, is_error)) = result {
1990 out.push(StreamEvent::ToolResult {
1991 id,
1992 name,
1993 output,
1994 is_error,
1995 });
1996 }
1997
1998 out
1999 }
2000 "step_finish" if json_str(v, "/part/reason") == "stop" => {
2001 vec![StreamEvent::Done(String::new())]
2002 }
2003 "error" => vec![StreamEvent::Error(json_error_message(v))],
2004 _ => vec![],
2005 }
2006}
2007
2008#[cfg(test)]
2009mod stream_tests {
2010 use super::*;
2011 use serde_json::json;
2012
2013 fn events(line: &str) -> Vec<StreamEvent> {
2014 OpenCodeRunner::new().stream_events(&serde_json::from_str(line).unwrap())
2015 }
2016
2017 #[test]
2018 fn text_in_part_or_top_level() {
2019 assert_eq!(
2020 events(r#"{"type":"text","part":{"type":"text","text":"Reviewing."}}"#),
2021 vec![StreamEvent::Text("Reviewing.".into())]
2022 );
2023 assert_eq!(
2024 events(r#"{"type":"text","text":"Plain."}"#),
2025 vec![StreamEvent::Text("Plain.".into())]
2026 );
2027 }
2028
2029 #[test]
2030 fn completed_tool_has_call_and_result() {
2031 let line = r#"{"type":"tool_use","part":{"type":"tool","tool":"read","callID":"c1","state":{"status":"completed","input":{"filePath":"src/lib.rs"},"output":"a\nb"}}}"#;
2032 assert_eq!(
2033 events(line),
2034 vec![
2035 StreamEvent::ToolCall {
2036 id: "c1".into(),
2037 name: "read".into(),
2038 input: json!({"filePath": "src/lib.rs"}),
2039 },
2040 StreamEvent::ToolResult {
2041 id: "c1".into(),
2042 name: "read".into(),
2043 output: "a\nb".into(),
2044 is_error: false,
2045 },
2046 ]
2047 );
2048 }
2049
2050 #[test]
2051 fn failed_tool_result_is_error() {
2052 let line = r#"{"type":"tool_use","part":{"tool":"bash","callID":"c2","state":{"status":"error","input":{"command":"x"},"error":"not found"}}}"#;
2053 assert_eq!(
2054 events(line)[1],
2055 StreamEvent::ToolResult {
2056 id: "c2".into(),
2057 name: "bash".into(),
2058 output: "not found".into(),
2059 is_error: true,
2060 }
2061 );
2062 }
2063
2064 #[test]
2065 fn only_final_step_is_done() {
2066 assert!(events(r#"{"type":"step_finish","part":{"reason":"tool-calls"}}"#).is_empty());
2067 assert_eq!(
2068 events(r#"{"type":"step_finish","part":{"reason":"stop"}}"#),
2069 vec![StreamEvent::Done(String::new())]
2070 );
2071 assert!(events(r#"{"type":"step_start"}"#).is_empty());
2072 }
2073
2074 #[test]
2075 fn error_event() {
2076 let line = r#"{"type":"error","error":{"name":"UnknownError","data":{"message":"Provider returned error"}}}"#;
2077 assert_eq!(
2078 events(line),
2079 vec![StreamEvent::Error("Provider returned error".into())]
2080 );
2081 }
2082}