Skip to main content

orch/engine/runner/agents/
opencode.rs

1//! OpenCode agent runner.
2//!
3//! ## CLI invocation
4//!
5//! ```bash
6//! opencode run --format json -m {model} - < "{msg_file}"
7//! ```
8//!
9//! ## Output format (`run --format json`)
10//!
11//! NDJSON stream:
12//! ```jsonl
13//! {"type":"step_start","timestamp":...,"part":{"type":"step-start","snapshot":"..."}}
14//! {"type":"text","timestamp":...,"part":{"type":"text","text":"hello"}}
15//! {"type":"step_finish","timestamp":...,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":17512,"input":17509,"output":3}}}
16//! ```
17//!
18//! ## Token extraction
19//!
20//! Tokens are in the `step_finish` event: `part.tokens.{input,output,total}`
21//!
22//! ## Free models
23//!
24//! Discoverable via: `opencode models | grep free`
25//! Discovered dynamically at startup with 1-hour cache.
26
27use 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
37/// Module-level cache for discovered free opencode models.
38/// Shared across all callers (router, runner, failover).
39static FREE_MODELS_CACHE: OnceLock<Mutex<(i64, Vec<String>)>> = OnceLock::new();
40static FREE_MODELS_REFRESH_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
41/// Module-level cache for all discovered opencode models.
42/// Shared across all callers (router, runner, failover).
43static 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
62/**
63 * Model discovery runs the real `opencode` binary, and opencode 2.2.1 starts an
64 * `opencode serve --service` child that outlives it, so a suite run left one server
65 * behind per test process. It also made the result depend on the machine, and CI has no
66 * opencode at all. A test that needs models passes them in, or uses `Router::new_for_test`.
67 * `cfg(test)` is unset when the integration tests link the library, so debug builds count
68 * too, and the run-time check keeps the live path compiled in every build
69 **/
70fn 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        // Still advance the timestamp so a persistently-failing discovery
93        // backs off for the full TTL window instead of being retried on
94        // every subsequent call.
95        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/// Runner for OpenCode agent.
108#[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        // OpenCode emits several shapes across versions. Also scan assistant
118        // messages coming from Claude-like wrappers which use `type: "assistant"`
119        // with a `message.content` array containing text items.
120        // We intentionally restrict extraction to event/part/message types that
121        // represent assistant text output (not tool I/O).
122        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            // Handle opencode `text` events that put text at top-level
128            if let Some(text) = event.get("text").and_then(|v| v.as_str()) {
129                texts.push(text.to_string());
130            }
131
132            // Handle Claude-like assistant messages: message.content -> [{type:"text", text:...}]
133            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        // Some builds use a generic event type but keep a text-ish part.
150        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 we found no text-like events, try to look for a terminal `result` event
159    // which some opencode builds emit. Prefer the last result event (newest).
160    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                // Ignore system/init envelopes
164                if event_type == "system" {
165                    continue;
166                }
167            }
168
169            if let Some(result) = event.get("result") {
170                // If result is a string, return it; if object/array return its JSON
171                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            // Some events include direct text payloads outside of `part`
180            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        // Prefer a full-json payload that parses as an AgentResponse.
193        if crate::parser::parse(&json).is_ok() {
194            return Some(json);
195        }
196
197        // Also accept JSON that looks like a router decision / agent envelope
198        // even when it's not a full AgentResponse (e.g. {"executor":"opencode"}).
199        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        // Otherwise fall through and try per-event heuristics below
209    }
210
211    for text in texts.iter().rev() {
212        let trimmed = text.trim();
213        if let Some(json) = extract_json_object(trimmed) {
214            // Prefer JSON blobs that parse as AgentResponse. This allows
215            // accepting payloads that don't include routing keys but are
216            // nevertheless valid task responses.
217            if crate::parser::parse(&json).is_ok() {
218                return Some(json);
219            }
220
221            // Also accept routing-style JSON objects that don't match the
222            // AgentResponse schema (router decisions, etc.).
223            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        // As a conservative fallback, if the trimmed chunk contains a '{' and
231        // looks like it mentions routing keys, return it. We keep this branch
232        // minimal to avoid accidental acceptance of unrelated log blobs.
233        if trimmed.contains('{') {
234            // As a last-ditch conservative heuristic, accept the chunk if it
235            // can be interpreted as a valid AgentResponse or if it contains
236            // routing keys. This avoids accepting unrelated log blobs while
237            // remaining tolerant of router-style payloads.
238            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    // Some schemas store text content as: {"content":[{"type":"text","text":"..."}, ...]}
264    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        // Some opencode versions nest assistant text in a message.content array.
311        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        // Early opencode wrappers may emit a system/init envelope before the
324        // actual text/result event. Ensure the extractor skips the init envelope
325        // and returns the real JSON payload from the subsequent text event.
326        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
338// Each worktree gets its own snapshot git repo under opencode's data dir and nothing removes it.
339// Orch commits the work itself, so turn snapshots off for orch runs only
340const NO_SNAPSHOT_CONFIG: &str = r#"{"snapshot":false}"#;
341
342impl OpenCodeRunner {
343    pub fn new() -> Self {
344        Self
345    }
346
347    /// Split an opencode model identifier into base model and optional variant.
348    ///
349    /// The identifier shape is: "provider/model[@variant]". Splitting is
350    /// performed on the LAST `@` so provider/model strings containing `@`
351    /// are handled correctly. Returns (base, Some(variant)) when a trailing
352    /// `@` is present (variant may be empty), or (s, None) when no `@` exists.
353    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    /// Extract text from `text` events.
364    ///
365    /// Concatenates all text events, then tries to find the structured
366    /// JSON response. If the concatenated text doesn't parse as JSON,
367    /// tries each text event individually (newest first) since earlier
368    /// events are often progress messages.
369    ///
370    /// Handles two text event formats emitted by different opencode versions:
371    /// - Format 1 (current): `{"type":"text","part":{"type":"text","text":"..."}}`
372    /// - Format 2 (newer):   `{"type":"text","text":"..."}`
373    fn extract_text_from_events(events: &[serde_json::Value]) -> Option<String> {
374        extract_ndjson_text(events)
375    }
376
377    /// Extract token usage from `step_finish` events.
378    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    /// Check for error events in the stream.
396    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
423/// Render an error value as text: strings as-is, objects by their message/name or raw JSON
424fn 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
436/// Extract the message from an opencode `error` event, keeping the raw JSON when no shape matches
437fn 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        // Propagate terminal errors so the review pipeline records cooldowns.
479        if let Some(err) = Self::detect_error(&events) {
480            return Err(err);
481        }
482
483        // Concatenate all text events; fall back to raw output if none found.
484        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        // Support optional variant suffix on opencode model identifiers:
496        // provider/model[@variant]
497        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        // OpenCode autonomous permission control.
507        //
508        // `--dangerously-skip-permissions` auto-approves any tool that isn't
509        // explicitly denied, which is what orch needs for unattended dispatch.
510        // It avoids the user's global ~/.config/opencode/opencode.json defaults
511        // (e.g. "edit":"ask", "external_directory":"ask") that would otherwise
512        // block agent file edits and git operations on the worktree.
513        //
514        // The previously-used .orch-opencode/opencode/opencode.json +
515        // XDG_CONFIG_HOME override is no longer needed: the only `deny` entries
516        // it set were for opencode's interactive-only features (todowrite,
517        // question, codesearch, list) which never fire under `opencode run`.
518        let skip_perms = if permissions.autonomous {
519            "--dangerously-skip-permissions "
520        } else {
521            ""
522        };
523
524        // Append variant flag when present
525        let variant_part = variant_flag.unwrap_or_default();
526        // Feed both files via process substitution rather than a leading `cat |`
527        // pipe stage — a leading pipe would make `cat`, not opencode, the first
528        // stage in the runner script's pipeline, so PIPESTATUS[0] there would
529        // always report cat's (always-zero) exit code instead of opencode's.
530        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        // Empty output with exit code 0 is a silent failure — return Unknown with
546        // a deterministic message matching what fallback.rs expects so the model
547        // gets a cooldown and free-model retry is attempted.
548        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            // Maybe direct JSON
560            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        // Check for errors first
574        if let Some(err) = Self::detect_error(&events) {
575            return Err(err);
576        }
577
578        // Extract tokens (needed for both the text path and the tool-only fallback)
579        let (input_tokens, output_tokens) = Self::extract_tokens(&events);
580
581        // Extract text — when opencode emits only tool_use/step events with no
582        // text content, synthesize a human-readable summary from the event
583        // stream instead of falling through to InvalidResponse. Otherwise the
584        // upstream `synthesize_response_from_text` heuristic gets handed the
585        // entire raw NDJSON, matches a "completed" substring inside tool state
586        // JSON, and emits 500 bytes of raw NDJSON as the task summary.
587        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        // Parse the text through standard parser
598        let response = match parser::parse(&text) {
599            Ok(r) => r,
600            Err(_) => {
601                // Structured parse failed — try plain-text synthesis before
602                // giving up, same rescue path claude.rs uses (issue #3467).
603                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        // Try parsing NDJSON events from stdout for structured errors
618        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        // Known default models; could be extended with `opencode models` discovery
633        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        // Routing only needs text output — no tools fire.
645        // `--dangerously-skip-permissions` prevents the user's global
646        // ~/.config/opencode/opencode.json interactive defaults (e.g.
647        // "edit":"ask") from producing PermissionError events that the router
648        // would misclassify as model failures.
649        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                // pass variant even if empty (last-token-wins semantics)
660                cmd.arg("--variant").arg(v);
661            }
662        }
663        cmd.arg(prompt);
664        Ok(cmd)
665    }
666}
667
668/// Classify an OpenCode error message.
669fn classify_opencode_message(message: &str) -> AgentError {
670    let lower = message.to_lowercase();
671
672    // Rate limit — delegate to shared helper
673    if let Some(e) = super::patterns::detect_rate_limit(message) {
674        return e;
675    }
676
677    // Context overflow — delegate to shared helper (covers "context overflow", "context length", etc.)
678    if let Some(e) = super::patterns::detect_context_overflow(message) {
679        return e;
680    }
681
682    // Auth errors — delegate to shared helper (includes HTTP 401/403, billing, etc.)
683    if let Some(e) = super::patterns::detect_auth_error(message) {
684        return e;
685    }
686
687    // Permission denied — delegate to shared helper
688    if let Some(e) = super::patterns::detect_permission_denied(message) {
689        return e;
690    }
691
692    // Model not available (opencode-specific extraction pattern)
693    //
694    // Patterns handled:
695    //   "Model not found: anthropic/claude-sonnet-4-6."
696    //   "Model not found: github-copilot/gemini-3.1-pro. Did you mean: gemini-3.1-pro-preview?"
697    //   "model not supported: X"
698    //   "The free model has been deprecated. Transition to qwen/qwen3.6-plus for continued paid access."
699    //   "No endpoints found for qwen/qwen3.6-plus:free."
700    //   "This model is unavailable for free. The paid version is available now - use this slug instead: X"
701    //   "This model is not available in your country."
702    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        // Try to extract model name from patterns like "Model not found: anthropic/claude-sonnet-4-6."
713        // or "No endpoints found for qwen/qwen3.6-plus:free."
714        let model = if lower.contains("no endpoints found") {
715            // "No endpoints found for X." — extract X after "for "
716            message
717                .split(" for ")
718                .nth(1)
719                .map(|s| s.trim_end_matches('.').to_string())
720                .unwrap_or_default()
721        } else {
722            // Try "Model not found: X" or "model not supported: X" patterns
723            let from_colon = message
724                .split(": ")
725                .nth(1)
726                .map(|s| {
727                    // Strip opencode's "Did you mean: X?" suggestion suffix if present
728                    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            // For deprecated messages like "The free model has been deprecated. Transition to X for..."
737            // extract the model from "Transition to X" when colon-based extraction yields nothing.
738            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            // For "unavailable for free" messages like "... use this slug instead: X"
750            // extract the suggested replacement slug.
751            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            // Transition/slug extractions are tied to specific message shapes and take
762            // priority over the generic colon split, which would otherwise grab the
763            // wrong substring (e.g. the "[404] ..." text before the real slug).
764            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    // OpenCode surfaces opaque upstream provider failures as "Provider returned error".
776    // Treat this as a usage-limit class signal so fallback/error tracking records a
777    // rate-limit event (instead of a generic failure), allowing router health/degraded
778    // checks to react faster to unstable provider/model endpoints.
779    if lower.contains("provider returned error") {
780        return AgentError::RateLimit {
781            message: message.to_string(),
782        };
783    }
784
785    // Transient network/transport errors (e.g. "Upstream idle timeout exceeded")
786    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
795/// Discover free opencode models, with 1-hour cache and background refresh.
796///
797/// This is the single source of truth for free model discovery. Called by
798/// the router (pool expansion, model_map), the runner (failover), and
799/// startup priming.
800///
801/// Non-blocking: returns cached data immediately. When the cache is cold
802/// or expired, spawns a background thread to refresh via `opencode models`.
803pub 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    // Fast path: return cached data if still fresh (1-hour TTL).
811    {
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    // Cache is cold or expired. Spawn a background task on the current runtime to refresh.
822    // Only one refresh runs at a time.
823    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                // Use the current tokio runtime's handle to spawn the async discovery task,
845                // avoiding the overhead of a std::thread + new tokio runtime.
846                handle.spawn(refresh);
847            }
848            _ => {
849                // No tokio runtime (e.g. called from a plain unit test). Spawn a thread
850                // with a dedicated runtime — this is the fallback path.
851                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    // Return whatever is in cache (may be empty on first call).
861    let guard = cache
862        .lock()
863        .unwrap_or_else(std::sync::PoisonError::into_inner);
864    guard.1.clone()
865}
866
867/// Discover all opencode models, with 1-hour cache and background refresh.
868///
869/// Non-blocking: returns cached data immediately. When the cache is cold
870/// or expired, spawns a background refresh via `opencode models`.
871pub 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
917/// Prime the free-model cache synchronously at startup.
918///
919/// Called from `Router::new()` via `spawn_blocking` so the initial
920/// blocking subprocess doesn't stall the Tokio runtime.
921/// Runs `run_opencode_models_discovery_async()` in a dedicated thread with its
922/// own tokio runtime, avoiding `block_on` restrictions when called from within
923/// an existing runtime (e.g. `#[tokio::test]` or a nested runtime context).
924pub 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
954/// Async version of `run_opencode_models_discovery` that uses `tokio::time::sleep`
955/// instead of blocking `std::thread::sleep`.
956///
957/// Returns the discovered models plus a reason code describing why the list
958/// is empty (`"ok"` when discovery actually succeeded with a non-empty
959/// result). The reason is threaded into `update_discovered_models_cache` so
960/// the "returned empty" warning says which of the failure branches fired,
961/// without requiring `RUST_LOG=debug` in production.
962async fn run_opencode_models_discovery_async() -> (Vec<String>, &'static str) {
963    run_opencode_models_cli(std::time::Duration::from_secs(30)).await
964}
965
966/// Run `opencode models` and reap any process-group leftovers when it returns
967pub(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/// `kill(2)`. A negative `pid` addresses the process group with that id
983#[cfg(unix)]
984fn raw_kill(pid: i32, sig: i32) -> i32 {
985    unsafe extern "C" {
986        fn kill(pid: i32, sig: i32) -> i32;
987    }
988    // SAFETY: both arguments are plain integers and kill has no other effect on this process
989    unsafe { kill(pid, sig) }
990}
991
992/// SIGKILL the group that `opencode models` leads, because the `opencode serve --service`
993/// child it starts outlives it and the group is the only handle on that grandchild
994#[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
1078/// Build a synthetic `AgentResponse` summarizing an opencode NDJSON event
1079/// stream when no `text` events are present.
1080///
1081/// Opencode runs that produced tool calls but never emitted a text event
1082/// (model exited mid-tool, stop_reason without a final reply, etc.) would
1083/// otherwise be passed to `synthesize_response_from_text` as raw NDJSON.
1084/// That heuristic matches "completed" substrings inside tool state JSON and
1085/// emits the first 500 bytes of NDJSON as the notification summary — what
1086/// the user sees as garbage in Telegram/Discord/Slack.
1087///
1088/// Status is `needs_review` because there is no text confirming completion;
1089/// the agent did work but never reported on it.
1090fn 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        // Capture tool names from common opencode/claude-style shapes.
1114        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
1172/// Extract a structured `AgentResult` from OpenCode NDJSON output.
1173///
1174/// Concatenates text from `type:text` events (both `part.text` and direct
1175/// `text` field formats), extracts tokens from the last `step_finish` event,
1176/// and checks for `error` / `step_finish.reason=error` events.
1177///
1178/// Returns `None` only if no parseable NDJSON events are found.
1179pub 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    // Check for errors first
1186    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    // Extract text content
1197    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    // Extract tokens from step_finish
1204    let (input_tokens, output_tokens) = OpenCodeRunner::extract_tokens(&events);
1205
1206    // The model was cut off for exceeding its output/reasoning token budget,
1207    // not because it produced malformed prose. Distinguish this from a
1208    // generic parse failure so callers don't apply the long
1209    // format-incapable-model cooldown for a token-budget fluke. A reasoning
1210    // model that spends its entire budget on hidden reasoning emits zero
1211    // text and a final step_finish of reason="length" (or opencode's
1212    // "unknown" with all-zero tokens) — same failure mode, must be computed
1213    // before the empty-text early return below or it never gets checked.
1214    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    // Extract cost from step_finish if available
1236    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    /// Newer opencode versions emit text directly in the event (no "part" wrapper).
1281    /// The extract_text method must handle both formats.
1282    #[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        // NDJSON with a text event that contains a JSON-like fragment which does
1322        // NOT include routing keys (executor/status). This should NOT be
1323        // interpreted as a routing payload; parse_response should fail with
1324        // InvalidResponse rather than accidentally accepting the fragment.
1325        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                // expected
1333            }
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    /// Regression: opencode runs that emit only tool_use / step_start / step_finish
1347    /// events (no `text` events) must synthesize a clean needs_review response from
1348    /// the event stream instead of returning InvalidResponse with the raw NDJSON
1349    /// blob, which would otherwise be passed to synthesize_response_from_text and
1350    /// leak as 500 bytes of raw JSON into the notification summary.
1351    #[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        // Tokens from step_finish must still be propagated.
1379        assert_eq!(parsed.input_tokens, Some(500));
1380        assert_eq!(parsed.output_tokens, Some(0));
1381    }
1382
1383    /// Regression test for issue #3467: when opencode's `text` event contains
1384    /// a plain-prose completion summary (not JSON), parser::parse fails but
1385    /// synthesize_response_from_text must rescue it instead of returning
1386    /// InvalidResponse, mirroring the fallback claude.rs already has.
1387    #[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        // Empty output with exit 0 is treated as silent failure so fallback.rs
1414        // can apply model cooldown and retry with free models
1415        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        // Issue #1934: opencode returns "Model not found: X. Did you mean: Y?"
1443        // The parser must extract "X", not "X. Did you mean"
1444        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        // Issue #2228: "The free model has been deprecated." should be ModelUnavailable
1459        // with the model extracted from "Transition to X" so the cooldown key is non-empty.
1460        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        // Issue #3475: "This model is unavailable for free ... use this slug instead: X"
1477        // must classify as ModelUnavailable, not fall through to the transient
1478        // network-error path via the "Upstream request failed" substring.
1479        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        // Issue #3535: "This model is not available in your country." must classify as
1496        // ModelUnavailable (persistent per-model cooldown), not fall through to the
1497        // generic AgentFailed bucket which applies a weaker cooldown plus an
1498        // unwarranted agent-wide penalty.
1499        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        // Issue #2228: "No endpoints found for X." should be ModelUnavailable with model extracted
1510        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        // Autonomous mode passes --dangerously-skip-permissions instead of
1540        // writing a config file or overriding XDG_CONFIG_HOME.
1541        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        // Regression: the command must not start with a `cat sys msg |` pipe
1558        // stage. `build_runner_script` captures the agent's exit status via
1559        // `PIPESTATUS[0]` of `{agent_cmd} | tee ...` — a leading `cat |` would
1560        // make PIPESTATUS[0] always report cat's (always-zero) exit code
1561        // instead of opencode's real exit code. sys/msg files must be fed via
1562        // input redirection instead.
1563        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        // Issue #2478: "Provider returned error" is an opaque upstream failure from the
1648        // opencode CLI. Classify as RateLimit so fallback records a rate-limit signal
1649        // and applies cooldown behavior tuned for transient provider instability.
1650        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        // Issue #3301: "Upstream idle timeout exceeded" must produce NetworkError so
1666        // fallback.rs applies a model-level cooldown instead of retrying immediately.
1667        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        // discover_free_opencode_models returns empty in test/debug builds
1677        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    /// The stub stands in for opencode 2.2.1, which starts `opencode serve --service`
1694    /// as a child that outlives it
1695    #[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        // The production discovery timeout, so a loaded machine does not turn this into
1714        // a timeout run, which reaps the group too and would assert nothing
1715        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        // Signal 0 is the POSIX existence probe
1741        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        // Timestamp must advance on a failed refresh so the TTL provides a
1761        // real backoff window instead of retrying on every subsequent call.
1762        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    // ── Fixture-based tests ─────────────────────────────────────
1813
1814    #[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    /// Real failure: OpenCode rejects a tool permission in non-interactive mode.
1826    /// The error has `{"error":{"name":"PermissionError","data":{"message":"user rejected permission..."}}}`.
1827    /// Parser must extract the nested message and classify as PermissionDenied.
1828    #[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    /// Real failure: OpenCode rate limit via top-level message field.
1839    #[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    /// Real failure: OpenCode returns nested error for unknown model.
1850    /// The error object is `{"name":"UnknownError","data":{"message":"Model not found: X"}}`.
1851    /// Parser must extract the nested message and classify as ModelUnavailable.
1852    #[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    /// Real failure: OpenCode returns "Provider returned error" as an opaque upstream
1866    /// provider failure. Classified as RateLimit so degraded/rate-limit tracking applies.
1867    #[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    // ── extract_text ─────────────────────────────────────────────
1878
1879    /// OpenCode: extract_text concatenates text events for review parsing.
1880    #[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    /// OpenCode: extract_text with newer direct-text format.
1897    #[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    /// OpenCode: extract_text propagates RateLimit for terminal errors.
1914    #[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    /// OpenCode: extract_text propagates Auth error.
1929    #[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    /// OpenCode: extract_text on empty input returns empty string (not an error).
1944    #[test]
1945    fn extract_text_empty_input_returns_empty_ok() {
1946        let text = runner().extract_text("").unwrap();
1947        assert_eq!(text, "");
1948    }
1949
1950    // ── find_opencode_result ────────────────────────────────────
1951
1952    #[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    /// step_finish reason=length signals the model was cut off by its
1980    /// output/reasoning token budget, not malformed output — the flag must
1981    /// be set so callers avoid the persistent-format-failure cooldown.
1982    #[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    /// A reasoning model can burn its entire budget on hidden reasoning and
1997    /// emit zero text before step_finish reason=length. The old early return
1998    /// on empty result_text bailed out with None before truncated_by_length
1999    /// was ever computed, so this got misclassified as parse_error and hit
2000    /// the 4h-7d persistent-model cooldown instead of standard backoff.
2001    #[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    /// opencode also emits reason="unknown" with all-zero tokens for the same
2015    /// zero-output failure mode — must be treated as an equivalent signal.
2016    #[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    /// reason="unknown" with nonzero output tokens means the model did
2030    /// produce something and the step just wasn't cleanly terminated — not
2031    /// the same "wrote nothing" signal, so it must not be flagged.
2032    #[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
2097/// OpenCode `--format json`: a `tool_use` event carries both the call and its finished state
2098fn 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}