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
327const NO_SNAPSHOT_CONFIG: &str = r#"{"snapshot":false}"#;
330
331impl OpenCodeRunner {
332 pub fn new() -> Self {
333 Self
334 }
335
336 pub(crate) fn split_model_variant(s: &str) -> (&str, Option<&str>) {
343 if let Some(pos) = s.rfind('@') {
344 let base = &s[..pos];
345 let variant = &s[pos + 1..];
346 (base, Some(variant))
347 } else {
348 (s, None)
349 }
350 }
351
352 fn extract_text_from_events(&self, events: &[serde_json::Value]) -> Option<String> {
363 extract_ndjson_text(events)
364 }
365
366 fn extract_tokens(&self, events: &[serde_json::Value]) -> (Option<u64>, Option<u64>) {
368 for event in events.iter().rev() {
369 let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
370
371 if event_type == "step_finish" {
372 if let Some(part) = event.get("part") {
373 if let Some(tokens) = part.get("tokens").and_then(|v| v.as_object()) {
374 let input = tokens.get("input").and_then(|v| v.as_u64());
375 let output = tokens.get("output").and_then(|v| v.as_u64());
376 return (input, output);
377 }
378 }
379 }
380 }
381
382 (None, None)
383 }
384
385 fn detect_error(&self, events: &[serde_json::Value]) -> Option<AgentError> {
387 super::patterns::detect_ndjson_error(
388 events,
389 |event| {
390 let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
391 match event_type {
392 "error" => Some(opencode_error_text(event)),
393 "step_finish" => {
394 let part = event.get("part")?;
395 let reason = part.get("reason").and_then(|v| v.as_str()).unwrap_or("");
396 if reason == "error" || reason == "failed" {
397 Some(
398 part.get("error")
399 .map(error_value_text)
400 .unwrap_or_else(|| "step failed".to_string()),
401 )
402 } else {
403 None
404 }
405 }
406 _ => None,
407 }
408 },
409 classify_opencode_message,
410 )
411 }
412}
413
414fn error_value_text(v: &serde_json::Value) -> String {
416 if let Some(s) = v.as_str() {
417 return s.to_string();
418 }
419 v.get("data")
420 .and_then(|d| d.get("message").or(Some(d)))
421 .and_then(|m| m.as_str())
422 .or_else(|| v.get("message").and_then(|m| m.as_str()))
423 .or_else(|| v.get("name").and_then(|n| n.as_str()))
424 .map(str::to_string)
425 .unwrap_or_else(|| v.to_string())
426}
427
428fn opencode_error_text(event: &serde_json::Value) -> String {
430 if let Some(s) = event.get("message").and_then(|v| v.as_str()) {
431 return s.to_string();
432 }
433 match event.get("error") {
434 Some(e) => {
435 let text = error_value_text(e);
436 if e.is_object() && text == *e {
437 tracing::warn!(event = %event, "unrecognized opencode error event shape");
438 }
439 text
440 }
441 None => {
442 tracing::warn!(event = %event, "unrecognized opencode error event shape");
443 event.to_string()
444 }
445 }
446}
447
448impl AgentRunner for OpenCodeRunner {
449 #[cfg(test)]
450 fn name(&self) -> &str {
451 "opencode"
452 }
453
454 fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent> {
455 stream_events(event)
456 }
457
458 fn router_transcript(&self) -> super::RouterTranscript {
459 super::RouterTranscript::OpenCode
460 }
461
462 fn extract_text(&self, raw: &str) -> Result<String, AgentError> {
463 let trimmed = raw.trim();
464 if trimmed.is_empty() {
465 return Ok(String::new());
466 }
467
468 let events = super::parse_ndjson(trimmed);
469 if events.is_empty() {
470 return Ok(trimmed.to_string());
471 }
472
473 if let Some(err) = self.detect_error(&events) {
475 return Err(err);
476 }
477
478 Ok(extract_ndjson_text(&events).unwrap_or_else(|| trimmed.to_string()))
480 }
481
482 fn build_command(
483 &self,
484 model: Option<&str>,
485 timeout_cmd: &str,
486 sys_file: &str,
487 msg_file: &str,
488 permissions: &PermissionRules,
489 ) -> String {
490 let (model_flag, variant_flag) = if let Some(m) = model {
493 let (base, variant) = Self::split_model_variant(m);
494 let mf = format!("--model {}", super::shell_single_quote(base));
495 let vf = variant.map(|v| format!("--variant {}", super::shell_single_quote(v)));
496 (mf, vf)
497 } else {
498 (String::new(), None)
499 };
500
501 let skip_perms = if permissions.autonomous {
514 "--dangerously-skip-permissions "
515 } else {
516 ""
517 };
518
519 let variant_part = variant_flag.unwrap_or_default();
521 format!(
526 r#"OPENCODE_CONFIG_CONTENT={config} {timeout_cmd} opencode run {model_flag} {variant_part} {skip_perms}\
527 --format json - < <(cat "{sys_file}" "{msg_file}")"#,
528 config = super::shell_single_quote(NO_SNAPSHOT_CONFIG),
529 sys_file = sys_file,
530 msg_file = msg_file,
531 timeout_cmd = timeout_cmd,
532 model_flag = model_flag,
533 variant_part = variant_part,
534 skip_perms = skip_perms,
535 )
536 }
537
538 fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
539 let trimmed = raw.trim();
540 if trimmed.is_empty() {
544 return Err(AgentError::Unknown {
545 exit_code: 0,
546 message: "empty-output-exit0: opencode returned exit 0 with empty stdout"
547 .to_string(),
548 });
549 }
550
551 let events = super::parse_ndjson(trimmed);
552
553 if events.is_empty() {
554 if let Ok(resp) = parser::parse(trimmed) {
556 return Ok(ParsedResponse {
557 response: resp,
558 input_tokens: None,
559 output_tokens: None,
560 duration_ms: None,
561 });
562 }
563 return Err(AgentError::InvalidResponse {
564 raw: trimmed.to_string(),
565 });
566 }
567
568 if let Some(err) = self.detect_error(&events) {
570 return Err(err);
571 }
572
573 let (input_tokens, output_tokens) = self.extract_tokens(&events);
575
576 let text = match self.extract_text_from_events(&events) {
583 Some(t) => t,
584 None => {
585 let response = synthesize_response_from_events(&events);
586 return Ok(ParsedResponse {
587 response,
588 input_tokens,
589 output_tokens,
590 duration_ms: None,
591 });
592 }
593 };
594
595 let response = match parser::parse(&text) {
597 Ok(r) => r,
598 Err(_) => {
599 super::synthesize_response_from_text(&text)
602 .ok_or_else(|| AgentError::InvalidResponse { raw: text.clone() })?
603 }
604 };
605
606 Ok(ParsedResponse {
607 response,
608 input_tokens,
609 output_tokens,
610 duration_ms: None,
611 })
612 }
613
614 fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
615 let events = super::parse_ndjson(stdout);
617 if let Some(err) = self.detect_error(&events) {
618 return err;
619 }
620
621 let combined = format!("{stdout}\n{stderr}");
622 super::patterns::classify_from_text(exit_code, &combined)
623 }
624
625 fn free_models(&self) -> Vec<String> {
626 discover_free_opencode_models()
627 }
628
629 fn available_models(&self) -> Vec<String> {
630 vec![
632 "anthropic/claude-sonnet-4-20250514".to_string(),
633 "openai/gpt-4.1".to_string(),
634 ]
635 }
636
637 fn router_command(
638 &self,
639 prompt: &str,
640 model: Option<&str>,
641 ) -> anyhow::Result<tokio::process::Command> {
642 let mut cmd = tokio::process::Command::new("opencode");
648 cmd.env("OPENCODE_CONFIG_CONTENT", NO_SNAPSHOT_CONFIG)
649 .arg("run")
650 .arg("--format")
651 .arg("json")
652 .arg("--dangerously-skip-permissions");
653 if let Some(m) = model {
654 let (base, variant) = Self::split_model_variant(m);
655 cmd.arg("--model").arg(base);
656 if let Some(v) = variant {
657 cmd.arg("--variant").arg(v);
659 }
660 }
661 cmd.arg(prompt);
662 Ok(cmd)
663 }
664}
665
666fn classify_opencode_message(message: &str) -> AgentError {
668 let lower = message.to_lowercase();
669
670 if let Some(e) = super::patterns::detect_rate_limit(message) {
672 return e;
673 }
674
675 if let Some(e) = super::patterns::detect_context_overflow(message) {
677 return e;
678 }
679
680 if let Some(e) = super::patterns::detect_auth_error(message) {
682 return e;
683 }
684
685 if let Some(e) = super::patterns::detect_permission_denied(message) {
687 return e;
688 }
689
690 let is_model_unavailable = (lower.contains("model")
701 && (lower.contains("not found")
702 || lower.contains("not supported")
703 || lower.contains("deprecated")
704 || lower.contains("unavailable for free")
705 || lower.contains("not available in your country")
706 || lower.contains("not available in your region")))
707 || lower.contains("no endpoints found");
708
709 if is_model_unavailable {
710 let model = if lower.contains("no endpoints found") {
713 message
715 .split(" for ")
716 .nth(1)
717 .map(|s| s.trim_end_matches('.').to_string())
718 .unwrap_or_default()
719 } else {
720 let from_colon = message
722 .split(": ")
723 .nth(1)
724 .map(|s| {
725 s.split(". Did you mean")
727 .next()
728 .unwrap_or(s)
729 .trim_end_matches('.')
730 .to_string()
731 })
732 .filter(|s| !s.is_empty());
733
734 let from_transition = if lower.contains("deprecated") {
737 message
738 .split("Transition to ")
739 .nth(1)
740 .and_then(|s| s.split(" for ").next())
741 .map(|s| s.trim_end_matches('.').to_string())
742 .filter(|s| !s.is_empty())
743 } else {
744 None
745 };
746
747 let from_slug_suggestion = if lower.contains("unavailable for free") {
750 message
751 .split("use this slug instead: ")
752 .nth(1)
753 .map(|s| s.trim_end_matches('.').trim().to_string())
754 .filter(|s| !s.is_empty())
755 } else {
756 None
757 };
758
759 from_transition
763 .or(from_slug_suggestion)
764 .or(from_colon)
765 .unwrap_or_default()
766 };
767 return AgentError::ModelUnavailable {
768 message: message.to_string(),
769 model,
770 };
771 }
772
773 if lower.contains("provider returned error") {
778 return AgentError::RateLimit {
779 message: message.to_string(),
780 };
781 }
782
783 if let Some(e) = super::patterns::detect_network_error(message) {
785 return e;
786 }
787
788 AgentError::AgentFailed {
789 message: message.to_string(),
790 }
791}
792
793pub fn discover_free_opencode_models() -> Vec<String> {
802 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
803
804 let now = chrono::Utc::now().timestamp();
805 {
807 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
808 let (ts, models) = &*guard;
809 if *ts != 0 && now.saturating_sub(*ts) < 3600 {
810 return models.clone();
811 }
812 }
813
814 if !FREE_MODELS_REFRESH_IN_PROGRESS.swap(true, Ordering::AcqRel) {
817 let refresh = async {
818 let _guard = scopeguard::guard((), |_| {
819 FREE_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
820 });
821 let (models, empty_reason) = run_opencode_models_discovery_async().await;
822 let raw_count = models.len();
823 let discovered = models
824 .into_iter()
825 .filter(|m| m.to_lowercase().contains("free"))
826 .collect::<Vec<_>>();
827 let empty_reason = if discovered.is_empty() && raw_count > 0 {
828 "no_free_models_in_catalog"
829 } else {
830 empty_reason
831 };
832 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
833 update_discovered_models_cache(cache, discovered, "free", empty_reason);
834 };
835 if let Ok(handle) = tokio::runtime::Handle::try_current() {
836 handle.spawn(refresh);
839 } else {
840 std::thread::spawn(move || {
843 if let Ok(rt) = tokio::runtime::Runtime::new() {
844 rt.block_on(refresh);
845 }
846 });
847 }
848 }
849
850 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
852 guard.1.clone()
853}
854
855pub fn discover_opencode_models() -> Vec<String> {
860 let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
861 let now = chrono::Utc::now().timestamp();
862
863 {
864 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
865 let (ts, models) = &*guard;
866 if *ts != 0 && now.saturating_sub(*ts) < 3600 {
867 return models.clone();
868 }
869 }
870
871 if !ALL_MODELS_REFRESH_IN_PROGRESS.swap(true, Ordering::AcqRel) {
872 let refresh = async {
873 let _guard = scopeguard::guard((), |_| {
874 ALL_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
875 });
876 let (discovered, empty_reason) = run_opencode_models_discovery_async().await;
877 let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
878 update_discovered_models_cache(cache, discovered, "all", empty_reason);
879 };
880 if let Ok(handle) = tokio::runtime::Handle::try_current() {
881 handle.spawn(refresh);
882 } else {
883 std::thread::spawn(move || {
884 if let Ok(rt) = tokio::runtime::Runtime::new() {
885 rt.block_on(refresh);
886 }
887 });
888 }
889 }
890
891 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
892 guard.1.clone()
893}
894
895pub fn prime_free_model_cache() {
903 let discovered_all = std::thread::spawn(|| {
904 if let Ok(rt) = tokio::runtime::Runtime::new() {
905 rt.block_on(run_opencode_models_discovery_async()).0
906 } else {
907 vec![]
908 }
909 })
910 .join()
911 .unwrap_or_default();
912 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
913 let now = chrono::Utc::now().timestamp();
914 if let Ok(mut guard) = cache.lock() {
915 *guard = (
916 now,
917 discovered_all
918 .iter()
919 .filter(|m| m.to_lowercase().contains("free"))
920 .cloned()
921 .collect(),
922 );
923 }
924 let all_cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
925 if let Ok(mut guard) = all_cache.lock() {
926 *guard = (now, discovered_all);
927 }
928}
929
930async fn run_opencode_models_discovery_async() -> (Vec<String>, &'static str) {
939 if !crate::cmd_cache::command_exists("opencode") {
940 tracing::warn!("opencode not in PATH — skipping model discovery");
941 return (vec![], "command_not_found");
942 }
943
944 let mut child = match tokio::process::Command::new("opencode")
945 .args(["models"])
946 .stdout(std::process::Stdio::piped())
947 .stderr(std::process::Stdio::piped())
948 .spawn()
949 {
950 Ok(c) => c,
951 Err(e) => {
952 tracing::warn!(error = %e, "failed to spawn opencode models");
953 return (vec![], "spawn_failed");
954 }
955 };
956
957 let timeout = std::time::Duration::from_secs(30);
958 let start = std::time::Instant::now();
959 loop {
960 match child.try_wait() {
961 Ok(Some(status)) if status.success() => {
962 let stdout = match child.stdout.take() {
964 Some(mut s) => {
965 let mut buf = String::new();
966 use tokio::io::AsyncReadExt;
967 let _ = s.read_to_string(&mut buf).await.ok();
968 buf
969 }
970 None => String::new(),
971 };
972 let models: Vec<String> = stdout
973 .lines()
974 .map(|l| l.trim().to_string())
975 .filter(|l| !l.is_empty())
976 .collect();
977 let reason = if models.is_empty() {
978 "empty_output"
979 } else {
980 "ok"
981 };
982 return (models, reason);
983 }
984 Ok(Some(status)) => {
985 let stderr = match child.stderr.take() {
986 Some(mut s) => {
987 let mut buf = String::new();
988 use tokio::io::AsyncReadExt;
989 let _ = s.read_to_string(&mut buf).await.ok();
990 buf
991 }
992 None => String::new(),
993 };
994 let stderr_trimmed = stderr.trim();
995 let stderr_display = if stderr_trimmed.len() > 500 {
996 format!("{}...", &stderr_trimmed[..500])
997 } else {
998 stderr_trimmed.to_string()
999 };
1000 tracing::warn!(?status, stderr = %stderr_display, "opencode models command failed");
1001 return (vec![], "exit_status_failure");
1002 }
1003 Ok(None) => {
1004 if start.elapsed() > timeout {
1005 tracing::warn!("opencode models timed out after 30s, killing process");
1006 let _ = child.kill().await;
1007 let _ = child.wait().await;
1008 return (vec![], "timeout");
1009 }
1010 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1011 }
1012 Err(e) => {
1013 tracing::warn!(error = %e, "failed to wait on opencode models");
1014 let _ = child.kill().await;
1015 return (vec![], "wait_failed");
1016 }
1017 }
1018 }
1019}
1020
1021fn synthesize_response_from_events(events: &[serde_json::Value]) -> crate::parser::AgentResponse {
1034 use std::collections::BTreeMap;
1035
1036 let mut counts: BTreeMap<String, u32> = BTreeMap::new();
1037 let mut tool_names: Vec<String> = Vec::new();
1038 let mut step_finish_reason: Option<String> = None;
1039
1040 for event in events {
1041 let event_type = event
1042 .get("type")
1043 .and_then(|v| v.as_str())
1044 .unwrap_or("unknown");
1045 *counts.entry(event_type.to_string()).or_insert(0) += 1;
1046
1047 if event_type == "step_finish" {
1048 if let Some(reason) = event
1049 .get("part")
1050 .and_then(|p| p.get("reason"))
1051 .and_then(|r| r.as_str())
1052 {
1053 step_finish_reason = Some(reason.to_string());
1054 }
1055 }
1056
1057 if event_type == "tool_use" {
1059 if let Some(name) = event
1060 .get("name")
1061 .and_then(|v| v.as_str())
1062 .or_else(|| event.get("tool").and_then(|v| v.as_str()))
1063 .or_else(|| {
1064 event
1065 .get("part")
1066 .and_then(|p| p.get("name"))
1067 .and_then(|v| v.as_str())
1068 })
1069 .or_else(|| {
1070 event
1071 .get("tool")
1072 .and_then(|t| t.get("name"))
1073 .and_then(|v| v.as_str())
1074 })
1075 {
1076 tool_names.push(name.to_string());
1077 }
1078 }
1079 }
1080
1081 let total_events = events.len();
1082 let reason = step_finish_reason.as_deref().unwrap_or("unknown");
1083
1084 let tool_summary = if tool_names.is_empty() {
1085 String::new()
1086 } else {
1087 let mut tool_counts: BTreeMap<String, u32> = BTreeMap::new();
1088 for n in &tool_names {
1089 *tool_counts.entry(n.clone()).or_insert(0) += 1;
1090 }
1091 let parts: Vec<String> = tool_counts
1092 .iter()
1093 .map(|(k, v)| {
1094 if *v > 1 {
1095 format!("{k}×{v}")
1096 } else {
1097 k.clone()
1098 }
1099 })
1100 .collect();
1101 format!(" (tools: {})", parts.join(", "))
1102 };
1103
1104 let summary = format!(
1105 "opencode emitted {total_events} event(s) with no text output{tool_summary}; \
1106 step_finish reason: {reason}"
1107 );
1108
1109 crate::parser::AgentResponse {
1110 status: "needs_review".to_string(),
1111 summary,
1112 error: Some("agent emitted no text response — only tool/step events".to_string()),
1113 ..Default::default()
1114 }
1115}
1116
1117pub fn find_opencode_result(ndjson: &str) -> Option<super::AgentResult> {
1125 let events = super::parse_ndjson(ndjson.trim());
1126 if events.is_empty() {
1127 return None;
1128 }
1129
1130 let is_error;
1132 let error_text;
1133 let runner = OpenCodeRunner::new();
1134 if let Some(err) = runner.detect_error(&events) {
1135 is_error = true;
1136 error_text = Some(err.to_string());
1137 } else {
1138 is_error = false;
1139 error_text = None;
1140 }
1141
1142 let result_text = if is_error {
1144 error_text.unwrap_or_default()
1145 } else {
1146 extract_ndjson_text(&events).unwrap_or_default()
1147 };
1148
1149 let (input_tokens, output_tokens) = runner.extract_tokens(&events);
1151
1152 let last_step_finish_reason = events
1161 .iter()
1162 .rev()
1163 .find(|event| event.get("type").and_then(|v| v.as_str()) == Some("step_finish"))
1164 .and_then(|event| {
1165 event
1166 .get("part")?
1167 .get("reason")?
1168 .as_str()
1169 .map(str::to_string)
1170 });
1171 let truncated_by_length = match last_step_finish_reason.as_deref() {
1172 Some("length") => true,
1173 Some("unknown") => output_tokens == Some(0),
1174 _ => false,
1175 };
1176
1177 if result_text.is_empty() && !is_error && !truncated_by_length {
1178 return None;
1179 }
1180
1181 let cost_usd = events.iter().rev().find_map(|event| {
1183 if event.get("type").and_then(|v| v.as_str()) == Some("step_finish") {
1184 event
1185 .get("part")
1186 .and_then(|p| p.get("cost"))
1187 .and_then(|c| c.as_f64())
1188 .filter(|&c| c > 0.0)
1189 } else {
1190 None
1191 }
1192 });
1193
1194 Some(super::AgentResult {
1195 is_error,
1196 result_text,
1197 input_tokens,
1198 output_tokens,
1199 cost_usd,
1200 duration_ms: None,
1201 truncated_by_length,
1202 })
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207 use super::*;
1208
1209 fn runner() -> OpenCodeRunner {
1210 OpenCodeRunner::new()
1211 }
1212
1213 #[test]
1214 fn parse_opencode_ndjson_success() {
1215 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start","snapshot":"..."}}
1216{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"status\":\"done\",\"summary\":\"hello\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}}
1217{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":17512,"input":17509,"output":3}}}"#;
1218
1219 let parsed = runner().parse_response(raw).unwrap();
1220 assert_eq!(parsed.response.status, "done");
1221 assert_eq!(parsed.response.summary, "hello");
1222 assert_eq!(parsed.input_tokens, Some(17509));
1223 assert_eq!(parsed.output_tokens, Some(3));
1224 }
1225
1226 #[test]
1229 fn parse_opencode_ndjson_direct_text_format() {
1230 let raw = r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}
1231{"type":"text","timestamp":1001,"sessionID":"ses_abc","text":"{\"status\":\"done\",\"summary\":\"fixed\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}
1232{"type":"step_finish","timestamp":1002,"sessionID":"ses_abc","part":{"type":"step-finish","reason":"stop","tokens":{"input":200,"output":10}}}"#;
1233
1234 let parsed = runner().parse_response(raw).unwrap();
1235 assert_eq!(parsed.response.status, "done");
1236 assert_eq!(parsed.response.summary, "fixed");
1237 assert_eq!(parsed.input_tokens, Some(200));
1238 assert_eq!(parsed.output_tokens, Some(10));
1239 }
1240
1241 #[test]
1242 fn parse_opencode_concatenated_text() {
1243 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1244{"type":"text","timestamp":1001,"part":{"type":"text","text":"Working on "}}
1245{"type":"text","timestamp":1002,"part":{"type":"text","text":"the task. "}}
1246{"type":"text","timestamp":1003,"part":{"type":"text","text":"{\"status\":\"done\",\"summary\":\"finished\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}}
1247{"type":"step_finish","timestamp":1004,"part":{"type":"step-finish","reason":"stop","tokens":{"input":100,"output":50}}}"#;
1248
1249 let parsed = runner().parse_response(raw).unwrap();
1250 assert_eq!(parsed.response.status, "done");
1251 assert_eq!(parsed.input_tokens, Some(100));
1252 assert_eq!(parsed.output_tokens, Some(50));
1253 }
1254
1255 #[test]
1256 fn extract_router_text_prefers_json_payload() {
1257 let raw = r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"Thinking..."}}
1258{"type":"text","timestamp":1002,"part":{"type":"text","text":"{\"executor\":\"claude\",\"complexity\":\"medium\",\"reason\":\"fit\"}"}}"#;
1259
1260 let text = extract_router_text(raw).unwrap();
1261 let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
1262 assert_eq!(parsed["executor"], "claude");
1263 }
1264
1265 #[test]
1266 fn parse_opencode_embedded_json_fragment_without_executor_is_ignored() {
1267 let raw = r#"{"type":"step_start","timestamp":1}
1272{"type":"text","timestamp":2,"part":{"type":"text","text":"Here is a debug trace: {\"trace\":{\"duration\":123,\"id\":\"abc\"}}"}}
1273{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
1274
1275 let err = runner().parse_response(raw).unwrap_err();
1276 match err {
1277 AgentError::InvalidResponse { raw: _ } => {
1278 }
1280 other => panic!("expected InvalidResponse, got: {other:?}"),
1281 }
1282 }
1283
1284 #[test]
1285 fn parse_opencode_error_event() {
1286 let raw = r#"{"type":"error","message":"rate limit exceeded for this model"}"#;
1287
1288 let err = runner().parse_response(raw).unwrap_err();
1289 assert!(matches!(err, AgentError::RateLimit { .. }));
1290 }
1291
1292 #[test]
1298 fn parse_opencode_tool_only_stream_synthesizes_needs_review() {
1299 let raw = r#"{"type":"step_start","timestamp":1,"part":{"type":"step-start"}}
1300{"type":"tool_use","timestamp":2,"name":"bash","input":{"cmd":"ls"}}
1301{"type":"tool_use","timestamp":3,"name":"read","input":{"path":"src/main.rs"}}
1302{"type":"tool_use","timestamp":4,"name":"bash","input":{"cmd":"cargo check"}}
1303{"type":"step_finish","timestamp":5,"part":{"type":"step-finish","reason":"stop","tokens":{"input":500,"output":0}}}"#;
1304
1305 let parsed = runner()
1306 .parse_response(raw)
1307 .expect("tool-only NDJSON must synthesize a response, not error");
1308 assert_eq!(parsed.response.status, "needs_review");
1309 assert!(
1310 !parsed.response.summary.contains("\"type\":\"step_start\""),
1311 "summary must not contain raw NDJSON; got: {}",
1312 parsed.response.summary
1313 );
1314 assert!(
1315 parsed.response.summary.contains("bash") && parsed.response.summary.contains("read"),
1316 "summary should mention tools used; got: {}",
1317 parsed.response.summary
1318 );
1319 assert!(
1320 parsed.response.summary.contains("stop"),
1321 "summary should mention step_finish reason; got: {}",
1322 parsed.response.summary
1323 );
1324 assert_eq!(parsed.input_tokens, Some(500));
1326 assert_eq!(parsed.output_tokens, Some(0));
1327 }
1328
1329 #[test]
1334 fn parse_opencode_plain_prose_completion_synthesizes_response() {
1335 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1336{"type":"text","timestamp":1001,"part":{"type":"text","text":"Fixed the typo and corrected the deadline reference in the review."}}
1337{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","tokens":{"input":300,"output":40}}}"#;
1338
1339 let parsed = runner()
1340 .parse_response(raw)
1341 .expect("plain-prose completion text must synthesize successfully");
1342 assert_eq!(parsed.response.status, "done");
1343 assert_eq!(parsed.input_tokens, Some(300));
1344 assert_eq!(parsed.output_tokens, Some(40));
1345 }
1346
1347 #[test]
1348 fn parse_opencode_step_finish_error() {
1349 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1350{"type":"step_finish","timestamp":1001,"part":{"type":"step-finish","reason":"error","error":"context length exceeded"}}"#;
1351
1352 let err = runner().parse_response(raw).unwrap_err();
1353 assert!(matches!(err, AgentError::ContextOverflow { .. }));
1354 }
1355
1356 #[test]
1357 fn parse_opencode_empty_returns_silent_failure() {
1358 let err = runner().parse_response("").unwrap_err();
1359 assert!(matches!(err, AgentError::Unknown { exit_code: 0, .. }));
1362 }
1363
1364 #[test]
1365 fn extract_tokens_from_step_finish() {
1366 let r = runner();
1367 let events: Vec<serde_json::Value> = vec![
1368 serde_json::json!({"type":"step_finish","part":{"type":"step-finish","tokens":{"input":1000,"output":500,"total":1500}}}),
1369 ];
1370 let (input, output) = r.extract_tokens(&events);
1371 assert_eq!(input, Some(1000));
1372 assert_eq!(output, Some(500));
1373 }
1374
1375 #[test]
1376 fn classify_opencode_rate_limit() {
1377 let err = classify_opencode_message("429 Too Many Requests");
1378 assert!(matches!(err, AgentError::RateLimit { .. }));
1379 }
1380
1381 #[test]
1382 fn classify_opencode_model_not_found() {
1383 let err = classify_opencode_message("model not found: gpt-5");
1384 assert!(matches!(err, AgentError::ModelUnavailable { .. }));
1385 }
1386
1387 #[test]
1388 fn classify_opencode_model_not_found_with_suggestion() {
1389 let err = classify_opencode_message(
1392 "Model not found: github-copilot/gemini-3.1-pro. Did you mean: gemini-3.1-pro-preview?",
1393 );
1394 match err {
1395 AgentError::ModelUnavailable { model, message } => {
1396 assert_eq!(model, "github-copilot/gemini-3.1-pro");
1397 assert!(message.contains("Did you mean"));
1398 }
1399 other => panic!("expected ModelUnavailable, got: {other:?}"),
1400 }
1401 }
1402
1403 #[test]
1404 fn classify_opencode_model_deprecated() {
1405 let err = classify_opencode_message(
1408 "The free model has been deprecated. Transition to qwen/qwen3.6-plus for continued paid access.",
1409 );
1410 match err {
1411 AgentError::ModelUnavailable { model, .. } => {
1412 assert_eq!(
1413 model, "qwen/qwen3.6-plus",
1414 "expected model extracted from 'Transition to X', got: {model:?}"
1415 );
1416 }
1417 other => panic!("expected ModelUnavailable, got: {other:?}"),
1418 }
1419 }
1420
1421 #[test]
1422 fn classify_opencode_model_unavailable_for_free() {
1423 let err = classify_opencode_message(
1427 "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",
1428 );
1429 match err {
1430 AgentError::ModelUnavailable { model, .. } => {
1431 assert_eq!(
1432 model, "inclusionai/ling-3.0-flash",
1433 "expected model extracted from 'use this slug instead: X', got: {model:?}"
1434 );
1435 }
1436 other => panic!("expected ModelUnavailable, got: {other:?}"),
1437 }
1438 }
1439
1440 #[test]
1441 fn classify_opencode_model_not_available_in_country() {
1442 let err =
1447 classify_opencode_message("agent failed: This model is not available in your country.");
1448 match err {
1449 AgentError::ModelUnavailable { .. } => {}
1450 other => panic!("expected ModelUnavailable, got: {other:?}"),
1451 }
1452 }
1453
1454 #[test]
1455 fn classify_opencode_no_endpoints_found() {
1456 let err = classify_opencode_message("No endpoints found for qwen/qwen3.6-plus:free.");
1458 match err {
1459 AgentError::ModelUnavailable { model, .. } => {
1460 assert_eq!(model, "qwen/qwen3.6-plus:free");
1461 }
1462 other => panic!("expected ModelUnavailable, got: {other:?}"),
1463 }
1464 }
1465
1466 #[test]
1467 fn build_command_opencode() {
1468 let r = runner();
1469 let perms = PermissionRules::default();
1470 let cmd = r.build_command(
1471 Some("anthropic/claude-sonnet-4-20250514"),
1472 "timeout 1800",
1473 "/tmp/sys.txt",
1474 "/tmp/msg.txt",
1475 &perms,
1476 );
1477 assert!(cmd.contains("opencode run"));
1478 assert!(cmd.contains("--model 'anthropic/claude-sonnet-4-20250514'"));
1479 assert!(cmd.contains("--format json"));
1480 assert!(
1481 cmd.starts_with(
1482 r#"OPENCODE_CONFIG_CONTENT='{"snapshot":false}' timeout 1800 opencode run"#
1483 ),
1484 "snapshots must be off for orch runs, got: {cmd}"
1485 );
1486 assert!(
1489 cmd.contains("--dangerously-skip-permissions"),
1490 "expected --dangerously-skip-permissions, got: {cmd}"
1491 );
1492 assert!(
1493 !cmd.contains("opencode.json"),
1494 "should not write a config file, got: {cmd}"
1495 );
1496 assert!(
1497 !cmd.contains("XDG_CONFIG_HOME"),
1498 "should not override XDG_CONFIG_HOME, got: {cmd}"
1499 );
1500 assert!(
1501 !cmd.contains(".orch-opencode"),
1502 "should not reference .orch-opencode, got: {cmd}"
1503 );
1504 assert!(
1511 !cmd.trim_start().starts_with("cat "),
1512 "opencode command must not start with a `cat |` pipe stage, got: {cmd}"
1513 );
1514 assert!(
1515 cmd.contains("< <(cat \"/tmp/sys.txt\" \"/tmp/msg.txt\")"),
1516 "sys/msg files must be fed via process substitution, got: {cmd}"
1517 );
1518 }
1519
1520 #[test]
1521 fn build_command_opencode_supervised_no_skip_flag() {
1522 let r = runner();
1523 let perms = PermissionRules {
1524 autonomous: false,
1525 ..PermissionRules::default()
1526 };
1527 let cmd = r.build_command(
1528 Some("anthropic/claude-sonnet-4-20250514"),
1529 "timeout 1800",
1530 "/tmp/sys.txt",
1531 "/tmp/msg.txt",
1532 &perms,
1533 );
1534 assert!(
1535 !cmd.contains("--dangerously-skip-permissions"),
1536 "supervised mode must not pass --dangerously-skip-permissions, got: {cmd}"
1537 );
1538 }
1539
1540 #[test]
1541 fn split_model_variant_no_at() {
1542 let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5");
1543 assert_eq!(base, "openai/gpt-5.5");
1544 assert!(variant.is_none());
1545 }
1546
1547 #[test]
1548 fn split_model_variant_with_variant() {
1549 let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5@xhigh");
1550 assert_eq!(base, "openai/gpt-5.5");
1551 assert_eq!(variant, Some("xhigh"));
1552 }
1553
1554 #[test]
1555 fn split_model_variant_trailing_at_empty() {
1556 let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5@");
1557 assert_eq!(base, "openai/gpt-5.5");
1558 assert_eq!(variant, Some(""));
1559 }
1560
1561 #[test]
1562 fn split_model_variant_multiple_ats_last_wins() {
1563 let (base, variant) = OpenCodeRunner::split_model_variant("a@b@c@v");
1564 assert_eq!(base, "a@b@c");
1565 assert_eq!(variant, Some("v"));
1566 }
1567
1568 #[test]
1569 fn build_command_opencode_with_variant() {
1570 let r = runner();
1571 let perms = PermissionRules::default();
1572 let cmd = r.build_command(
1573 Some("openai/gpt-5.5@high"),
1574 "timeout 1800",
1575 "/tmp/sys.txt",
1576 "/tmp/msg.txt",
1577 &perms,
1578 );
1579 assert!(cmd.contains("--model 'openai/gpt-5.5'"));
1580 assert!(cmd.contains("--variant 'high'"));
1581 }
1582
1583 #[test]
1584 fn classify_opencode_permission_denied() {
1585 let err = classify_opencode_message("user rejected permission for tool bash: cargo test");
1586 assert!(
1587 matches!(err, AgentError::PermissionDenied { .. }),
1588 "expected PermissionDenied, got: {err:?}"
1589 );
1590 }
1591
1592 #[test]
1593 fn classify_opencode_provider_returned_error() {
1594 let err = classify_opencode_message("Provider returned error");
1598 assert!(
1599 matches!(err, AgentError::RateLimit { .. }),
1600 "expected RateLimit, got: {err:?}"
1601 );
1602 let err_with_detail =
1603 classify_opencode_message("Provider returned error: upstream connection timed out");
1604 assert!(
1605 matches!(err_with_detail, AgentError::RateLimit { .. }),
1606 "expected RateLimit, got: {err_with_detail:?}"
1607 );
1608 }
1609
1610 #[test]
1611 fn classify_opencode_upstream_idle_timeout() {
1612 let err = classify_opencode_message("Upstream idle timeout exceeded");
1615 assert!(
1616 matches!(err, AgentError::NetworkError { .. }),
1617 "expected NetworkError, got: {err:?}"
1618 );
1619 }
1620
1621 #[test]
1622 fn free_models_discovery_returns_vec() {
1623 let models = discover_free_opencode_models();
1626 assert!(models.len() < 1000, "sanity check");
1629 }
1630
1631 #[test]
1632 fn empty_free_discovery_preserves_existing_cache_contents() {
1633 reset_model_caches_for_test();
1634 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1635 {
1636 let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
1637 *guard = (123, vec!["opencode/model-free".to_string()]);
1638 }
1639
1640 update_discovered_models_cache(cache, Vec::new(), "free", "empty_output");
1641
1642 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
1643 assert!(guard.0 > 123);
1646 assert_eq!(guard.1, vec!["opencode/model-free".to_string()]);
1647 }
1648
1649 #[test]
1650 fn empty_all_models_discovery_preserves_existing_cache_contents() {
1651 reset_model_caches_for_test();
1652 let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1653 {
1654 let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
1655 *guard = (
1656 456,
1657 vec![
1658 "opencode/model-free".to_string(),
1659 "opencode/model-paid".to_string(),
1660 ],
1661 );
1662 }
1663
1664 update_discovered_models_cache(cache, Vec::new(), "all", "exit_status_failure");
1665
1666 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
1667 assert!(guard.0 > 456);
1668 assert_eq!(
1669 guard.1,
1670 vec![
1671 "opencode/model-free".to_string(),
1672 "opencode/model-paid".to_string(),
1673 ]
1674 );
1675 }
1676
1677 #[test]
1678 fn empty_discovery_populates_empty_cache() {
1679 reset_model_caches_for_test();
1680 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1681
1682 update_discovered_models_cache(cache, Vec::new(), "free", "command_not_found");
1683
1684 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
1685 assert!(guard.0 > 0);
1686 assert!(guard.1.is_empty());
1687 }
1688
1689 #[test]
1692 fn fixture_opencode_success() {
1693 let raw = include_str!("../../../../tests/fixtures/opencode_success.jsonl");
1694 let parsed = runner().parse_response(raw).unwrap();
1695 assert_eq!(parsed.response.status, "done");
1696 assert!(parsed.response.summary.contains("Fixed"));
1697 assert_eq!(parsed.response.accomplished.len(), 2);
1698 assert_eq!(parsed.input_tokens, Some(17500));
1699 assert_eq!(parsed.output_tokens, Some(500));
1700 }
1701
1702 #[test]
1706 fn fixture_opencode_permission_denied() {
1707 let raw = include_str!("../../../../tests/fixtures/opencode_permission_denied.jsonl");
1708 let err = runner().parse_response(raw).unwrap_err();
1709 assert!(
1710 matches!(err, AgentError::PermissionDenied { .. }),
1711 "expected PermissionDenied, got: {err:?}"
1712 );
1713 }
1714
1715 #[test]
1717 fn fixture_opencode_rate_limit() {
1718 let raw = include_str!("../../../../tests/fixtures/opencode_rate_limit.jsonl");
1719 let err = runner().parse_response(raw).unwrap_err();
1720 assert!(
1721 matches!(err, AgentError::RateLimit { .. }),
1722 "expected RateLimit, got: {err:?}"
1723 );
1724 }
1725
1726 #[test]
1730 fn fixture_opencode_model_not_found() {
1731 let raw = include_str!("../../../../tests/fixtures/opencode_model_not_found.jsonl");
1732 let err = runner().parse_response(raw).unwrap_err();
1733 assert!(
1734 matches!(err, AgentError::ModelUnavailable { .. }),
1735 "expected ModelUnavailable, got: {err:?}"
1736 );
1737 if let AgentError::ModelUnavailable { model, .. } = &err {
1738 assert_eq!(model, "anthropic/claude-sonnet-4-6");
1739 }
1740 }
1741
1742 #[test]
1745 fn fixture_opencode_provider_returned_error() {
1746 let raw = include_str!("../../../../tests/fixtures/opencode_provider_returned_error.jsonl");
1747 let err = runner().parse_response(raw).unwrap_err();
1748 assert!(
1749 matches!(err, AgentError::RateLimit { .. }),
1750 "expected RateLimit, got: {err:?}"
1751 );
1752 }
1753
1754 #[test]
1758 fn extract_text_returns_text_events() {
1759 let raw = concat!(
1760 r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1761 "\n",
1762 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
1763 "\n",
1764 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":100,"input":90,"output":10}}}"#,
1765 );
1766 let text = runner().extract_text(raw).unwrap();
1767 assert_eq!(
1768 text,
1769 r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#
1770 );
1771 }
1772
1773 #[test]
1775 fn extract_text_direct_text_event_format() {
1776 let raw = concat!(
1777 r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1778 "\n",
1779 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"}"#,
1780 "\n",
1781 r#"{"type":"step_finish","timestamp":1002,"sessionID":"ses_abc"}"#,
1782 );
1783 let text = runner().extract_text(raw).unwrap();
1784 assert!(
1785 text.contains("request_changes"),
1786 "expected review JSON, got: {text}"
1787 );
1788 }
1789
1790 #[test]
1792 fn extract_text_rate_limit_propagates() {
1793 let raw = concat!(
1794 r#"{"type":"step_start","timestamp":1000}"#,
1795 "\n",
1796 r#"{"type":"error","message":"rate limit exceeded: 429 Too Many Requests"}"#,
1797 );
1798 let err = runner().extract_text(raw).unwrap_err();
1799 assert!(
1800 matches!(err, AgentError::RateLimit { .. }),
1801 "expected RateLimit, got: {err:?}"
1802 );
1803 }
1804
1805 #[test]
1807 fn extract_text_auth_error_propagates() {
1808 let raw = concat!(
1809 r#"{"type":"step_start","timestamp":1000}"#,
1810 "\n",
1811 r#"{"type":"error","message":"401 Unauthorized: invalid api key"}"#,
1812 );
1813 let err = runner().extract_text(raw).unwrap_err();
1814 assert!(
1815 matches!(err, AgentError::Auth { .. }),
1816 "expected Auth, got: {err:?}"
1817 );
1818 }
1819
1820 #[test]
1822 fn extract_text_empty_input_returns_empty_ok() {
1823 let text = runner().extract_text("").unwrap();
1824 assert!(text.is_empty());
1825 }
1826
1827 #[test]
1830 fn find_opencode_result_success_with_tokens() {
1831 let ndjson = concat!(
1832 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1833 "\n",
1834 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\"}"}}"#,
1835 "\n",
1836 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0.05,"tokens":{"input":5000,"output":200}}}"#,
1837 );
1838 let result = find_opencode_result(ndjson).expect("should find result");
1839 assert!(!result.is_error);
1840 assert!(result.result_text.contains("approve"));
1841 assert_eq!(result.input_tokens, Some(5000));
1842 assert_eq!(result.output_tokens, Some(200));
1843 assert_eq!(result.cost_usd, Some(0.05));
1844 assert!(!result.truncated_by_length);
1845 }
1846
1847 #[test]
1848 fn find_opencode_result_error_event() {
1849 let ndjson = r#"{"type":"error","message":"rate limit exceeded: 429"}"#;
1850 let result = find_opencode_result(ndjson).expect("should find error result");
1851 assert!(result.is_error);
1852 assert!(result.result_text.contains("rate limit"));
1853 assert!(!result.truncated_by_length);
1854 }
1855
1856 #[test]
1860 fn find_opencode_result_length_truncation() {
1861 let ndjson = concat!(
1862 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1863 "\n",
1864 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":"}}"#,
1865 "\n",
1866 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"length","tokens":{"input":5485,"output":759,"reasoning":31241}}}"#,
1867 );
1868 let result = find_opencode_result(ndjson).expect("should find result");
1869 assert!(result.truncated_by_length);
1870 assert!(!result.is_error, "length truncation is not a hard error");
1871 }
1872
1873 #[test]
1879 fn find_opencode_result_zero_output_length_truncation() {
1880 let ndjson = concat!(
1881 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1882 "\n",
1883 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"length","tokens":{"total":71482,"input":39482,"output":0,"reasoning":40000}}}"#,
1884 );
1885 let result = find_opencode_result(ndjson).expect("should find result, not None");
1886 assert!(result.truncated_by_length);
1887 assert!(!result.is_error);
1888 assert!(result.result_text.is_empty());
1889 }
1890
1891 #[test]
1894 fn find_opencode_result_zero_output_unknown_reason_truncation() {
1895 let ndjson = concat!(
1896 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1897 "\n",
1898 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"unknown","tokens":{"input":0,"output":0,"reasoning":0}}}"#,
1899 );
1900 let result = find_opencode_result(ndjson).expect("should find result, not None");
1901 assert!(result.truncated_by_length);
1902 assert!(!result.is_error);
1903 assert!(result.result_text.is_empty());
1904 }
1905
1906 #[test]
1910 fn find_opencode_result_unknown_reason_with_output_not_truncated() {
1911 let ndjson = concat!(
1912 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1913 "\n",
1914 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"partial answer"}}"#,
1915 "\n",
1916 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"unknown","tokens":{"input":100,"output":50,"reasoning":0}}}"#,
1917 );
1918 let result = find_opencode_result(ndjson).expect("should find result");
1919 assert!(!result.truncated_by_length);
1920 assert!(!result.is_error);
1921 }
1922
1923 #[test]
1924 fn find_opencode_result_empty_returns_none() {
1925 assert!(find_opencode_result("").is_none());
1926 assert!(find_opencode_result(" ").is_none());
1927 }
1928
1929 #[test]
1930 fn find_opencode_result_direct_text_format() {
1931 let ndjson = concat!(
1932 r#"{"type":"step_start","timestamp":1000}"#,
1933 "\n",
1934 r#"{"type":"text","timestamp":1001,"text":"Review complete. All tests passed."}"#,
1935 "\n",
1936 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","tokens":{"input":100,"output":10}}}"#,
1937 );
1938 let result = find_opencode_result(ndjson).expect("should find result");
1939 assert!(!result.is_error);
1940 assert!(result.result_text.contains("All tests passed"));
1941 }
1942
1943 #[test]
1944 fn find_opencode_result_plain_text_returns_none() {
1945 assert!(find_opencode_result("just some plain text").is_none());
1946 }
1947}
1948
1949#[cfg(test)]
1950mod error_text_tests {
1951 use super::*;
1952 use serde_json::json;
1953
1954 #[test]
1955 fn unrecognized_error_shape_keeps_raw_json() {
1956 let e = json!({"type":"error","error":{"code":429,"detail":"slow down"}});
1957 assert!(opencode_error_text(&e).contains("slow down"));
1958 let e = json!({"type":"error"});
1959 assert!(opencode_error_text(&e).contains("\"type\""));
1960 }
1961
1962 #[test]
1963 fn known_shapes_still_extract() {
1964 assert_eq!(opencode_error_text(&json!({"message":"a"})), "a");
1965 assert_eq!(opencode_error_text(&json!({"error":"b"})), "b");
1966 assert_eq!(opencode_error_text(&json!({"error":{"message":"c"}})), "c");
1967 assert_eq!(
1968 opencode_error_text(&json!({"error":{"name":"N","data":{"message":"d"}}})),
1969 "d"
1970 );
1971 }
1972}
1973
1974fn stream_events(v: &serde_json::Value) -> Vec<StreamEvent> {
1976 let text = || {
1977 v.pointer("/part/text")
1978 .or_else(|| v.get("text"))
1979 .and_then(serde_json::Value::as_str)
1980 .unwrap_or("")
1981 .to_string()
1982 };
1983 match json_str(v, "/type") {
1984 "text" => vec![StreamEvent::Text(text())],
1985 "reasoning" => vec![StreamEvent::Thinking(text())],
1986 "tool_use" => {
1987 let part = &v["part"];
1988 let state = &part["state"];
1989 let id = json_str(part, "/callID").to_string();
1990 let name = json_str(part, "/tool").to_string();
1991 let mut out = vec![StreamEvent::ToolCall {
1992 id: id.clone(),
1993 name: name.clone(),
1994 input: state["input"].clone(),
1995 }];
1996 let result = match json_str(state, "/status") {
1997 "completed" => Some((json_str(state, "/output").to_string(), false)),
1998 "error" => Some((json_error_message(state), true)),
1999 _ => None,
2000 };
2001 if let Some((output, is_error)) = result {
2002 out.push(StreamEvent::ToolResult {
2003 id,
2004 name,
2005 output,
2006 is_error,
2007 });
2008 }
2009
2010 out
2011 }
2012 "step_finish" if json_str(v, "/part/reason") == "stop" => {
2013 vec![StreamEvent::Done(String::new())]
2014 }
2015 "error" => vec![StreamEvent::Error(json_error_message(v))],
2016 _ => vec![],
2017 }
2018}
2019
2020#[cfg(test)]
2021mod stream_tests {
2022 use super::*;
2023 use serde_json::json;
2024
2025 fn events(line: &str) -> Vec<StreamEvent> {
2026 OpenCodeRunner::new().stream_events(&serde_json::from_str(line).unwrap())
2027 }
2028
2029 #[test]
2030 fn text_in_part_or_top_level() {
2031 assert_eq!(
2032 events(r#"{"type":"text","part":{"type":"text","text":"Reviewing."}}"#),
2033 vec![StreamEvent::Text("Reviewing.".into())]
2034 );
2035 assert_eq!(
2036 events(r#"{"type":"text","text":"Plain."}"#),
2037 vec![StreamEvent::Text("Plain.".into())]
2038 );
2039 }
2040
2041 #[test]
2042 fn completed_tool_has_call_and_result() {
2043 let line = r#"{"type":"tool_use","part":{"type":"tool","tool":"read","callID":"c1","state":{"status":"completed","input":{"filePath":"src/lib.rs"},"output":"a\nb"}}}"#;
2044 assert_eq!(
2045 events(line),
2046 vec![
2047 StreamEvent::ToolCall {
2048 id: "c1".into(),
2049 name: "read".into(),
2050 input: json!({"filePath": "src/lib.rs"}),
2051 },
2052 StreamEvent::ToolResult {
2053 id: "c1".into(),
2054 name: "read".into(),
2055 output: "a\nb".into(),
2056 is_error: false,
2057 },
2058 ]
2059 );
2060 }
2061
2062 #[test]
2063 fn failed_tool_result_is_error() {
2064 let line = r#"{"type":"tool_use","part":{"tool":"bash","callID":"c2","state":{"status":"error","input":{"command":"x"},"error":"not found"}}}"#;
2065 assert_eq!(
2066 events(line)[1],
2067 StreamEvent::ToolResult {
2068 id: "c2".into(),
2069 name: "bash".into(),
2070 output: "not found".into(),
2071 is_error: true,
2072 }
2073 );
2074 }
2075
2076 #[test]
2077 fn only_final_step_is_done() {
2078 assert!(events(r#"{"type":"step_finish","part":{"reason":"tool-calls"}}"#).is_empty());
2079 assert_eq!(
2080 events(r#"{"type":"step_finish","part":{"reason":"stop"}}"#),
2081 vec![StreamEvent::Done(String::new())]
2082 );
2083 assert!(events(r#"{"type":"step_start"}"#).is_empty());
2084 }
2085
2086 #[test]
2087 fn error_event() {
2088 let line = r#"{"type":"error","error":{"name":"UnknownError","data":{"message":"Provider returned error"}}}"#;
2089 assert_eq!(
2090 events(line),
2091 vec![StreamEvent::Error("Provider returned error".into())]
2092 );
2093 }
2094}