1use super::{
28 json_error_message, json_str, AgentError, AgentRunner, ParsedResponse, PermissionRules,
29 StreamEvent,
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 if let Ok(mut guard) = cache.lock() {
50 *guard = (0, Vec::new());
51 }
52 }
53 if let Some(cache) = ALL_MODELS_CACHE.get() {
54 if let Ok(mut guard) = cache.lock() {
55 *guard = (0, Vec::new());
56 }
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.lock().unwrap_or_else(|e| e.into_inner());
82
83 if discovered.is_empty() && !guard.1.is_empty() {
84 tracing::warn!(
85 cache = cache_name,
86 cached_models = guard.1.len(),
87 reason = empty_reason,
88 "opencode model discovery returned empty; preserving previous cache contents"
89 );
90 guard.0 = now;
94 return;
95 }
96
97 tracing::debug!(
98 cache = cache_name,
99 discovered_models = discovered.len(),
100 "opencode model discovery refreshed cache"
101 );
102 *guard = (now, discovered);
103}
104
105pub struct OpenCodeRunner;
107
108pub(crate) fn extract_ndjson_text(events: &[serde_json::Value]) -> Option<String> {
109 let mut texts = Vec::new();
110
111 for event in events {
112 let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
113
114 if event_type == "text" || event_type == "message" || event_type == "assistant" {
120 if let Some(part) = event.get("part") {
121 texts.extend(extract_text_from_part(part));
122 }
123
124 if let Some(text) = event.get("text").and_then(|v| v.as_str()) {
126 texts.push(text.to_string());
127 }
128
129 if let Some(message) = event.get("message") {
131 if let Some(items) = message.get("content").and_then(|v| v.as_array()) {
132 for item in items {
133 let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
134 if item_type == "text" {
135 if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
136 texts.push(text.to_string());
137 }
138 }
139 }
140 }
141 }
142
143 continue;
144 }
145
146 if let Some(part) = event.get("part") {
148 let part_type = part.get("type").and_then(|v| v.as_str()).unwrap_or("");
149 if part_type == "text" || part_type == "output_text" || part_type == "message" {
150 texts.extend(extract_text_from_part(part));
151 }
152 }
153 }
154
155 if texts.is_empty() {
158 for event in events.iter().rev() {
159 if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
160 if event_type == "system" {
162 continue;
163 }
164 }
165
166 if let Some(result) = event.get("result") {
167 if let Some(s) = result.as_str() {
169 return Some(s.to_string());
170 }
171 if result.is_object() || result.is_array() {
172 return Some(result.to_string());
173 }
174 }
175
176 if let Some(text) = event.get("text").and_then(|v| v.as_str()) {
178 if !text.trim().is_empty() {
179 return Some(text.to_string());
180 }
181 }
182 }
183
184 return None;
185 }
186
187 let full = texts.join("");
188 if let Some(json) = extract_json_object(&full) {
189 if crate::parser::parse(&json).is_ok() {
191 return Some(json);
192 }
193
194 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&json) {
197 if let Some(obj) = val.as_object() {
198 if obj.contains_key("executor")
199 || obj.contains_key("status")
200 || obj.contains_key("complexity")
201 || obj.contains_key("decision")
202 {
203 return Some(json);
204 }
205 }
206 }
207 }
209
210 for text in texts.iter().rev() {
211 let trimmed = text.trim();
212 if let Some(json) = extract_json_object(trimmed) {
213 if crate::parser::parse(&json).is_ok() {
217 return Some(json);
218 }
219
220 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&json) {
223 if let Some(obj) = val.as_object() {
224 if obj.contains_key("executor") || obj.contains_key("status") {
225 return Some(json);
226 }
227 }
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 if let Some(obj) = val.as_object() {
244 if 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 }
254
255 Some(full)
256}
257
258fn extract_text_from_part(part: &serde_json::Value) -> Vec<String> {
259 let mut out = Vec::new();
260
261 if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
262 out.push(text.to_string());
263 }
264
265 if let Some(items) = part.get("content").and_then(|v| v.as_array()) {
267 for item in items {
268 let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
269 if item_type == "text" {
270 if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
271 out.push(text.to_string());
272 }
273 }
274 }
275 }
276
277 out
278}
279
280fn extract_json_object(text: &str) -> Option<String> {
281 let trimmed = text.trim();
282 if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
283 return Some(trimmed.to_string());
284 }
285
286 let start = trimmed.find('{')?;
287 let end = trimmed.rfind('}')?;
288 if end <= start {
289 return None;
290 }
291
292 let candidate = trimmed[start..=end].trim();
293 serde_json::from_str::<serde_json::Value>(candidate)
294 .ok()
295 .map(|_| candidate.to_string())
296}
297
298pub(crate) fn extract_router_text(raw: &str) -> Option<String> {
299 let events = super::parse_ndjson(raw.trim());
300 if events.is_empty() {
301 return None;
302 }
303 extract_ndjson_text(&events)
304}
305
306#[cfg(test)]
307mod router_text_tests {
308 use super::*;
309
310 #[test]
311 fn extract_router_text_supports_message_with_content_array() {
312 let raw = r#"{"type":"step_start","timestamp":1}
314{"type":"message","timestamp":2,"part":{"type":"message","content":[{"type":"text","text":"```json\n{\"executor\":\"opencode\",\"complexity\":\"medium\",\"reason\":\"ok\"}\n```"}]}}
315{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
316 let text = extract_router_text(raw).expect("should extract text");
317 assert!(
318 text.contains("executor"),
319 "extracted text should include JSON"
320 );
321 }
322
323 #[test]
324 fn extract_router_text_ignores_leading_system_init() {
325 let raw = r#"{"type":"system","subtype":"init","cwd":"/"}
329{"type":"text","timestamp":2,"text":"{\"executor\":\"opencode\",\"complexity\":\"medium\",\"reason\":\"ndjson\"}"}
330{"type":"step_finish","timestamp":3}"#;
331
332 let text = extract_router_text(raw).expect("should extract text even with leading init");
333 let parsed: serde_json::Value =
334 serde_json::from_str(&text).expect("extracted text must be JSON");
335 assert_eq!(parsed["executor"], "opencode");
336 assert_eq!(parsed["complexity"], "medium");
337 }
338}
339
340const NO_SNAPSHOT_CONFIG: &str = r#"{"snapshot":false}"#;
343
344impl OpenCodeRunner {
345 pub fn new() -> Self {
346 Self
347 }
348
349 pub(crate) fn split_model_variant(s: &str) -> (&str, Option<&str>) {
356 if let Some(pos) = s.rfind('@') {
357 let base = &s[..pos];
358 let variant = &s[pos + 1..];
359 (base, Some(variant))
360 } else {
361 (s, None)
362 }
363 }
364
365 fn extract_text_from_events(&self, events: &[serde_json::Value]) -> Option<String> {
376 extract_ndjson_text(events)
377 }
378
379 fn extract_tokens(&self, events: &[serde_json::Value]) -> (Option<u64>, Option<u64>) {
381 for event in events.iter().rev() {
382 let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
383
384 if event_type == "step_finish" {
385 if let Some(part) = event.get("part") {
386 if let Some(tokens) = part.get("tokens").and_then(|v| v.as_object()) {
387 let input = tokens.get("input").and_then(|v| v.as_u64());
388 let output = tokens.get("output").and_then(|v| v.as_u64());
389 return (input, output);
390 }
391 }
392 }
393 }
394
395 (None, None)
396 }
397
398 fn detect_error(&self, events: &[serde_json::Value]) -> Option<AgentError> {
400 super::patterns::detect_ndjson_error(
401 events,
402 |event| {
403 let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
404 match event_type {
405 "error" => Some(opencode_error_text(event)),
406 "step_finish" => {
407 let part = event.get("part")?;
408 let reason = part.get("reason").and_then(|v| v.as_str()).unwrap_or("");
409 if reason == "error" || reason == "failed" {
410 Some(
411 part.get("error")
412 .map(error_value_text)
413 .unwrap_or_else(|| "step failed".to_string()),
414 )
415 } else {
416 None
417 }
418 }
419 _ => None,
420 }
421 },
422 classify_opencode_message,
423 )
424 }
425}
426
427fn error_value_text(v: &serde_json::Value) -> String {
429 if let Some(s) = v.as_str() {
430 return s.to_string();
431 }
432 v.get("data")
433 .and_then(|d| d.get("message").or(Some(d)))
434 .and_then(|m| m.as_str())
435 .or_else(|| v.get("message").and_then(|m| m.as_str()))
436 .or_else(|| v.get("name").and_then(|n| n.as_str()))
437 .map(str::to_string)
438 .unwrap_or_else(|| v.to_string())
439}
440
441fn opencode_error_text(event: &serde_json::Value) -> String {
443 if let Some(s) = event.get("message").and_then(|v| v.as_str()) {
444 return s.to_string();
445 }
446 match event.get("error") {
447 Some(e) => {
448 let text = error_value_text(e);
449 if e.is_object() && text == *e {
450 tracing::warn!(event = %event, "unrecognized opencode error event shape");
451 }
452 text
453 }
454 None => {
455 tracing::warn!(event = %event, "unrecognized opencode error event shape");
456 event.to_string()
457 }
458 }
459}
460
461impl AgentRunner for OpenCodeRunner {
462 #[cfg(test)]
463 fn name(&self) -> &str {
464 "opencode"
465 }
466
467 fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent> {
468 stream_events(event)
469 }
470
471 fn router_transcript(&self) -> super::RouterTranscript {
472 super::RouterTranscript::OpenCode
473 }
474
475 fn extract_text(&self, raw: &str) -> Result<String, AgentError> {
476 let trimmed = raw.trim();
477 if trimmed.is_empty() {
478 return Ok(String::new());
479 }
480
481 let events = super::parse_ndjson(trimmed);
482 if events.is_empty() {
483 return Ok(trimmed.to_string());
484 }
485
486 if let Some(err) = self.detect_error(&events) {
488 return Err(err);
489 }
490
491 Ok(extract_ndjson_text(&events).unwrap_or_else(|| trimmed.to_string()))
493 }
494
495 fn build_command(
496 &self,
497 model: Option<&str>,
498 timeout_cmd: &str,
499 sys_file: &str,
500 msg_file: &str,
501 permissions: &PermissionRules,
502 ) -> String {
503 let (model_flag, variant_flag) = if let Some(m) = model {
506 let (base, variant) = Self::split_model_variant(m);
507 let mf = format!("--model {}", super::shell_single_quote(base));
508 let vf = variant.map(|v| format!("--variant {}", super::shell_single_quote(v)));
509 (mf, vf)
510 } else {
511 (String::new(), None)
512 };
513
514 let skip_perms = if permissions.autonomous {
527 "--dangerously-skip-permissions "
528 } else {
529 ""
530 };
531
532 let variant_part = variant_flag.unwrap_or_default();
534 format!(
539 r#"OPENCODE_CONFIG_CONTENT={config} {timeout_cmd} opencode run {model_flag} {variant_part} {skip_perms}\
540 --format json - < <(cat "{sys_file}" "{msg_file}")"#,
541 config = super::shell_single_quote(NO_SNAPSHOT_CONFIG),
542 sys_file = sys_file,
543 msg_file = msg_file,
544 timeout_cmd = timeout_cmd,
545 model_flag = model_flag,
546 variant_part = variant_part,
547 skip_perms = skip_perms,
548 )
549 }
550
551 fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
552 let trimmed = raw.trim();
553 if trimmed.is_empty() {
557 return Err(AgentError::Unknown {
558 exit_code: 0,
559 message: "empty-output-exit0: opencode returned exit 0 with empty stdout"
560 .to_string(),
561 });
562 }
563
564 let events = super::parse_ndjson(trimmed);
565
566 if events.is_empty() {
567 if let Ok(resp) = parser::parse(trimmed) {
569 return Ok(ParsedResponse {
570 response: resp,
571 input_tokens: None,
572 output_tokens: None,
573 duration_ms: None,
574 });
575 }
576 return Err(AgentError::InvalidResponse {
577 raw: trimmed.to_string(),
578 });
579 }
580
581 if let Some(err) = self.detect_error(&events) {
583 return Err(err);
584 }
585
586 let (input_tokens, output_tokens) = self.extract_tokens(&events);
588
589 let text = match self.extract_text_from_events(&events) {
596 Some(t) => t,
597 None => {
598 let response = synthesize_response_from_events(&events);
599 return Ok(ParsedResponse {
600 response,
601 input_tokens,
602 output_tokens,
603 duration_ms: None,
604 });
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(
651 &self,
652 prompt: &str,
653 model: Option<&str>,
654 ) -> anyhow::Result<tokio::process::Command> {
655 let mut cmd = tokio::process::Command::new("opencode");
661 cmd.env("OPENCODE_CONFIG_CONTENT", NO_SNAPSHOT_CONFIG)
662 .arg("run")
663 .arg("--format")
664 .arg("json")
665 .arg("--dangerously-skip-permissions");
666 if let Some(m) = model {
667 let (base, variant) = Self::split_model_variant(m);
668 cmd.arg("--model").arg(base);
669 if let Some(v) = variant {
670 cmd.arg("--variant").arg(v);
672 }
673 }
674 cmd.arg(prompt);
675 Ok(cmd)
676 }
677}
678
679fn classify_opencode_message(message: &str) -> AgentError {
681 let lower = message.to_lowercase();
682
683 if let Some(e) = super::patterns::detect_rate_limit(message) {
685 return e;
686 }
687
688 if let Some(e) = super::patterns::detect_context_overflow(message) {
690 return e;
691 }
692
693 if let Some(e) = super::patterns::detect_auth_error(message) {
695 return e;
696 }
697
698 if let Some(e) = super::patterns::detect_permission_denied(message) {
700 return e;
701 }
702
703 let is_model_unavailable = (lower.contains("model")
714 && (lower.contains("not found")
715 || lower.contains("not supported")
716 || lower.contains("deprecated")
717 || lower.contains("unavailable for free")
718 || lower.contains("not available in your country")
719 || lower.contains("not available in your region")))
720 || lower.contains("no endpoints found");
721
722 if is_model_unavailable {
723 let model = if lower.contains("no endpoints found") {
726 message
728 .split(" for ")
729 .nth(1)
730 .map(|s| s.trim_end_matches('.').to_string())
731 .unwrap_or_default()
732 } else {
733 let from_colon = message
735 .split(": ")
736 .nth(1)
737 .map(|s| {
738 s.split(". Did you mean")
740 .next()
741 .unwrap_or(s)
742 .trim_end_matches('.')
743 .to_string()
744 })
745 .filter(|s| !s.is_empty());
746
747 let from_transition = if lower.contains("deprecated") {
750 message
751 .split("Transition to ")
752 .nth(1)
753 .and_then(|s| s.split(" for ").next())
754 .map(|s| s.trim_end_matches('.').to_string())
755 .filter(|s| !s.is_empty())
756 } else {
757 None
758 };
759
760 let from_slug_suggestion = if lower.contains("unavailable for free") {
763 message
764 .split("use this slug instead: ")
765 .nth(1)
766 .map(|s| s.trim_end_matches('.').trim().to_string())
767 .filter(|s| !s.is_empty())
768 } else {
769 None
770 };
771
772 from_transition
776 .or(from_slug_suggestion)
777 .or(from_colon)
778 .unwrap_or_default()
779 };
780 return AgentError::ModelUnavailable {
781 message: message.to_string(),
782 model,
783 };
784 }
785
786 if lower.contains("provider returned error") {
791 return AgentError::RateLimit {
792 message: message.to_string(),
793 };
794 }
795
796 if let Some(e) = super::patterns::detect_network_error(message) {
798 return e;
799 }
800
801 AgentError::AgentFailed {
802 message: message.to_string(),
803 }
804}
805
806pub fn discover_free_opencode_models() -> Vec<String> {
815 if skip_live_opencode_discovery() {
816 return Vec::new();
817 }
818 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
819
820 let now = chrono::Utc::now().timestamp();
821 {
823 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
824 let (ts, models) = &*guard;
825 if *ts != 0 && now.saturating_sub(*ts) < 3600 {
826 return models.clone();
827 }
828 }
829
830 if !FREE_MODELS_REFRESH_IN_PROGRESS.swap(true, Ordering::AcqRel) {
833 let refresh = async {
834 let _guard = scopeguard::guard((), |_| {
835 FREE_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
836 });
837 let (models, empty_reason) = run_opencode_models_discovery_async().await;
838 let raw_count = models.len();
839 let discovered = models
840 .into_iter()
841 .filter(|m| m.to_lowercase().contains("free"))
842 .collect::<Vec<_>>();
843 let empty_reason = if discovered.is_empty() && raw_count > 0 {
844 "no_free_models_in_catalog"
845 } else {
846 empty_reason
847 };
848 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
849 update_discovered_models_cache(cache, discovered, "free", empty_reason);
850 };
851 if let Ok(handle) = tokio::runtime::Handle::try_current() {
852 handle.spawn(refresh);
855 } else {
856 std::thread::spawn(move || {
859 if let Ok(rt) = tokio::runtime::Runtime::new() {
860 rt.block_on(refresh);
861 }
862 });
863 }
864 }
865
866 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
868 guard.1.clone()
869}
870
871pub fn discover_opencode_models() -> Vec<String> {
876 if skip_live_opencode_discovery() {
877 return Vec::new();
878 }
879 let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
880 let now = chrono::Utc::now().timestamp();
881
882 {
883 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
884 let (ts, models) = &*guard;
885 if *ts != 0 && now.saturating_sub(*ts) < 3600 {
886 return models.clone();
887 }
888 }
889
890 if !ALL_MODELS_REFRESH_IN_PROGRESS.swap(true, Ordering::AcqRel) {
891 let refresh = async {
892 let _guard = scopeguard::guard((), |_| {
893 ALL_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
894 });
895 let (discovered, empty_reason) = run_opencode_models_discovery_async().await;
896 let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
897 update_discovered_models_cache(cache, discovered, "all", empty_reason);
898 };
899 if let Ok(handle) = tokio::runtime::Handle::try_current() {
900 handle.spawn(refresh);
901 } else {
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 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
911 guard.1.clone()
912}
913
914pub fn prime_free_model_cache() {
922 if skip_live_opencode_discovery() {
923 return;
924 }
925 let discovered_all = std::thread::spawn(|| {
926 if let Ok(rt) = tokio::runtime::Runtime::new() {
927 rt.block_on(run_opencode_models_discovery_async()).0
928 } else {
929 vec![]
930 }
931 })
932 .join()
933 .unwrap_or_default();
934 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
935 let now = chrono::Utc::now().timestamp();
936 if let Ok(mut guard) = cache.lock() {
937 *guard = (
938 now,
939 discovered_all
940 .iter()
941 .filter(|m| m.to_lowercase().contains("free"))
942 .cloned()
943 .collect(),
944 );
945 }
946 let all_cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
947 if let Ok(mut guard) = all_cache.lock() {
948 *guard = (now, discovered_all);
949 }
950}
951
952async fn run_opencode_models_discovery_async() -> (Vec<String>, &'static str) {
961 run_opencode_models_cli(std::time::Duration::from_secs(30)).await
962}
963
964pub(crate) async fn run_opencode_models_cli(
966 timeout: std::time::Duration,
967) -> (Vec<String>, &'static str) {
968 run_opencode_models_cli_inner("opencode", timeout).await
969}
970
971fn opencode_program_available(program: &str) -> bool {
972 let path = Path::new(program);
973 if path.is_absolute() || program.contains('/') {
974 path.is_file()
975 } else {
976 crate::cmd_cache::command_exists(program)
977 }
978}
979
980#[cfg(unix)]
982fn raw_kill(pid: i32, sig: i32) -> i32 {
983 extern "C" {
984 fn kill(pid: i32, sig: i32) -> i32;
985 }
986 unsafe { kill(pid, sig) }
988}
989
990#[cfg(unix)]
993fn kill_process_group(pgid: u32) {
994 const SIGKILL: i32 = 9;
995 let _ = raw_kill(-(pgid as i32), SIGKILL);
996}
997
998async fn run_opencode_models_cli_inner(
999 program: &str,
1000 timeout: std::time::Duration,
1001) -> (Vec<String>, &'static str) {
1002 if !opencode_program_available(program) {
1003 tracing::warn!("opencode not in PATH, skipping model discovery");
1004 return (vec![], "command_not_found");
1005 }
1006
1007 let mut cmd = tokio::process::Command::new(program);
1008 cmd.args(["models"])
1009 .stdout(std::process::Stdio::piped())
1010 .stderr(std::process::Stdio::piped())
1011 .kill_on_drop(true);
1012 #[cfg(unix)]
1013 cmd.process_group(0);
1014
1015 let child = match cmd.spawn() {
1016 Ok(c) => c,
1017 Err(e) => {
1018 tracing::warn!(error = %e, "failed to spawn opencode models");
1019 return (vec![], "spawn_failed");
1020 }
1021 };
1022 let pgid = child.id();
1023 let wait = tokio::time::timeout(timeout, child.wait_with_output()).await;
1024
1025 #[cfg(unix)]
1026 if let Some(pid) = pgid {
1027 kill_process_group(pid);
1028 }
1029
1030 match wait {
1031 Ok(Ok(output)) if output.status.success() => {
1032 let models: Vec<String> = String::from_utf8_lossy(&output.stdout)
1033 .lines()
1034 .map(|l| l.trim().to_string())
1035 .filter(|l| !l.is_empty())
1036 .collect();
1037 let reason = if models.is_empty() {
1038 "empty_output"
1039 } else {
1040 "ok"
1041 };
1042 (models, reason)
1043 }
1044 Ok(Ok(output)) => {
1045 let stderr = String::from_utf8_lossy(&output.stderr);
1046 let stderr = stderr.trim();
1047 let stderr_display = if stderr.len() > 500 {
1048 format!("{}...", &stderr[..stderr.floor_char_boundary(500)])
1049 } else {
1050 stderr.to_string()
1051 };
1052 tracing::warn!(
1053 status = ?output.status,
1054 stderr = %stderr_display,
1055 "opencode models command failed"
1056 );
1057 (vec![], "exit_status_failure")
1058 }
1059 Ok(Err(e)) => {
1060 tracing::warn!(error = %e, "failed to wait on opencode models");
1061 (vec![], "wait_failed")
1062 }
1063 Err(_) => {
1064 tracing::warn!(
1065 secs = timeout.as_secs(),
1066 "opencode models timed out, killing process group"
1067 );
1068 (vec![], "timeout")
1069 }
1070 }
1071}
1072
1073fn synthesize_response_from_events(events: &[serde_json::Value]) -> crate::parser::AgentResponse {
1086 use std::collections::BTreeMap;
1087
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 if 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
1109 if event_type == "tool_use" {
1111 if let Some(name) = event
1112 .get("name")
1113 .and_then(|v| v.as_str())
1114 .or_else(|| event.get("tool").and_then(|v| v.as_str()))
1115 .or_else(|| {
1116 event
1117 .get("part")
1118 .and_then(|p| p.get("name"))
1119 .and_then(|v| v.as_str())
1120 })
1121 .or_else(|| {
1122 event
1123 .get("tool")
1124 .and_then(|t| t.get("name"))
1125 .and_then(|v| v.as_str())
1126 })
1127 {
1128 tool_names.push(name.to_string());
1129 }
1130 }
1131 }
1132
1133 let total_events = events.len();
1134 let reason = step_finish_reason.as_deref().unwrap_or("unknown");
1135
1136 let tool_summary = if tool_names.is_empty() {
1137 String::new()
1138 } else {
1139 let mut tool_counts: BTreeMap<String, u32> = BTreeMap::new();
1140 for n in &tool_names {
1141 *tool_counts.entry(n.clone()).or_insert(0) += 1;
1142 }
1143 let parts: Vec<String> = tool_counts
1144 .iter()
1145 .map(|(k, v)| {
1146 if *v > 1 {
1147 format!("{k}×{v}")
1148 } else {
1149 k.clone()
1150 }
1151 })
1152 .collect();
1153 format!(" (tools: {})", parts.join(", "))
1154 };
1155
1156 let summary = format!(
1157 "opencode emitted {total_events} event(s) with no text output{tool_summary}; \
1158 step_finish reason: {reason}"
1159 );
1160
1161 crate::parser::AgentResponse {
1162 status: "needs_review".to_string(),
1163 summary,
1164 error: Some("agent emitted no text response — only tool/step events".to_string()),
1165 ..Default::default()
1166 }
1167}
1168
1169pub fn find_opencode_result(ndjson: &str) -> Option<super::AgentResult> {
1177 let events = super::parse_ndjson(ndjson.trim());
1178 if events.is_empty() {
1179 return None;
1180 }
1181
1182 let is_error;
1184 let error_text;
1185 let runner = OpenCodeRunner::new();
1186 if let Some(err) = runner.detect_error(&events) {
1187 is_error = true;
1188 error_text = Some(err.to_string());
1189 } else {
1190 is_error = false;
1191 error_text = None;
1192 }
1193
1194 let result_text = if is_error {
1196 error_text.unwrap_or_default()
1197 } else {
1198 extract_ndjson_text(&events).unwrap_or_default()
1199 };
1200
1201 let (input_tokens, output_tokens) = runner.extract_tokens(&events);
1203
1204 let last_step_finish_reason = events
1213 .iter()
1214 .rev()
1215 .find(|event| event.get("type").and_then(|v| v.as_str()) == Some("step_finish"))
1216 .and_then(|event| {
1217 event
1218 .get("part")?
1219 .get("reason")?
1220 .as_str()
1221 .map(str::to_string)
1222 });
1223 let truncated_by_length = match last_step_finish_reason.as_deref() {
1224 Some("length") => true,
1225 Some("unknown") => output_tokens == Some(0),
1226 _ => false,
1227 };
1228
1229 if result_text.is_empty() && !is_error && !truncated_by_length {
1230 return None;
1231 }
1232
1233 let cost_usd = events.iter().rev().find_map(|event| {
1235 if event.get("type").and_then(|v| v.as_str()) == Some("step_finish") {
1236 event
1237 .get("part")
1238 .and_then(|p| p.get("cost"))
1239 .and_then(|c| c.as_f64())
1240 .filter(|&c| c > 0.0)
1241 } else {
1242 None
1243 }
1244 });
1245
1246 Some(super::AgentResult {
1247 is_error,
1248 result_text,
1249 input_tokens,
1250 output_tokens,
1251 cost_usd,
1252 duration_ms: None,
1253 truncated_by_length,
1254 })
1255}
1256
1257#[cfg(test)]
1258mod tests {
1259 use super::*;
1260
1261 fn runner() -> OpenCodeRunner {
1262 OpenCodeRunner::new()
1263 }
1264
1265 #[test]
1266 fn parse_opencode_ndjson_success() {
1267 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start","snapshot":"..."}}
1268{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"status\":\"done\",\"summary\":\"hello\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}}
1269{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":17512,"input":17509,"output":3}}}"#;
1270
1271 let parsed = runner().parse_response(raw).unwrap();
1272 assert_eq!(parsed.response.status, "done");
1273 assert_eq!(parsed.response.summary, "hello");
1274 assert_eq!(parsed.input_tokens, Some(17509));
1275 assert_eq!(parsed.output_tokens, Some(3));
1276 }
1277
1278 #[test]
1281 fn parse_opencode_ndjson_direct_text_format() {
1282 let raw = r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}
1283{"type":"text","timestamp":1001,"sessionID":"ses_abc","text":"{\"status\":\"done\",\"summary\":\"fixed\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}
1284{"type":"step_finish","timestamp":1002,"sessionID":"ses_abc","part":{"type":"step-finish","reason":"stop","tokens":{"input":200,"output":10}}}"#;
1285
1286 let parsed = runner().parse_response(raw).unwrap();
1287 assert_eq!(parsed.response.status, "done");
1288 assert_eq!(parsed.response.summary, "fixed");
1289 assert_eq!(parsed.input_tokens, Some(200));
1290 assert_eq!(parsed.output_tokens, Some(10));
1291 }
1292
1293 #[test]
1294 fn parse_opencode_concatenated_text() {
1295 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1296{"type":"text","timestamp":1001,"part":{"type":"text","text":"Working on "}}
1297{"type":"text","timestamp":1002,"part":{"type":"text","text":"the task. "}}
1298{"type":"text","timestamp":1003,"part":{"type":"text","text":"{\"status\":\"done\",\"summary\":\"finished\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}}
1299{"type":"step_finish","timestamp":1004,"part":{"type":"step-finish","reason":"stop","tokens":{"input":100,"output":50}}}"#;
1300
1301 let parsed = runner().parse_response(raw).unwrap();
1302 assert_eq!(parsed.response.status, "done");
1303 assert_eq!(parsed.input_tokens, Some(100));
1304 assert_eq!(parsed.output_tokens, Some(50));
1305 }
1306
1307 #[test]
1308 fn extract_router_text_prefers_json_payload() {
1309 let raw = r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"Thinking..."}}
1310{"type":"text","timestamp":1002,"part":{"type":"text","text":"{\"executor\":\"claude\",\"complexity\":\"medium\",\"reason\":\"fit\"}"}}"#;
1311
1312 let text = extract_router_text(raw).unwrap();
1313 let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
1314 assert_eq!(parsed["executor"], "claude");
1315 }
1316
1317 #[test]
1318 fn parse_opencode_embedded_json_fragment_without_executor_is_ignored() {
1319 let raw = r#"{"type":"step_start","timestamp":1}
1324{"type":"text","timestamp":2,"part":{"type":"text","text":"Here is a debug trace: {\"trace\":{\"duration\":123,\"id\":\"abc\"}}"}}
1325{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
1326
1327 let err = runner().parse_response(raw).unwrap_err();
1328 match err {
1329 AgentError::InvalidResponse { raw: _ } => {
1330 }
1332 other => panic!("expected InvalidResponse, got: {other:?}"),
1333 }
1334 }
1335
1336 #[test]
1337 fn parse_opencode_error_event() {
1338 let raw = r#"{"type":"error","message":"rate limit exceeded for this model"}"#;
1339
1340 let err = runner().parse_response(raw).unwrap_err();
1341 assert!(matches!(err, AgentError::RateLimit { .. }));
1342 }
1343
1344 #[test]
1350 fn parse_opencode_tool_only_stream_synthesizes_needs_review() {
1351 let raw = r#"{"type":"step_start","timestamp":1,"part":{"type":"step-start"}}
1352{"type":"tool_use","timestamp":2,"name":"bash","input":{"cmd":"ls"}}
1353{"type":"tool_use","timestamp":3,"name":"read","input":{"path":"src/main.rs"}}
1354{"type":"tool_use","timestamp":4,"name":"bash","input":{"cmd":"cargo check"}}
1355{"type":"step_finish","timestamp":5,"part":{"type":"step-finish","reason":"stop","tokens":{"input":500,"output":0}}}"#;
1356
1357 let parsed = runner()
1358 .parse_response(raw)
1359 .expect("tool-only NDJSON must synthesize a response, not error");
1360 assert_eq!(parsed.response.status, "needs_review");
1361 assert!(
1362 !parsed.response.summary.contains("\"type\":\"step_start\""),
1363 "summary must not contain raw NDJSON; got: {}",
1364 parsed.response.summary
1365 );
1366 assert!(
1367 parsed.response.summary.contains("bash") && parsed.response.summary.contains("read"),
1368 "summary should mention tools used; got: {}",
1369 parsed.response.summary
1370 );
1371 assert!(
1372 parsed.response.summary.contains("stop"),
1373 "summary should mention step_finish reason; got: {}",
1374 parsed.response.summary
1375 );
1376 assert_eq!(parsed.input_tokens, Some(500));
1378 assert_eq!(parsed.output_tokens, Some(0));
1379 }
1380
1381 #[test]
1386 fn parse_opencode_plain_prose_completion_synthesizes_response() {
1387 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1388{"type":"text","timestamp":1001,"part":{"type":"text","text":"Fixed the typo and corrected the deadline reference in the review."}}
1389{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","tokens":{"input":300,"output":40}}}"#;
1390
1391 let parsed = runner()
1392 .parse_response(raw)
1393 .expect("plain-prose completion text must synthesize successfully");
1394 assert_eq!(parsed.response.status, "done");
1395 assert_eq!(parsed.input_tokens, Some(300));
1396 assert_eq!(parsed.output_tokens, Some(40));
1397 }
1398
1399 #[test]
1400 fn parse_opencode_step_finish_error() {
1401 let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1402{"type":"step_finish","timestamp":1001,"part":{"type":"step-finish","reason":"error","error":"context length exceeded"}}"#;
1403
1404 let err = runner().parse_response(raw).unwrap_err();
1405 assert!(matches!(err, AgentError::ContextOverflow { .. }));
1406 }
1407
1408 #[test]
1409 fn parse_opencode_empty_returns_silent_failure() {
1410 let err = runner().parse_response("").unwrap_err();
1411 assert!(matches!(err, AgentError::Unknown { exit_code: 0, .. }));
1414 }
1415
1416 #[test]
1417 fn extract_tokens_from_step_finish() {
1418 let r = runner();
1419 let events: Vec<serde_json::Value> = vec![
1420 serde_json::json!({"type":"step_finish","part":{"type":"step-finish","tokens":{"input":1000,"output":500,"total":1500}}}),
1421 ];
1422 let (input, output) = r.extract_tokens(&events);
1423 assert_eq!(input, Some(1000));
1424 assert_eq!(output, Some(500));
1425 }
1426
1427 #[test]
1428 fn classify_opencode_rate_limit() {
1429 let err = classify_opencode_message("429 Too Many Requests");
1430 assert!(matches!(err, AgentError::RateLimit { .. }));
1431 }
1432
1433 #[test]
1434 fn classify_opencode_model_not_found() {
1435 let err = classify_opencode_message("model not found: gpt-5");
1436 assert!(matches!(err, AgentError::ModelUnavailable { .. }));
1437 }
1438
1439 #[test]
1440 fn classify_opencode_model_not_found_with_suggestion() {
1441 let err = classify_opencode_message(
1444 "Model not found: github-copilot/gemini-3.1-pro. Did you mean: gemini-3.1-pro-preview?",
1445 );
1446 match err {
1447 AgentError::ModelUnavailable { model, message } => {
1448 assert_eq!(model, "github-copilot/gemini-3.1-pro");
1449 assert!(message.contains("Did you mean"));
1450 }
1451 other => panic!("expected ModelUnavailable, got: {other:?}"),
1452 }
1453 }
1454
1455 #[test]
1456 fn classify_opencode_model_deprecated() {
1457 let err = classify_opencode_message(
1460 "The free model has been deprecated. Transition to qwen/qwen3.6-plus for continued paid access.",
1461 );
1462 match err {
1463 AgentError::ModelUnavailable { model, .. } => {
1464 assert_eq!(
1465 model, "qwen/qwen3.6-plus",
1466 "expected model extracted from 'Transition to X', got: {model:?}"
1467 );
1468 }
1469 other => panic!("expected ModelUnavailable, got: {other:?}"),
1470 }
1471 }
1472
1473 #[test]
1474 fn classify_opencode_model_unavailable_for_free() {
1475 let err = classify_opencode_message(
1479 "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",
1480 );
1481 match err {
1482 AgentError::ModelUnavailable { model, .. } => {
1483 assert_eq!(
1484 model, "inclusionai/ling-3.0-flash",
1485 "expected model extracted from 'use this slug instead: X', got: {model:?}"
1486 );
1487 }
1488 other => panic!("expected ModelUnavailable, got: {other:?}"),
1489 }
1490 }
1491
1492 #[test]
1493 fn classify_opencode_model_not_available_in_country() {
1494 let err =
1499 classify_opencode_message("agent failed: This model is not available in your country.");
1500 match err {
1501 AgentError::ModelUnavailable { .. } => {}
1502 other => panic!("expected ModelUnavailable, got: {other:?}"),
1503 }
1504 }
1505
1506 #[test]
1507 fn classify_opencode_no_endpoints_found() {
1508 let err = classify_opencode_message("No endpoints found for qwen/qwen3.6-plus:free.");
1510 match err {
1511 AgentError::ModelUnavailable { model, .. } => {
1512 assert_eq!(model, "qwen/qwen3.6-plus:free");
1513 }
1514 other => panic!("expected ModelUnavailable, got: {other:?}"),
1515 }
1516 }
1517
1518 #[test]
1519 fn build_command_opencode() {
1520 let r = runner();
1521 let perms = PermissionRules::default();
1522 let cmd = r.build_command(
1523 Some("anthropic/claude-sonnet-4-20250514"),
1524 "timeout 1800",
1525 "/tmp/sys.txt",
1526 "/tmp/msg.txt",
1527 &perms,
1528 );
1529 assert!(cmd.contains("opencode run"));
1530 assert!(cmd.contains("--model 'anthropic/claude-sonnet-4-20250514'"));
1531 assert!(cmd.contains("--format json"));
1532 assert!(
1533 cmd.starts_with(
1534 r#"OPENCODE_CONFIG_CONTENT='{"snapshot":false}' timeout 1800 opencode run"#
1535 ),
1536 "snapshots must be off for orch runs, got: {cmd}"
1537 );
1538 assert!(
1541 cmd.contains("--dangerously-skip-permissions"),
1542 "expected --dangerously-skip-permissions, got: {cmd}"
1543 );
1544 assert!(
1545 !cmd.contains("opencode.json"),
1546 "should not write a config file, got: {cmd}"
1547 );
1548 assert!(
1549 !cmd.contains("XDG_CONFIG_HOME"),
1550 "should not override XDG_CONFIG_HOME, got: {cmd}"
1551 );
1552 assert!(
1553 !cmd.contains(".orch-opencode"),
1554 "should not reference .orch-opencode, got: {cmd}"
1555 );
1556 assert!(
1563 !cmd.trim_start().starts_with("cat "),
1564 "opencode command must not start with a `cat |` pipe stage, got: {cmd}"
1565 );
1566 assert!(
1567 cmd.contains("< <(cat \"/tmp/sys.txt\" \"/tmp/msg.txt\")"),
1568 "sys/msg files must be fed via process substitution, got: {cmd}"
1569 );
1570 }
1571
1572 #[test]
1573 fn build_command_opencode_supervised_no_skip_flag() {
1574 let r = runner();
1575 let perms = PermissionRules {
1576 autonomous: false,
1577 ..PermissionRules::default()
1578 };
1579 let cmd = r.build_command(
1580 Some("anthropic/claude-sonnet-4-20250514"),
1581 "timeout 1800",
1582 "/tmp/sys.txt",
1583 "/tmp/msg.txt",
1584 &perms,
1585 );
1586 assert!(
1587 !cmd.contains("--dangerously-skip-permissions"),
1588 "supervised mode must not pass --dangerously-skip-permissions, got: {cmd}"
1589 );
1590 }
1591
1592 #[test]
1593 fn split_model_variant_no_at() {
1594 let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5");
1595 assert_eq!(base, "openai/gpt-5.5");
1596 assert!(variant.is_none());
1597 }
1598
1599 #[test]
1600 fn split_model_variant_with_variant() {
1601 let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5@xhigh");
1602 assert_eq!(base, "openai/gpt-5.5");
1603 assert_eq!(variant, Some("xhigh"));
1604 }
1605
1606 #[test]
1607 fn split_model_variant_trailing_at_empty() {
1608 let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5@");
1609 assert_eq!(base, "openai/gpt-5.5");
1610 assert_eq!(variant, Some(""));
1611 }
1612
1613 #[test]
1614 fn split_model_variant_multiple_ats_last_wins() {
1615 let (base, variant) = OpenCodeRunner::split_model_variant("a@b@c@v");
1616 assert_eq!(base, "a@b@c");
1617 assert_eq!(variant, Some("v"));
1618 }
1619
1620 #[test]
1621 fn build_command_opencode_with_variant() {
1622 let r = runner();
1623 let perms = PermissionRules::default();
1624 let cmd = r.build_command(
1625 Some("openai/gpt-5.5@high"),
1626 "timeout 1800",
1627 "/tmp/sys.txt",
1628 "/tmp/msg.txt",
1629 &perms,
1630 );
1631 assert!(cmd.contains("--model 'openai/gpt-5.5'"));
1632 assert!(cmd.contains("--variant 'high'"));
1633 }
1634
1635 #[test]
1636 fn classify_opencode_permission_denied() {
1637 let err = classify_opencode_message("user rejected permission for tool bash: cargo test");
1638 assert!(
1639 matches!(err, AgentError::PermissionDenied { .. }),
1640 "expected PermissionDenied, got: {err:?}"
1641 );
1642 }
1643
1644 #[test]
1645 fn classify_opencode_provider_returned_error() {
1646 let err = classify_opencode_message("Provider returned error");
1650 assert!(
1651 matches!(err, AgentError::RateLimit { .. }),
1652 "expected RateLimit, got: {err:?}"
1653 );
1654 let err_with_detail =
1655 classify_opencode_message("Provider returned error: upstream connection timed out");
1656 assert!(
1657 matches!(err_with_detail, AgentError::RateLimit { .. }),
1658 "expected RateLimit, got: {err_with_detail:?}"
1659 );
1660 }
1661
1662 #[test]
1663 fn classify_opencode_upstream_idle_timeout() {
1664 let err = classify_opencode_message("Upstream idle timeout exceeded");
1667 assert!(
1668 matches!(err, AgentError::NetworkError { .. }),
1669 "expected NetworkError, got: {err:?}"
1670 );
1671 }
1672
1673 #[test]
1674 fn free_models_discovery_returns_vec() {
1675 let models = discover_free_opencode_models();
1677 assert!(models.is_empty());
1678 }
1679
1680 #[test]
1681 fn live_opencode_discovery_is_skipped_in_test_builds() {
1682 assert!(skip_live_opencode_discovery());
1683 assert!(discover_opencode_models().is_empty());
1684 assert!(discover_free_opencode_models().is_empty());
1685 prime_free_model_cache();
1686 assert!(discover_opencode_models().is_empty());
1687 }
1688
1689 #[cfg(unix)]
1692 #[tokio::test]
1693 async fn models_cli_reaps_orphaned_process_group() {
1694 use std::os::unix::fs::PermissionsExt;
1695
1696 let dir = tempfile::tempdir().unwrap();
1697 let sleeper_pid_path = dir.path().join("sleeper.pid");
1698 let script = dir.path().join("opencode");
1699 std::fs::write(
1700 &script,
1701 format!(
1702 "#!/bin/sh\n/bin/sleep 60 </dev/null >/dev/null 2>&1 &\necho $! > '{}'\necho test-model-free\n",
1703 sleeper_pid_path.display()
1704 ),
1705 )
1706 .unwrap();
1707 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1708
1709 let (models, reason) = run_opencode_models_cli_inner(
1712 script.to_str().unwrap(),
1713 std::time::Duration::from_secs(30),
1714 )
1715 .await;
1716 assert_eq!(reason, "ok");
1717 assert_eq!(models, vec!["test-model-free".to_string()]);
1718
1719 let sleeper: u32 = std::fs::read_to_string(&sleeper_pid_path)
1720 .unwrap()
1721 .trim()
1722 .parse()
1723 .expect("sleeper pid");
1724 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1725 while pid_is_alive(sleeper) && std::time::Instant::now() < deadline {
1726 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1727 }
1728 assert!(
1729 !pid_is_alive(sleeper),
1730 "sleeper {sleeper} must die with the process group when opencode models returns"
1731 );
1732 }
1733
1734 #[cfg(unix)]
1735 fn pid_is_alive(pid: u32) -> bool {
1736 super::raw_kill(pid as i32, 0) == 0
1738 }
1739
1740 #[test]
1741 fn empty_free_discovery_preserves_existing_cache_contents() {
1742 reset_model_caches_for_test();
1743 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1744 {
1745 let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
1746 *guard = (123, vec!["opencode/model-free".to_string()]);
1747 }
1748
1749 update_discovered_models_cache(cache, Vec::new(), "free", "empty_output");
1750
1751 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
1752 assert!(guard.0 > 123);
1755 assert_eq!(guard.1, vec!["opencode/model-free".to_string()]);
1756 }
1757
1758 #[test]
1759 fn empty_all_models_discovery_preserves_existing_cache_contents() {
1760 reset_model_caches_for_test();
1761 let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1762 {
1763 let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
1764 *guard = (
1765 456,
1766 vec![
1767 "opencode/model-free".to_string(),
1768 "opencode/model-paid".to_string(),
1769 ],
1770 );
1771 }
1772
1773 update_discovered_models_cache(cache, Vec::new(), "all", "exit_status_failure");
1774
1775 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
1776 assert!(guard.0 > 456);
1777 assert_eq!(
1778 guard.1,
1779 vec![
1780 "opencode/model-free".to_string(),
1781 "opencode/model-paid".to_string(),
1782 ]
1783 );
1784 }
1785
1786 #[test]
1787 fn empty_discovery_populates_empty_cache() {
1788 reset_model_caches_for_test();
1789 let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1790
1791 update_discovered_models_cache(cache, Vec::new(), "free", "command_not_found");
1792
1793 let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
1794 assert!(guard.0 > 0);
1795 assert!(guard.1.is_empty());
1796 }
1797
1798 #[test]
1801 fn fixture_opencode_success() {
1802 let raw = include_str!("../../../../tests/fixtures/opencode_success.jsonl");
1803 let parsed = runner().parse_response(raw).unwrap();
1804 assert_eq!(parsed.response.status, "done");
1805 assert!(parsed.response.summary.contains("Fixed"));
1806 assert_eq!(parsed.response.accomplished.len(), 2);
1807 assert_eq!(parsed.input_tokens, Some(17500));
1808 assert_eq!(parsed.output_tokens, Some(500));
1809 }
1810
1811 #[test]
1815 fn fixture_opencode_permission_denied() {
1816 let raw = include_str!("../../../../tests/fixtures/opencode_permission_denied.jsonl");
1817 let err = runner().parse_response(raw).unwrap_err();
1818 assert!(
1819 matches!(err, AgentError::PermissionDenied { .. }),
1820 "expected PermissionDenied, got: {err:?}"
1821 );
1822 }
1823
1824 #[test]
1826 fn fixture_opencode_rate_limit() {
1827 let raw = include_str!("../../../../tests/fixtures/opencode_rate_limit.jsonl");
1828 let err = runner().parse_response(raw).unwrap_err();
1829 assert!(
1830 matches!(err, AgentError::RateLimit { .. }),
1831 "expected RateLimit, got: {err:?}"
1832 );
1833 }
1834
1835 #[test]
1839 fn fixture_opencode_model_not_found() {
1840 let raw = include_str!("../../../../tests/fixtures/opencode_model_not_found.jsonl");
1841 let err = runner().parse_response(raw).unwrap_err();
1842 assert!(
1843 matches!(err, AgentError::ModelUnavailable { .. }),
1844 "expected ModelUnavailable, got: {err:?}"
1845 );
1846 if let AgentError::ModelUnavailable { model, .. } = &err {
1847 assert_eq!(model, "anthropic/claude-sonnet-4-6");
1848 }
1849 }
1850
1851 #[test]
1854 fn fixture_opencode_provider_returned_error() {
1855 let raw = include_str!("../../../../tests/fixtures/opencode_provider_returned_error.jsonl");
1856 let err = runner().parse_response(raw).unwrap_err();
1857 assert!(
1858 matches!(err, AgentError::RateLimit { .. }),
1859 "expected RateLimit, got: {err:?}"
1860 );
1861 }
1862
1863 #[test]
1867 fn extract_text_returns_text_events() {
1868 let raw = concat!(
1869 r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1870 "\n",
1871 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
1872 "\n",
1873 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":100,"input":90,"output":10}}}"#,
1874 );
1875 let text = runner().extract_text(raw).unwrap();
1876 assert_eq!(
1877 text,
1878 r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#
1879 );
1880 }
1881
1882 #[test]
1884 fn extract_text_direct_text_event_format() {
1885 let raw = concat!(
1886 r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1887 "\n",
1888 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"}"#,
1889 "\n",
1890 r#"{"type":"step_finish","timestamp":1002,"sessionID":"ses_abc"}"#,
1891 );
1892 let text = runner().extract_text(raw).unwrap();
1893 assert!(
1894 text.contains("request_changes"),
1895 "expected review JSON, got: {text}"
1896 );
1897 }
1898
1899 #[test]
1901 fn extract_text_rate_limit_propagates() {
1902 let raw = concat!(
1903 r#"{"type":"step_start","timestamp":1000}"#,
1904 "\n",
1905 r#"{"type":"error","message":"rate limit exceeded: 429 Too Many Requests"}"#,
1906 );
1907 let err = runner().extract_text(raw).unwrap_err();
1908 assert!(
1909 matches!(err, AgentError::RateLimit { .. }),
1910 "expected RateLimit, got: {err:?}"
1911 );
1912 }
1913
1914 #[test]
1916 fn extract_text_auth_error_propagates() {
1917 let raw = concat!(
1918 r#"{"type":"step_start","timestamp":1000}"#,
1919 "\n",
1920 r#"{"type":"error","message":"401 Unauthorized: invalid api key"}"#,
1921 );
1922 let err = runner().extract_text(raw).unwrap_err();
1923 assert!(
1924 matches!(err, AgentError::Auth { .. }),
1925 "expected Auth, got: {err:?}"
1926 );
1927 }
1928
1929 #[test]
1931 fn extract_text_empty_input_returns_empty_ok() {
1932 let text = runner().extract_text("").unwrap();
1933 assert!(text.is_empty());
1934 }
1935
1936 #[test]
1939 fn find_opencode_result_success_with_tokens() {
1940 let ndjson = concat!(
1941 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1942 "\n",
1943 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\"}"}}"#,
1944 "\n",
1945 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0.05,"tokens":{"input":5000,"output":200}}}"#,
1946 );
1947 let result = find_opencode_result(ndjson).expect("should find result");
1948 assert!(!result.is_error);
1949 assert!(result.result_text.contains("approve"));
1950 assert_eq!(result.input_tokens, Some(5000));
1951 assert_eq!(result.output_tokens, Some(200));
1952 assert_eq!(result.cost_usd, Some(0.05));
1953 assert!(!result.truncated_by_length);
1954 }
1955
1956 #[test]
1957 fn find_opencode_result_error_event() {
1958 let ndjson = r#"{"type":"error","message":"rate limit exceeded: 429"}"#;
1959 let result = find_opencode_result(ndjson).expect("should find error result");
1960 assert!(result.is_error);
1961 assert!(result.result_text.contains("rate limit"));
1962 assert!(!result.truncated_by_length);
1963 }
1964
1965 #[test]
1969 fn find_opencode_result_length_truncation() {
1970 let ndjson = concat!(
1971 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1972 "\n",
1973 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":"}}"#,
1974 "\n",
1975 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"length","tokens":{"input":5485,"output":759,"reasoning":31241}}}"#,
1976 );
1977 let result = find_opencode_result(ndjson).expect("should find result");
1978 assert!(result.truncated_by_length);
1979 assert!(!result.is_error, "length truncation is not a hard error");
1980 }
1981
1982 #[test]
1988 fn find_opencode_result_zero_output_length_truncation() {
1989 let ndjson = concat!(
1990 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1991 "\n",
1992 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"length","tokens":{"total":71482,"input":39482,"output":0,"reasoning":40000}}}"#,
1993 );
1994 let result = find_opencode_result(ndjson).expect("should find result, not None");
1995 assert!(result.truncated_by_length);
1996 assert!(!result.is_error);
1997 assert!(result.result_text.is_empty());
1998 }
1999
2000 #[test]
2003 fn find_opencode_result_zero_output_unknown_reason_truncation() {
2004 let ndjson = concat!(
2005 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
2006 "\n",
2007 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"unknown","tokens":{"input":0,"output":0,"reasoning":0}}}"#,
2008 );
2009 let result = find_opencode_result(ndjson).expect("should find result, not None");
2010 assert!(result.truncated_by_length);
2011 assert!(!result.is_error);
2012 assert!(result.result_text.is_empty());
2013 }
2014
2015 #[test]
2019 fn find_opencode_result_unknown_reason_with_output_not_truncated() {
2020 let ndjson = concat!(
2021 r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
2022 "\n",
2023 r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"partial answer"}}"#,
2024 "\n",
2025 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"unknown","tokens":{"input":100,"output":50,"reasoning":0}}}"#,
2026 );
2027 let result = find_opencode_result(ndjson).expect("should find result");
2028 assert!(!result.truncated_by_length);
2029 assert!(!result.is_error);
2030 }
2031
2032 #[test]
2033 fn find_opencode_result_empty_returns_none() {
2034 assert!(find_opencode_result("").is_none());
2035 assert!(find_opencode_result(" ").is_none());
2036 }
2037
2038 #[test]
2039 fn find_opencode_result_direct_text_format() {
2040 let ndjson = concat!(
2041 r#"{"type":"step_start","timestamp":1000}"#,
2042 "\n",
2043 r#"{"type":"text","timestamp":1001,"text":"Review complete. All tests passed."}"#,
2044 "\n",
2045 r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","tokens":{"input":100,"output":10}}}"#,
2046 );
2047 let result = find_opencode_result(ndjson).expect("should find result");
2048 assert!(!result.is_error);
2049 assert!(result.result_text.contains("All tests passed"));
2050 }
2051
2052 #[test]
2053 fn find_opencode_result_plain_text_returns_none() {
2054 assert!(find_opencode_result("just some plain text").is_none());
2055 }
2056}
2057
2058#[cfg(test)]
2059mod error_text_tests {
2060 use super::*;
2061 use serde_json::json;
2062
2063 #[test]
2064 fn unrecognized_error_shape_keeps_raw_json() {
2065 let e = json!({"type":"error","error":{"code":429,"detail":"slow down"}});
2066 assert!(opencode_error_text(&e).contains("slow down"));
2067 let e = json!({"type":"error"});
2068 assert!(opencode_error_text(&e).contains("\"type\""));
2069 }
2070
2071 #[test]
2072 fn known_shapes_still_extract() {
2073 assert_eq!(opencode_error_text(&json!({"message":"a"})), "a");
2074 assert_eq!(opencode_error_text(&json!({"error":"b"})), "b");
2075 assert_eq!(opencode_error_text(&json!({"error":{"message":"c"}})), "c");
2076 assert_eq!(
2077 opencode_error_text(&json!({"error":{"name":"N","data":{"message":"d"}}})),
2078 "d"
2079 );
2080 }
2081}
2082
2083fn stream_events(v: &serde_json::Value) -> Vec<StreamEvent> {
2085 let text = || {
2086 v.pointer("/part/text")
2087 .or_else(|| v.get("text"))
2088 .and_then(serde_json::Value::as_str)
2089 .unwrap_or("")
2090 .to_string()
2091 };
2092 match json_str(v, "/type") {
2093 "text" => vec![StreamEvent::Text(text())],
2094 "reasoning" => vec![StreamEvent::Thinking(text())],
2095 "tool_use" => {
2096 let part = &v["part"];
2097 let state = &part["state"];
2098 let id = json_str(part, "/callID").to_string();
2099 let name = json_str(part, "/tool").to_string();
2100 let mut out = vec![StreamEvent::ToolCall {
2101 id: id.clone(),
2102 name: name.clone(),
2103 input: state["input"].clone(),
2104 }];
2105 let result = match json_str(state, "/status") {
2106 "completed" => Some((json_str(state, "/output").to_string(), false)),
2107 "error" => Some((json_error_message(state), true)),
2108 _ => None,
2109 };
2110 if let Some((output, is_error)) = result {
2111 out.push(StreamEvent::ToolResult {
2112 id,
2113 name,
2114 output,
2115 is_error,
2116 });
2117 }
2118
2119 out
2120 }
2121 "step_finish" if json_str(v, "/part/reason") == "stop" => {
2122 vec![StreamEvent::Done(String::new())]
2123 }
2124 "error" => vec![StreamEvent::Error(json_error_message(v))],
2125 _ => vec![],
2126 }
2127}
2128
2129#[cfg(test)]
2130mod stream_tests {
2131 use super::*;
2132 use serde_json::json;
2133
2134 fn events(line: &str) -> Vec<StreamEvent> {
2135 OpenCodeRunner::new().stream_events(&serde_json::from_str(line).unwrap())
2136 }
2137
2138 #[test]
2139 fn text_in_part_or_top_level() {
2140 assert_eq!(
2141 events(r#"{"type":"text","part":{"type":"text","text":"Reviewing."}}"#),
2142 vec![StreamEvent::Text("Reviewing.".into())]
2143 );
2144 assert_eq!(
2145 events(r#"{"type":"text","text":"Plain."}"#),
2146 vec![StreamEvent::Text("Plain.".into())]
2147 );
2148 }
2149
2150 #[test]
2151 fn completed_tool_has_call_and_result() {
2152 let line = r#"{"type":"tool_use","part":{"type":"tool","tool":"read","callID":"c1","state":{"status":"completed","input":{"filePath":"src/lib.rs"},"output":"a\nb"}}}"#;
2153 assert_eq!(
2154 events(line),
2155 vec![
2156 StreamEvent::ToolCall {
2157 id: "c1".into(),
2158 name: "read".into(),
2159 input: json!({"filePath": "src/lib.rs"}),
2160 },
2161 StreamEvent::ToolResult {
2162 id: "c1".into(),
2163 name: "read".into(),
2164 output: "a\nb".into(),
2165 is_error: false,
2166 },
2167 ]
2168 );
2169 }
2170
2171 #[test]
2172 fn failed_tool_result_is_error() {
2173 let line = r#"{"type":"tool_use","part":{"tool":"bash","callID":"c2","state":{"status":"error","input":{"command":"x"},"error":"not found"}}}"#;
2174 assert_eq!(
2175 events(line)[1],
2176 StreamEvent::ToolResult {
2177 id: "c2".into(),
2178 name: "bash".into(),
2179 output: "not found".into(),
2180 is_error: true,
2181 }
2182 );
2183 }
2184
2185 #[test]
2186 fn only_final_step_is_done() {
2187 assert!(events(r#"{"type":"step_finish","part":{"reason":"tool-calls"}}"#).is_empty());
2188 assert_eq!(
2189 events(r#"{"type":"step_finish","part":{"reason":"stop"}}"#),
2190 vec![StreamEvent::Done(String::new())]
2191 );
2192 assert!(events(r#"{"type":"step_start"}"#).is_empty());
2193 }
2194
2195 #[test]
2196 fn error_event() {
2197 let line = r#"{"type":"error","error":{"name":"UnknownError","data":{"message":"Provider returned error"}}}"#;
2198 assert_eq!(
2199 events(line),
2200 vec![StreamEvent::Error("Provider returned error".into())]
2201 );
2202 }
2203}