1use super::{
28 AgentError, AgentRunner, ParsedResponse, PermissionRules, StreamEvent, json_error_message,
29 json_str,
30};
31use crate::cmd_cache;
32use crate::parser;
33use crate::parser::AgentResponse;
34
35use std::path::Path;
36use std::sync::atomic::{AtomicBool, Ordering};
37use std::sync::{Mutex, OnceLock};
38
39use tokio::process::Command;
40use tokio::runtime::Handle;
41use tokio::runtime::Runtime;
42use tokio::time;
43
44use std::collections::BTreeMap;
45use std::process::Stdio;
46use std::sync::PoisonError;
47use std::thread;
48use std::time::Duration;
49
50static FREE_MODELS_CACHE: OnceLock<Mutex<(i64, Vec<String>)>> = OnceLock::new();
53static FREE_MODELS_REFRESH_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
54static ALL_MODELS_CACHE: OnceLock<Mutex<(i64, Vec<String>)>> = OnceLock::new();
57static ALL_MODELS_REFRESH_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
58
59#[cfg(test)]
60pub(crate) fn reset_model_caches_for_test() {
61 if let Some(cache) = FREE_MODELS_CACHE.get()
62 && let Ok(mut guard) = cache.lock()
63 {
64 *guard = (0, Vec::new());
65 }
66 if let Some(cache) = ALL_MODELS_CACHE.get()
67 && let Ok(mut guard) = cache.lock()
68 {
69 *guard = (0, Vec::new());
70 }
71 FREE_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
72 ALL_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
73}
74
75fn skip_live_opencode_discovery() -> bool {
84 cfg!(any(test, debug_assertions))
85}
86
87fn update_discovered_models_cache(
88 cache: &Mutex<(i64, Vec<String>)>,
89 discovered: Vec<String>,
90 cache_name: &'static str,
91 empty_reason: &'static str,
92) {
93 let now = chrono::Utc::now().timestamp();
94 let mut guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
95
96 if discovered.is_empty() && !guard.1.is_empty() {
97 tracing::warn!(
98 cache = cache_name,
99 cached_models = guard.1.len(),
100 reason = empty_reason,
101 "opencode model discovery returned empty; preserving previous cache contents"
102 );
103 guard.0 = now;
107 return;
108 }
109
110 tracing::debug!(
111 cache = cache_name,
112 discovered_models = discovered.len(),
113 "opencode model discovery refreshed cache"
114 );
115 *guard = (now, discovered);
116}
117
118#[derive(Debug)]
120pub struct OpenCodeRunner;
121
122pub(crate) fn extract_ndjson_text(events: &[serde_json::Value]) -> Option<String> {
123 let mut texts = Vec::new();
124
125 for event in events {
126 let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
127
128 if event_type == "text" || event_type == "message" || event_type == "assistant" {
134 if let Some(part) = event.get("part") {
135 texts.extend(extract_text_from_part(part));
136 }
137
138 if let Some(text) = event.get("text").and_then(|v| v.as_str()) {
140 texts.push(text.to_string());
141 }
142
143 if let Some(message) = event.get("message")
145 && let Some(items) = message.get("content").and_then(|v| v.as_array())
146 {
147 for item in items {
148 let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
149 if item_type == "text"
150 && let Some(text) = item.get("text").and_then(|v| v.as_str())
151 {
152 texts.push(text.to_string());
153 }
154 }
155 }
156
157 continue;
158 }
159
160 if let Some(part) = event.get("part") {
162 let part_type = part.get("type").and_then(|v| v.as_str()).unwrap_or("");
163 if part_type == "text" || part_type == "output_text" || part_type == "message" {
164 texts.extend(extract_text_from_part(part));
165 }
166 }
167 }
168
169 if texts.is_empty() {
172 for event in events.iter().rev() {
173 if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
174 if event_type == "system" {
176 continue;
177 }
178 }
179
180 if let Some(result) = event.get("result") {
181 if let Some(s) = result.as_str() {
183 return Some(s.to_string());
184 }
185 if result.is_object() || result.is_array() {
186 return Some(result.to_string());
187 }
188 }
189
190 if let Some(text) = event.get("text").and_then(|v| v.as_str())
192 && !text.trim().is_empty()
193 {
194 return Some(text.to_string());
195 }
196 }
197
198 return None;
199 }
200
201 let full = texts.join("");
202 if let Some(json) = extract_json_object(&full) {
203 if parser::parse(&json).is_ok() {
205 return Some(json);
206 }
207
208 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&json)
211 && let Some(obj) = val.as_object()
212 && (obj.contains_key("executor")
213 || obj.contains_key("status")
214 || obj.contains_key("complexity")
215 || obj.contains_key("decision"))
216 {
217 return Some(json);
218 }
219 }
221
222 for text in texts.iter().rev() {
223 let trimmed = text.trim();
224 if let Some(json) = extract_json_object(trimmed) {
225 if parser::parse(&json).is_ok() {
229 return Some(json);
230 }
231
232 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&json)
235 && let Some(obj) = val.as_object()
236 && (obj.contains_key("executor") || obj.contains_key("status"))
237 {
238 return Some(json);
239 }
240 }
241 if trimmed.contains('{') {
245 if parser::parse(trimmed).is_ok() {
250 return Some(trimmed.to_string());
251 }
252
253 if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed)
254 && let Some(obj) = val.as_object()
255 && (obj.contains_key("executor")
256 || obj.contains_key("status")
257 || obj.contains_key("complexity"))
258 {
259 return Some(trimmed.to_string());
260 }
261 }
262 }
263
264 Some(full)
265}
266
267fn extract_text_from_part(part: &serde_json::Value) -> Vec<String> {
268 let mut out = Vec::new();
269
270 if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
271 out.push(text.to_string());
272 }
273
274 if let Some(items) = part.get("content").and_then(|v| v.as_array()) {
276 for item in items {
277 let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
278 if item_type == "text"
279 && let Some(text) = item.get("text").and_then(|v| v.as_str())
280 {
281 out.push(text.to_string());
282 }
283 }
284 }
285
286 out
287}
288
289fn extract_json_object(text: &str) -> Option<String> {
290 let trimmed = text.trim();
291 if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
292 return Some(trimmed.to_string());
293 }
294
295 let start = trimmed.find('{')?;
296 let end = trimmed.rfind('}')?;
297 if end <= start {
298 return None;
299 }
300
301 let candidate = trimmed[start..=end].trim();
302 serde_json::from_str::<serde_json::Value>(candidate)
303 .ok()
304 .map(|_| candidate.to_string())
305}
306
307pub(crate) fn extract_router_text(raw: &str) -> Option<String> {
308 let events = super::parse_ndjson(raw.trim());
309 if events.is_empty() {
310 return None;
311 }
312 extract_ndjson_text(&events)
313}
314
315#[cfg(test)]
316mod router_text_tests {
317 use super::*;
318
319 #[test]
320 fn extract_router_text_supports_message_with_content_array() {
321 let raw = r#"{"type":"step_start","timestamp":1}
323{"type":"message","timestamp":2,"part":{"type":"message","content":[{"type":"text","text":"```json\n{\"executor\":\"opencode\",\"complexity\":\"medium\",\"reason\":\"ok\"}\n```"}]}}
324{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
325 let text = extract_router_text(raw).expect("should extract text");
326 assert!(
327 text.contains("executor"),
328 "extracted text should include JSON"
329 );
330 }
331
332 #[test]
333 fn extract_router_text_ignores_leading_system_init() {
334 let raw = r#"{"type":"system","subtype":"init","cwd":"/"}
338{"type":"text","timestamp":2,"text":"{\"executor\":\"opencode\",\"complexity\":\"medium\",\"reason\":\"ndjson\"}"}
339{"type":"step_finish","timestamp":3}"#;
340
341 let text = extract_router_text(raw).expect("should extract text even with leading init");
342 let parsed: serde_json::Value =
343 serde_json::from_str(&text).expect("extracted text must be JSON");
344 assert_eq!(parsed["executor"], "opencode");
345 assert_eq!(parsed["complexity"], "medium");
346 }
347}
348
349const NO_SNAPSHOT_CONFIG: &str = r#"{"snapshot":false}"#;
352
353impl OpenCodeRunner {
354 pub fn new() -> Self {
355 Self
356 }
357
358 pub(crate) fn split_model_variant(s: &str) -> (&str, Option<&str>) {
365 if let Some(pos) = s.rfind('@') {
366 let base = &s[..pos];
367 let variant = &s[pos + 1..];
368 (base, Some(variant))
369 } else {
370 (s, None)
371 }
372 }
373
374 fn extract_text_from_events(events: &[serde_json::Value]) -> Option<String> {
385 extract_ndjson_text(events)
386 }
387
388 fn extract_tokens(events: &[serde_json::Value]) -> (Option<u64>, Option<u64>) {
390 for event in events.iter().rev() {
391 let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
392
393 if event_type == "step_finish"
394 && let Some(part) = event.get("part")
395 && let Some(tokens) = part.get("tokens").and_then(|v| v.as_object())
396 {
397 let input = tokens.get("input").and_then(serde_json::Value::as_u64);
398 let output = tokens.get("output").and_then(serde_json::Value::as_u64);
399 return (input, output);
400 }
401 }
402
403 (None, None)
404 }
405
406 fn detect_error(events: &[serde_json::Value]) -> Option<AgentError> {
408 super::patterns::detect_ndjson_error(
409 events,
410 |event| {
411 let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
412 match event_type {
413 "error" => Some(opencode_error_text(event)),
414 "step_finish" => {
415 let part = event.get("part")?;
416 let reason = part.get("reason").and_then(|v| v.as_str()).unwrap_or("");
417 if reason == "error" || reason == "failed" {
418 Some(
419 part.get("error")
420 .map_or_else(|| "step failed".to_string(), error_value_text),
421 )
422 } else {
423 None
424 }
425 }
426 _ => None,
427 }
428 },
429 classify_opencode_message,
430 )
431 }
432}
433
434fn error_value_text(v: &serde_json::Value) -> String {
436 if let Some(s) = v.as_str() {
437 return s.to_string();
438 }
439 v.get("data")
440 .and_then(|d| d.get("message").or(Some(d)))
441 .and_then(|m| m.as_str())
442 .or_else(|| v.get("message").and_then(|m| m.as_str()))
443 .or_else(|| v.get("name").and_then(|n| n.as_str()))
444 .map_or_else(|| v.to_string(), str::to_string)
445}
446
447fn opencode_error_text(event: &serde_json::Value) -> String {
449 if let Some(s) = event.get("message").and_then(|v| v.as_str()) {
450 return s.to_string();
451 }
452 if let Some(e) = event.get("error") {
453 let text = error_value_text(e);
454 if e.is_object() && text == *e {
455 tracing::warn!(event = %event, "unrecognized opencode error event shape");
456 }
457 text
458 } else {
459 tracing::warn!(event = %event, "unrecognized opencode error event shape");
460 event.to_string()
461 }
462}
463
464impl AgentRunner for OpenCodeRunner {
465 #[cfg(test)]
466 fn name(&self) -> &'static str {
467 "opencode"
468 }
469
470 fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent> {
471 stream_events(event)
472 }
473
474 fn router_transcript(&self) -> super::RouterTranscript {
475 super::RouterTranscript::OpenCode
476 }
477
478 fn extract_text(&self, raw: &str) -> Result<String, AgentError> {
479 let trimmed = raw.trim();
480 if trimmed.is_empty() {
481 return Ok(String::new());
482 }
483
484 let events = super::parse_ndjson(trimmed);
485 if events.is_empty() {
486 return Ok(trimmed.to_string());
487 }
488
489 if let Some(err) = Self::detect_error(&events) {
491 return Err(err);
492 }
493
494 Ok(extract_ndjson_text(&events).unwrap_or_else(|| trimmed.to_string()))
496 }
497
498 fn build_command(
499 &self,
500 model: Option<&str>,
501 timeout_cmd: &str,
502 sys_file: &str,
503 msg_file: &str,
504 permissions: &PermissionRules,
505 ) -> String {
506 let (model_flag, variant_flag) = if let Some(m) = model {
509 let (base, variant) = Self::split_model_variant(m);
510 let mf = format!("--model {}", super::shell_single_quote(base));
511 let vf = variant.map(|v| format!("--variant {}", super::shell_single_quote(v)));
512 (mf, vf)
513 } else {
514 (String::new(), None)
515 };
516
517 let skip_perms = if permissions.autonomous {
530 "--dangerously-skip-permissions "
531 } else {
532 ""
533 };
534
535 let variant_part = variant_flag.unwrap_or_default();
537 format!(
542 r#"OPENCODE_CONFIG_CONTENT={config} {timeout_cmd} opencode run {model_flag} {variant_part} {skip_perms}\
543 --format json - < <(cat "{sys_file}" "{msg_file}")"#,
544 config = super::shell_single_quote(NO_SNAPSHOT_CONFIG),
545 sys_file = sys_file,
546 msg_file = msg_file,
547 timeout_cmd = timeout_cmd,
548 model_flag = model_flag,
549 variant_part = variant_part,
550 skip_perms = skip_perms,
551 )
552 }
553
554 fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
555 let trimmed = raw.trim();
556 if trimmed.is_empty() {
560 return Err(AgentError::Unknown {
561 exit_code: 0,
562 message: "empty-output-exit0: opencode returned exit 0 with empty stdout"
563 .to_string(),
564 });
565 }
566
567 let events = super::parse_ndjson(trimmed);
568
569 if events.is_empty() {
570 if let Ok(resp) = parser::parse(trimmed) {
572 return Ok(ParsedResponse {
573 response: resp,
574 input_tokens: None,
575 output_tokens: None,
576 cost_usd: None,
577 duration_ms: None,
578 });
579 }
580 return Err(AgentError::InvalidResponse {
581 raw: trimmed.to_string(),
582 });
583 }
584
585 if let Some(err) = Self::detect_error(&events) {
587 return Err(err);
588 }
589
590 let (input_tokens, output_tokens) = Self::extract_tokens(&events);
592
593 let Some(text) = Self::extract_text_from_events(&events) else {
600 let response = synthesize_response_from_events(&events);
601 return Ok(ParsedResponse {
602 response,
603 input_tokens,
604 output_tokens,
605 cost_usd: extract_cost(&events),
606 duration_ms: None,
607 });
608 };
609
610 let response = match parser::parse(&text) {
612 Ok(r) => r,
613 Err(_) => {
614 super::synthesize_response_from_text(&text)
617 .ok_or_else(|| AgentError::InvalidResponse { raw: text.clone() })?
618 }
619 };
620
621 Ok(ParsedResponse {
622 response,
623 input_tokens,
624 output_tokens,
625 cost_usd: extract_cost(&events),
626 duration_ms: None,
627 })
628 }
629
630 fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
631 let events = super::parse_ndjson(stdout);
633 if let Some(err) = Self::detect_error(&events) {
634 return err;
635 }
636
637 let combined = format!("{stdout}\n{stderr}");
638 super::patterns::classify_from_text(exit_code, &combined)
639 }
640
641 fn free_models(&self) -> Vec<String> {
642 discover_free_opencode_models()
643 }
644
645 fn available_models(&self) -> Vec<String> {
646 vec![
648 "anthropic/claude-sonnet-4-20250514".to_string(),
649 "openai/gpt-4.1".to_string(),
650 ]
651 }
652
653 fn router_command(&self, prompt: &str, model: Option<&str>) -> anyhow::Result<Command> {
654 let mut cmd = Command::new("opencode");
660 cmd.env("OPENCODE_CONFIG_CONTENT", NO_SNAPSHOT_CONFIG)
661 .arg("run")
662 .arg("--format")
663 .arg("json")
664 .arg("--dangerously-skip-permissions");
665 if let Some(m) = model {
666 let (base, variant) = Self::split_model_variant(m);
667 cmd.arg("--model").arg(base);
668 if let Some(v) = variant {
669 cmd.arg("--variant").arg(v);
671 }
672 }
673 cmd.arg(prompt);
674 Ok(cmd)
675 }
676}
677
678fn classify_opencode_message(message: &str) -> AgentError {
680 let lower = message.to_lowercase();
681
682 if let Some(e) = super::patterns::detect_rate_limit(message) {
684 return e;
685 }
686
687 if let Some(e) = super::patterns::detect_context_overflow(message) {
689 return e;
690 }
691
692 if let Some(e) = super::patterns::detect_auth_error(message) {
694 return e;
695 }
696
697 if let Some(e) = super::patterns::detect_permission_denied(message) {
699 return e;
700 }
701
702 let is_model_unavailable = (lower.contains("model")
713 && (lower.contains("not found")
714 || lower.contains("not supported")
715 || lower.contains("deprecated")
716 || lower.contains("unavailable for free")
717 || lower.contains("not available in your country")
718 || lower.contains("not available in your region")))
719 || lower.contains("no endpoints found");
720
721 if is_model_unavailable {
722 let model = if lower.contains("no endpoints found") {
725 message
727 .split(" for ")
728 .nth(1)
729 .map(|s| s.trim_end_matches('.').to_string())
730 .unwrap_or_default()
731 } else {
732 let from_colon = message
734 .split(": ")
735 .nth(1)
736 .map(|s| {
737 s.split(". Did you mean")
739 .next()
740 .unwrap_or(s)
741 .trim_end_matches('.')
742 .to_string()
743 })
744 .filter(|s| !s.is_empty());
745
746 let from_transition = if lower.contains("deprecated") {
749 message
750 .split("Transition to ")
751 .nth(1)
752 .and_then(|s| s.split(" for ").next())
753 .map(|s| s.trim_end_matches('.').to_string())
754 .filter(|s| !s.is_empty())
755 } else {
756 None
757 };
758
759 let from_slug_suggestion = if lower.contains("unavailable for free") {
762 message
763 .split("use this slug instead: ")
764 .nth(1)
765 .map(|s| s.trim_end_matches('.').trim().to_string())
766 .filter(|s| !s.is_empty())
767 } else {
768 None
769 };
770
771 from_transition
775 .or(from_slug_suggestion)
776 .or(from_colon)
777 .unwrap_or_default()
778 };
779 return AgentError::ModelUnavailable {
780 message: message.to_string(),
781 model,
782 };
783 }
784
785 if lower.contains("provider returned error") {
790 return AgentError::RateLimit {
791 message: message.to_string(),
792 };
793 }
794
795 if let Some(e) = super::patterns::detect_network_error(message) {
797 return e;
798 }
799
800 AgentError::AgentFailed {
801 message: message.to_string(),
802 }
803}
804
805pub fn discover_free_opencode_models() -> Vec<String> {
814 if skip_live_opencode_discovery() {
815 return Vec::new();
816 }
817 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
818
819 let now = chrono::Utc::now().timestamp();
820 {
822 let guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
823 let (ts, models) = &*guard;
824 if *ts != 0 && now.saturating_sub(*ts) < 3600 {
825 return models.clone();
826 }
827 }
828
829 if !FREE_MODELS_REFRESH_IN_PROGRESS.swap(true, Ordering::AcqRel) {
832 let refresh = async {
833 let _guard = scopeguard::guard((), |()| {
834 FREE_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
835 });
836 let (models, empty_reason) = run_opencode_models_discovery_async().await;
837 let raw_count = models.len();
838 let discovered = models
839 .into_iter()
840 .filter(|m| m.to_lowercase().contains("free"))
841 .collect::<Vec<_>>();
842 let empty_reason = if discovered.is_empty() && raw_count > 0 {
843 "no_free_models_in_catalog"
844 } else {
845 empty_reason
846 };
847 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
848 update_discovered_models_cache(cache, discovered, "free", empty_reason);
849 };
850 match Handle::try_current() {
851 Ok(handle) => {
852 handle.spawn(refresh);
855 }
856 _ => {
857 thread::spawn(move || {
860 if let Ok(rt) = Runtime::new() {
861 rt.block_on(refresh);
862 }
863 });
864 }
865 }
866 }
867
868 let guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
870 guard.1.clone()
871}
872
873pub fn discover_opencode_models() -> Vec<String> {
878 if skip_live_opencode_discovery() {
879 return Vec::new();
880 }
881 let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
882 let now = chrono::Utc::now().timestamp();
883
884 {
885 let guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
886 let (ts, models) = &*guard;
887 if *ts != 0 && now.saturating_sub(*ts) < 3600 {
888 return models.clone();
889 }
890 }
891
892 if !ALL_MODELS_REFRESH_IN_PROGRESS.swap(true, Ordering::AcqRel) {
893 let refresh = async {
894 let _guard = scopeguard::guard((), |()| {
895 ALL_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
896 });
897 let (discovered, empty_reason) = run_opencode_models_discovery_async().await;
898 let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
899 update_discovered_models_cache(cache, discovered, "all", empty_reason);
900 };
901 match Handle::try_current() {
902 Ok(handle) => {
903 handle.spawn(refresh);
904 }
905 _ => {
906 thread::spawn(move || {
907 if let Ok(rt) = Runtime::new() {
908 rt.block_on(refresh);
909 }
910 });
911 }
912 }
913 }
914
915 let guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
916 guard.1.clone()
917}
918
919pub fn prime_free_model_cache() {
927 if skip_live_opencode_discovery() {
928 return;
929 }
930 let discovered_all = thread::spawn(|| match Runtime::new() {
931 Ok(rt) => rt.block_on(run_opencode_models_discovery_async()).0,
932 _ => {
933 vec![]
934 }
935 })
936 .join()
937 .unwrap_or_default();
938 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
939 let now = chrono::Utc::now().timestamp();
940 if let Ok(mut guard) = cache.lock() {
941 *guard = (
942 now,
943 discovered_all
944 .iter()
945 .filter(|m| m.to_lowercase().contains("free"))
946 .cloned()
947 .collect(),
948 );
949 }
950 let all_cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
951 if let Ok(mut guard) = all_cache.lock() {
952 *guard = (now, discovered_all);
953 }
954}
955
956async fn run_opencode_models_discovery_async() -> (Vec<String>, &'static str) {
965 run_opencode_models_cli(Duration::from_secs(30)).await
966}
967
968pub(crate) async fn run_opencode_models_cli(timeout: Duration) -> (Vec<String>, &'static str) {
970 run_opencode_models_cli_inner("opencode", timeout).await
971}
972
973fn opencode_program_available(program: &str) -> bool {
974 let path = Path::new(program);
975 if path.is_absolute() || program.contains('/') {
976 path.is_file()
977 } else {
978 cmd_cache::command_exists(program)
979 }
980}
981
982#[cfg(unix)]
984fn raw_kill(pid: i32, sig: i32) -> i32 {
985 unsafe extern "C" {
986 fn kill(pid: i32, sig: i32) -> i32;
987 }
988 unsafe { kill(pid, sig) }
990}
991
992#[cfg(unix)]
995fn kill_process_group(pgid: u32) {
996 const SIGKILL: i32 = 9;
997 let Ok(pgid) = i32::try_from(pgid) else {
998 return;
999 };
1000 let _ = raw_kill(-pgid, SIGKILL);
1001}
1002
1003async fn run_opencode_models_cli_inner(
1004 program: &str,
1005 timeout: Duration,
1006) -> (Vec<String>, &'static str) {
1007 if !opencode_program_available(program) {
1008 tracing::warn!("opencode not in PATH, skipping model discovery");
1009 return (vec![], "command_not_found");
1010 }
1011
1012 let mut cmd = Command::new(program);
1013 cmd.args(["models"])
1014 .stdout(Stdio::piped())
1015 .stderr(Stdio::piped())
1016 .kill_on_drop(true);
1017 #[cfg(unix)]
1018 cmd.process_group(0);
1019
1020 let child = match cmd.spawn() {
1021 Ok(c) => c,
1022 Err(e) => {
1023 tracing::warn!(error = %e, "failed to spawn opencode models");
1024 return (vec![], "spawn_failed");
1025 }
1026 };
1027 let pgid = child.id();
1028 let wait = time::timeout(timeout, child.wait_with_output()).await;
1029
1030 #[cfg(unix)]
1031 if let Some(pid) = pgid {
1032 kill_process_group(pid);
1033 }
1034
1035 match wait {
1036 Ok(Ok(output)) if output.status.success() => {
1037 let models: Vec<String> = String::from_utf8_lossy(&output.stdout)
1038 .lines()
1039 .map(|l| l.trim().to_string())
1040 .filter(|l| !l.is_empty())
1041 .collect();
1042 let reason = if models.is_empty() {
1043 "empty_output"
1044 } else {
1045 "ok"
1046 };
1047 (models, reason)
1048 }
1049 Ok(Ok(output)) => {
1050 let stderr = String::from_utf8_lossy(&output.stderr);
1051 let stderr = stderr.trim();
1052 let stderr_display = if stderr.len() > 500 {
1053 format!("{}...", &stderr[..stderr.floor_char_boundary(500)])
1054 } else {
1055 stderr.to_string()
1056 };
1057 tracing::warn!(
1058 status = ?output.status,
1059 stderr = %stderr_display,
1060 "opencode models command failed"
1061 );
1062 (vec![], "exit_status_failure")
1063 }
1064 Ok(Err(e)) => {
1065 tracing::warn!(error = %e, "failed to wait on opencode models");
1066 (vec![], "wait_failed")
1067 }
1068 Err(_) => {
1069 tracing::warn!(
1070 secs = timeout.as_secs(),
1071 "opencode models timed out, killing process group"
1072 );
1073 (vec![], "timeout")
1074 }
1075 }
1076}
1077
1078fn synthesize_response_from_events(events: &[serde_json::Value]) -> AgentResponse {
1091 let mut counts: BTreeMap<String, u32> = BTreeMap::new();
1092 let mut tool_names: Vec<String> = Vec::new();
1093 let mut step_finish_reason: Option<String> = None;
1094
1095 for event in events {
1096 let event_type = event
1097 .get("type")
1098 .and_then(|v| v.as_str())
1099 .unwrap_or("unknown");
1100 *counts.entry(event_type.to_string()).or_insert(0) += 1;
1101
1102 if event_type == "step_finish"
1103 && let Some(reason) = event
1104 .get("part")
1105 .and_then(|p| p.get("reason"))
1106 .and_then(|r| r.as_str())
1107 {
1108 step_finish_reason = Some(reason.to_string());
1109 }
1110
1111 if event_type == "tool_use"
1113 && let Some(name) = event
1114 .get("name")
1115 .and_then(|v| v.as_str())
1116 .or_else(|| event.get("tool").and_then(|v| v.as_str()))
1117 .or_else(|| {
1118 event
1119 .get("part")
1120 .and_then(|p| p.get("name"))
1121 .and_then(|v| v.as_str())
1122 })
1123 .or_else(|| {
1124 event
1125 .get("tool")
1126 .and_then(|t| t.get("name"))
1127 .and_then(|v| v.as_str())
1128 })
1129 {
1130 tool_names.push(name.to_string());
1131 }
1132 }
1133
1134 let total_events = events.len();
1135 let reason = step_finish_reason.as_deref().unwrap_or("unknown");
1136
1137 let tool_summary = if tool_names.is_empty() {
1138 String::new()
1139 } else {
1140 let mut tool_counts: BTreeMap<String, u32> = BTreeMap::new();
1141 for n in &tool_names {
1142 *tool_counts.entry(n.clone()).or_insert(0) += 1;
1143 }
1144 let parts: Vec<String> = tool_counts
1145 .iter()
1146 .map(|(k, v)| {
1147 if *v > 1 {
1148 format!("{k}×{v}")
1149 } else {
1150 k.clone()
1151 }
1152 })
1153 .collect();
1154 format!(" (tools: {})", parts.join(", "))
1155 };
1156
1157 let summary = format!(
1158 "opencode emitted {total_events} event(s) with no text output{tool_summary}; \
1159 step_finish reason: {reason}"
1160 );
1161
1162 AgentResponse {
1163 status: "needs_review".to_string(),
1164 summary,
1165 error: Some("agent emitted no text response — only tool/step events".to_string()),
1166 ..Default::default()
1167 }
1168}
1169
1170fn extract_cost(events: &[serde_json::Value]) -> Option<f64> {
1172 events.iter().rev().find_map(|event| {
1173 if event.get("type").and_then(|v| v.as_str()) == Some("step_finish") {
1174 event
1175 .get("part")
1176 .and_then(|p| p.get("cost"))
1177 .and_then(serde_json::Value::as_f64)
1178 .filter(|&c| c > 0.0)
1179 } else {
1180 None
1181 }
1182 })
1183}
1184
1185pub fn find_opencode_result(ndjson: &str) -> Option<super::AgentResult> {
1193 let events = super::parse_ndjson(ndjson.trim());
1194 if events.is_empty() {
1195 return None;
1196 }
1197
1198 let is_error;
1200 let error_text;
1201 if let Some(err) = OpenCodeRunner::detect_error(&events) {
1202 is_error = true;
1203 error_text = Some(err.to_string());
1204 } else {
1205 is_error = false;
1206 error_text = None;
1207 }
1208
1209 let result_text = if is_error {
1211 error_text.unwrap_or_default()
1212 } else {
1213 extract_ndjson_text(&events).unwrap_or_default()
1214 };
1215
1216 let (input_tokens, output_tokens) = OpenCodeRunner::extract_tokens(&events);
1218
1219 let last_step_finish_reason = events
1228 .iter()
1229 .rev()
1230 .find(|event| event.get("type").and_then(|v| v.as_str()) == Some("step_finish"))
1231 .and_then(|event| {
1232 event
1233 .get("part")?
1234 .get("reason")?
1235 .as_str()
1236 .map(str::to_string)
1237 });
1238 let truncated_by_length = match last_step_finish_reason.as_deref() {
1239 Some("length") => true,
1240 Some("unknown") => output_tokens == Some(0),
1241 _ => false,
1242 };
1243
1244 if result_text.is_empty() && !is_error && !truncated_by_length {
1245 return None;
1246 }
1247
1248 let cost_usd = extract_cost(&events);
1249
1250 Some(super::AgentResult {
1251 is_error,
1252 result_text,
1253 input_tokens,
1254 output_tokens,
1255 cost_usd,
1256 duration_ms: None,
1257 truncated_by_length,
1258 })
1259}
1260
1261#[cfg(test)]
1262mod tests {
1263 #[cfg(unix)]
1264 use std::fs;
1265 #[cfg(unix)]
1266 use std::fs::Permissions;
1267 #[cfg(unix)]
1268 use std::os::unix::fs::PermissionsExt;
1269 #[cfg(unix)]
1270 use std::time::Instant;
1271
1272 use super::*;
1273
1274 fn runner() -> OpenCodeRunner {
1275 OpenCodeRunner::new()
1276 }
1277
1278 #[test]
1279 fn parse_opencode_ndjson_success() {
1280 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start","snapshot":"..."}}
1281{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"status\":\"done\",\"summary\":\"hello\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}}
1282{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":17512,"input":17509,"output":3}}}"#;
1283
1284 let parsed = runner().parse_response(raw).unwrap();
1285 assert_eq!(parsed.response.status, "done");
1286 assert_eq!(parsed.response.summary, "hello");
1287 assert_eq!(parsed.input_tokens, Some(17509));
1288 assert_eq!(parsed.output_tokens, Some(3));
1289 }
1290
1291 #[test]
1294 fn parse_opencode_ndjson_direct_text_format() {
1295 let raw = r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}
1296{"type":"text","timestamp":1001,"sessionID":"ses_abc","text":"{\"status\":\"done\",\"summary\":\"fixed\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}
1297{"type":"step_finish","timestamp":1002,"sessionID":"ses_abc","part":{"type":"step-finish","reason":"stop","tokens":{"input":200,"output":10}}}"#;
1298
1299 let parsed = runner().parse_response(raw).unwrap();
1300 assert_eq!(parsed.response.status, "done");
1301 assert_eq!(parsed.response.summary, "fixed");
1302 assert_eq!(parsed.input_tokens, Some(200));
1303 assert_eq!(parsed.output_tokens, Some(10));
1304 }
1305
1306 #[test]
1307 fn parse_opencode_concatenated_text() {
1308 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1309{"type":"text","timestamp":1001,"part":{"type":"text","text":"Working on "}}
1310{"type":"text","timestamp":1002,"part":{"type":"text","text":"the task. "}}
1311{"type":"text","timestamp":1003,"part":{"type":"text","text":"{\"status\":\"done\",\"summary\":\"finished\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}}
1312{"type":"step_finish","timestamp":1004,"part":{"type":"step-finish","reason":"stop","tokens":{"input":100,"output":50}}}"#;
1313
1314 let parsed = runner().parse_response(raw).unwrap();
1315 assert_eq!(parsed.response.status, "done");
1316 assert_eq!(parsed.input_tokens, Some(100));
1317 assert_eq!(parsed.output_tokens, Some(50));
1318 }
1319
1320 #[test]
1321 fn extract_router_text_prefers_json_payload() {
1322 let raw = r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"Thinking..."}}
1323{"type":"text","timestamp":1002,"part":{"type":"text","text":"{\"executor\":\"claude\",\"complexity\":\"medium\",\"reason\":\"fit\"}"}}"#;
1324
1325 let text = extract_router_text(raw).unwrap();
1326 let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
1327 assert_eq!(parsed["executor"], "claude");
1328 }
1329
1330 #[test]
1331 fn parse_opencode_embedded_json_fragment_without_executor_is_ignored() {
1332 let raw = r#"{"type":"step_start","timestamp":1}
1337{"type":"text","timestamp":2,"part":{"type":"text","text":"Here is a debug trace: {\"trace\":{\"duration\":123,\"id\":\"abc\"}}"}}
1338{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
1339
1340 let err = runner().parse_response(raw).unwrap_err();
1341 match err {
1342 AgentError::InvalidResponse { raw: _ } => {
1343 }
1345 other => panic!("expected InvalidResponse, got: {other:?}"),
1346 }
1347 }
1348
1349 #[test]
1350 fn parse_opencode_error_event() {
1351 let raw = r#"{"type":"error","message":"rate limit exceeded for this model"}"#;
1352
1353 let err = runner().parse_response(raw).unwrap_err();
1354 assert!(matches!(err, AgentError::RateLimit { .. }));
1355 }
1356
1357 #[test]
1363 fn parse_opencode_tool_only_stream_synthesizes_needs_review() {
1364 let raw = r#"{"type":"step_start","timestamp":1,"part":{"type":"step-start"}}
1365{"type":"tool_use","timestamp":2,"name":"bash","input":{"cmd":"ls"}}
1366{"type":"tool_use","timestamp":3,"name":"read","input":{"path":"src/main.rs"}}
1367{"type":"tool_use","timestamp":4,"name":"bash","input":{"cmd":"cargo check"}}
1368{"type":"step_finish","timestamp":5,"part":{"type":"step-finish","reason":"stop","tokens":{"input":500,"output":0}}}"#;
1369
1370 let parsed = runner()
1371 .parse_response(raw)
1372 .expect("tool-only NDJSON must synthesize a response, not error");
1373 assert_eq!(parsed.response.status, "needs_review");
1374 assert!(
1375 !parsed.response.summary.contains("\"type\":\"step_start\""),
1376 "summary must not contain raw NDJSON; got: {}",
1377 parsed.response.summary
1378 );
1379 assert!(
1380 parsed.response.summary.contains("bash") && parsed.response.summary.contains("read"),
1381 "summary should mention tools used; got: {}",
1382 parsed.response.summary
1383 );
1384 assert!(
1385 parsed.response.summary.contains("stop"),
1386 "summary should mention step_finish reason; got: {}",
1387 parsed.response.summary
1388 );
1389 assert_eq!(parsed.input_tokens, Some(500));
1391 assert_eq!(parsed.output_tokens, Some(0));
1392 }
1393
1394 #[test]
1399 fn parse_opencode_plain_prose_completion_synthesizes_response() {
1400 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1401{"type":"text","timestamp":1001,"part":{"type":"text","text":"Fixed the typo and corrected the deadline reference in the review."}}
1402{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","tokens":{"input":300,"output":40}}}"#;
1403
1404 let parsed = runner()
1405 .parse_response(raw)
1406 .expect("plain-prose completion text must synthesize successfully");
1407 assert_eq!(parsed.response.status, "done");
1408 assert_eq!(parsed.input_tokens, Some(300));
1409 assert_eq!(parsed.output_tokens, Some(40));
1410 }
1411
1412 #[test]
1413 fn parse_opencode_step_finish_error() {
1414 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1415{"type":"step_finish","timestamp":1001,"part":{"type":"step-finish","reason":"error","error":"context length exceeded"}}"#;
1416
1417 let err = runner().parse_response(raw).unwrap_err();
1418 assert!(matches!(err, AgentError::ContextOverflow { .. }));
1419 }
1420
1421 #[test]
1422 fn parse_opencode_empty_returns_silent_failure() {
1423 let err = runner().parse_response("").unwrap_err();
1424 assert!(matches!(err, AgentError::Unknown { exit_code: 0, .. }));
1427 }
1428
1429 #[test]
1430 fn extract_tokens_from_step_finish() {
1431 let events: Vec<serde_json::Value> = vec![
1432 serde_json::json!({"type":"step_finish","part":{"type":"step-finish","tokens":{"input":1000,"output":500,"total":1500}}}),
1433 ];
1434 let (input, output) = OpenCodeRunner::extract_tokens(&events);
1435 assert_eq!(input, Some(1000));
1436 assert_eq!(output, Some(500));
1437 }
1438
1439 #[test]
1440 fn classify_opencode_rate_limit() {
1441 let err = classify_opencode_message("429 Too Many Requests");
1442 assert!(matches!(err, AgentError::RateLimit { .. }));
1443 }
1444
1445 #[test]
1446 fn classify_opencode_model_not_found() {
1447 let err = classify_opencode_message("model not found: gpt-5");
1448 assert!(matches!(err, AgentError::ModelUnavailable { .. }));
1449 }
1450
1451 #[test]
1452 fn classify_opencode_model_not_found_with_suggestion() {
1453 let err = classify_opencode_message(
1456 "Model not found: github-copilot/gemini-3.1-pro. Did you mean: gemini-3.1-pro-preview?",
1457 );
1458 match err {
1459 AgentError::ModelUnavailable { model, message } => {
1460 assert_eq!(model, "github-copilot/gemini-3.1-pro");
1461 assert!(message.contains("Did you mean"));
1462 }
1463 other => panic!("expected ModelUnavailable, got: {other:?}"),
1464 }
1465 }
1466
1467 #[test]
1468 fn classify_opencode_model_deprecated() {
1469 let err = classify_opencode_message(
1472 "The free model has been deprecated. Transition to qwen/qwen3.6-plus for continued paid access.",
1473 );
1474 match err {
1475 AgentError::ModelUnavailable { model, .. } => {
1476 assert_eq!(
1477 model, "qwen/qwen3.6-plus",
1478 "expected model extracted from 'Transition to X', got: {model:?}"
1479 );
1480 }
1481 other => panic!("expected ModelUnavailable, got: {other:?}"),
1482 }
1483 }
1484
1485 #[test]
1486 fn classify_opencode_model_unavailable_for_free() {
1487 let err = classify_opencode_message(
1491 "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",
1492 );
1493 match err {
1494 AgentError::ModelUnavailable { model, .. } => {
1495 assert_eq!(
1496 model, "inclusionai/ling-3.0-flash",
1497 "expected model extracted from 'use this slug instead: X', got: {model:?}"
1498 );
1499 }
1500 other => panic!("expected ModelUnavailable, got: {other:?}"),
1501 }
1502 }
1503
1504 #[test]
1505 fn classify_opencode_model_not_available_in_country() {
1506 let err =
1511 classify_opencode_message("agent failed: This model is not available in your country.");
1512 match err {
1513 AgentError::ModelUnavailable { .. } => {}
1514 other => panic!("expected ModelUnavailable, got: {other:?}"),
1515 }
1516 }
1517
1518 #[test]
1519 fn classify_opencode_no_endpoints_found() {
1520 let err = classify_opencode_message("No endpoints found for qwen/qwen3.6-plus:free.");
1522 match err {
1523 AgentError::ModelUnavailable { model, .. } => {
1524 assert_eq!(model, "qwen/qwen3.6-plus:free");
1525 }
1526 other => panic!("expected ModelUnavailable, got: {other:?}"),
1527 }
1528 }
1529
1530 #[test]
1531 fn build_command_opencode() {
1532 let r = runner();
1533 let perms = PermissionRules::default();
1534 let cmd = r.build_command(
1535 Some("anthropic/claude-sonnet-4-20250514"),
1536 "timeout 1800",
1537 "/tmp/sys.txt",
1538 "/tmp/msg.txt",
1539 &perms,
1540 );
1541 assert!(cmd.contains("opencode run"));
1542 assert!(cmd.contains("--model 'anthropic/claude-sonnet-4-20250514'"));
1543 assert!(cmd.contains("--format json"));
1544 assert!(
1545 cmd.starts_with(
1546 r#"OPENCODE_CONFIG_CONTENT='{"snapshot":false}' timeout 1800 opencode run"#
1547 ),
1548 "snapshots must be off for orch runs, got: {cmd}"
1549 );
1550 assert!(
1553 cmd.contains("--dangerously-skip-permissions"),
1554 "expected --dangerously-skip-permissions, got: {cmd}"
1555 );
1556 assert!(
1557 !cmd.contains("opencode.json"),
1558 "should not write a config file, got: {cmd}"
1559 );
1560 assert!(
1561 !cmd.contains("XDG_CONFIG_HOME"),
1562 "should not override XDG_CONFIG_HOME, got: {cmd}"
1563 );
1564 assert!(
1565 !cmd.contains(".orch-opencode"),
1566 "should not reference .orch-opencode, got: {cmd}"
1567 );
1568 assert!(
1575 !cmd.trim_start().starts_with("cat "),
1576 "opencode command must not start with a `cat |` pipe stage, got: {cmd}"
1577 );
1578 assert!(
1579 cmd.contains("< <(cat \"/tmp/sys.txt\" \"/tmp/msg.txt\")"),
1580 "sys/msg files must be fed via process substitution, got: {cmd}"
1581 );
1582 }
1583
1584 #[test]
1585 fn build_command_opencode_supervised_no_skip_flag() {
1586 let r = runner();
1587 let perms = PermissionRules {
1588 autonomous: false,
1589 ..PermissionRules::default()
1590 };
1591 let cmd = r.build_command(
1592 Some("anthropic/claude-sonnet-4-20250514"),
1593 "timeout 1800",
1594 "/tmp/sys.txt",
1595 "/tmp/msg.txt",
1596 &perms,
1597 );
1598 assert!(
1599 !cmd.contains("--dangerously-skip-permissions"),
1600 "supervised mode must not pass --dangerously-skip-permissions, got: {cmd}"
1601 );
1602 }
1603
1604 #[test]
1605 fn split_model_variant_no_at() {
1606 let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5");
1607 assert_eq!(base, "openai/gpt-5.5");
1608 assert!(variant.is_none());
1609 }
1610
1611 #[test]
1612 fn split_model_variant_with_variant() {
1613 let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5@xhigh");
1614 assert_eq!(base, "openai/gpt-5.5");
1615 assert_eq!(variant, Some("xhigh"));
1616 }
1617
1618 #[test]
1619 fn split_model_variant_trailing_at_empty() {
1620 let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5@");
1621 assert_eq!(base, "openai/gpt-5.5");
1622 assert_eq!(variant, Some(""));
1623 }
1624
1625 #[test]
1626 fn split_model_variant_multiple_ats_last_wins() {
1627 let (base, variant) = OpenCodeRunner::split_model_variant("a@b@c@v");
1628 assert_eq!(base, "a@b@c");
1629 assert_eq!(variant, Some("v"));
1630 }
1631
1632 #[test]
1633 fn build_command_opencode_with_variant() {
1634 let r = runner();
1635 let perms = PermissionRules::default();
1636 let cmd = r.build_command(
1637 Some("openai/gpt-5.5@high"),
1638 "timeout 1800",
1639 "/tmp/sys.txt",
1640 "/tmp/msg.txt",
1641 &perms,
1642 );
1643 assert!(cmd.contains("--model 'openai/gpt-5.5'"));
1644 assert!(cmd.contains("--variant 'high'"));
1645 }
1646
1647 #[test]
1648 fn classify_opencode_permission_denied() {
1649 let err = classify_opencode_message("user rejected permission for tool bash: cargo test");
1650 assert!(
1651 matches!(err, AgentError::PermissionDenied { .. }),
1652 "expected PermissionDenied, got: {err:?}"
1653 );
1654 }
1655
1656 #[test]
1657 fn classify_opencode_provider_returned_error() {
1658 let err = classify_opencode_message("Provider returned error");
1662 assert!(
1663 matches!(err, AgentError::RateLimit { .. }),
1664 "expected RateLimit, got: {err:?}"
1665 );
1666 let err_with_detail =
1667 classify_opencode_message("Provider returned error: upstream connection timed out");
1668 assert!(
1669 matches!(err_with_detail, AgentError::RateLimit { .. }),
1670 "expected RateLimit, got: {err_with_detail:?}"
1671 );
1672 }
1673
1674 #[test]
1675 fn classify_opencode_upstream_idle_timeout() {
1676 let err = classify_opencode_message("Upstream idle timeout exceeded");
1679 assert!(
1680 matches!(err, AgentError::NetworkError { .. }),
1681 "expected NetworkError, got: {err:?}"
1682 );
1683 }
1684
1685 #[test]
1686 fn free_models_discovery_returns_vec() {
1687 let models = discover_free_opencode_models();
1689 assert!(models.is_empty(), "{models:?}");
1690 }
1691
1692 #[test]
1693 fn live_opencode_discovery_is_skipped_in_test_builds() {
1694 assert!(skip_live_opencode_discovery());
1695 let models = discover_opencode_models();
1696 assert!(models.is_empty(), "{models:?}");
1697 let models = discover_free_opencode_models();
1698 assert!(models.is_empty(), "{models:?}");
1699 prime_free_model_cache();
1700 let models = discover_opencode_models();
1701 assert!(models.is_empty(), "{models:?}");
1702 }
1703
1704 #[cfg(unix)]
1707 #[tokio::test]
1708 async fn models_cli_reaps_orphaned_process_group() {
1709 let dir = tempfile::tempdir().unwrap();
1710 let sleeper_pid_path = dir.path().join("sleeper.pid");
1711 let script = dir.path().join("opencode");
1712 fs::write(
1713 &script,
1714 format!(
1715 "#!/bin/sh\n/bin/sleep 60 </dev/null >/dev/null 2>&1 &\necho $! > '{}'\necho test-model-free\n",
1716 sleeper_pid_path.display()
1717 ),
1718 )
1719 .unwrap();
1720 fs::set_permissions(&script, Permissions::from_mode(0o755)).unwrap();
1721
1722 let (models, reason) =
1725 run_opencode_models_cli_inner(script.to_str().unwrap(), Duration::from_secs(30)).await;
1726 assert_eq!(reason, "ok");
1727 assert_eq!(models, vec!["test-model-free".to_string()]);
1728
1729 let sleeper: u32 = fs::read_to_string(&sleeper_pid_path)
1730 .unwrap()
1731 .trim()
1732 .parse()
1733 .expect("sleeper pid");
1734 let deadline = Instant::now() + Duration::from_secs(10);
1735 while pid_is_alive(sleeper) && Instant::now() < deadline {
1736 time::sleep(Duration::from_millis(20)).await;
1737 }
1738 assert!(
1739 !pid_is_alive(sleeper),
1740 "sleeper {sleeper} must die with the process group when opencode models returns"
1741 );
1742 }
1743
1744 #[cfg(unix)]
1745 fn pid_is_alive(pid: u32) -> bool {
1746 i32::try_from(pid).is_ok_and(|pid| super::raw_kill(pid, 0) == 0)
1748 }
1749
1750 #[test]
1751 fn empty_free_discovery_preserves_existing_cache_contents() {
1752 reset_model_caches_for_test();
1753 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1754 {
1755 let mut guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
1756 *guard = (123, vec!["opencode/model-free".to_string()]);
1757 }
1758
1759 update_discovered_models_cache(cache, Vec::new(), "free", "empty_output");
1760
1761 let guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
1762 assert!(guard.0 > 123);
1765 assert_eq!(guard.1, vec!["opencode/model-free".to_string()]);
1766 }
1767
1768 #[test]
1769 fn empty_all_models_discovery_preserves_existing_cache_contents() {
1770 reset_model_caches_for_test();
1771 let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1772 {
1773 let mut guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
1774 *guard = (
1775 456,
1776 vec![
1777 "opencode/model-free".to_string(),
1778 "opencode/model-paid".to_string(),
1779 ],
1780 );
1781 }
1782
1783 update_discovered_models_cache(cache, Vec::new(), "all", "exit_status_failure");
1784
1785 let guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
1786 assert!(guard.0 > 456);
1787 assert_eq!(
1788 guard.1,
1789 vec![
1790 "opencode/model-free".to_string(),
1791 "opencode/model-paid".to_string(),
1792 ]
1793 );
1794 }
1795
1796 #[test]
1797 fn empty_discovery_populates_empty_cache() {
1798 reset_model_caches_for_test();
1799 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1800
1801 update_discovered_models_cache(cache, Vec::new(), "free", "command_not_found");
1802
1803 let guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
1804 assert!(guard.0 > 0);
1805 assert!(guard.1.is_empty(), "{:?}", guard.1);
1806 }
1807
1808 #[test]
1811 fn fixture_opencode_success() {
1812 let raw = include_str!("../../../../tests/fixtures/opencode_success.jsonl");
1813 let parsed = runner().parse_response(raw).unwrap();
1814 assert_eq!(parsed.response.status, "done");
1815 assert!(parsed.response.summary.contains("Fixed"));
1816 assert_eq!(parsed.response.accomplished.len(), 2);
1817 assert_eq!(parsed.input_tokens, Some(17500));
1818 assert_eq!(parsed.output_tokens, Some(500));
1819 }
1820
1821 #[test]
1825 fn fixture_opencode_permission_denied() {
1826 let raw = include_str!("../../../../tests/fixtures/opencode_permission_denied.jsonl");
1827 let err = runner().parse_response(raw).unwrap_err();
1828 assert!(
1829 matches!(err, AgentError::PermissionDenied { .. }),
1830 "expected PermissionDenied, got: {err:?}"
1831 );
1832 }
1833
1834 #[test]
1836 fn fixture_opencode_rate_limit() {
1837 let raw = include_str!("../../../../tests/fixtures/opencode_rate_limit.jsonl");
1838 let err = runner().parse_response(raw).unwrap_err();
1839 assert!(
1840 matches!(err, AgentError::RateLimit { .. }),
1841 "expected RateLimit, got: {err:?}"
1842 );
1843 }
1844
1845 #[test]
1849 fn fixture_opencode_model_not_found() {
1850 let raw = include_str!("../../../../tests/fixtures/opencode_model_not_found.jsonl");
1851 let err = runner().parse_response(raw).unwrap_err();
1852 assert!(
1853 matches!(err, AgentError::ModelUnavailable { .. }),
1854 "expected ModelUnavailable, got: {err:?}"
1855 );
1856 if let AgentError::ModelUnavailable { model, .. } = &err {
1857 assert_eq!(model, "anthropic/claude-sonnet-4-6");
1858 }
1859 }
1860
1861 #[test]
1864 fn fixture_opencode_provider_returned_error() {
1865 let raw = include_str!("../../../../tests/fixtures/opencode_provider_returned_error.jsonl");
1866 let err = runner().parse_response(raw).unwrap_err();
1867 assert!(
1868 matches!(err, AgentError::RateLimit { .. }),
1869 "expected RateLimit, got: {err:?}"
1870 );
1871 }
1872
1873 #[test]
1877 fn extract_text_returns_text_events() {
1878 let raw = concat!(
1879 r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1880 "\n",
1881 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
1882 "\n",
1883 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":100,"input":90,"output":10}}}"#,
1884 );
1885 let text = runner().extract_text(raw).unwrap();
1886 assert_eq!(
1887 text,
1888 r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#
1889 );
1890 }
1891
1892 #[test]
1894 fn extract_text_direct_text_event_format() {
1895 let raw = concat!(
1896 r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1897 "\n",
1898 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"}"#,
1899 "\n",
1900 r#"{"type":"step_finish","timestamp":1002,"sessionID":"ses_abc"}"#,
1901 );
1902 let text = runner().extract_text(raw).unwrap();
1903 assert!(
1904 text.contains("request_changes"),
1905 "expected review JSON, got: {text}"
1906 );
1907 }
1908
1909 #[test]
1911 fn extract_text_rate_limit_propagates() {
1912 let raw = concat!(
1913 r#"{"type":"step_start","timestamp":1000}"#,
1914 "\n",
1915 r#"{"type":"error","message":"rate limit exceeded: 429 Too Many Requests"}"#,
1916 );
1917 let err = runner().extract_text(raw).unwrap_err();
1918 assert!(
1919 matches!(err, AgentError::RateLimit { .. }),
1920 "expected RateLimit, got: {err:?}"
1921 );
1922 }
1923
1924 #[test]
1926 fn extract_text_auth_error_propagates() {
1927 let raw = concat!(
1928 r#"{"type":"step_start","timestamp":1000}"#,
1929 "\n",
1930 r#"{"type":"error","message":"401 Unauthorized: invalid api key"}"#,
1931 );
1932 let err = runner().extract_text(raw).unwrap_err();
1933 assert!(
1934 matches!(err, AgentError::Auth { .. }),
1935 "expected Auth, got: {err:?}"
1936 );
1937 }
1938
1939 #[test]
1941 fn extract_text_empty_input_returns_empty_ok() {
1942 let text = runner().extract_text("").unwrap();
1943 assert_eq!(text, "");
1944 }
1945
1946 #[test]
1949 fn find_opencode_result_success_with_tokens() {
1950 let ndjson = concat!(
1951 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1952 "\n",
1953 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\"}"}}"#,
1954 "\n",
1955 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0.05,"tokens":{"input":5000,"output":200}}}"#,
1956 );
1957 let result = find_opencode_result(ndjson).expect("should find result");
1958 assert!(!result.is_error);
1959 assert!(result.result_text.contains("approve"));
1960 assert_eq!(result.input_tokens, Some(5000));
1961 assert_eq!(result.output_tokens, Some(200));
1962 assert_eq!(result.cost_usd, Some(0.05));
1963 assert!(!result.truncated_by_length);
1964 }
1965
1966 #[test]
1967 fn find_opencode_result_error_event() {
1968 let ndjson = r#"{"type":"error","message":"rate limit exceeded: 429"}"#;
1969 let result = find_opencode_result(ndjson).expect("should find error result");
1970 assert!(result.is_error);
1971 assert!(result.result_text.contains("rate limit"));
1972 assert!(!result.truncated_by_length);
1973 }
1974
1975 #[test]
1979 fn find_opencode_result_length_truncation() {
1980 let ndjson = concat!(
1981 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1982 "\n",
1983 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":"}}"#,
1984 "\n",
1985 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"length","tokens":{"input":5485,"output":759,"reasoning":31241}}}"#,
1986 );
1987 let result = find_opencode_result(ndjson).expect("should find result");
1988 assert!(result.truncated_by_length);
1989 assert!(!result.is_error, "length truncation is not a hard error");
1990 }
1991
1992 #[test]
1998 fn find_opencode_result_zero_output_length_truncation() {
1999 let ndjson = concat!(
2000 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
2001 "\n",
2002 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"length","tokens":{"total":71482,"input":39482,"output":0,"reasoning":40000}}}"#,
2003 );
2004 let result = find_opencode_result(ndjson).expect("should find result, not None");
2005 assert!(result.truncated_by_length);
2006 assert!(!result.is_error);
2007 assert_eq!(result.result_text, "");
2008 }
2009
2010 #[test]
2013 fn find_opencode_result_zero_output_unknown_reason_truncation() {
2014 let ndjson = concat!(
2015 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
2016 "\n",
2017 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"unknown","tokens":{"input":0,"output":0,"reasoning":0}}}"#,
2018 );
2019 let result = find_opencode_result(ndjson).expect("should find result, not None");
2020 assert!(result.truncated_by_length);
2021 assert!(!result.is_error);
2022 assert_eq!(result.result_text, "");
2023 }
2024
2025 #[test]
2029 fn find_opencode_result_unknown_reason_with_output_not_truncated() {
2030 let ndjson = concat!(
2031 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
2032 "\n",
2033 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"partial answer"}}"#,
2034 "\n",
2035 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"unknown","tokens":{"input":100,"output":50,"reasoning":0}}}"#,
2036 );
2037 let result = find_opencode_result(ndjson).expect("should find result");
2038 assert!(!result.truncated_by_length);
2039 assert!(!result.is_error);
2040 }
2041
2042 #[test]
2043 fn find_opencode_result_empty_returns_none() {
2044 assert!(find_opencode_result("").is_none());
2045 assert!(find_opencode_result(" ").is_none());
2046 }
2047
2048 #[test]
2049 fn find_opencode_result_direct_text_format() {
2050 let ndjson = concat!(
2051 r#"{"type":"step_start","timestamp":1000}"#,
2052 "\n",
2053 r#"{"type":"text","timestamp":1001,"text":"Review complete. All tests passed."}"#,
2054 "\n",
2055 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","tokens":{"input":100,"output":10}}}"#,
2056 );
2057 let result = find_opencode_result(ndjson).expect("should find result");
2058 assert!(!result.is_error);
2059 assert!(result.result_text.contains("All tests passed"));
2060 }
2061
2062 #[test]
2063 fn find_opencode_result_plain_text_returns_none() {
2064 assert!(find_opencode_result("just some plain text").is_none());
2065 }
2066}
2067
2068#[cfg(test)]
2069mod error_text_tests {
2070 use super::*;
2071 use serde_json::json;
2072
2073 #[test]
2074 fn unrecognized_error_shape_keeps_raw_json() {
2075 let e = json!({"type":"error","error":{"code":429,"detail":"slow down"}});
2076 assert!(opencode_error_text(&e).contains("slow down"));
2077 let e = json!({"type":"error"});
2078 assert!(opencode_error_text(&e).contains("\"type\""));
2079 }
2080
2081 #[test]
2082 fn known_shapes_still_extract() {
2083 assert_eq!(opencode_error_text(&json!({"message":"a"})), "a");
2084 assert_eq!(opencode_error_text(&json!({"error":"b"})), "b");
2085 assert_eq!(opencode_error_text(&json!({"error":{"message":"c"}})), "c");
2086 assert_eq!(
2087 opencode_error_text(&json!({"error":{"name":"N","data":{"message":"d"}}})),
2088 "d"
2089 );
2090 }
2091}
2092
2093fn stream_events(v: &serde_json::Value) -> Vec<StreamEvent> {
2095 let text = || {
2096 v.pointer("/part/text")
2097 .or_else(|| v.get("text"))
2098 .and_then(serde_json::Value::as_str)
2099 .unwrap_or("")
2100 .to_string()
2101 };
2102 match json_str(v, "/type") {
2103 "text" => vec![StreamEvent::Text(text())],
2104 "reasoning" => vec![StreamEvent::Thinking(text())],
2105 "tool_use" => {
2106 let part = &v["part"];
2107 let state = &part["state"];
2108 let id = json_str(part, "/callID").to_string();
2109 let name = json_str(part, "/tool").to_string();
2110 let mut out = vec![StreamEvent::ToolCall {
2111 id: id.clone(),
2112 name: name.clone(),
2113 input: state["input"].clone(),
2114 }];
2115 let result = match json_str(state, "/status") {
2116 "completed" => Some((json_str(state, "/output").to_string(), false)),
2117 "error" => Some((json_error_message(state), true)),
2118 _ => None,
2119 };
2120 if let Some((output, is_error)) = result {
2121 out.push(StreamEvent::ToolResult {
2122 id,
2123 name,
2124 output,
2125 is_error,
2126 });
2127 }
2128
2129 out
2130 }
2131 "step_finish" if json_str(v, "/part/reason") == "stop" => {
2132 vec![StreamEvent::Done(String::new())]
2133 }
2134 "error" => vec![StreamEvent::Error(json_error_message(v))],
2135 _ => vec![],
2136 }
2137}
2138
2139#[cfg(test)]
2140mod stream_tests {
2141 use super::*;
2142 use serde_json::json;
2143
2144 fn events(line: &str) -> Vec<StreamEvent> {
2145 OpenCodeRunner::new().stream_events(&serde_json::from_str(line).unwrap())
2146 }
2147
2148 #[test]
2149 fn text_in_part_or_top_level() {
2150 assert_eq!(
2151 events(r#"{"type":"text","part":{"type":"text","text":"Reviewing."}}"#),
2152 vec![StreamEvent::Text("Reviewing.".into())]
2153 );
2154 assert_eq!(
2155 events(r#"{"type":"text","text":"Plain."}"#),
2156 vec![StreamEvent::Text("Plain.".into())]
2157 );
2158 }
2159
2160 #[test]
2161 fn completed_tool_has_call_and_result() {
2162 let line = r#"{"type":"tool_use","part":{"type":"tool","tool":"read","callID":"c1","state":{"status":"completed","input":{"filePath":"src/lib.rs"},"output":"a\nb"}}}"#;
2163 assert_eq!(
2164 events(line),
2165 vec![
2166 StreamEvent::ToolCall {
2167 id: "c1".into(),
2168 name: "read".into(),
2169 input: json!({"filePath": "src/lib.rs"}),
2170 },
2171 StreamEvent::ToolResult {
2172 id: "c1".into(),
2173 name: "read".into(),
2174 output: "a\nb".into(),
2175 is_error: false,
2176 },
2177 ]
2178 );
2179 }
2180
2181 #[test]
2182 fn failed_tool_result_is_error() {
2183 let line = r#"{"type":"tool_use","part":{"tool":"bash","callID":"c2","state":{"status":"error","input":{"command":"x"},"error":"not found"}}}"#;
2184 assert_eq!(
2185 events(line)[1],
2186 StreamEvent::ToolResult {
2187 id: "c2".into(),
2188 name: "bash".into(),
2189 output: "not found".into(),
2190 is_error: true,
2191 }
2192 );
2193 }
2194
2195 #[test]
2196 fn only_final_step_is_done() {
2197 let dropped = events(r#"{"type":"step_finish","part":{"reason":"tool-calls"}}"#);
2198 assert!(dropped.is_empty(), "{dropped:?}");
2199 assert_eq!(
2200 events(r#"{"type":"step_finish","part":{"reason":"stop"}}"#),
2201 vec![StreamEvent::Done(String::new())]
2202 );
2203 let dropped = events(r#"{"type":"step_start"}"#);
2204 assert!(dropped.is_empty(), "{dropped:?}");
2205 }
2206
2207 #[test]
2208 fn error_event() {
2209 let line = r#"{"type":"error","error":{"name":"UnknownError","data":{"message":"Provider returned error"}}}"#;
2210 assert_eq!(
2211 events(line),
2212 vec![StreamEvent::Error("Provider returned error".into())]
2213 );
2214 }
2215}