1use super::{
28 AgentError, AgentRunner, ParsedResponse, PermissionRules, StreamEvent, json_error_message,
29 json_str,
30};
31use crate::parser;
32
33use std::path::Path;
34use std::sync::atomic::{AtomicBool, Ordering};
35use std::sync::{Mutex, OnceLock};
36
37static FREE_MODELS_CACHE: OnceLock<Mutex<(i64, Vec<String>)>> = OnceLock::new();
40static FREE_MODELS_REFRESH_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
41static ALL_MODELS_CACHE: OnceLock<Mutex<(i64, Vec<String>)>> = OnceLock::new();
44static ALL_MODELS_REFRESH_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
45
46#[cfg(test)]
47pub(crate) fn reset_model_caches_for_test() {
48 if let Some(cache) = FREE_MODELS_CACHE.get()
49 && let Ok(mut guard) = cache.lock()
50 {
51 *guard = (0, Vec::new());
52 }
53 if let Some(cache) = ALL_MODELS_CACHE.get()
54 && let Ok(mut guard) = cache.lock()
55 {
56 *guard = (0, Vec::new());
57 }
58 FREE_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
59 ALL_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
60}
61
62fn skip_live_opencode_discovery() -> bool {
71 cfg!(any(test, debug_assertions))
72}
73
74fn update_discovered_models_cache(
75 cache: &Mutex<(i64, Vec<String>)>,
76 discovered: Vec<String>,
77 cache_name: &'static str,
78 empty_reason: &'static str,
79) {
80 let now = chrono::Utc::now().timestamp();
81 let mut guard = cache
82 .lock()
83 .unwrap_or_else(std::sync::PoisonError::into_inner);
84
85 if discovered.is_empty() && !guard.1.is_empty() {
86 tracing::warn!(
87 cache = cache_name,
88 cached_models = guard.1.len(),
89 reason = empty_reason,
90 "opencode model discovery returned empty; preserving previous cache contents"
91 );
92 guard.0 = now;
96 return;
97 }
98
99 tracing::debug!(
100 cache = cache_name,
101 discovered_models = discovered.len(),
102 "opencode model discovery refreshed cache"
103 );
104 *guard = (now, discovered);
105}
106
107#[derive(Debug)]
109pub struct OpenCodeRunner;
110
111pub(crate) fn extract_ndjson_text(events: &[serde_json::Value]) -> Option<String> {
112 let mut texts = Vec::new();
113
114 for event in events {
115 let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
116
117 if event_type == "text" || event_type == "message" || event_type == "assistant" {
123 if let Some(part) = event.get("part") {
124 texts.extend(extract_text_from_part(part));
125 }
126
127 if let Some(text) = event.get("text").and_then(|v| v.as_str()) {
129 texts.push(text.to_string());
130 }
131
132 if let Some(message) = event.get("message")
134 && let Some(items) = message.get("content").and_then(|v| v.as_array())
135 {
136 for item in items {
137 let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
138 if item_type == "text"
139 && let Some(text) = item.get("text").and_then(|v| v.as_str())
140 {
141 texts.push(text.to_string());
142 }
143 }
144 }
145
146 continue;
147 }
148
149 if let Some(part) = event.get("part") {
151 let part_type = part.get("type").and_then(|v| v.as_str()).unwrap_or("");
152 if part_type == "text" || part_type == "output_text" || part_type == "message" {
153 texts.extend(extract_text_from_part(part));
154 }
155 }
156 }
157
158 if texts.is_empty() {
161 for event in events.iter().rev() {
162 if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
163 if event_type == "system" {
165 continue;
166 }
167 }
168
169 if let Some(result) = event.get("result") {
170 if let Some(s) = result.as_str() {
172 return Some(s.to_string());
173 }
174 if result.is_object() || result.is_array() {
175 return Some(result.to_string());
176 }
177 }
178
179 if let Some(text) = event.get("text").and_then(|v| v.as_str())
181 && !text.trim().is_empty()
182 {
183 return Some(text.to_string());
184 }
185 }
186
187 return None;
188 }
189
190 let full = texts.join("");
191 if let Some(json) = extract_json_object(&full) {
192 if crate::parser::parse(&json).is_ok() {
194 return Some(json);
195 }
196
197 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&json)
200 && let Some(obj) = val.as_object()
201 && (obj.contains_key("executor")
202 || obj.contains_key("status")
203 || obj.contains_key("complexity")
204 || obj.contains_key("decision"))
205 {
206 return Some(json);
207 }
208 }
210
211 for text in texts.iter().rev() {
212 let trimmed = text.trim();
213 if let Some(json) = extract_json_object(trimmed) {
214 if crate::parser::parse(&json).is_ok() {
218 return Some(json);
219 }
220
221 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&json)
224 && let Some(obj) = val.as_object()
225 && (obj.contains_key("executor") || obj.contains_key("status"))
226 {
227 return Some(json);
228 }
229 }
230 if trimmed.contains('{') {
234 if crate::parser::parse(trimmed).is_ok() {
239 return Some(trimmed.to_string());
240 }
241
242 if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed)
243 && let Some(obj) = val.as_object()
244 && (obj.contains_key("executor")
245 || obj.contains_key("status")
246 || obj.contains_key("complexity"))
247 {
248 return Some(trimmed.to_string());
249 }
250 }
251 }
252
253 Some(full)
254}
255
256fn extract_text_from_part(part: &serde_json::Value) -> Vec<String> {
257 let mut out = Vec::new();
258
259 if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
260 out.push(text.to_string());
261 }
262
263 if let Some(items) = part.get("content").and_then(|v| v.as_array()) {
265 for item in items {
266 let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
267 if item_type == "text"
268 && let Some(text) = item.get("text").and_then(|v| v.as_str())
269 {
270 out.push(text.to_string());
271 }
272 }
273 }
274
275 out
276}
277
278fn extract_json_object(text: &str) -> Option<String> {
279 let trimmed = text.trim();
280 if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
281 return Some(trimmed.to_string());
282 }
283
284 let start = trimmed.find('{')?;
285 let end = trimmed.rfind('}')?;
286 if end <= start {
287 return None;
288 }
289
290 let candidate = trimmed[start..=end].trim();
291 serde_json::from_str::<serde_json::Value>(candidate)
292 .ok()
293 .map(|_| candidate.to_string())
294}
295
296pub(crate) fn extract_router_text(raw: &str) -> Option<String> {
297 let events = super::parse_ndjson(raw.trim());
298 if events.is_empty() {
299 return None;
300 }
301 extract_ndjson_text(&events)
302}
303
304#[cfg(test)]
305mod router_text_tests {
306 use super::*;
307
308 #[test]
309 fn extract_router_text_supports_message_with_content_array() {
310 let raw = r#"{"type":"step_start","timestamp":1}
312{"type":"message","timestamp":2,"part":{"type":"message","content":[{"type":"text","text":"```json\n{\"executor\":\"opencode\",\"complexity\":\"medium\",\"reason\":\"ok\"}\n```"}]}}
313{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
314 let text = extract_router_text(raw).expect("should extract text");
315 assert!(
316 text.contains("executor"),
317 "extracted text should include JSON"
318 );
319 }
320
321 #[test]
322 fn extract_router_text_ignores_leading_system_init() {
323 let raw = r#"{"type":"system","subtype":"init","cwd":"/"}
327{"type":"text","timestamp":2,"text":"{\"executor\":\"opencode\",\"complexity\":\"medium\",\"reason\":\"ndjson\"}"}
328{"type":"step_finish","timestamp":3}"#;
329
330 let text = extract_router_text(raw).expect("should extract text even with leading init");
331 let parsed: serde_json::Value =
332 serde_json::from_str(&text).expect("extracted text must be JSON");
333 assert_eq!(parsed["executor"], "opencode");
334 assert_eq!(parsed["complexity"], "medium");
335 }
336}
337
338const NO_SNAPSHOT_CONFIG: &str = r#"{"snapshot":false}"#;
341
342impl OpenCodeRunner {
343 pub fn new() -> Self {
344 Self
345 }
346
347 pub(crate) fn split_model_variant(s: &str) -> (&str, Option<&str>) {
354 if let Some(pos) = s.rfind('@') {
355 let base = &s[..pos];
356 let variant = &s[pos + 1..];
357 (base, Some(variant))
358 } else {
359 (s, None)
360 }
361 }
362
363 fn extract_text_from_events(events: &[serde_json::Value]) -> Option<String> {
374 extract_ndjson_text(events)
375 }
376
377 fn extract_tokens(events: &[serde_json::Value]) -> (Option<u64>, Option<u64>) {
379 for event in events.iter().rev() {
380 let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
381
382 if event_type == "step_finish"
383 && let Some(part) = event.get("part")
384 && let Some(tokens) = part.get("tokens").and_then(|v| v.as_object())
385 {
386 let input = tokens.get("input").and_then(serde_json::Value::as_u64);
387 let output = tokens.get("output").and_then(serde_json::Value::as_u64);
388 return (input, output);
389 }
390 }
391
392 (None, None)
393 }
394
395 fn detect_error(events: &[serde_json::Value]) -> Option<AgentError> {
397 super::patterns::detect_ndjson_error(
398 events,
399 |event| {
400 let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
401 match event_type {
402 "error" => Some(opencode_error_text(event)),
403 "step_finish" => {
404 let part = event.get("part")?;
405 let reason = part.get("reason").and_then(|v| v.as_str()).unwrap_or("");
406 if reason == "error" || reason == "failed" {
407 Some(
408 part.get("error")
409 .map_or_else(|| "step failed".to_string(), error_value_text),
410 )
411 } else {
412 None
413 }
414 }
415 _ => None,
416 }
417 },
418 classify_opencode_message,
419 )
420 }
421}
422
423fn error_value_text(v: &serde_json::Value) -> String {
425 if let Some(s) = v.as_str() {
426 return s.to_string();
427 }
428 v.get("data")
429 .and_then(|d| d.get("message").or(Some(d)))
430 .and_then(|m| m.as_str())
431 .or_else(|| v.get("message").and_then(|m| m.as_str()))
432 .or_else(|| v.get("name").and_then(|n| n.as_str()))
433 .map_or_else(|| v.to_string(), str::to_string)
434}
435
436fn opencode_error_text(event: &serde_json::Value) -> String {
438 if let Some(s) = event.get("message").and_then(|v| v.as_str()) {
439 return s.to_string();
440 }
441 if let Some(e) = event.get("error") {
442 let text = error_value_text(e);
443 if e.is_object() && text == *e {
444 tracing::warn!(event = %event, "unrecognized opencode error event shape");
445 }
446 text
447 } else {
448 tracing::warn!(event = %event, "unrecognized opencode error event shape");
449 event.to_string()
450 }
451}
452
453impl AgentRunner for OpenCodeRunner {
454 #[cfg(test)]
455 fn name(&self) -> &'static str {
456 "opencode"
457 }
458
459 fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent> {
460 stream_events(event)
461 }
462
463 fn router_transcript(&self) -> super::RouterTranscript {
464 super::RouterTranscript::OpenCode
465 }
466
467 fn extract_text(&self, raw: &str) -> Result<String, AgentError> {
468 let trimmed = raw.trim();
469 if trimmed.is_empty() {
470 return Ok(String::new());
471 }
472
473 let events = super::parse_ndjson(trimmed);
474 if events.is_empty() {
475 return Ok(trimmed.to_string());
476 }
477
478 if let Some(err) = Self::detect_error(&events) {
480 return Err(err);
481 }
482
483 Ok(extract_ndjson_text(&events).unwrap_or_else(|| trimmed.to_string()))
485 }
486
487 fn build_command(
488 &self,
489 model: Option<&str>,
490 timeout_cmd: &str,
491 sys_file: &str,
492 msg_file: &str,
493 permissions: &PermissionRules,
494 ) -> String {
495 let (model_flag, variant_flag) = if let Some(m) = model {
498 let (base, variant) = Self::split_model_variant(m);
499 let mf = format!("--model {}", super::shell_single_quote(base));
500 let vf = variant.map(|v| format!("--variant {}", super::shell_single_quote(v)));
501 (mf, vf)
502 } else {
503 (String::new(), None)
504 };
505
506 let skip_perms = if permissions.autonomous {
519 "--dangerously-skip-permissions "
520 } else {
521 ""
522 };
523
524 let variant_part = variant_flag.unwrap_or_default();
526 format!(
531 r#"OPENCODE_CONFIG_CONTENT={config} {timeout_cmd} opencode run {model_flag} {variant_part} {skip_perms}\
532 --format json - < <(cat "{sys_file}" "{msg_file}")"#,
533 config = super::shell_single_quote(NO_SNAPSHOT_CONFIG),
534 sys_file = sys_file,
535 msg_file = msg_file,
536 timeout_cmd = timeout_cmd,
537 model_flag = model_flag,
538 variant_part = variant_part,
539 skip_perms = skip_perms,
540 )
541 }
542
543 fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
544 let trimmed = raw.trim();
545 if trimmed.is_empty() {
549 return Err(AgentError::Unknown {
550 exit_code: 0,
551 message: "empty-output-exit0: opencode returned exit 0 with empty stdout"
552 .to_string(),
553 });
554 }
555
556 let events = super::parse_ndjson(trimmed);
557
558 if events.is_empty() {
559 if let Ok(resp) = parser::parse(trimmed) {
561 return Ok(ParsedResponse {
562 response: resp,
563 input_tokens: None,
564 output_tokens: None,
565 duration_ms: None,
566 });
567 }
568 return Err(AgentError::InvalidResponse {
569 raw: trimmed.to_string(),
570 });
571 }
572
573 if let Some(err) = Self::detect_error(&events) {
575 return Err(err);
576 }
577
578 let (input_tokens, output_tokens) = Self::extract_tokens(&events);
580
581 let Some(text) = Self::extract_text_from_events(&events) else {
588 let response = synthesize_response_from_events(&events);
589 return Ok(ParsedResponse {
590 response,
591 input_tokens,
592 output_tokens,
593 duration_ms: None,
594 });
595 };
596
597 let response = match parser::parse(&text) {
599 Ok(r) => r,
600 Err(_) => {
601 super::synthesize_response_from_text(&text)
604 .ok_or_else(|| AgentError::InvalidResponse { raw: text.clone() })?
605 }
606 };
607
608 Ok(ParsedResponse {
609 response,
610 input_tokens,
611 output_tokens,
612 duration_ms: None,
613 })
614 }
615
616 fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
617 let events = super::parse_ndjson(stdout);
619 if let Some(err) = Self::detect_error(&events) {
620 return err;
621 }
622
623 let combined = format!("{stdout}\n{stderr}");
624 super::patterns::classify_from_text(exit_code, &combined)
625 }
626
627 fn free_models(&self) -> Vec<String> {
628 discover_free_opencode_models()
629 }
630
631 fn available_models(&self) -> Vec<String> {
632 vec![
634 "anthropic/claude-sonnet-4-20250514".to_string(),
635 "openai/gpt-4.1".to_string(),
636 ]
637 }
638
639 fn router_command(
640 &self,
641 prompt: &str,
642 model: Option<&str>,
643 ) -> anyhow::Result<tokio::process::Command> {
644 let mut cmd = tokio::process::Command::new("opencode");
650 cmd.env("OPENCODE_CONFIG_CONTENT", NO_SNAPSHOT_CONFIG)
651 .arg("run")
652 .arg("--format")
653 .arg("json")
654 .arg("--dangerously-skip-permissions");
655 if let Some(m) = model {
656 let (base, variant) = Self::split_model_variant(m);
657 cmd.arg("--model").arg(base);
658 if let Some(v) = variant {
659 cmd.arg("--variant").arg(v);
661 }
662 }
663 cmd.arg(prompt);
664 Ok(cmd)
665 }
666}
667
668fn classify_opencode_message(message: &str) -> AgentError {
670 let lower = message.to_lowercase();
671
672 if let Some(e) = super::patterns::detect_rate_limit(message) {
674 return e;
675 }
676
677 if let Some(e) = super::patterns::detect_context_overflow(message) {
679 return e;
680 }
681
682 if let Some(e) = super::patterns::detect_auth_error(message) {
684 return e;
685 }
686
687 if let Some(e) = super::patterns::detect_permission_denied(message) {
689 return e;
690 }
691
692 let is_model_unavailable = (lower.contains("model")
703 && (lower.contains("not found")
704 || lower.contains("not supported")
705 || lower.contains("deprecated")
706 || lower.contains("unavailable for free")
707 || lower.contains("not available in your country")
708 || lower.contains("not available in your region")))
709 || lower.contains("no endpoints found");
710
711 if is_model_unavailable {
712 let model = if lower.contains("no endpoints found") {
715 message
717 .split(" for ")
718 .nth(1)
719 .map(|s| s.trim_end_matches('.').to_string())
720 .unwrap_or_default()
721 } else {
722 let from_colon = message
724 .split(": ")
725 .nth(1)
726 .map(|s| {
727 s.split(". Did you mean")
729 .next()
730 .unwrap_or(s)
731 .trim_end_matches('.')
732 .to_string()
733 })
734 .filter(|s| !s.is_empty());
735
736 let from_transition = if lower.contains("deprecated") {
739 message
740 .split("Transition to ")
741 .nth(1)
742 .and_then(|s| s.split(" for ").next())
743 .map(|s| s.trim_end_matches('.').to_string())
744 .filter(|s| !s.is_empty())
745 } else {
746 None
747 };
748
749 let from_slug_suggestion = if lower.contains("unavailable for free") {
752 message
753 .split("use this slug instead: ")
754 .nth(1)
755 .map(|s| s.trim_end_matches('.').trim().to_string())
756 .filter(|s| !s.is_empty())
757 } else {
758 None
759 };
760
761 from_transition
765 .or(from_slug_suggestion)
766 .or(from_colon)
767 .unwrap_or_default()
768 };
769 return AgentError::ModelUnavailable {
770 message: message.to_string(),
771 model,
772 };
773 }
774
775 if lower.contains("provider returned error") {
780 return AgentError::RateLimit {
781 message: message.to_string(),
782 };
783 }
784
785 if let Some(e) = super::patterns::detect_network_error(message) {
787 return e;
788 }
789
790 AgentError::AgentFailed {
791 message: message.to_string(),
792 }
793}
794
795pub fn discover_free_opencode_models() -> Vec<String> {
804 if skip_live_opencode_discovery() {
805 return Vec::new();
806 }
807 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
808
809 let now = chrono::Utc::now().timestamp();
810 {
812 let guard = cache
813 .lock()
814 .unwrap_or_else(std::sync::PoisonError::into_inner);
815 let (ts, models) = &*guard;
816 if *ts != 0 && now.saturating_sub(*ts) < 3600 {
817 return models.clone();
818 }
819 }
820
821 if !FREE_MODELS_REFRESH_IN_PROGRESS.swap(true, Ordering::AcqRel) {
824 let refresh = async {
825 let _guard = scopeguard::guard((), |()| {
826 FREE_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
827 });
828 let (models, empty_reason) = run_opencode_models_discovery_async().await;
829 let raw_count = models.len();
830 let discovered = models
831 .into_iter()
832 .filter(|m| m.to_lowercase().contains("free"))
833 .collect::<Vec<_>>();
834 let empty_reason = if discovered.is_empty() && raw_count > 0 {
835 "no_free_models_in_catalog"
836 } else {
837 empty_reason
838 };
839 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
840 update_discovered_models_cache(cache, discovered, "free", empty_reason);
841 };
842 match tokio::runtime::Handle::try_current() {
843 Ok(handle) => {
844 handle.spawn(refresh);
847 }
848 _ => {
849 std::thread::spawn(move || {
852 if let Ok(rt) = tokio::runtime::Runtime::new() {
853 rt.block_on(refresh);
854 }
855 });
856 }
857 }
858 }
859
860 let guard = cache
862 .lock()
863 .unwrap_or_else(std::sync::PoisonError::into_inner);
864 guard.1.clone()
865}
866
867pub fn discover_opencode_models() -> Vec<String> {
872 if skip_live_opencode_discovery() {
873 return Vec::new();
874 }
875 let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
876 let now = chrono::Utc::now().timestamp();
877
878 {
879 let guard = cache
880 .lock()
881 .unwrap_or_else(std::sync::PoisonError::into_inner);
882 let (ts, models) = &*guard;
883 if *ts != 0 && now.saturating_sub(*ts) < 3600 {
884 return models.clone();
885 }
886 }
887
888 if !ALL_MODELS_REFRESH_IN_PROGRESS.swap(true, Ordering::AcqRel) {
889 let refresh = async {
890 let _guard = scopeguard::guard((), |()| {
891 ALL_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
892 });
893 let (discovered, empty_reason) = run_opencode_models_discovery_async().await;
894 let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
895 update_discovered_models_cache(cache, discovered, "all", empty_reason);
896 };
897 match tokio::runtime::Handle::try_current() {
898 Ok(handle) => {
899 handle.spawn(refresh);
900 }
901 _ => {
902 std::thread::spawn(move || {
903 if let Ok(rt) = tokio::runtime::Runtime::new() {
904 rt.block_on(refresh);
905 }
906 });
907 }
908 }
909 }
910
911 let guard = cache
912 .lock()
913 .unwrap_or_else(std::sync::PoisonError::into_inner);
914 guard.1.clone()
915}
916
917pub fn prime_free_model_cache() {
925 if skip_live_opencode_discovery() {
926 return;
927 }
928 let discovered_all = std::thread::spawn(|| match tokio::runtime::Runtime::new() {
929 Ok(rt) => rt.block_on(run_opencode_models_discovery_async()).0,
930 _ => {
931 vec![]
932 }
933 })
934 .join()
935 .unwrap_or_default();
936 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
937 let now = chrono::Utc::now().timestamp();
938 if let Ok(mut guard) = cache.lock() {
939 *guard = (
940 now,
941 discovered_all
942 .iter()
943 .filter(|m| m.to_lowercase().contains("free"))
944 .cloned()
945 .collect(),
946 );
947 }
948 let all_cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
949 if let Ok(mut guard) = all_cache.lock() {
950 *guard = (now, discovered_all);
951 }
952}
953
954async fn run_opencode_models_discovery_async() -> (Vec<String>, &'static str) {
963 run_opencode_models_cli(std::time::Duration::from_secs(30)).await
964}
965
966pub(crate) async fn run_opencode_models_cli(
968 timeout: std::time::Duration,
969) -> (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 crate::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: std::time::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 = tokio::process::Command::new(program);
1013 cmd.args(["models"])
1014 .stdout(std::process::Stdio::piped())
1015 .stderr(std::process::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 = tokio::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]) -> crate::parser::AgentResponse {
1091 use std::collections::BTreeMap;
1092
1093 let mut counts: BTreeMap<String, u32> = BTreeMap::new();
1094 let mut tool_names: Vec<String> = Vec::new();
1095 let mut step_finish_reason: Option<String> = None;
1096
1097 for event in events {
1098 let event_type = event
1099 .get("type")
1100 .and_then(|v| v.as_str())
1101 .unwrap_or("unknown");
1102 *counts.entry(event_type.to_string()).or_insert(0) += 1;
1103
1104 if event_type == "step_finish"
1105 && let Some(reason) = event
1106 .get("part")
1107 .and_then(|p| p.get("reason"))
1108 .and_then(|r| r.as_str())
1109 {
1110 step_finish_reason = Some(reason.to_string());
1111 }
1112
1113 if event_type == "tool_use"
1115 && let Some(name) = event
1116 .get("name")
1117 .and_then(|v| v.as_str())
1118 .or_else(|| event.get("tool").and_then(|v| v.as_str()))
1119 .or_else(|| {
1120 event
1121 .get("part")
1122 .and_then(|p| p.get("name"))
1123 .and_then(|v| v.as_str())
1124 })
1125 .or_else(|| {
1126 event
1127 .get("tool")
1128 .and_then(|t| t.get("name"))
1129 .and_then(|v| v.as_str())
1130 })
1131 {
1132 tool_names.push(name.to_string());
1133 }
1134 }
1135
1136 let total_events = events.len();
1137 let reason = step_finish_reason.as_deref().unwrap_or("unknown");
1138
1139 let tool_summary = if tool_names.is_empty() {
1140 String::new()
1141 } else {
1142 let mut tool_counts: BTreeMap<String, u32> = BTreeMap::new();
1143 for n in &tool_names {
1144 *tool_counts.entry(n.clone()).or_insert(0) += 1;
1145 }
1146 let parts: Vec<String> = tool_counts
1147 .iter()
1148 .map(|(k, v)| {
1149 if *v > 1 {
1150 format!("{k}×{v}")
1151 } else {
1152 k.clone()
1153 }
1154 })
1155 .collect();
1156 format!(" (tools: {})", parts.join(", "))
1157 };
1158
1159 let summary = format!(
1160 "opencode emitted {total_events} event(s) with no text output{tool_summary}; \
1161 step_finish reason: {reason}"
1162 );
1163
1164 crate::parser::AgentResponse {
1165 status: "needs_review".to_string(),
1166 summary,
1167 error: Some("agent emitted no text response — only tool/step events".to_string()),
1168 ..Default::default()
1169 }
1170}
1171
1172pub fn find_opencode_result(ndjson: &str) -> Option<super::AgentResult> {
1180 let events = super::parse_ndjson(ndjson.trim());
1181 if events.is_empty() {
1182 return None;
1183 }
1184
1185 let is_error;
1187 let error_text;
1188 if let Some(err) = OpenCodeRunner::detect_error(&events) {
1189 is_error = true;
1190 error_text = Some(err.to_string());
1191 } else {
1192 is_error = false;
1193 error_text = None;
1194 }
1195
1196 let result_text = if is_error {
1198 error_text.unwrap_or_default()
1199 } else {
1200 extract_ndjson_text(&events).unwrap_or_default()
1201 };
1202
1203 let (input_tokens, output_tokens) = OpenCodeRunner::extract_tokens(&events);
1205
1206 let last_step_finish_reason = events
1215 .iter()
1216 .rev()
1217 .find(|event| event.get("type").and_then(|v| v.as_str()) == Some("step_finish"))
1218 .and_then(|event| {
1219 event
1220 .get("part")?
1221 .get("reason")?
1222 .as_str()
1223 .map(str::to_string)
1224 });
1225 let truncated_by_length = match last_step_finish_reason.as_deref() {
1226 Some("length") => true,
1227 Some("unknown") => output_tokens == Some(0),
1228 _ => false,
1229 };
1230
1231 if result_text.is_empty() && !is_error && !truncated_by_length {
1232 return None;
1233 }
1234
1235 let cost_usd = events.iter().rev().find_map(|event| {
1237 if event.get("type").and_then(|v| v.as_str()) == Some("step_finish") {
1238 event
1239 .get("part")
1240 .and_then(|p| p.get("cost"))
1241 .and_then(serde_json::Value::as_f64)
1242 .filter(|&c| c > 0.0)
1243 } else {
1244 None
1245 }
1246 });
1247
1248 Some(super::AgentResult {
1249 is_error,
1250 result_text,
1251 input_tokens,
1252 output_tokens,
1253 cost_usd,
1254 duration_ms: None,
1255 truncated_by_length,
1256 })
1257}
1258
1259#[cfg(test)]
1260mod tests {
1261 use super::*;
1262
1263 fn runner() -> OpenCodeRunner {
1264 OpenCodeRunner::new()
1265 }
1266
1267 #[test]
1268 fn parse_opencode_ndjson_success() {
1269 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start","snapshot":"..."}}
1270{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"status\":\"done\",\"summary\":\"hello\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}}
1271{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":17512,"input":17509,"output":3}}}"#;
1272
1273 let parsed = runner().parse_response(raw).unwrap();
1274 assert_eq!(parsed.response.status, "done");
1275 assert_eq!(parsed.response.summary, "hello");
1276 assert_eq!(parsed.input_tokens, Some(17509));
1277 assert_eq!(parsed.output_tokens, Some(3));
1278 }
1279
1280 #[test]
1283 fn parse_opencode_ndjson_direct_text_format() {
1284 let raw = r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}
1285{"type":"text","timestamp":1001,"sessionID":"ses_abc","text":"{\"status\":\"done\",\"summary\":\"fixed\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}
1286{"type":"step_finish","timestamp":1002,"sessionID":"ses_abc","part":{"type":"step-finish","reason":"stop","tokens":{"input":200,"output":10}}}"#;
1287
1288 let parsed = runner().parse_response(raw).unwrap();
1289 assert_eq!(parsed.response.status, "done");
1290 assert_eq!(parsed.response.summary, "fixed");
1291 assert_eq!(parsed.input_tokens, Some(200));
1292 assert_eq!(parsed.output_tokens, Some(10));
1293 }
1294
1295 #[test]
1296 fn parse_opencode_concatenated_text() {
1297 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1298{"type":"text","timestamp":1001,"part":{"type":"text","text":"Working on "}}
1299{"type":"text","timestamp":1002,"part":{"type":"text","text":"the task. "}}
1300{"type":"text","timestamp":1003,"part":{"type":"text","text":"{\"status\":\"done\",\"summary\":\"finished\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}}
1301{"type":"step_finish","timestamp":1004,"part":{"type":"step-finish","reason":"stop","tokens":{"input":100,"output":50}}}"#;
1302
1303 let parsed = runner().parse_response(raw).unwrap();
1304 assert_eq!(parsed.response.status, "done");
1305 assert_eq!(parsed.input_tokens, Some(100));
1306 assert_eq!(parsed.output_tokens, Some(50));
1307 }
1308
1309 #[test]
1310 fn extract_router_text_prefers_json_payload() {
1311 let raw = r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"Thinking..."}}
1312{"type":"text","timestamp":1002,"part":{"type":"text","text":"{\"executor\":\"claude\",\"complexity\":\"medium\",\"reason\":\"fit\"}"}}"#;
1313
1314 let text = extract_router_text(raw).unwrap();
1315 let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
1316 assert_eq!(parsed["executor"], "claude");
1317 }
1318
1319 #[test]
1320 fn parse_opencode_embedded_json_fragment_without_executor_is_ignored() {
1321 let raw = r#"{"type":"step_start","timestamp":1}
1326{"type":"text","timestamp":2,"part":{"type":"text","text":"Here is a debug trace: {\"trace\":{\"duration\":123,\"id\":\"abc\"}}"}}
1327{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
1328
1329 let err = runner().parse_response(raw).unwrap_err();
1330 match err {
1331 AgentError::InvalidResponse { raw: _ } => {
1332 }
1334 other => panic!("expected InvalidResponse, got: {other:?}"),
1335 }
1336 }
1337
1338 #[test]
1339 fn parse_opencode_error_event() {
1340 let raw = r#"{"type":"error","message":"rate limit exceeded for this model"}"#;
1341
1342 let err = runner().parse_response(raw).unwrap_err();
1343 assert!(matches!(err, AgentError::RateLimit { .. }));
1344 }
1345
1346 #[test]
1352 fn parse_opencode_tool_only_stream_synthesizes_needs_review() {
1353 let raw = r#"{"type":"step_start","timestamp":1,"part":{"type":"step-start"}}
1354{"type":"tool_use","timestamp":2,"name":"bash","input":{"cmd":"ls"}}
1355{"type":"tool_use","timestamp":3,"name":"read","input":{"path":"src/main.rs"}}
1356{"type":"tool_use","timestamp":4,"name":"bash","input":{"cmd":"cargo check"}}
1357{"type":"step_finish","timestamp":5,"part":{"type":"step-finish","reason":"stop","tokens":{"input":500,"output":0}}}"#;
1358
1359 let parsed = runner()
1360 .parse_response(raw)
1361 .expect("tool-only NDJSON must synthesize a response, not error");
1362 assert_eq!(parsed.response.status, "needs_review");
1363 assert!(
1364 !parsed.response.summary.contains("\"type\":\"step_start\""),
1365 "summary must not contain raw NDJSON; got: {}",
1366 parsed.response.summary
1367 );
1368 assert!(
1369 parsed.response.summary.contains("bash") && parsed.response.summary.contains("read"),
1370 "summary should mention tools used; got: {}",
1371 parsed.response.summary
1372 );
1373 assert!(
1374 parsed.response.summary.contains("stop"),
1375 "summary should mention step_finish reason; got: {}",
1376 parsed.response.summary
1377 );
1378 assert_eq!(parsed.input_tokens, Some(500));
1380 assert_eq!(parsed.output_tokens, Some(0));
1381 }
1382
1383 #[test]
1388 fn parse_opencode_plain_prose_completion_synthesizes_response() {
1389 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1390{"type":"text","timestamp":1001,"part":{"type":"text","text":"Fixed the typo and corrected the deadline reference in the review."}}
1391{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","tokens":{"input":300,"output":40}}}"#;
1392
1393 let parsed = runner()
1394 .parse_response(raw)
1395 .expect("plain-prose completion text must synthesize successfully");
1396 assert_eq!(parsed.response.status, "done");
1397 assert_eq!(parsed.input_tokens, Some(300));
1398 assert_eq!(parsed.output_tokens, Some(40));
1399 }
1400
1401 #[test]
1402 fn parse_opencode_step_finish_error() {
1403 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1404{"type":"step_finish","timestamp":1001,"part":{"type":"step-finish","reason":"error","error":"context length exceeded"}}"#;
1405
1406 let err = runner().parse_response(raw).unwrap_err();
1407 assert!(matches!(err, AgentError::ContextOverflow { .. }));
1408 }
1409
1410 #[test]
1411 fn parse_opencode_empty_returns_silent_failure() {
1412 let err = runner().parse_response("").unwrap_err();
1413 assert!(matches!(err, AgentError::Unknown { exit_code: 0, .. }));
1416 }
1417
1418 #[test]
1419 fn extract_tokens_from_step_finish() {
1420 let events: Vec<serde_json::Value> = vec![
1421 serde_json::json!({"type":"step_finish","part":{"type":"step-finish","tokens":{"input":1000,"output":500,"total":1500}}}),
1422 ];
1423 let (input, output) = OpenCodeRunner::extract_tokens(&events);
1424 assert_eq!(input, Some(1000));
1425 assert_eq!(output, Some(500));
1426 }
1427
1428 #[test]
1429 fn classify_opencode_rate_limit() {
1430 let err = classify_opencode_message("429 Too Many Requests");
1431 assert!(matches!(err, AgentError::RateLimit { .. }));
1432 }
1433
1434 #[test]
1435 fn classify_opencode_model_not_found() {
1436 let err = classify_opencode_message("model not found: gpt-5");
1437 assert!(matches!(err, AgentError::ModelUnavailable { .. }));
1438 }
1439
1440 #[test]
1441 fn classify_opencode_model_not_found_with_suggestion() {
1442 let err = classify_opencode_message(
1445 "Model not found: github-copilot/gemini-3.1-pro. Did you mean: gemini-3.1-pro-preview?",
1446 );
1447 match err {
1448 AgentError::ModelUnavailable { model, message } => {
1449 assert_eq!(model, "github-copilot/gemini-3.1-pro");
1450 assert!(message.contains("Did you mean"));
1451 }
1452 other => panic!("expected ModelUnavailable, got: {other:?}"),
1453 }
1454 }
1455
1456 #[test]
1457 fn classify_opencode_model_deprecated() {
1458 let err = classify_opencode_message(
1461 "The free model has been deprecated. Transition to qwen/qwen3.6-plus for continued paid access.",
1462 );
1463 match err {
1464 AgentError::ModelUnavailable { model, .. } => {
1465 assert_eq!(
1466 model, "qwen/qwen3.6-plus",
1467 "expected model extracted from 'Transition to X', got: {model:?}"
1468 );
1469 }
1470 other => panic!("expected ModelUnavailable, got: {other:?}"),
1471 }
1472 }
1473
1474 #[test]
1475 fn classify_opencode_model_unavailable_for_free() {
1476 let err = classify_opencode_message(
1480 "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",
1481 );
1482 match err {
1483 AgentError::ModelUnavailable { model, .. } => {
1484 assert_eq!(
1485 model, "inclusionai/ling-3.0-flash",
1486 "expected model extracted from 'use this slug instead: X', got: {model:?}"
1487 );
1488 }
1489 other => panic!("expected ModelUnavailable, got: {other:?}"),
1490 }
1491 }
1492
1493 #[test]
1494 fn classify_opencode_model_not_available_in_country() {
1495 let err =
1500 classify_opencode_message("agent failed: This model is not available in your country.");
1501 match err {
1502 AgentError::ModelUnavailable { .. } => {}
1503 other => panic!("expected ModelUnavailable, got: {other:?}"),
1504 }
1505 }
1506
1507 #[test]
1508 fn classify_opencode_no_endpoints_found() {
1509 let err = classify_opencode_message("No endpoints found for qwen/qwen3.6-plus:free.");
1511 match err {
1512 AgentError::ModelUnavailable { model, .. } => {
1513 assert_eq!(model, "qwen/qwen3.6-plus:free");
1514 }
1515 other => panic!("expected ModelUnavailable, got: {other:?}"),
1516 }
1517 }
1518
1519 #[test]
1520 fn build_command_opencode() {
1521 let r = runner();
1522 let perms = PermissionRules::default();
1523 let cmd = r.build_command(
1524 Some("anthropic/claude-sonnet-4-20250514"),
1525 "timeout 1800",
1526 "/tmp/sys.txt",
1527 "/tmp/msg.txt",
1528 &perms,
1529 );
1530 assert!(cmd.contains("opencode run"));
1531 assert!(cmd.contains("--model 'anthropic/claude-sonnet-4-20250514'"));
1532 assert!(cmd.contains("--format json"));
1533 assert!(
1534 cmd.starts_with(
1535 r#"OPENCODE_CONFIG_CONTENT='{"snapshot":false}' timeout 1800 opencode run"#
1536 ),
1537 "snapshots must be off for orch runs, got: {cmd}"
1538 );
1539 assert!(
1542 cmd.contains("--dangerously-skip-permissions"),
1543 "expected --dangerously-skip-permissions, got: {cmd}"
1544 );
1545 assert!(
1546 !cmd.contains("opencode.json"),
1547 "should not write a config file, got: {cmd}"
1548 );
1549 assert!(
1550 !cmd.contains("XDG_CONFIG_HOME"),
1551 "should not override XDG_CONFIG_HOME, got: {cmd}"
1552 );
1553 assert!(
1554 !cmd.contains(".orch-opencode"),
1555 "should not reference .orch-opencode, got: {cmd}"
1556 );
1557 assert!(
1564 !cmd.trim_start().starts_with("cat "),
1565 "opencode command must not start with a `cat |` pipe stage, got: {cmd}"
1566 );
1567 assert!(
1568 cmd.contains("< <(cat \"/tmp/sys.txt\" \"/tmp/msg.txt\")"),
1569 "sys/msg files must be fed via process substitution, got: {cmd}"
1570 );
1571 }
1572
1573 #[test]
1574 fn build_command_opencode_supervised_no_skip_flag() {
1575 let r = runner();
1576 let perms = PermissionRules {
1577 autonomous: false,
1578 ..PermissionRules::default()
1579 };
1580 let cmd = r.build_command(
1581 Some("anthropic/claude-sonnet-4-20250514"),
1582 "timeout 1800",
1583 "/tmp/sys.txt",
1584 "/tmp/msg.txt",
1585 &perms,
1586 );
1587 assert!(
1588 !cmd.contains("--dangerously-skip-permissions"),
1589 "supervised mode must not pass --dangerously-skip-permissions, got: {cmd}"
1590 );
1591 }
1592
1593 #[test]
1594 fn split_model_variant_no_at() {
1595 let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5");
1596 assert_eq!(base, "openai/gpt-5.5");
1597 assert!(variant.is_none());
1598 }
1599
1600 #[test]
1601 fn split_model_variant_with_variant() {
1602 let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5@xhigh");
1603 assert_eq!(base, "openai/gpt-5.5");
1604 assert_eq!(variant, Some("xhigh"));
1605 }
1606
1607 #[test]
1608 fn split_model_variant_trailing_at_empty() {
1609 let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5@");
1610 assert_eq!(base, "openai/gpt-5.5");
1611 assert_eq!(variant, Some(""));
1612 }
1613
1614 #[test]
1615 fn split_model_variant_multiple_ats_last_wins() {
1616 let (base, variant) = OpenCodeRunner::split_model_variant("a@b@c@v");
1617 assert_eq!(base, "a@b@c");
1618 assert_eq!(variant, Some("v"));
1619 }
1620
1621 #[test]
1622 fn build_command_opencode_with_variant() {
1623 let r = runner();
1624 let perms = PermissionRules::default();
1625 let cmd = r.build_command(
1626 Some("openai/gpt-5.5@high"),
1627 "timeout 1800",
1628 "/tmp/sys.txt",
1629 "/tmp/msg.txt",
1630 &perms,
1631 );
1632 assert!(cmd.contains("--model 'openai/gpt-5.5'"));
1633 assert!(cmd.contains("--variant 'high'"));
1634 }
1635
1636 #[test]
1637 fn classify_opencode_permission_denied() {
1638 let err = classify_opencode_message("user rejected permission for tool bash: cargo test");
1639 assert!(
1640 matches!(err, AgentError::PermissionDenied { .. }),
1641 "expected PermissionDenied, got: {err:?}"
1642 );
1643 }
1644
1645 #[test]
1646 fn classify_opencode_provider_returned_error() {
1647 let err = classify_opencode_message("Provider returned error");
1651 assert!(
1652 matches!(err, AgentError::RateLimit { .. }),
1653 "expected RateLimit, got: {err:?}"
1654 );
1655 let err_with_detail =
1656 classify_opencode_message("Provider returned error: upstream connection timed out");
1657 assert!(
1658 matches!(err_with_detail, AgentError::RateLimit { .. }),
1659 "expected RateLimit, got: {err_with_detail:?}"
1660 );
1661 }
1662
1663 #[test]
1664 fn classify_opencode_upstream_idle_timeout() {
1665 let err = classify_opencode_message("Upstream idle timeout exceeded");
1668 assert!(
1669 matches!(err, AgentError::NetworkError { .. }),
1670 "expected NetworkError, got: {err:?}"
1671 );
1672 }
1673
1674 #[test]
1675 fn free_models_discovery_returns_vec() {
1676 let models = discover_free_opencode_models();
1678 assert!(models.is_empty(), "{models:?}");
1679 }
1680
1681 #[test]
1682 fn live_opencode_discovery_is_skipped_in_test_builds() {
1683 assert!(skip_live_opencode_discovery());
1684 let models = discover_opencode_models();
1685 assert!(models.is_empty(), "{models:?}");
1686 let models = discover_free_opencode_models();
1687 assert!(models.is_empty(), "{models:?}");
1688 prime_free_model_cache();
1689 let models = discover_opencode_models();
1690 assert!(models.is_empty(), "{models:?}");
1691 }
1692
1693 #[cfg(unix)]
1696 #[tokio::test]
1697 async fn models_cli_reaps_orphaned_process_group() {
1698 use std::os::unix::fs::PermissionsExt;
1699
1700 let dir = tempfile::tempdir().unwrap();
1701 let sleeper_pid_path = dir.path().join("sleeper.pid");
1702 let script = dir.path().join("opencode");
1703 std::fs::write(
1704 &script,
1705 format!(
1706 "#!/bin/sh\n/bin/sleep 60 </dev/null >/dev/null 2>&1 &\necho $! > '{}'\necho test-model-free\n",
1707 sleeper_pid_path.display()
1708 ),
1709 )
1710 .unwrap();
1711 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1712
1713 let (models, reason) = run_opencode_models_cli_inner(
1716 script.to_str().unwrap(),
1717 std::time::Duration::from_secs(30),
1718 )
1719 .await;
1720 assert_eq!(reason, "ok");
1721 assert_eq!(models, vec!["test-model-free".to_string()]);
1722
1723 let sleeper: u32 = std::fs::read_to_string(&sleeper_pid_path)
1724 .unwrap()
1725 .trim()
1726 .parse()
1727 .expect("sleeper pid");
1728 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1729 while pid_is_alive(sleeper) && std::time::Instant::now() < deadline {
1730 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1731 }
1732 assert!(
1733 !pid_is_alive(sleeper),
1734 "sleeper {sleeper} must die with the process group when opencode models returns"
1735 );
1736 }
1737
1738 #[cfg(unix)]
1739 fn pid_is_alive(pid: u32) -> bool {
1740 i32::try_from(pid).is_ok_and(|pid| super::raw_kill(pid, 0) == 0)
1742 }
1743
1744 #[test]
1745 fn empty_free_discovery_preserves_existing_cache_contents() {
1746 reset_model_caches_for_test();
1747 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1748 {
1749 let mut guard = cache
1750 .lock()
1751 .unwrap_or_else(std::sync::PoisonError::into_inner);
1752 *guard = (123, vec!["opencode/model-free".to_string()]);
1753 }
1754
1755 update_discovered_models_cache(cache, Vec::new(), "free", "empty_output");
1756
1757 let guard = cache
1758 .lock()
1759 .unwrap_or_else(std::sync::PoisonError::into_inner);
1760 assert!(guard.0 > 123);
1763 assert_eq!(guard.1, vec!["opencode/model-free".to_string()]);
1764 }
1765
1766 #[test]
1767 fn empty_all_models_discovery_preserves_existing_cache_contents() {
1768 reset_model_caches_for_test();
1769 let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1770 {
1771 let mut guard = cache
1772 .lock()
1773 .unwrap_or_else(std::sync::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
1786 .lock()
1787 .unwrap_or_else(std::sync::PoisonError::into_inner);
1788 assert!(guard.0 > 456);
1789 assert_eq!(
1790 guard.1,
1791 vec![
1792 "opencode/model-free".to_string(),
1793 "opencode/model-paid".to_string(),
1794 ]
1795 );
1796 }
1797
1798 #[test]
1799 fn empty_discovery_populates_empty_cache() {
1800 reset_model_caches_for_test();
1801 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1802
1803 update_discovered_models_cache(cache, Vec::new(), "free", "command_not_found");
1804
1805 let guard = cache
1806 .lock()
1807 .unwrap_or_else(std::sync::PoisonError::into_inner);
1808 assert!(guard.0 > 0);
1809 assert!(guard.1.is_empty(), "{:?}", guard.1);
1810 }
1811
1812 #[test]
1815 fn fixture_opencode_success() {
1816 let raw = include_str!("../../../../tests/fixtures/opencode_success.jsonl");
1817 let parsed = runner().parse_response(raw).unwrap();
1818 assert_eq!(parsed.response.status, "done");
1819 assert!(parsed.response.summary.contains("Fixed"));
1820 assert_eq!(parsed.response.accomplished.len(), 2);
1821 assert_eq!(parsed.input_tokens, Some(17500));
1822 assert_eq!(parsed.output_tokens, Some(500));
1823 }
1824
1825 #[test]
1829 fn fixture_opencode_permission_denied() {
1830 let raw = include_str!("../../../../tests/fixtures/opencode_permission_denied.jsonl");
1831 let err = runner().parse_response(raw).unwrap_err();
1832 assert!(
1833 matches!(err, AgentError::PermissionDenied { .. }),
1834 "expected PermissionDenied, got: {err:?}"
1835 );
1836 }
1837
1838 #[test]
1840 fn fixture_opencode_rate_limit() {
1841 let raw = include_str!("../../../../tests/fixtures/opencode_rate_limit.jsonl");
1842 let err = runner().parse_response(raw).unwrap_err();
1843 assert!(
1844 matches!(err, AgentError::RateLimit { .. }),
1845 "expected RateLimit, got: {err:?}"
1846 );
1847 }
1848
1849 #[test]
1853 fn fixture_opencode_model_not_found() {
1854 let raw = include_str!("../../../../tests/fixtures/opencode_model_not_found.jsonl");
1855 let err = runner().parse_response(raw).unwrap_err();
1856 assert!(
1857 matches!(err, AgentError::ModelUnavailable { .. }),
1858 "expected ModelUnavailable, got: {err:?}"
1859 );
1860 if let AgentError::ModelUnavailable { model, .. } = &err {
1861 assert_eq!(model, "anthropic/claude-sonnet-4-6");
1862 }
1863 }
1864
1865 #[test]
1868 fn fixture_opencode_provider_returned_error() {
1869 let raw = include_str!("../../../../tests/fixtures/opencode_provider_returned_error.jsonl");
1870 let err = runner().parse_response(raw).unwrap_err();
1871 assert!(
1872 matches!(err, AgentError::RateLimit { .. }),
1873 "expected RateLimit, got: {err:?}"
1874 );
1875 }
1876
1877 #[test]
1881 fn extract_text_returns_text_events() {
1882 let raw = concat!(
1883 r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1884 "\n",
1885 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
1886 "\n",
1887 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":100,"input":90,"output":10}}}"#,
1888 );
1889 let text = runner().extract_text(raw).unwrap();
1890 assert_eq!(
1891 text,
1892 r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#
1893 );
1894 }
1895
1896 #[test]
1898 fn extract_text_direct_text_event_format() {
1899 let raw = concat!(
1900 r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1901 "\n",
1902 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"}"#,
1903 "\n",
1904 r#"{"type":"step_finish","timestamp":1002,"sessionID":"ses_abc"}"#,
1905 );
1906 let text = runner().extract_text(raw).unwrap();
1907 assert!(
1908 text.contains("request_changes"),
1909 "expected review JSON, got: {text}"
1910 );
1911 }
1912
1913 #[test]
1915 fn extract_text_rate_limit_propagates() {
1916 let raw = concat!(
1917 r#"{"type":"step_start","timestamp":1000}"#,
1918 "\n",
1919 r#"{"type":"error","message":"rate limit exceeded: 429 Too Many Requests"}"#,
1920 );
1921 let err = runner().extract_text(raw).unwrap_err();
1922 assert!(
1923 matches!(err, AgentError::RateLimit { .. }),
1924 "expected RateLimit, got: {err:?}"
1925 );
1926 }
1927
1928 #[test]
1930 fn extract_text_auth_error_propagates() {
1931 let raw = concat!(
1932 r#"{"type":"step_start","timestamp":1000}"#,
1933 "\n",
1934 r#"{"type":"error","message":"401 Unauthorized: invalid api key"}"#,
1935 );
1936 let err = runner().extract_text(raw).unwrap_err();
1937 assert!(
1938 matches!(err, AgentError::Auth { .. }),
1939 "expected Auth, got: {err:?}"
1940 );
1941 }
1942
1943 #[test]
1945 fn extract_text_empty_input_returns_empty_ok() {
1946 let text = runner().extract_text("").unwrap();
1947 assert_eq!(text, "");
1948 }
1949
1950 #[test]
1953 fn find_opencode_result_success_with_tokens() {
1954 let ndjson = concat!(
1955 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1956 "\n",
1957 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\"}"}}"#,
1958 "\n",
1959 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0.05,"tokens":{"input":5000,"output":200}}}"#,
1960 );
1961 let result = find_opencode_result(ndjson).expect("should find result");
1962 assert!(!result.is_error);
1963 assert!(result.result_text.contains("approve"));
1964 assert_eq!(result.input_tokens, Some(5000));
1965 assert_eq!(result.output_tokens, Some(200));
1966 assert_eq!(result.cost_usd, Some(0.05));
1967 assert!(!result.truncated_by_length);
1968 }
1969
1970 #[test]
1971 fn find_opencode_result_error_event() {
1972 let ndjson = r#"{"type":"error","message":"rate limit exceeded: 429"}"#;
1973 let result = find_opencode_result(ndjson).expect("should find error result");
1974 assert!(result.is_error);
1975 assert!(result.result_text.contains("rate limit"));
1976 assert!(!result.truncated_by_length);
1977 }
1978
1979 #[test]
1983 fn find_opencode_result_length_truncation() {
1984 let ndjson = concat!(
1985 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1986 "\n",
1987 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":"}}"#,
1988 "\n",
1989 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"length","tokens":{"input":5485,"output":759,"reasoning":31241}}}"#,
1990 );
1991 let result = find_opencode_result(ndjson).expect("should find result");
1992 assert!(result.truncated_by_length);
1993 assert!(!result.is_error, "length truncation is not a hard error");
1994 }
1995
1996 #[test]
2002 fn find_opencode_result_zero_output_length_truncation() {
2003 let ndjson = concat!(
2004 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
2005 "\n",
2006 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"length","tokens":{"total":71482,"input":39482,"output":0,"reasoning":40000}}}"#,
2007 );
2008 let result = find_opencode_result(ndjson).expect("should find result, not None");
2009 assert!(result.truncated_by_length);
2010 assert!(!result.is_error);
2011 assert_eq!(result.result_text, "");
2012 }
2013
2014 #[test]
2017 fn find_opencode_result_zero_output_unknown_reason_truncation() {
2018 let ndjson = concat!(
2019 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
2020 "\n",
2021 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"unknown","tokens":{"input":0,"output":0,"reasoning":0}}}"#,
2022 );
2023 let result = find_opencode_result(ndjson).expect("should find result, not None");
2024 assert!(result.truncated_by_length);
2025 assert!(!result.is_error);
2026 assert_eq!(result.result_text, "");
2027 }
2028
2029 #[test]
2033 fn find_opencode_result_unknown_reason_with_output_not_truncated() {
2034 let ndjson = concat!(
2035 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
2036 "\n",
2037 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"partial answer"}}"#,
2038 "\n",
2039 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"unknown","tokens":{"input":100,"output":50,"reasoning":0}}}"#,
2040 );
2041 let result = find_opencode_result(ndjson).expect("should find result");
2042 assert!(!result.truncated_by_length);
2043 assert!(!result.is_error);
2044 }
2045
2046 #[test]
2047 fn find_opencode_result_empty_returns_none() {
2048 assert!(find_opencode_result("").is_none());
2049 assert!(find_opencode_result(" ").is_none());
2050 }
2051
2052 #[test]
2053 fn find_opencode_result_direct_text_format() {
2054 let ndjson = concat!(
2055 r#"{"type":"step_start","timestamp":1000}"#,
2056 "\n",
2057 r#"{"type":"text","timestamp":1001,"text":"Review complete. All tests passed."}"#,
2058 "\n",
2059 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","tokens":{"input":100,"output":10}}}"#,
2060 );
2061 let result = find_opencode_result(ndjson).expect("should find result");
2062 assert!(!result.is_error);
2063 assert!(result.result_text.contains("All tests passed"));
2064 }
2065
2066 #[test]
2067 fn find_opencode_result_plain_text_returns_none() {
2068 assert!(find_opencode_result("just some plain text").is_none());
2069 }
2070}
2071
2072#[cfg(test)]
2073mod error_text_tests {
2074 use super::*;
2075 use serde_json::json;
2076
2077 #[test]
2078 fn unrecognized_error_shape_keeps_raw_json() {
2079 let e = json!({"type":"error","error":{"code":429,"detail":"slow down"}});
2080 assert!(opencode_error_text(&e).contains("slow down"));
2081 let e = json!({"type":"error"});
2082 assert!(opencode_error_text(&e).contains("\"type\""));
2083 }
2084
2085 #[test]
2086 fn known_shapes_still_extract() {
2087 assert_eq!(opencode_error_text(&json!({"message":"a"})), "a");
2088 assert_eq!(opencode_error_text(&json!({"error":"b"})), "b");
2089 assert_eq!(opencode_error_text(&json!({"error":{"message":"c"}})), "c");
2090 assert_eq!(
2091 opencode_error_text(&json!({"error":{"name":"N","data":{"message":"d"}}})),
2092 "d"
2093 );
2094 }
2095}
2096
2097fn stream_events(v: &serde_json::Value) -> Vec<StreamEvent> {
2099 let text = || {
2100 v.pointer("/part/text")
2101 .or_else(|| v.get("text"))
2102 .and_then(serde_json::Value::as_str)
2103 .unwrap_or("")
2104 .to_string()
2105 };
2106 match json_str(v, "/type") {
2107 "text" => vec![StreamEvent::Text(text())],
2108 "reasoning" => vec![StreamEvent::Thinking(text())],
2109 "tool_use" => {
2110 let part = &v["part"];
2111 let state = &part["state"];
2112 let id = json_str(part, "/callID").to_string();
2113 let name = json_str(part, "/tool").to_string();
2114 let mut out = vec![StreamEvent::ToolCall {
2115 id: id.clone(),
2116 name: name.clone(),
2117 input: state["input"].clone(),
2118 }];
2119 let result = match json_str(state, "/status") {
2120 "completed" => Some((json_str(state, "/output").to_string(), false)),
2121 "error" => Some((json_error_message(state), true)),
2122 _ => None,
2123 };
2124 if let Some((output, is_error)) = result {
2125 out.push(StreamEvent::ToolResult {
2126 id,
2127 name,
2128 output,
2129 is_error,
2130 });
2131 }
2132
2133 out
2134 }
2135 "step_finish" if json_str(v, "/part/reason") == "stop" => {
2136 vec![StreamEvent::Done(String::new())]
2137 }
2138 "error" => vec![StreamEvent::Error(json_error_message(v))],
2139 _ => vec![],
2140 }
2141}
2142
2143#[cfg(test)]
2144mod stream_tests {
2145 use super::*;
2146 use serde_json::json;
2147
2148 fn events(line: &str) -> Vec<StreamEvent> {
2149 OpenCodeRunner::new().stream_events(&serde_json::from_str(line).unwrap())
2150 }
2151
2152 #[test]
2153 fn text_in_part_or_top_level() {
2154 assert_eq!(
2155 events(r#"{"type":"text","part":{"type":"text","text":"Reviewing."}}"#),
2156 vec![StreamEvent::Text("Reviewing.".into())]
2157 );
2158 assert_eq!(
2159 events(r#"{"type":"text","text":"Plain."}"#),
2160 vec![StreamEvent::Text("Plain.".into())]
2161 );
2162 }
2163
2164 #[test]
2165 fn completed_tool_has_call_and_result() {
2166 let line = r#"{"type":"tool_use","part":{"type":"tool","tool":"read","callID":"c1","state":{"status":"completed","input":{"filePath":"src/lib.rs"},"output":"a\nb"}}}"#;
2167 assert_eq!(
2168 events(line),
2169 vec![
2170 StreamEvent::ToolCall {
2171 id: "c1".into(),
2172 name: "read".into(),
2173 input: json!({"filePath": "src/lib.rs"}),
2174 },
2175 StreamEvent::ToolResult {
2176 id: "c1".into(),
2177 name: "read".into(),
2178 output: "a\nb".into(),
2179 is_error: false,
2180 },
2181 ]
2182 );
2183 }
2184
2185 #[test]
2186 fn failed_tool_result_is_error() {
2187 let line = r#"{"type":"tool_use","part":{"tool":"bash","callID":"c2","state":{"status":"error","input":{"command":"x"},"error":"not found"}}}"#;
2188 assert_eq!(
2189 events(line)[1],
2190 StreamEvent::ToolResult {
2191 id: "c2".into(),
2192 name: "bash".into(),
2193 output: "not found".into(),
2194 is_error: true,
2195 }
2196 );
2197 }
2198
2199 #[test]
2200 fn only_final_step_is_done() {
2201 let dropped = events(r#"{"type":"step_finish","part":{"reason":"tool-calls"}}"#);
2202 assert!(dropped.is_empty(), "{dropped:?}");
2203 assert_eq!(
2204 events(r#"{"type":"step_finish","part":{"reason":"stop"}}"#),
2205 vec![StreamEvent::Done(String::new())]
2206 );
2207 let dropped = events(r#"{"type":"step_start"}"#);
2208 assert!(dropped.is_empty(), "{dropped:?}");
2209 }
2210
2211 #[test]
2212 fn error_event() {
2213 let line = r#"{"type":"error","error":{"name":"UnknownError","data":{"message":"Provider returned error"}}}"#;
2214 assert_eq!(
2215 events(line),
2216 vec![StreamEvent::Error("Provider returned error".into())]
2217 );
2218 }
2219}