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 duration_ms: None,
577 });
578 }
579 return Err(AgentError::InvalidResponse {
580 raw: trimmed.to_string(),
581 });
582 }
583
584 if let Some(err) = Self::detect_error(&events) {
586 return Err(err);
587 }
588
589 let (input_tokens, output_tokens) = Self::extract_tokens(&events);
591
592 let Some(text) = Self::extract_text_from_events(&events) else {
599 let response = synthesize_response_from_events(&events);
600 return Ok(ParsedResponse {
601 response,
602 input_tokens,
603 output_tokens,
604 duration_ms: None,
605 });
606 };
607
608 let response = match parser::parse(&text) {
610 Ok(r) => r,
611 Err(_) => {
612 super::synthesize_response_from_text(&text)
615 .ok_or_else(|| AgentError::InvalidResponse { raw: text.clone() })?
616 }
617 };
618
619 Ok(ParsedResponse {
620 response,
621 input_tokens,
622 output_tokens,
623 duration_ms: None,
624 })
625 }
626
627 fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
628 let events = super::parse_ndjson(stdout);
630 if let Some(err) = Self::detect_error(&events) {
631 return err;
632 }
633
634 let combined = format!("{stdout}\n{stderr}");
635 super::patterns::classify_from_text(exit_code, &combined)
636 }
637
638 fn free_models(&self) -> Vec<String> {
639 discover_free_opencode_models()
640 }
641
642 fn available_models(&self) -> Vec<String> {
643 vec![
645 "anthropic/claude-sonnet-4-20250514".to_string(),
646 "openai/gpt-4.1".to_string(),
647 ]
648 }
649
650 fn router_command(&self, prompt: &str, model: Option<&str>) -> anyhow::Result<Command> {
651 let mut cmd = Command::new("opencode");
657 cmd.env("OPENCODE_CONFIG_CONTENT", NO_SNAPSHOT_CONFIG)
658 .arg("run")
659 .arg("--format")
660 .arg("json")
661 .arg("--dangerously-skip-permissions");
662 if let Some(m) = model {
663 let (base, variant) = Self::split_model_variant(m);
664 cmd.arg("--model").arg(base);
665 if let Some(v) = variant {
666 cmd.arg("--variant").arg(v);
668 }
669 }
670 cmd.arg(prompt);
671 Ok(cmd)
672 }
673}
674
675fn classify_opencode_message(message: &str) -> AgentError {
677 let lower = message.to_lowercase();
678
679 if let Some(e) = super::patterns::detect_rate_limit(message) {
681 return e;
682 }
683
684 if let Some(e) = super::patterns::detect_context_overflow(message) {
686 return e;
687 }
688
689 if let Some(e) = super::patterns::detect_auth_error(message) {
691 return e;
692 }
693
694 if let Some(e) = super::patterns::detect_permission_denied(message) {
696 return e;
697 }
698
699 let is_model_unavailable = (lower.contains("model")
710 && (lower.contains("not found")
711 || lower.contains("not supported")
712 || lower.contains("deprecated")
713 || lower.contains("unavailable for free")
714 || lower.contains("not available in your country")
715 || lower.contains("not available in your region")))
716 || lower.contains("no endpoints found");
717
718 if is_model_unavailable {
719 let model = if lower.contains("no endpoints found") {
722 message
724 .split(" for ")
725 .nth(1)
726 .map(|s| s.trim_end_matches('.').to_string())
727 .unwrap_or_default()
728 } else {
729 let from_colon = message
731 .split(": ")
732 .nth(1)
733 .map(|s| {
734 s.split(". Did you mean")
736 .next()
737 .unwrap_or(s)
738 .trim_end_matches('.')
739 .to_string()
740 })
741 .filter(|s| !s.is_empty());
742
743 let from_transition = if lower.contains("deprecated") {
746 message
747 .split("Transition to ")
748 .nth(1)
749 .and_then(|s| s.split(" for ").next())
750 .map(|s| s.trim_end_matches('.').to_string())
751 .filter(|s| !s.is_empty())
752 } else {
753 None
754 };
755
756 let from_slug_suggestion = if lower.contains("unavailable for free") {
759 message
760 .split("use this slug instead: ")
761 .nth(1)
762 .map(|s| s.trim_end_matches('.').trim().to_string())
763 .filter(|s| !s.is_empty())
764 } else {
765 None
766 };
767
768 from_transition
772 .or(from_slug_suggestion)
773 .or(from_colon)
774 .unwrap_or_default()
775 };
776 return AgentError::ModelUnavailable {
777 message: message.to_string(),
778 model,
779 };
780 }
781
782 if lower.contains("provider returned error") {
787 return AgentError::RateLimit {
788 message: message.to_string(),
789 };
790 }
791
792 if let Some(e) = super::patterns::detect_network_error(message) {
794 return e;
795 }
796
797 AgentError::AgentFailed {
798 message: message.to_string(),
799 }
800}
801
802pub fn discover_free_opencode_models() -> Vec<String> {
811 if skip_live_opencode_discovery() {
812 return Vec::new();
813 }
814 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
815
816 let now = chrono::Utc::now().timestamp();
817 {
819 let guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
820 let (ts, models) = &*guard;
821 if *ts != 0 && now.saturating_sub(*ts) < 3600 {
822 return models.clone();
823 }
824 }
825
826 if !FREE_MODELS_REFRESH_IN_PROGRESS.swap(true, Ordering::AcqRel) {
829 let refresh = async {
830 let _guard = scopeguard::guard((), |()| {
831 FREE_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
832 });
833 let (models, empty_reason) = run_opencode_models_discovery_async().await;
834 let raw_count = models.len();
835 let discovered = models
836 .into_iter()
837 .filter(|m| m.to_lowercase().contains("free"))
838 .collect::<Vec<_>>();
839 let empty_reason = if discovered.is_empty() && raw_count > 0 {
840 "no_free_models_in_catalog"
841 } else {
842 empty_reason
843 };
844 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
845 update_discovered_models_cache(cache, discovered, "free", empty_reason);
846 };
847 match Handle::try_current() {
848 Ok(handle) => {
849 handle.spawn(refresh);
852 }
853 _ => {
854 thread::spawn(move || {
857 if let Ok(rt) = Runtime::new() {
858 rt.block_on(refresh);
859 }
860 });
861 }
862 }
863 }
864
865 let guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
867 guard.1.clone()
868}
869
870pub fn discover_opencode_models() -> Vec<String> {
875 if skip_live_opencode_discovery() {
876 return Vec::new();
877 }
878 let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
879 let now = chrono::Utc::now().timestamp();
880
881 {
882 let guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
883 let (ts, models) = &*guard;
884 if *ts != 0 && now.saturating_sub(*ts) < 3600 {
885 return models.clone();
886 }
887 }
888
889 if !ALL_MODELS_REFRESH_IN_PROGRESS.swap(true, Ordering::AcqRel) {
890 let refresh = async {
891 let _guard = scopeguard::guard((), |()| {
892 ALL_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
893 });
894 let (discovered, empty_reason) = run_opencode_models_discovery_async().await;
895 let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
896 update_discovered_models_cache(cache, discovered, "all", empty_reason);
897 };
898 match Handle::try_current() {
899 Ok(handle) => {
900 handle.spawn(refresh);
901 }
902 _ => {
903 thread::spawn(move || {
904 if let Ok(rt) = Runtime::new() {
905 rt.block_on(refresh);
906 }
907 });
908 }
909 }
910 }
911
912 let guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
913 guard.1.clone()
914}
915
916pub fn prime_free_model_cache() {
924 if skip_live_opencode_discovery() {
925 return;
926 }
927 let discovered_all = thread::spawn(|| match Runtime::new() {
928 Ok(rt) => rt.block_on(run_opencode_models_discovery_async()).0,
929 _ => {
930 vec![]
931 }
932 })
933 .join()
934 .unwrap_or_default();
935 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
936 let now = chrono::Utc::now().timestamp();
937 if let Ok(mut guard) = cache.lock() {
938 *guard = (
939 now,
940 discovered_all
941 .iter()
942 .filter(|m| m.to_lowercase().contains("free"))
943 .cloned()
944 .collect(),
945 );
946 }
947 let all_cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
948 if let Ok(mut guard) = all_cache.lock() {
949 *guard = (now, discovered_all);
950 }
951}
952
953async fn run_opencode_models_discovery_async() -> (Vec<String>, &'static str) {
962 run_opencode_models_cli(Duration::from_secs(30)).await
963}
964
965pub(crate) async fn run_opencode_models_cli(timeout: Duration) -> (Vec<String>, &'static str) {
967 run_opencode_models_cli_inner("opencode", timeout).await
968}
969
970fn opencode_program_available(program: &str) -> bool {
971 let path = Path::new(program);
972 if path.is_absolute() || program.contains('/') {
973 path.is_file()
974 } else {
975 cmd_cache::command_exists(program)
976 }
977}
978
979#[cfg(unix)]
981fn raw_kill(pid: i32, sig: i32) -> i32 {
982 unsafe extern "C" {
983 fn kill(pid: i32, sig: i32) -> i32;
984 }
985 unsafe { kill(pid, sig) }
987}
988
989#[cfg(unix)]
992fn kill_process_group(pgid: u32) {
993 const SIGKILL: i32 = 9;
994 let Ok(pgid) = i32::try_from(pgid) else {
995 return;
996 };
997 let _ = raw_kill(-pgid, SIGKILL);
998}
999
1000async fn run_opencode_models_cli_inner(
1001 program: &str,
1002 timeout: Duration,
1003) -> (Vec<String>, &'static str) {
1004 if !opencode_program_available(program) {
1005 tracing::warn!("opencode not in PATH, skipping model discovery");
1006 return (vec![], "command_not_found");
1007 }
1008
1009 let mut cmd = Command::new(program);
1010 cmd.args(["models"])
1011 .stdout(Stdio::piped())
1012 .stderr(Stdio::piped())
1013 .kill_on_drop(true);
1014 #[cfg(unix)]
1015 cmd.process_group(0);
1016
1017 let child = match cmd.spawn() {
1018 Ok(c) => c,
1019 Err(e) => {
1020 tracing::warn!(error = %e, "failed to spawn opencode models");
1021 return (vec![], "spawn_failed");
1022 }
1023 };
1024 let pgid = child.id();
1025 let wait = time::timeout(timeout, child.wait_with_output()).await;
1026
1027 #[cfg(unix)]
1028 if let Some(pid) = pgid {
1029 kill_process_group(pid);
1030 }
1031
1032 match wait {
1033 Ok(Ok(output)) if output.status.success() => {
1034 let models: Vec<String> = String::from_utf8_lossy(&output.stdout)
1035 .lines()
1036 .map(|l| l.trim().to_string())
1037 .filter(|l| !l.is_empty())
1038 .collect();
1039 let reason = if models.is_empty() {
1040 "empty_output"
1041 } else {
1042 "ok"
1043 };
1044 (models, reason)
1045 }
1046 Ok(Ok(output)) => {
1047 let stderr = String::from_utf8_lossy(&output.stderr);
1048 let stderr = stderr.trim();
1049 let stderr_display = if stderr.len() > 500 {
1050 format!("{}...", &stderr[..stderr.floor_char_boundary(500)])
1051 } else {
1052 stderr.to_string()
1053 };
1054 tracing::warn!(
1055 status = ?output.status,
1056 stderr = %stderr_display,
1057 "opencode models command failed"
1058 );
1059 (vec![], "exit_status_failure")
1060 }
1061 Ok(Err(e)) => {
1062 tracing::warn!(error = %e, "failed to wait on opencode models");
1063 (vec![], "wait_failed")
1064 }
1065 Err(_) => {
1066 tracing::warn!(
1067 secs = timeout.as_secs(),
1068 "opencode models timed out, killing process group"
1069 );
1070 (vec![], "timeout")
1071 }
1072 }
1073}
1074
1075fn synthesize_response_from_events(events: &[serde_json::Value]) -> AgentResponse {
1088 let mut counts: BTreeMap<String, u32> = BTreeMap::new();
1089 let mut tool_names: Vec<String> = Vec::new();
1090 let mut step_finish_reason: Option<String> = None;
1091
1092 for event in events {
1093 let event_type = event
1094 .get("type")
1095 .and_then(|v| v.as_str())
1096 .unwrap_or("unknown");
1097 *counts.entry(event_type.to_string()).or_insert(0) += 1;
1098
1099 if event_type == "step_finish"
1100 && let Some(reason) = event
1101 .get("part")
1102 .and_then(|p| p.get("reason"))
1103 .and_then(|r| r.as_str())
1104 {
1105 step_finish_reason = Some(reason.to_string());
1106 }
1107
1108 if event_type == "tool_use"
1110 && let Some(name) = event
1111 .get("name")
1112 .and_then(|v| v.as_str())
1113 .or_else(|| event.get("tool").and_then(|v| v.as_str()))
1114 .or_else(|| {
1115 event
1116 .get("part")
1117 .and_then(|p| p.get("name"))
1118 .and_then(|v| v.as_str())
1119 })
1120 .or_else(|| {
1121 event
1122 .get("tool")
1123 .and_then(|t| t.get("name"))
1124 .and_then(|v| v.as_str())
1125 })
1126 {
1127 tool_names.push(name.to_string());
1128 }
1129 }
1130
1131 let total_events = events.len();
1132 let reason = step_finish_reason.as_deref().unwrap_or("unknown");
1133
1134 let tool_summary = if tool_names.is_empty() {
1135 String::new()
1136 } else {
1137 let mut tool_counts: BTreeMap<String, u32> = BTreeMap::new();
1138 for n in &tool_names {
1139 *tool_counts.entry(n.clone()).or_insert(0) += 1;
1140 }
1141 let parts: Vec<String> = tool_counts
1142 .iter()
1143 .map(|(k, v)| {
1144 if *v > 1 {
1145 format!("{k}×{v}")
1146 } else {
1147 k.clone()
1148 }
1149 })
1150 .collect();
1151 format!(" (tools: {})", parts.join(", "))
1152 };
1153
1154 let summary = format!(
1155 "opencode emitted {total_events} event(s) with no text output{tool_summary}; \
1156 step_finish reason: {reason}"
1157 );
1158
1159 AgentResponse {
1160 status: "needs_review".to_string(),
1161 summary,
1162 error: Some("agent emitted no text response — only tool/step events".to_string()),
1163 ..Default::default()
1164 }
1165}
1166
1167pub fn find_opencode_result(ndjson: &str) -> Option<super::AgentResult> {
1175 let events = super::parse_ndjson(ndjson.trim());
1176 if events.is_empty() {
1177 return None;
1178 }
1179
1180 let is_error;
1182 let error_text;
1183 if let Some(err) = OpenCodeRunner::detect_error(&events) {
1184 is_error = true;
1185 error_text = Some(err.to_string());
1186 } else {
1187 is_error = false;
1188 error_text = None;
1189 }
1190
1191 let result_text = if is_error {
1193 error_text.unwrap_or_default()
1194 } else {
1195 extract_ndjson_text(&events).unwrap_or_default()
1196 };
1197
1198 let (input_tokens, output_tokens) = OpenCodeRunner::extract_tokens(&events);
1200
1201 let last_step_finish_reason = events
1210 .iter()
1211 .rev()
1212 .find(|event| event.get("type").and_then(|v| v.as_str()) == Some("step_finish"))
1213 .and_then(|event| {
1214 event
1215 .get("part")?
1216 .get("reason")?
1217 .as_str()
1218 .map(str::to_string)
1219 });
1220 let truncated_by_length = match last_step_finish_reason.as_deref() {
1221 Some("length") => true,
1222 Some("unknown") => output_tokens == Some(0),
1223 _ => false,
1224 };
1225
1226 if result_text.is_empty() && !is_error && !truncated_by_length {
1227 return None;
1228 }
1229
1230 let cost_usd = events.iter().rev().find_map(|event| {
1232 if event.get("type").and_then(|v| v.as_str()) == Some("step_finish") {
1233 event
1234 .get("part")
1235 .and_then(|p| p.get("cost"))
1236 .and_then(serde_json::Value::as_f64)
1237 .filter(|&c| c > 0.0)
1238 } else {
1239 None
1240 }
1241 });
1242
1243 Some(super::AgentResult {
1244 is_error,
1245 result_text,
1246 input_tokens,
1247 output_tokens,
1248 cost_usd,
1249 duration_ms: None,
1250 truncated_by_length,
1251 })
1252}
1253
1254#[cfg(test)]
1255mod tests {
1256 #[cfg(unix)]
1257 use std::fs;
1258 #[cfg(unix)]
1259 use std::fs::Permissions;
1260 #[cfg(unix)]
1261 use std::os::unix::fs::PermissionsExt;
1262 #[cfg(unix)]
1263 use std::time::Instant;
1264
1265 use super::*;
1266
1267 fn runner() -> OpenCodeRunner {
1268 OpenCodeRunner::new()
1269 }
1270
1271 #[test]
1272 fn parse_opencode_ndjson_success() {
1273 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start","snapshot":"..."}}
1274{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"status\":\"done\",\"summary\":\"hello\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}}
1275{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":17512,"input":17509,"output":3}}}"#;
1276
1277 let parsed = runner().parse_response(raw).unwrap();
1278 assert_eq!(parsed.response.status, "done");
1279 assert_eq!(parsed.response.summary, "hello");
1280 assert_eq!(parsed.input_tokens, Some(17509));
1281 assert_eq!(parsed.output_tokens, Some(3));
1282 }
1283
1284 #[test]
1287 fn parse_opencode_ndjson_direct_text_format() {
1288 let raw = r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}
1289{"type":"text","timestamp":1001,"sessionID":"ses_abc","text":"{\"status\":\"done\",\"summary\":\"fixed\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}
1290{"type":"step_finish","timestamp":1002,"sessionID":"ses_abc","part":{"type":"step-finish","reason":"stop","tokens":{"input":200,"output":10}}}"#;
1291
1292 let parsed = runner().parse_response(raw).unwrap();
1293 assert_eq!(parsed.response.status, "done");
1294 assert_eq!(parsed.response.summary, "fixed");
1295 assert_eq!(parsed.input_tokens, Some(200));
1296 assert_eq!(parsed.output_tokens, Some(10));
1297 }
1298
1299 #[test]
1300 fn parse_opencode_concatenated_text() {
1301 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1302{"type":"text","timestamp":1001,"part":{"type":"text","text":"Working on "}}
1303{"type":"text","timestamp":1002,"part":{"type":"text","text":"the task. "}}
1304{"type":"text","timestamp":1003,"part":{"type":"text","text":"{\"status\":\"done\",\"summary\":\"finished\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}}
1305{"type":"step_finish","timestamp":1004,"part":{"type":"step-finish","reason":"stop","tokens":{"input":100,"output":50}}}"#;
1306
1307 let parsed = runner().parse_response(raw).unwrap();
1308 assert_eq!(parsed.response.status, "done");
1309 assert_eq!(parsed.input_tokens, Some(100));
1310 assert_eq!(parsed.output_tokens, Some(50));
1311 }
1312
1313 #[test]
1314 fn extract_router_text_prefers_json_payload() {
1315 let raw = r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"Thinking..."}}
1316{"type":"text","timestamp":1002,"part":{"type":"text","text":"{\"executor\":\"claude\",\"complexity\":\"medium\",\"reason\":\"fit\"}"}}"#;
1317
1318 let text = extract_router_text(raw).unwrap();
1319 let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
1320 assert_eq!(parsed["executor"], "claude");
1321 }
1322
1323 #[test]
1324 fn parse_opencode_embedded_json_fragment_without_executor_is_ignored() {
1325 let raw = r#"{"type":"step_start","timestamp":1}
1330{"type":"text","timestamp":2,"part":{"type":"text","text":"Here is a debug trace: {\"trace\":{\"duration\":123,\"id\":\"abc\"}}"}}
1331{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
1332
1333 let err = runner().parse_response(raw).unwrap_err();
1334 match err {
1335 AgentError::InvalidResponse { raw: _ } => {
1336 }
1338 other => panic!("expected InvalidResponse, got: {other:?}"),
1339 }
1340 }
1341
1342 #[test]
1343 fn parse_opencode_error_event() {
1344 let raw = r#"{"type":"error","message":"rate limit exceeded for this model"}"#;
1345
1346 let err = runner().parse_response(raw).unwrap_err();
1347 assert!(matches!(err, AgentError::RateLimit { .. }));
1348 }
1349
1350 #[test]
1356 fn parse_opencode_tool_only_stream_synthesizes_needs_review() {
1357 let raw = r#"{"type":"step_start","timestamp":1,"part":{"type":"step-start"}}
1358{"type":"tool_use","timestamp":2,"name":"bash","input":{"cmd":"ls"}}
1359{"type":"tool_use","timestamp":3,"name":"read","input":{"path":"src/main.rs"}}
1360{"type":"tool_use","timestamp":4,"name":"bash","input":{"cmd":"cargo check"}}
1361{"type":"step_finish","timestamp":5,"part":{"type":"step-finish","reason":"stop","tokens":{"input":500,"output":0}}}"#;
1362
1363 let parsed = runner()
1364 .parse_response(raw)
1365 .expect("tool-only NDJSON must synthesize a response, not error");
1366 assert_eq!(parsed.response.status, "needs_review");
1367 assert!(
1368 !parsed.response.summary.contains("\"type\":\"step_start\""),
1369 "summary must not contain raw NDJSON; got: {}",
1370 parsed.response.summary
1371 );
1372 assert!(
1373 parsed.response.summary.contains("bash") && parsed.response.summary.contains("read"),
1374 "summary should mention tools used; got: {}",
1375 parsed.response.summary
1376 );
1377 assert!(
1378 parsed.response.summary.contains("stop"),
1379 "summary should mention step_finish reason; got: {}",
1380 parsed.response.summary
1381 );
1382 assert_eq!(parsed.input_tokens, Some(500));
1384 assert_eq!(parsed.output_tokens, Some(0));
1385 }
1386
1387 #[test]
1392 fn parse_opencode_plain_prose_completion_synthesizes_response() {
1393 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1394{"type":"text","timestamp":1001,"part":{"type":"text","text":"Fixed the typo and corrected the deadline reference in the review."}}
1395{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","tokens":{"input":300,"output":40}}}"#;
1396
1397 let parsed = runner()
1398 .parse_response(raw)
1399 .expect("plain-prose completion text must synthesize successfully");
1400 assert_eq!(parsed.response.status, "done");
1401 assert_eq!(parsed.input_tokens, Some(300));
1402 assert_eq!(parsed.output_tokens, Some(40));
1403 }
1404
1405 #[test]
1406 fn parse_opencode_step_finish_error() {
1407 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1408{"type":"step_finish","timestamp":1001,"part":{"type":"step-finish","reason":"error","error":"context length exceeded"}}"#;
1409
1410 let err = runner().parse_response(raw).unwrap_err();
1411 assert!(matches!(err, AgentError::ContextOverflow { .. }));
1412 }
1413
1414 #[test]
1415 fn parse_opencode_empty_returns_silent_failure() {
1416 let err = runner().parse_response("").unwrap_err();
1417 assert!(matches!(err, AgentError::Unknown { exit_code: 0, .. }));
1420 }
1421
1422 #[test]
1423 fn extract_tokens_from_step_finish() {
1424 let events: Vec<serde_json::Value> = vec![
1425 serde_json::json!({"type":"step_finish","part":{"type":"step-finish","tokens":{"input":1000,"output":500,"total":1500}}}),
1426 ];
1427 let (input, output) = OpenCodeRunner::extract_tokens(&events);
1428 assert_eq!(input, Some(1000));
1429 assert_eq!(output, Some(500));
1430 }
1431
1432 #[test]
1433 fn classify_opencode_rate_limit() {
1434 let err = classify_opencode_message("429 Too Many Requests");
1435 assert!(matches!(err, AgentError::RateLimit { .. }));
1436 }
1437
1438 #[test]
1439 fn classify_opencode_model_not_found() {
1440 let err = classify_opencode_message("model not found: gpt-5");
1441 assert!(matches!(err, AgentError::ModelUnavailable { .. }));
1442 }
1443
1444 #[test]
1445 fn classify_opencode_model_not_found_with_suggestion() {
1446 let err = classify_opencode_message(
1449 "Model not found: github-copilot/gemini-3.1-pro. Did you mean: gemini-3.1-pro-preview?",
1450 );
1451 match err {
1452 AgentError::ModelUnavailable { model, message } => {
1453 assert_eq!(model, "github-copilot/gemini-3.1-pro");
1454 assert!(message.contains("Did you mean"));
1455 }
1456 other => panic!("expected ModelUnavailable, got: {other:?}"),
1457 }
1458 }
1459
1460 #[test]
1461 fn classify_opencode_model_deprecated() {
1462 let err = classify_opencode_message(
1465 "The free model has been deprecated. Transition to qwen/qwen3.6-plus for continued paid access.",
1466 );
1467 match err {
1468 AgentError::ModelUnavailable { model, .. } => {
1469 assert_eq!(
1470 model, "qwen/qwen3.6-plus",
1471 "expected model extracted from 'Transition to X', got: {model:?}"
1472 );
1473 }
1474 other => panic!("expected ModelUnavailable, got: {other:?}"),
1475 }
1476 }
1477
1478 #[test]
1479 fn classify_opencode_model_unavailable_for_free() {
1480 let err = classify_opencode_message(
1484 "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",
1485 );
1486 match err {
1487 AgentError::ModelUnavailable { model, .. } => {
1488 assert_eq!(
1489 model, "inclusionai/ling-3.0-flash",
1490 "expected model extracted from 'use this slug instead: X', got: {model:?}"
1491 );
1492 }
1493 other => panic!("expected ModelUnavailable, got: {other:?}"),
1494 }
1495 }
1496
1497 #[test]
1498 fn classify_opencode_model_not_available_in_country() {
1499 let err =
1504 classify_opencode_message("agent failed: This model is not available in your country.");
1505 match err {
1506 AgentError::ModelUnavailable { .. } => {}
1507 other => panic!("expected ModelUnavailable, got: {other:?}"),
1508 }
1509 }
1510
1511 #[test]
1512 fn classify_opencode_no_endpoints_found() {
1513 let err = classify_opencode_message("No endpoints found for qwen/qwen3.6-plus:free.");
1515 match err {
1516 AgentError::ModelUnavailable { model, .. } => {
1517 assert_eq!(model, "qwen/qwen3.6-plus:free");
1518 }
1519 other => panic!("expected ModelUnavailable, got: {other:?}"),
1520 }
1521 }
1522
1523 #[test]
1524 fn build_command_opencode() {
1525 let r = runner();
1526 let perms = PermissionRules::default();
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!(cmd.contains("opencode run"));
1535 assert!(cmd.contains("--model 'anthropic/claude-sonnet-4-20250514'"));
1536 assert!(cmd.contains("--format json"));
1537 assert!(
1538 cmd.starts_with(
1539 r#"OPENCODE_CONFIG_CONTENT='{"snapshot":false}' timeout 1800 opencode run"#
1540 ),
1541 "snapshots must be off for orch runs, got: {cmd}"
1542 );
1543 assert!(
1546 cmd.contains("--dangerously-skip-permissions"),
1547 "expected --dangerously-skip-permissions, got: {cmd}"
1548 );
1549 assert!(
1550 !cmd.contains("opencode.json"),
1551 "should not write a config file, got: {cmd}"
1552 );
1553 assert!(
1554 !cmd.contains("XDG_CONFIG_HOME"),
1555 "should not override XDG_CONFIG_HOME, got: {cmd}"
1556 );
1557 assert!(
1558 !cmd.contains(".orch-opencode"),
1559 "should not reference .orch-opencode, got: {cmd}"
1560 );
1561 assert!(
1568 !cmd.trim_start().starts_with("cat "),
1569 "opencode command must not start with a `cat |` pipe stage, got: {cmd}"
1570 );
1571 assert!(
1572 cmd.contains("< <(cat \"/tmp/sys.txt\" \"/tmp/msg.txt\")"),
1573 "sys/msg files must be fed via process substitution, got: {cmd}"
1574 );
1575 }
1576
1577 #[test]
1578 fn build_command_opencode_supervised_no_skip_flag() {
1579 let r = runner();
1580 let perms = PermissionRules {
1581 autonomous: false,
1582 ..PermissionRules::default()
1583 };
1584 let cmd = r.build_command(
1585 Some("anthropic/claude-sonnet-4-20250514"),
1586 "timeout 1800",
1587 "/tmp/sys.txt",
1588 "/tmp/msg.txt",
1589 &perms,
1590 );
1591 assert!(
1592 !cmd.contains("--dangerously-skip-permissions"),
1593 "supervised mode must not pass --dangerously-skip-permissions, got: {cmd}"
1594 );
1595 }
1596
1597 #[test]
1598 fn split_model_variant_no_at() {
1599 let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5");
1600 assert_eq!(base, "openai/gpt-5.5");
1601 assert!(variant.is_none());
1602 }
1603
1604 #[test]
1605 fn split_model_variant_with_variant() {
1606 let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5@xhigh");
1607 assert_eq!(base, "openai/gpt-5.5");
1608 assert_eq!(variant, Some("xhigh"));
1609 }
1610
1611 #[test]
1612 fn split_model_variant_trailing_at_empty() {
1613 let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5@");
1614 assert_eq!(base, "openai/gpt-5.5");
1615 assert_eq!(variant, Some(""));
1616 }
1617
1618 #[test]
1619 fn split_model_variant_multiple_ats_last_wins() {
1620 let (base, variant) = OpenCodeRunner::split_model_variant("a@b@c@v");
1621 assert_eq!(base, "a@b@c");
1622 assert_eq!(variant, Some("v"));
1623 }
1624
1625 #[test]
1626 fn build_command_opencode_with_variant() {
1627 let r = runner();
1628 let perms = PermissionRules::default();
1629 let cmd = r.build_command(
1630 Some("openai/gpt-5.5@high"),
1631 "timeout 1800",
1632 "/tmp/sys.txt",
1633 "/tmp/msg.txt",
1634 &perms,
1635 );
1636 assert!(cmd.contains("--model 'openai/gpt-5.5'"));
1637 assert!(cmd.contains("--variant 'high'"));
1638 }
1639
1640 #[test]
1641 fn classify_opencode_permission_denied() {
1642 let err = classify_opencode_message("user rejected permission for tool bash: cargo test");
1643 assert!(
1644 matches!(err, AgentError::PermissionDenied { .. }),
1645 "expected PermissionDenied, got: {err:?}"
1646 );
1647 }
1648
1649 #[test]
1650 fn classify_opencode_provider_returned_error() {
1651 let err = classify_opencode_message("Provider returned error");
1655 assert!(
1656 matches!(err, AgentError::RateLimit { .. }),
1657 "expected RateLimit, got: {err:?}"
1658 );
1659 let err_with_detail =
1660 classify_opencode_message("Provider returned error: upstream connection timed out");
1661 assert!(
1662 matches!(err_with_detail, AgentError::RateLimit { .. }),
1663 "expected RateLimit, got: {err_with_detail:?}"
1664 );
1665 }
1666
1667 #[test]
1668 fn classify_opencode_upstream_idle_timeout() {
1669 let err = classify_opencode_message("Upstream idle timeout exceeded");
1672 assert!(
1673 matches!(err, AgentError::NetworkError { .. }),
1674 "expected NetworkError, got: {err:?}"
1675 );
1676 }
1677
1678 #[test]
1679 fn free_models_discovery_returns_vec() {
1680 let models = discover_free_opencode_models();
1682 assert!(models.is_empty(), "{models:?}");
1683 }
1684
1685 #[test]
1686 fn live_opencode_discovery_is_skipped_in_test_builds() {
1687 assert!(skip_live_opencode_discovery());
1688 let models = discover_opencode_models();
1689 assert!(models.is_empty(), "{models:?}");
1690 let models = discover_free_opencode_models();
1691 assert!(models.is_empty(), "{models:?}");
1692 prime_free_model_cache();
1693 let models = discover_opencode_models();
1694 assert!(models.is_empty(), "{models:?}");
1695 }
1696
1697 #[cfg(unix)]
1700 #[tokio::test]
1701 async fn models_cli_reaps_orphaned_process_group() {
1702 let dir = tempfile::tempdir().unwrap();
1703 let sleeper_pid_path = dir.path().join("sleeper.pid");
1704 let script = dir.path().join("opencode");
1705 fs::write(
1706 &script,
1707 format!(
1708 "#!/bin/sh\n/bin/sleep 60 </dev/null >/dev/null 2>&1 &\necho $! > '{}'\necho test-model-free\n",
1709 sleeper_pid_path.display()
1710 ),
1711 )
1712 .unwrap();
1713 fs::set_permissions(&script, Permissions::from_mode(0o755)).unwrap();
1714
1715 let (models, reason) =
1718 run_opencode_models_cli_inner(script.to_str().unwrap(), Duration::from_secs(30)).await;
1719 assert_eq!(reason, "ok");
1720 assert_eq!(models, vec!["test-model-free".to_string()]);
1721
1722 let sleeper: u32 = fs::read_to_string(&sleeper_pid_path)
1723 .unwrap()
1724 .trim()
1725 .parse()
1726 .expect("sleeper pid");
1727 let deadline = Instant::now() + Duration::from_secs(10);
1728 while pid_is_alive(sleeper) && Instant::now() < deadline {
1729 time::sleep(Duration::from_millis(20)).await;
1730 }
1731 assert!(
1732 !pid_is_alive(sleeper),
1733 "sleeper {sleeper} must die with the process group when opencode models returns"
1734 );
1735 }
1736
1737 #[cfg(unix)]
1738 fn pid_is_alive(pid: u32) -> bool {
1739 i32::try_from(pid).is_ok_and(|pid| super::raw_kill(pid, 0) == 0)
1741 }
1742
1743 #[test]
1744 fn empty_free_discovery_preserves_existing_cache_contents() {
1745 reset_model_caches_for_test();
1746 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1747 {
1748 let mut guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
1749 *guard = (123, vec!["opencode/model-free".to_string()]);
1750 }
1751
1752 update_discovered_models_cache(cache, Vec::new(), "free", "empty_output");
1753
1754 let guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
1755 assert!(guard.0 > 123);
1758 assert_eq!(guard.1, vec!["opencode/model-free".to_string()]);
1759 }
1760
1761 #[test]
1762 fn empty_all_models_discovery_preserves_existing_cache_contents() {
1763 reset_model_caches_for_test();
1764 let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1765 {
1766 let mut guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
1767 *guard = (
1768 456,
1769 vec![
1770 "opencode/model-free".to_string(),
1771 "opencode/model-paid".to_string(),
1772 ],
1773 );
1774 }
1775
1776 update_discovered_models_cache(cache, Vec::new(), "all", "exit_status_failure");
1777
1778 let guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
1779 assert!(guard.0 > 456);
1780 assert_eq!(
1781 guard.1,
1782 vec![
1783 "opencode/model-free".to_string(),
1784 "opencode/model-paid".to_string(),
1785 ]
1786 );
1787 }
1788
1789 #[test]
1790 fn empty_discovery_populates_empty_cache() {
1791 reset_model_caches_for_test();
1792 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1793
1794 update_discovered_models_cache(cache, Vec::new(), "free", "command_not_found");
1795
1796 let guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
1797 assert!(guard.0 > 0);
1798 assert!(guard.1.is_empty(), "{:?}", guard.1);
1799 }
1800
1801 #[test]
1804 fn fixture_opencode_success() {
1805 let raw = include_str!("../../../../tests/fixtures/opencode_success.jsonl");
1806 let parsed = runner().parse_response(raw).unwrap();
1807 assert_eq!(parsed.response.status, "done");
1808 assert!(parsed.response.summary.contains("Fixed"));
1809 assert_eq!(parsed.response.accomplished.len(), 2);
1810 assert_eq!(parsed.input_tokens, Some(17500));
1811 assert_eq!(parsed.output_tokens, Some(500));
1812 }
1813
1814 #[test]
1818 fn fixture_opencode_permission_denied() {
1819 let raw = include_str!("../../../../tests/fixtures/opencode_permission_denied.jsonl");
1820 let err = runner().parse_response(raw).unwrap_err();
1821 assert!(
1822 matches!(err, AgentError::PermissionDenied { .. }),
1823 "expected PermissionDenied, got: {err:?}"
1824 );
1825 }
1826
1827 #[test]
1829 fn fixture_opencode_rate_limit() {
1830 let raw = include_str!("../../../../tests/fixtures/opencode_rate_limit.jsonl");
1831 let err = runner().parse_response(raw).unwrap_err();
1832 assert!(
1833 matches!(err, AgentError::RateLimit { .. }),
1834 "expected RateLimit, got: {err:?}"
1835 );
1836 }
1837
1838 #[test]
1842 fn fixture_opencode_model_not_found() {
1843 let raw = include_str!("../../../../tests/fixtures/opencode_model_not_found.jsonl");
1844 let err = runner().parse_response(raw).unwrap_err();
1845 assert!(
1846 matches!(err, AgentError::ModelUnavailable { .. }),
1847 "expected ModelUnavailable, got: {err:?}"
1848 );
1849 if let AgentError::ModelUnavailable { model, .. } = &err {
1850 assert_eq!(model, "anthropic/claude-sonnet-4-6");
1851 }
1852 }
1853
1854 #[test]
1857 fn fixture_opencode_provider_returned_error() {
1858 let raw = include_str!("../../../../tests/fixtures/opencode_provider_returned_error.jsonl");
1859 let err = runner().parse_response(raw).unwrap_err();
1860 assert!(
1861 matches!(err, AgentError::RateLimit { .. }),
1862 "expected RateLimit, got: {err:?}"
1863 );
1864 }
1865
1866 #[test]
1870 fn extract_text_returns_text_events() {
1871 let raw = concat!(
1872 r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1873 "\n",
1874 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
1875 "\n",
1876 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":100,"input":90,"output":10}}}"#,
1877 );
1878 let text = runner().extract_text(raw).unwrap();
1879 assert_eq!(
1880 text,
1881 r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#
1882 );
1883 }
1884
1885 #[test]
1887 fn extract_text_direct_text_event_format() {
1888 let raw = concat!(
1889 r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1890 "\n",
1891 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"}"#,
1892 "\n",
1893 r#"{"type":"step_finish","timestamp":1002,"sessionID":"ses_abc"}"#,
1894 );
1895 let text = runner().extract_text(raw).unwrap();
1896 assert!(
1897 text.contains("request_changes"),
1898 "expected review JSON, got: {text}"
1899 );
1900 }
1901
1902 #[test]
1904 fn extract_text_rate_limit_propagates() {
1905 let raw = concat!(
1906 r#"{"type":"step_start","timestamp":1000}"#,
1907 "\n",
1908 r#"{"type":"error","message":"rate limit exceeded: 429 Too Many Requests"}"#,
1909 );
1910 let err = runner().extract_text(raw).unwrap_err();
1911 assert!(
1912 matches!(err, AgentError::RateLimit { .. }),
1913 "expected RateLimit, got: {err:?}"
1914 );
1915 }
1916
1917 #[test]
1919 fn extract_text_auth_error_propagates() {
1920 let raw = concat!(
1921 r#"{"type":"step_start","timestamp":1000}"#,
1922 "\n",
1923 r#"{"type":"error","message":"401 Unauthorized: invalid api key"}"#,
1924 );
1925 let err = runner().extract_text(raw).unwrap_err();
1926 assert!(
1927 matches!(err, AgentError::Auth { .. }),
1928 "expected Auth, got: {err:?}"
1929 );
1930 }
1931
1932 #[test]
1934 fn extract_text_empty_input_returns_empty_ok() {
1935 let text = runner().extract_text("").unwrap();
1936 assert_eq!(text, "");
1937 }
1938
1939 #[test]
1942 fn find_opencode_result_success_with_tokens() {
1943 let ndjson = concat!(
1944 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1945 "\n",
1946 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\"}"}}"#,
1947 "\n",
1948 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0.05,"tokens":{"input":5000,"output":200}}}"#,
1949 );
1950 let result = find_opencode_result(ndjson).expect("should find result");
1951 assert!(!result.is_error);
1952 assert!(result.result_text.contains("approve"));
1953 assert_eq!(result.input_tokens, Some(5000));
1954 assert_eq!(result.output_tokens, Some(200));
1955 assert_eq!(result.cost_usd, Some(0.05));
1956 assert!(!result.truncated_by_length);
1957 }
1958
1959 #[test]
1960 fn find_opencode_result_error_event() {
1961 let ndjson = r#"{"type":"error","message":"rate limit exceeded: 429"}"#;
1962 let result = find_opencode_result(ndjson).expect("should find error result");
1963 assert!(result.is_error);
1964 assert!(result.result_text.contains("rate limit"));
1965 assert!(!result.truncated_by_length);
1966 }
1967
1968 #[test]
1972 fn find_opencode_result_length_truncation() {
1973 let ndjson = concat!(
1974 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1975 "\n",
1976 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":"}}"#,
1977 "\n",
1978 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"length","tokens":{"input":5485,"output":759,"reasoning":31241}}}"#,
1979 );
1980 let result = find_opencode_result(ndjson).expect("should find result");
1981 assert!(result.truncated_by_length);
1982 assert!(!result.is_error, "length truncation is not a hard error");
1983 }
1984
1985 #[test]
1991 fn find_opencode_result_zero_output_length_truncation() {
1992 let ndjson = concat!(
1993 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1994 "\n",
1995 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"length","tokens":{"total":71482,"input":39482,"output":0,"reasoning":40000}}}"#,
1996 );
1997 let result = find_opencode_result(ndjson).expect("should find result, not None");
1998 assert!(result.truncated_by_length);
1999 assert!(!result.is_error);
2000 assert_eq!(result.result_text, "");
2001 }
2002
2003 #[test]
2006 fn find_opencode_result_zero_output_unknown_reason_truncation() {
2007 let ndjson = concat!(
2008 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
2009 "\n",
2010 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"unknown","tokens":{"input":0,"output":0,"reasoning":0}}}"#,
2011 );
2012 let result = find_opencode_result(ndjson).expect("should find result, not None");
2013 assert!(result.truncated_by_length);
2014 assert!(!result.is_error);
2015 assert_eq!(result.result_text, "");
2016 }
2017
2018 #[test]
2022 fn find_opencode_result_unknown_reason_with_output_not_truncated() {
2023 let ndjson = concat!(
2024 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
2025 "\n",
2026 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"partial answer"}}"#,
2027 "\n",
2028 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"unknown","tokens":{"input":100,"output":50,"reasoning":0}}}"#,
2029 );
2030 let result = find_opencode_result(ndjson).expect("should find result");
2031 assert!(!result.truncated_by_length);
2032 assert!(!result.is_error);
2033 }
2034
2035 #[test]
2036 fn find_opencode_result_empty_returns_none() {
2037 assert!(find_opencode_result("").is_none());
2038 assert!(find_opencode_result(" ").is_none());
2039 }
2040
2041 #[test]
2042 fn find_opencode_result_direct_text_format() {
2043 let ndjson = concat!(
2044 r#"{"type":"step_start","timestamp":1000}"#,
2045 "\n",
2046 r#"{"type":"text","timestamp":1001,"text":"Review complete. All tests passed."}"#,
2047 "\n",
2048 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","tokens":{"input":100,"output":10}}}"#,
2049 );
2050 let result = find_opencode_result(ndjson).expect("should find result");
2051 assert!(!result.is_error);
2052 assert!(result.result_text.contains("All tests passed"));
2053 }
2054
2055 #[test]
2056 fn find_opencode_result_plain_text_returns_none() {
2057 assert!(find_opencode_result("just some plain text").is_none());
2058 }
2059}
2060
2061#[cfg(test)]
2062mod error_text_tests {
2063 use super::*;
2064 use serde_json::json;
2065
2066 #[test]
2067 fn unrecognized_error_shape_keeps_raw_json() {
2068 let e = json!({"type":"error","error":{"code":429,"detail":"slow down"}});
2069 assert!(opencode_error_text(&e).contains("slow down"));
2070 let e = json!({"type":"error"});
2071 assert!(opencode_error_text(&e).contains("\"type\""));
2072 }
2073
2074 #[test]
2075 fn known_shapes_still_extract() {
2076 assert_eq!(opencode_error_text(&json!({"message":"a"})), "a");
2077 assert_eq!(opencode_error_text(&json!({"error":"b"})), "b");
2078 assert_eq!(opencode_error_text(&json!({"error":{"message":"c"}})), "c");
2079 assert_eq!(
2080 opencode_error_text(&json!({"error":{"name":"N","data":{"message":"d"}}})),
2081 "d"
2082 );
2083 }
2084}
2085
2086fn stream_events(v: &serde_json::Value) -> Vec<StreamEvent> {
2088 let text = || {
2089 v.pointer("/part/text")
2090 .or_else(|| v.get("text"))
2091 .and_then(serde_json::Value::as_str)
2092 .unwrap_or("")
2093 .to_string()
2094 };
2095 match json_str(v, "/type") {
2096 "text" => vec![StreamEvent::Text(text())],
2097 "reasoning" => vec![StreamEvent::Thinking(text())],
2098 "tool_use" => {
2099 let part = &v["part"];
2100 let state = &part["state"];
2101 let id = json_str(part, "/callID").to_string();
2102 let name = json_str(part, "/tool").to_string();
2103 let mut out = vec![StreamEvent::ToolCall {
2104 id: id.clone(),
2105 name: name.clone(),
2106 input: state["input"].clone(),
2107 }];
2108 let result = match json_str(state, "/status") {
2109 "completed" => Some((json_str(state, "/output").to_string(), false)),
2110 "error" => Some((json_error_message(state), true)),
2111 _ => None,
2112 };
2113 if let Some((output, is_error)) = result {
2114 out.push(StreamEvent::ToolResult {
2115 id,
2116 name,
2117 output,
2118 is_error,
2119 });
2120 }
2121
2122 out
2123 }
2124 "step_finish" if json_str(v, "/part/reason") == "stop" => {
2125 vec![StreamEvent::Done(String::new())]
2126 }
2127 "error" => vec![StreamEvent::Error(json_error_message(v))],
2128 _ => vec![],
2129 }
2130}
2131
2132#[cfg(test)]
2133mod stream_tests {
2134 use super::*;
2135 use serde_json::json;
2136
2137 fn events(line: &str) -> Vec<StreamEvent> {
2138 OpenCodeRunner::new().stream_events(&serde_json::from_str(line).unwrap())
2139 }
2140
2141 #[test]
2142 fn text_in_part_or_top_level() {
2143 assert_eq!(
2144 events(r#"{"type":"text","part":{"type":"text","text":"Reviewing."}}"#),
2145 vec![StreamEvent::Text("Reviewing.".into())]
2146 );
2147 assert_eq!(
2148 events(r#"{"type":"text","text":"Plain."}"#),
2149 vec![StreamEvent::Text("Plain.".into())]
2150 );
2151 }
2152
2153 #[test]
2154 fn completed_tool_has_call_and_result() {
2155 let line = r#"{"type":"tool_use","part":{"type":"tool","tool":"read","callID":"c1","state":{"status":"completed","input":{"filePath":"src/lib.rs"},"output":"a\nb"}}}"#;
2156 assert_eq!(
2157 events(line),
2158 vec![
2159 StreamEvent::ToolCall {
2160 id: "c1".into(),
2161 name: "read".into(),
2162 input: json!({"filePath": "src/lib.rs"}),
2163 },
2164 StreamEvent::ToolResult {
2165 id: "c1".into(),
2166 name: "read".into(),
2167 output: "a\nb".into(),
2168 is_error: false,
2169 },
2170 ]
2171 );
2172 }
2173
2174 #[test]
2175 fn failed_tool_result_is_error() {
2176 let line = r#"{"type":"tool_use","part":{"tool":"bash","callID":"c2","state":{"status":"error","input":{"command":"x"},"error":"not found"}}}"#;
2177 assert_eq!(
2178 events(line)[1],
2179 StreamEvent::ToolResult {
2180 id: "c2".into(),
2181 name: "bash".into(),
2182 output: "not found".into(),
2183 is_error: true,
2184 }
2185 );
2186 }
2187
2188 #[test]
2189 fn only_final_step_is_done() {
2190 let dropped = events(r#"{"type":"step_finish","part":{"reason":"tool-calls"}}"#);
2191 assert!(dropped.is_empty(), "{dropped:?}");
2192 assert_eq!(
2193 events(r#"{"type":"step_finish","part":{"reason":"stop"}}"#),
2194 vec![StreamEvent::Done(String::new())]
2195 );
2196 let dropped = events(r#"{"type":"step_start"}"#);
2197 assert!(dropped.is_empty(), "{dropped:?}");
2198 }
2199
2200 #[test]
2201 fn error_event() {
2202 let line = r#"{"type":"error","error":{"name":"UnknownError","data":{"message":"Provider returned error"}}}"#;
2203 assert_eq!(
2204 events(line),
2205 vec![StreamEvent::Error("Provider returned error".into())]
2206 );
2207 }
2208}