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    json_error_message, json_str, AgentError, AgentRunner, ParsedResponse, PermissionRules,
29    StreamEvent,
30};
31use crate::parser;
32
33use std::path::Path;
34use std::sync::atomic::{AtomicBool, Ordering};
35use std::sync::{Mutex, OnceLock};
36
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        if let Ok(mut guard) = cache.lock() {
50            *guard = (0, Vec::new());
51        }
52    }
53    if let Some(cache) = ALL_MODELS_CACHE.get() {
54        if let Ok(mut guard) = cache.lock() {
55            *guard = (0, Vec::new());
56        }
57    }
58    FREE_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
59    ALL_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
60}
61
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.lock().unwrap_or_else(|e| e.into_inner());
82
83    if discovered.is_empty() && !guard.1.is_empty() {
84        tracing::warn!(
85            cache = cache_name,
86            cached_models = guard.1.len(),
87            reason = empty_reason,
88            "opencode model discovery returned empty; preserving previous cache contents"
89        );
90        // Still advance the timestamp so a persistently-failing discovery
91        // backs off for the full TTL window instead of being retried on
92        // every subsequent call.
93        guard.0 = now;
94        return;
95    }
96
97    tracing::debug!(
98        cache = cache_name,
99        discovered_models = discovered.len(),
100        "opencode model discovery refreshed cache"
101    );
102    *guard = (now, discovered);
103}
104
105/// Runner for OpenCode agent.
106pub struct OpenCodeRunner;
107
108pub(crate) fn extract_ndjson_text(events: &[serde_json::Value]) -> Option<String> {
109    let mut texts = Vec::new();
110
111    for event in events {
112        let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
113
114        // OpenCode emits several shapes across versions. Also scan assistant
115        // messages coming from Claude-like wrappers which use `type: "assistant"`
116        // with a `message.content` array containing text items.
117        // We intentionally restrict extraction to event/part/message types that
118        // represent assistant text output (not tool I/O).
119        if event_type == "text" || event_type == "message" || event_type == "assistant" {
120            if let Some(part) = event.get("part") {
121                texts.extend(extract_text_from_part(part));
122            }
123
124            // Handle opencode `text` events that put text at top-level
125            if let Some(text) = event.get("text").and_then(|v| v.as_str()) {
126                texts.push(text.to_string());
127            }
128
129            // Handle Claude-like assistant messages: message.content -> [{type:"text", text:...}]
130            if let Some(message) = event.get("message") {
131                if let Some(items) = message.get("content").and_then(|v| v.as_array()) {
132                    for item in items {
133                        let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
134                        if item_type == "text" {
135                            if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
136                                texts.push(text.to_string());
137                            }
138                        }
139                    }
140                }
141            }
142
143            continue;
144        }
145
146        // Some builds use a generic event type but keep a text-ish part.
147        if let Some(part) = event.get("part") {
148            let part_type = part.get("type").and_then(|v| v.as_str()).unwrap_or("");
149            if part_type == "text" || part_type == "output_text" || part_type == "message" {
150                texts.extend(extract_text_from_part(part));
151            }
152        }
153    }
154
155    // If we found no text-like events, try to look for a terminal `result` event
156    // which some opencode builds emit. Prefer the last result event (newest).
157    if texts.is_empty() {
158        for event in events.iter().rev() {
159            if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
160                // Ignore system/init envelopes
161                if event_type == "system" {
162                    continue;
163                }
164            }
165
166            if let Some(result) = event.get("result") {
167                // If result is a string, return it; if object/array return its JSON
168                if let Some(s) = result.as_str() {
169                    return Some(s.to_string());
170                }
171                if result.is_object() || result.is_array() {
172                    return Some(result.to_string());
173                }
174            }
175
176            // Some events include direct text payloads outside of `part`
177            if let Some(text) = event.get("text").and_then(|v| v.as_str()) {
178                if !text.trim().is_empty() {
179                    return Some(text.to_string());
180                }
181            }
182        }
183
184        return None;
185    }
186
187    let full = texts.join("");
188    if let Some(json) = extract_json_object(&full) {
189        // Prefer a full-json payload that parses as an AgentResponse.
190        if crate::parser::parse(&json).is_ok() {
191            return Some(json);
192        }
193
194        // Also accept JSON that looks like a router decision / agent envelope
195        // even when it's not a full AgentResponse (e.g. {"executor":"opencode"}).
196        if let Ok(val) = serde_json::from_str::<serde_json::Value>(&json) {
197            if let Some(obj) = val.as_object() {
198                if obj.contains_key("executor")
199                    || obj.contains_key("status")
200                    || obj.contains_key("complexity")
201                    || obj.contains_key("decision")
202                {
203                    return Some(json);
204                }
205            }
206        }
207        // Otherwise fall through and try per-event heuristics below
208    }
209
210    for text in texts.iter().rev() {
211        let trimmed = text.trim();
212        if let Some(json) = extract_json_object(trimmed) {
213            // Prefer JSON blobs that parse as AgentResponse. This allows
214            // accepting payloads that don't include routing keys but are
215            // nevertheless valid task responses.
216            if crate::parser::parse(&json).is_ok() {
217                return Some(json);
218            }
219
220            // Also accept routing-style JSON objects that don't match the
221            // AgentResponse schema (router decisions, etc.).
222            if let Ok(val) = serde_json::from_str::<serde_json::Value>(&json) {
223                if let Some(obj) = val.as_object() {
224                    if obj.contains_key("executor") || obj.contains_key("status") {
225                        return Some(json);
226                    }
227                }
228            }
229        }
230        // 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                if let Some(obj) = val.as_object() {
244                    if obj.contains_key("executor")
245                        || obj.contains_key("status")
246                        || obj.contains_key("complexity")
247                    {
248                        return Some(trimmed.to_string());
249                    }
250                }
251            }
252        }
253    }
254
255    Some(full)
256}
257
258fn extract_text_from_part(part: &serde_json::Value) -> Vec<String> {
259    let mut out = Vec::new();
260
261    if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
262        out.push(text.to_string());
263    }
264
265    // Some schemas store text content as: {"content":[{"type":"text","text":"..."}, ...]}
266    if let Some(items) = part.get("content").and_then(|v| v.as_array()) {
267        for item in items {
268            let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
269            if item_type == "text" {
270                if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
271                    out.push(text.to_string());
272                }
273            }
274        }
275    }
276
277    out
278}
279
280fn extract_json_object(text: &str) -> Option<String> {
281    let trimmed = text.trim();
282    if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
283        return Some(trimmed.to_string());
284    }
285
286    let start = trimmed.find('{')?;
287    let end = trimmed.rfind('}')?;
288    if end <= start {
289        return None;
290    }
291
292    let candidate = trimmed[start..=end].trim();
293    serde_json::from_str::<serde_json::Value>(candidate)
294        .ok()
295        .map(|_| candidate.to_string())
296}
297
298pub(crate) fn extract_router_text(raw: &str) -> Option<String> {
299    let events = super::parse_ndjson(raw.trim());
300    if events.is_empty() {
301        return None;
302    }
303    extract_ndjson_text(&events)
304}
305
306#[cfg(test)]
307mod router_text_tests {
308    use super::*;
309
310    #[test]
311    fn extract_router_text_supports_message_with_content_array() {
312        // Some opencode versions nest assistant text in a message.content array.
313        let raw = r#"{"type":"step_start","timestamp":1}
314{"type":"message","timestamp":2,"part":{"type":"message","content":[{"type":"text","text":"```json\n{\"executor\":\"opencode\",\"complexity\":\"medium\",\"reason\":\"ok\"}\n```"}]}}
315{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
316        let text = extract_router_text(raw).expect("should extract text");
317        assert!(
318            text.contains("executor"),
319            "extracted text should include JSON"
320        );
321    }
322
323    #[test]
324    fn extract_router_text_ignores_leading_system_init() {
325        // Early opencode wrappers may emit a system/init envelope before the
326        // actual text/result event. Ensure the extractor skips the init envelope
327        // and returns the real JSON payload from the subsequent text event.
328        let raw = r#"{"type":"system","subtype":"init","cwd":"/"}
329{"type":"text","timestamp":2,"text":"{\"executor\":\"opencode\",\"complexity\":\"medium\",\"reason\":\"ndjson\"}"}
330{"type":"step_finish","timestamp":3}"#;
331
332        let text = extract_router_text(raw).expect("should extract text even with leading init");
333        let parsed: serde_json::Value =
334            serde_json::from_str(&text).expect("extracted text must be JSON");
335        assert_eq!(parsed["executor"], "opencode");
336        assert_eq!(parsed["complexity"], "medium");
337    }
338}
339
340// Each worktree gets its own snapshot git repo under opencode's data dir and nothing removes it.
341// Orch commits the work itself, so turn snapshots off for orch runs only
342const NO_SNAPSHOT_CONFIG: &str = r#"{"snapshot":false}"#;
343
344impl OpenCodeRunner {
345    pub fn new() -> Self {
346        Self
347    }
348
349    /// Split an opencode model identifier into base model and optional variant.
350    ///
351    /// The identifier shape is: "provider/model[@variant]". Splitting is
352    /// performed on the LAST `@` so provider/model strings containing `@`
353    /// are handled correctly. Returns (base, Some(variant)) when a trailing
354    /// `@` is present (variant may be empty), or (s, None) when no `@` exists.
355    pub(crate) fn split_model_variant(s: &str) -> (&str, Option<&str>) {
356        if let Some(pos) = s.rfind('@') {
357            let base = &s[..pos];
358            let variant = &s[pos + 1..];
359            (base, Some(variant))
360        } else {
361            (s, None)
362        }
363    }
364
365    /// Extract text from `text` events.
366    ///
367    /// Concatenates all text events, then tries to find the structured
368    /// JSON response. If the concatenated text doesn't parse as JSON,
369    /// tries each text event individually (newest first) since earlier
370    /// events are often progress messages.
371    ///
372    /// Handles two text event formats emitted by different opencode versions:
373    /// - Format 1 (current): `{"type":"text","part":{"type":"text","text":"..."}}`
374    /// - Format 2 (newer):   `{"type":"text","text":"..."}`
375    fn extract_text_from_events(&self, events: &[serde_json::Value]) -> Option<String> {
376        extract_ndjson_text(events)
377    }
378
379    /// Extract token usage from `step_finish` events.
380    fn extract_tokens(&self, events: &[serde_json::Value]) -> (Option<u64>, Option<u64>) {
381        for event in events.iter().rev() {
382            let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
383
384            if event_type == "step_finish" {
385                if let Some(part) = event.get("part") {
386                    if let Some(tokens) = part.get("tokens").and_then(|v| v.as_object()) {
387                        let input = tokens.get("input").and_then(|v| v.as_u64());
388                        let output = tokens.get("output").and_then(|v| v.as_u64());
389                        return (input, output);
390                    }
391                }
392            }
393        }
394
395        (None, None)
396    }
397
398    /// Check for error events in the stream.
399    fn detect_error(&self, events: &[serde_json::Value]) -> Option<AgentError> {
400        super::patterns::detect_ndjson_error(
401            events,
402            |event| {
403                let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
404                match event_type {
405                    "error" => Some(opencode_error_text(event)),
406                    "step_finish" => {
407                        let part = event.get("part")?;
408                        let reason = part.get("reason").and_then(|v| v.as_str()).unwrap_or("");
409                        if reason == "error" || reason == "failed" {
410                            Some(
411                                part.get("error")
412                                    .map(error_value_text)
413                                    .unwrap_or_else(|| "step failed".to_string()),
414                            )
415                        } else {
416                            None
417                        }
418                    }
419                    _ => None,
420                }
421            },
422            classify_opencode_message,
423        )
424    }
425}
426
427/// Render an error value as text: strings as-is, objects by their message/name or raw JSON
428fn error_value_text(v: &serde_json::Value) -> String {
429    if let Some(s) = v.as_str() {
430        return s.to_string();
431    }
432    v.get("data")
433        .and_then(|d| d.get("message").or(Some(d)))
434        .and_then(|m| m.as_str())
435        .or_else(|| v.get("message").and_then(|m| m.as_str()))
436        .or_else(|| v.get("name").and_then(|n| n.as_str()))
437        .map(str::to_string)
438        .unwrap_or_else(|| v.to_string())
439}
440
441/// Extract the message from an opencode `error` event, keeping the raw JSON when no shape matches
442fn opencode_error_text(event: &serde_json::Value) -> String {
443    if let Some(s) = event.get("message").and_then(|v| v.as_str()) {
444        return s.to_string();
445    }
446    match event.get("error") {
447        Some(e) => {
448            let text = error_value_text(e);
449            if e.is_object() && text == *e {
450                tracing::warn!(event = %event, "unrecognized opencode error event shape");
451            }
452            text
453        }
454        None => {
455            tracing::warn!(event = %event, "unrecognized opencode error event shape");
456            event.to_string()
457        }
458    }
459}
460
461impl AgentRunner for OpenCodeRunner {
462    #[cfg(test)]
463    fn name(&self) -> &str {
464        "opencode"
465    }
466
467    fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent> {
468        stream_events(event)
469    }
470
471    fn router_transcript(&self) -> super::RouterTranscript {
472        super::RouterTranscript::OpenCode
473    }
474
475    fn extract_text(&self, raw: &str) -> Result<String, AgentError> {
476        let trimmed = raw.trim();
477        if trimmed.is_empty() {
478            return Ok(String::new());
479        }
480
481        let events = super::parse_ndjson(trimmed);
482        if events.is_empty() {
483            return Ok(trimmed.to_string());
484        }
485
486        // Propagate terminal errors so the review pipeline records cooldowns.
487        if let Some(err) = self.detect_error(&events) {
488            return Err(err);
489        }
490
491        // Concatenate all text events; fall back to raw output if none found.
492        Ok(extract_ndjson_text(&events).unwrap_or_else(|| trimmed.to_string()))
493    }
494
495    fn build_command(
496        &self,
497        model: Option<&str>,
498        timeout_cmd: &str,
499        sys_file: &str,
500        msg_file: &str,
501        permissions: &PermissionRules,
502    ) -> String {
503        // Support optional variant suffix on opencode model identifiers:
504        // provider/model[@variant]
505        let (model_flag, variant_flag) = if let Some(m) = model {
506            let (base, variant) = Self::split_model_variant(m);
507            let mf = format!("--model {}", super::shell_single_quote(base));
508            let vf = variant.map(|v| format!("--variant {}", super::shell_single_quote(v)));
509            (mf, vf)
510        } else {
511            (String::new(), None)
512        };
513
514        // OpenCode autonomous permission control.
515        //
516        // `--dangerously-skip-permissions` auto-approves any tool that isn't
517        // explicitly denied, which is what orch needs for unattended dispatch.
518        // It avoids the user's global ~/.config/opencode/opencode.json defaults
519        // (e.g. "edit":"ask", "external_directory":"ask") that would otherwise
520        // block agent file edits and git operations on the worktree.
521        //
522        // The previously-used .orch-opencode/opencode/opencode.json +
523        // XDG_CONFIG_HOME override is no longer needed: the only `deny` entries
524        // it set were for opencode's interactive-only features (todowrite,
525        // question, codesearch, list) which never fire under `opencode run`.
526        let skip_perms = if permissions.autonomous {
527            "--dangerously-skip-permissions "
528        } else {
529            ""
530        };
531
532        // Append variant flag when present
533        let variant_part = variant_flag.unwrap_or_default();
534        // Feed both files via process substitution rather than a leading `cat |`
535        // pipe stage — a leading pipe would make `cat`, not opencode, the first
536        // stage in the runner script's pipeline, so PIPESTATUS[0] there would
537        // always report cat's (always-zero) exit code instead of opencode's.
538        format!(
539            r#"OPENCODE_CONFIG_CONTENT={config} {timeout_cmd} opencode run {model_flag} {variant_part} {skip_perms}\
540  --format json - < <(cat "{sys_file}" "{msg_file}")"#,
541            config = super::shell_single_quote(NO_SNAPSHOT_CONFIG),
542            sys_file = sys_file,
543            msg_file = msg_file,
544            timeout_cmd = timeout_cmd,
545            model_flag = model_flag,
546            variant_part = variant_part,
547            skip_perms = skip_perms,
548        )
549    }
550
551    fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
552        let trimmed = raw.trim();
553        // Empty output with exit code 0 is a silent failure — return Unknown with
554        // a deterministic message matching what fallback.rs expects so the model
555        // gets a cooldown and free-model retry is attempted.
556        if trimmed.is_empty() {
557            return Err(AgentError::Unknown {
558                exit_code: 0,
559                message: "empty-output-exit0: opencode returned exit 0 with empty stdout"
560                    .to_string(),
561            });
562        }
563
564        let events = super::parse_ndjson(trimmed);
565
566        if events.is_empty() {
567            // Maybe direct JSON
568            if let Ok(resp) = parser::parse(trimmed) {
569                return Ok(ParsedResponse {
570                    response: resp,
571                    input_tokens: None,
572                    output_tokens: None,
573                    duration_ms: None,
574                });
575            }
576            return Err(AgentError::InvalidResponse {
577                raw: trimmed.to_string(),
578            });
579        }
580
581        // Check for errors first
582        if let Some(err) = self.detect_error(&events) {
583            return Err(err);
584        }
585
586        // Extract tokens (needed for both the text path and the tool-only fallback)
587        let (input_tokens, output_tokens) = self.extract_tokens(&events);
588
589        // Extract text — when opencode emits only tool_use/step events with no
590        // text content, synthesize a human-readable summary from the event
591        // stream instead of falling through to InvalidResponse. Otherwise the
592        // upstream `synthesize_response_from_text` heuristic gets handed the
593        // entire raw NDJSON, matches a "completed" substring inside tool state
594        // JSON, and emits 500 bytes of raw NDJSON as the task summary.
595        let text = match self.extract_text_from_events(&events) {
596            Some(t) => t,
597            None => {
598                let response = synthesize_response_from_events(&events);
599                return Ok(ParsedResponse {
600                    response,
601                    input_tokens,
602                    output_tokens,
603                    duration_ms: None,
604                });
605            }
606        };
607
608        // Parse the text through standard parser
609        let response = match parser::parse(&text) {
610            Ok(r) => r,
611            Err(_) => {
612                // Structured parse failed — try plain-text synthesis before
613                // giving up, same rescue path claude.rs uses (issue #3467).
614                super::synthesize_response_from_text(&text)
615                    .ok_or_else(|| AgentError::InvalidResponse { raw: text.clone() })?
616            }
617        };
618
619        Ok(ParsedResponse {
620            response,
621            input_tokens,
622            output_tokens,
623            duration_ms: None,
624        })
625    }
626
627    fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
628        // Try parsing NDJSON events from stdout for structured errors
629        let events = super::parse_ndjson(stdout);
630        if let Some(err) = self.detect_error(&events) {
631            return err;
632        }
633
634        let combined = format!("{stdout}\n{stderr}");
635        super::patterns::classify_from_text(exit_code, &combined)
636    }
637
638    fn free_models(&self) -> Vec<String> {
639        discover_free_opencode_models()
640    }
641
642    fn available_models(&self) -> Vec<String> {
643        // Known default models; could be extended with `opencode models` discovery
644        vec![
645            "anthropic/claude-sonnet-4-20250514".to_string(),
646            "openai/gpt-4.1".to_string(),
647        ]
648    }
649
650    fn router_command(
651        &self,
652        prompt: &str,
653        model: Option<&str>,
654    ) -> anyhow::Result<tokio::process::Command> {
655        // Routing only needs text output — no tools fire.
656        // `--dangerously-skip-permissions` prevents the user's global
657        // ~/.config/opencode/opencode.json interactive defaults (e.g.
658        // "edit":"ask") from producing PermissionError events that the router
659        // would misclassify as model failures.
660        let mut cmd = tokio::process::Command::new("opencode");
661        cmd.env("OPENCODE_CONFIG_CONTENT", NO_SNAPSHOT_CONFIG)
662            .arg("run")
663            .arg("--format")
664            .arg("json")
665            .arg("--dangerously-skip-permissions");
666        if let Some(m) = model {
667            let (base, variant) = Self::split_model_variant(m);
668            cmd.arg("--model").arg(base);
669            if let Some(v) = variant {
670                // pass variant even if empty (last-token-wins semantics)
671                cmd.arg("--variant").arg(v);
672            }
673        }
674        cmd.arg(prompt);
675        Ok(cmd)
676    }
677}
678
679/// Classify an OpenCode error message.
680fn classify_opencode_message(message: &str) -> AgentError {
681    let lower = message.to_lowercase();
682
683    // Rate limit — delegate to shared helper
684    if let Some(e) = super::patterns::detect_rate_limit(message) {
685        return e;
686    }
687
688    // Context overflow — delegate to shared helper (covers "context overflow", "context length", etc.)
689    if let Some(e) = super::patterns::detect_context_overflow(message) {
690        return e;
691    }
692
693    // Auth errors — delegate to shared helper (includes HTTP 401/403, billing, etc.)
694    if let Some(e) = super::patterns::detect_auth_error(message) {
695        return e;
696    }
697
698    // Permission denied — delegate to shared helper
699    if let Some(e) = super::patterns::detect_permission_denied(message) {
700        return e;
701    }
702
703    // Model not available (opencode-specific extraction pattern)
704    //
705    // Patterns handled:
706    //   "Model not found: anthropic/claude-sonnet-4-6."
707    //   "Model not found: github-copilot/gemini-3.1-pro. Did you mean: gemini-3.1-pro-preview?"
708    //   "model not supported: X"
709    //   "The free model has been deprecated. Transition to qwen/qwen3.6-plus for continued paid access."
710    //   "No endpoints found for qwen/qwen3.6-plus:free."
711    //   "This model is unavailable for free. The paid version is available now - use this slug instead: X"
712    //   "This model is not available in your country."
713    let is_model_unavailable = (lower.contains("model")
714        && (lower.contains("not found")
715            || lower.contains("not supported")
716            || lower.contains("deprecated")
717            || lower.contains("unavailable for free")
718            || lower.contains("not available in your country")
719            || lower.contains("not available in your region")))
720        || lower.contains("no endpoints found");
721
722    if is_model_unavailable {
723        // Try to extract model name from patterns like "Model not found: anthropic/claude-sonnet-4-6."
724        // or "No endpoints found for qwen/qwen3.6-plus:free."
725        let model = if lower.contains("no endpoints found") {
726            // "No endpoints found for X." — extract X after "for "
727            message
728                .split(" for ")
729                .nth(1)
730                .map(|s| s.trim_end_matches('.').to_string())
731                .unwrap_or_default()
732        } else {
733            // Try "Model not found: X" or "model not supported: X" patterns
734            let from_colon = message
735                .split(": ")
736                .nth(1)
737                .map(|s| {
738                    // Strip opencode's "Did you mean: X?" suggestion suffix if present
739                    s.split(". Did you mean")
740                        .next()
741                        .unwrap_or(s)
742                        .trim_end_matches('.')
743                        .to_string()
744                })
745                .filter(|s| !s.is_empty());
746
747            // For deprecated messages like "The free model has been deprecated. Transition to X for..."
748            // extract the model from "Transition to X" when colon-based extraction yields nothing.
749            let from_transition = if lower.contains("deprecated") {
750                message
751                    .split("Transition to ")
752                    .nth(1)
753                    .and_then(|s| s.split(" for ").next())
754                    .map(|s| s.trim_end_matches('.').to_string())
755                    .filter(|s| !s.is_empty())
756            } else {
757                None
758            };
759
760            // For "unavailable for free" messages like "... use this slug instead: X"
761            // extract the suggested replacement slug.
762            let from_slug_suggestion = if lower.contains("unavailable for free") {
763                message
764                    .split("use this slug instead: ")
765                    .nth(1)
766                    .map(|s| s.trim_end_matches('.').trim().to_string())
767                    .filter(|s| !s.is_empty())
768            } else {
769                None
770            };
771
772            // Transition/slug extractions are tied to specific message shapes and take
773            // priority over the generic colon split, which would otherwise grab the
774            // wrong substring (e.g. the "[404] ..." text before the real slug).
775            from_transition
776                .or(from_slug_suggestion)
777                .or(from_colon)
778                .unwrap_or_default()
779        };
780        return AgentError::ModelUnavailable {
781            message: message.to_string(),
782            model,
783        };
784    }
785
786    // OpenCode surfaces opaque upstream provider failures as "Provider returned error".
787    // Treat this as a usage-limit class signal so fallback/error tracking records a
788    // rate-limit event (instead of a generic failure), allowing router health/degraded
789    // checks to react faster to unstable provider/model endpoints.
790    if lower.contains("provider returned error") {
791        return AgentError::RateLimit {
792            message: message.to_string(),
793        };
794    }
795
796    // Transient network/transport errors (e.g. "Upstream idle timeout exceeded")
797    if let Some(e) = super::patterns::detect_network_error(message) {
798        return e;
799    }
800
801    AgentError::AgentFailed {
802        message: message.to_string(),
803    }
804}
805
806/// Discover free opencode models, with 1-hour cache and background refresh.
807///
808/// This is the single source of truth for free model discovery. Called by
809/// the router (pool expansion, model_map), the runner (failover), and
810/// startup priming.
811///
812/// Non-blocking: returns cached data immediately. When the cache is cold
813/// or expired, spawns a background thread to refresh via `opencode models`.
814pub fn discover_free_opencode_models() -> Vec<String> {
815    if skip_live_opencode_discovery() {
816        return Vec::new();
817    }
818    let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
819
820    let now = chrono::Utc::now().timestamp();
821    // Fast path: return cached data if still fresh (1-hour TTL).
822    {
823        let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
824        let (ts, models) = &*guard;
825        if *ts != 0 && now.saturating_sub(*ts) < 3600 {
826            return models.clone();
827        }
828    }
829
830    // Cache is cold or expired. Spawn a background task on the current runtime to refresh.
831    // Only one refresh runs at a time.
832    if !FREE_MODELS_REFRESH_IN_PROGRESS.swap(true, Ordering::AcqRel) {
833        let refresh = async {
834            let _guard = scopeguard::guard((), |_| {
835                FREE_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
836            });
837            let (models, empty_reason) = run_opencode_models_discovery_async().await;
838            let raw_count = models.len();
839            let discovered = models
840                .into_iter()
841                .filter(|m| m.to_lowercase().contains("free"))
842                .collect::<Vec<_>>();
843            let empty_reason = if discovered.is_empty() && raw_count > 0 {
844                "no_free_models_in_catalog"
845            } else {
846                empty_reason
847            };
848            let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
849            update_discovered_models_cache(cache, discovered, "free", empty_reason);
850        };
851        if let Ok(handle) = tokio::runtime::Handle::try_current() {
852            // Use the current tokio runtime's handle to spawn the async discovery task,
853            // avoiding the overhead of a std::thread + new tokio runtime.
854            handle.spawn(refresh);
855        } else {
856            // No tokio runtime (e.g. called from a plain unit test). Spawn a thread
857            // with a dedicated runtime — this is the fallback path.
858            std::thread::spawn(move || {
859                if let Ok(rt) = tokio::runtime::Runtime::new() {
860                    rt.block_on(refresh);
861                }
862            });
863        }
864    }
865
866    // Return whatever is in cache (may be empty on first call).
867    let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
868    guard.1.clone()
869}
870
871/// Discover all opencode models, with 1-hour cache and background refresh.
872///
873/// Non-blocking: returns cached data immediately. When the cache is cold
874/// or expired, spawns a background refresh via `opencode models`.
875pub fn discover_opencode_models() -> Vec<String> {
876    if skip_live_opencode_discovery() {
877        return Vec::new();
878    }
879    let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
880    let now = chrono::Utc::now().timestamp();
881
882    {
883        let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
884        let (ts, models) = &*guard;
885        if *ts != 0 && now.saturating_sub(*ts) < 3600 {
886            return models.clone();
887        }
888    }
889
890    if !ALL_MODELS_REFRESH_IN_PROGRESS.swap(true, Ordering::AcqRel) {
891        let refresh = async {
892            let _guard = scopeguard::guard((), |_| {
893                ALL_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
894            });
895            let (discovered, empty_reason) = run_opencode_models_discovery_async().await;
896            let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
897            update_discovered_models_cache(cache, discovered, "all", empty_reason);
898        };
899        if let Ok(handle) = tokio::runtime::Handle::try_current() {
900            handle.spawn(refresh);
901        } else {
902            std::thread::spawn(move || {
903                if let Ok(rt) = tokio::runtime::Runtime::new() {
904                    rt.block_on(refresh);
905                }
906            });
907        }
908    }
909
910    let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
911    guard.1.clone()
912}
913
914/// Prime the free-model cache synchronously at startup.
915///
916/// Called from `Router::new()` via `spawn_blocking` so the initial
917/// blocking subprocess doesn't stall the Tokio runtime.
918/// Runs `run_opencode_models_discovery_async()` in a dedicated thread with its
919/// own tokio runtime, avoiding `block_on` restrictions when called from within
920/// an existing runtime (e.g. `#[tokio::test]` or a nested runtime context).
921pub fn prime_free_model_cache() {
922    if skip_live_opencode_discovery() {
923        return;
924    }
925    let discovered_all = std::thread::spawn(|| {
926        if let Ok(rt) = tokio::runtime::Runtime::new() {
927            rt.block_on(run_opencode_models_discovery_async()).0
928        } else {
929            vec![]
930        }
931    })
932    .join()
933    .unwrap_or_default();
934    let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
935    let now = chrono::Utc::now().timestamp();
936    if let Ok(mut guard) = cache.lock() {
937        *guard = (
938            now,
939            discovered_all
940                .iter()
941                .filter(|m| m.to_lowercase().contains("free"))
942                .cloned()
943                .collect(),
944        );
945    }
946    let all_cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
947    if let Ok(mut guard) = all_cache.lock() {
948        *guard = (now, discovered_all);
949    }
950}
951
952/// Async version of `run_opencode_models_discovery` that uses `tokio::time::sleep`
953/// instead of blocking `std::thread::sleep`.
954///
955/// Returns the discovered models plus a reason code describing why the list
956/// is empty (`"ok"` when discovery actually succeeded with a non-empty
957/// result). The reason is threaded into `update_discovered_models_cache` so
958/// the "returned empty" warning says which of the failure branches fired,
959/// without requiring `RUST_LOG=debug` in production.
960async fn run_opencode_models_discovery_async() -> (Vec<String>, &'static str) {
961    run_opencode_models_cli(std::time::Duration::from_secs(30)).await
962}
963
964/// Run `opencode models` and reap any process-group leftovers when it returns
965pub(crate) async fn run_opencode_models_cli(
966    timeout: std::time::Duration,
967) -> (Vec<String>, &'static str) {
968    run_opencode_models_cli_inner("opencode", timeout).await
969}
970
971fn opencode_program_available(program: &str) -> bool {
972    let path = Path::new(program);
973    if path.is_absolute() || program.contains('/') {
974        path.is_file()
975    } else {
976        crate::cmd_cache::command_exists(program)
977    }
978}
979
980/// `kill(2)`. A negative `pid` addresses the process group with that id
981#[cfg(unix)]
982fn raw_kill(pid: i32, sig: i32) -> i32 {
983    extern "C" {
984        fn kill(pid: i32, sig: i32) -> i32;
985    }
986    // SAFETY: both arguments are plain integers and kill has no other effect on this process
987    unsafe { kill(pid, sig) }
988}
989
990/// SIGKILL the group that `opencode models` leads, because the `opencode serve --service`
991/// child it starts outlives it and the group is the only handle on that grandchild
992#[cfg(unix)]
993fn kill_process_group(pgid: u32) {
994    const SIGKILL: i32 = 9;
995    let _ = raw_kill(-(pgid as i32), SIGKILL);
996}
997
998async fn run_opencode_models_cli_inner(
999    program: &str,
1000    timeout: std::time::Duration,
1001) -> (Vec<String>, &'static str) {
1002    if !opencode_program_available(program) {
1003        tracing::warn!("opencode not in PATH, skipping model discovery");
1004        return (vec![], "command_not_found");
1005    }
1006
1007    let mut cmd = tokio::process::Command::new(program);
1008    cmd.args(["models"])
1009        .stdout(std::process::Stdio::piped())
1010        .stderr(std::process::Stdio::piped())
1011        .kill_on_drop(true);
1012    #[cfg(unix)]
1013    cmd.process_group(0);
1014
1015    let child = match cmd.spawn() {
1016        Ok(c) => c,
1017        Err(e) => {
1018            tracing::warn!(error = %e, "failed to spawn opencode models");
1019            return (vec![], "spawn_failed");
1020        }
1021    };
1022    let pgid = child.id();
1023    let wait = tokio::time::timeout(timeout, child.wait_with_output()).await;
1024
1025    #[cfg(unix)]
1026    if let Some(pid) = pgid {
1027        kill_process_group(pid);
1028    }
1029
1030    match wait {
1031        Ok(Ok(output)) if output.status.success() => {
1032            let models: Vec<String> = String::from_utf8_lossy(&output.stdout)
1033                .lines()
1034                .map(|l| l.trim().to_string())
1035                .filter(|l| !l.is_empty())
1036                .collect();
1037            let reason = if models.is_empty() {
1038                "empty_output"
1039            } else {
1040                "ok"
1041            };
1042            (models, reason)
1043        }
1044        Ok(Ok(output)) => {
1045            let stderr = String::from_utf8_lossy(&output.stderr);
1046            let stderr = stderr.trim();
1047            let stderr_display = if stderr.len() > 500 {
1048                format!("{}...", &stderr[..stderr.floor_char_boundary(500)])
1049            } else {
1050                stderr.to_string()
1051            };
1052            tracing::warn!(
1053                status = ?output.status,
1054                stderr = %stderr_display,
1055                "opencode models command failed"
1056            );
1057            (vec![], "exit_status_failure")
1058        }
1059        Ok(Err(e)) => {
1060            tracing::warn!(error = %e, "failed to wait on opencode models");
1061            (vec![], "wait_failed")
1062        }
1063        Err(_) => {
1064            tracing::warn!(
1065                secs = timeout.as_secs(),
1066                "opencode models timed out, killing process group"
1067            );
1068            (vec![], "timeout")
1069        }
1070    }
1071}
1072
1073/// Build a synthetic `AgentResponse` summarizing an opencode NDJSON event
1074/// stream when no `text` events are present.
1075///
1076/// Opencode runs that produced tool calls but never emitted a text event
1077/// (model exited mid-tool, stop_reason without a final reply, etc.) would
1078/// otherwise be passed to `synthesize_response_from_text` as raw NDJSON.
1079/// That heuristic matches "completed" substrings inside tool state JSON and
1080/// emits the first 500 bytes of NDJSON as the notification summary — what
1081/// the user sees as garbage in Telegram/Discord/Slack.
1082///
1083/// Status is `needs_review` because there is no text confirming completion;
1084/// the agent did work but never reported on it.
1085fn synthesize_response_from_events(events: &[serde_json::Value]) -> crate::parser::AgentResponse {
1086    use std::collections::BTreeMap;
1087
1088    let mut counts: BTreeMap<String, u32> = BTreeMap::new();
1089    let mut tool_names: Vec<String> = Vec::new();
1090    let mut step_finish_reason: Option<String> = None;
1091
1092    for event in events {
1093        let event_type = event
1094            .get("type")
1095            .and_then(|v| v.as_str())
1096            .unwrap_or("unknown");
1097        *counts.entry(event_type.to_string()).or_insert(0) += 1;
1098
1099        if event_type == "step_finish" {
1100            if let Some(reason) = event
1101                .get("part")
1102                .and_then(|p| p.get("reason"))
1103                .and_then(|r| r.as_str())
1104            {
1105                step_finish_reason = Some(reason.to_string());
1106            }
1107        }
1108
1109        // Capture tool names from common opencode/claude-style shapes.
1110        if event_type == "tool_use" {
1111            if let Some(name) = event
1112                .get("name")
1113                .and_then(|v| v.as_str())
1114                .or_else(|| event.get("tool").and_then(|v| v.as_str()))
1115                .or_else(|| {
1116                    event
1117                        .get("part")
1118                        .and_then(|p| p.get("name"))
1119                        .and_then(|v| v.as_str())
1120                })
1121                .or_else(|| {
1122                    event
1123                        .get("tool")
1124                        .and_then(|t| t.get("name"))
1125                        .and_then(|v| v.as_str())
1126                })
1127            {
1128                tool_names.push(name.to_string());
1129            }
1130        }
1131    }
1132
1133    let total_events = events.len();
1134    let reason = step_finish_reason.as_deref().unwrap_or("unknown");
1135
1136    let tool_summary = if tool_names.is_empty() {
1137        String::new()
1138    } else {
1139        let mut tool_counts: BTreeMap<String, u32> = BTreeMap::new();
1140        for n in &tool_names {
1141            *tool_counts.entry(n.clone()).or_insert(0) += 1;
1142        }
1143        let parts: Vec<String> = tool_counts
1144            .iter()
1145            .map(|(k, v)| {
1146                if *v > 1 {
1147                    format!("{k}×{v}")
1148                } else {
1149                    k.clone()
1150                }
1151            })
1152            .collect();
1153        format!(" (tools: {})", parts.join(", "))
1154    };
1155
1156    let summary = format!(
1157        "opencode emitted {total_events} event(s) with no text output{tool_summary}; \
1158         step_finish reason: {reason}"
1159    );
1160
1161    crate::parser::AgentResponse {
1162        status: "needs_review".to_string(),
1163        summary,
1164        error: Some("agent emitted no text response — only tool/step events".to_string()),
1165        ..Default::default()
1166    }
1167}
1168
1169/// Extract a structured `AgentResult` from OpenCode NDJSON output.
1170///
1171/// Concatenates text from `type:text` events (both `part.text` and direct
1172/// `text` field formats), extracts tokens from the last `step_finish` event,
1173/// and checks for `error` / `step_finish.reason=error` events.
1174///
1175/// Returns `None` only if no parseable NDJSON events are found.
1176pub fn find_opencode_result(ndjson: &str) -> Option<super::AgentResult> {
1177    let events = super::parse_ndjson(ndjson.trim());
1178    if events.is_empty() {
1179        return None;
1180    }
1181
1182    // Check for errors first
1183    let is_error;
1184    let error_text;
1185    let runner = OpenCodeRunner::new();
1186    if let Some(err) = runner.detect_error(&events) {
1187        is_error = true;
1188        error_text = Some(err.to_string());
1189    } else {
1190        is_error = false;
1191        error_text = None;
1192    }
1193
1194    // Extract text content
1195    let result_text = if is_error {
1196        error_text.unwrap_or_default()
1197    } else {
1198        extract_ndjson_text(&events).unwrap_or_default()
1199    };
1200
1201    // Extract tokens from step_finish
1202    let (input_tokens, output_tokens) = runner.extract_tokens(&events);
1203
1204    // The model was cut off for exceeding its output/reasoning token budget,
1205    // not because it produced malformed prose. Distinguish this from a
1206    // generic parse failure so callers don't apply the long
1207    // format-incapable-model cooldown for a token-budget fluke. A reasoning
1208    // model that spends its entire budget on hidden reasoning emits zero
1209    // text and a final step_finish of reason="length" (or opencode's
1210    // "unknown" with all-zero tokens) — same failure mode, must be computed
1211    // before the empty-text early return below or it never gets checked.
1212    let last_step_finish_reason = events
1213        .iter()
1214        .rev()
1215        .find(|event| event.get("type").and_then(|v| v.as_str()) == Some("step_finish"))
1216        .and_then(|event| {
1217            event
1218                .get("part")?
1219                .get("reason")?
1220                .as_str()
1221                .map(str::to_string)
1222        });
1223    let truncated_by_length = match last_step_finish_reason.as_deref() {
1224        Some("length") => true,
1225        Some("unknown") => output_tokens == Some(0),
1226        _ => false,
1227    };
1228
1229    if result_text.is_empty() && !is_error && !truncated_by_length {
1230        return None;
1231    }
1232
1233    // Extract cost from step_finish if available
1234    let cost_usd = events.iter().rev().find_map(|event| {
1235        if event.get("type").and_then(|v| v.as_str()) == Some("step_finish") {
1236            event
1237                .get("part")
1238                .and_then(|p| p.get("cost"))
1239                .and_then(|c| c.as_f64())
1240                .filter(|&c| c > 0.0)
1241        } else {
1242            None
1243        }
1244    });
1245
1246    Some(super::AgentResult {
1247        is_error,
1248        result_text,
1249        input_tokens,
1250        output_tokens,
1251        cost_usd,
1252        duration_ms: None,
1253        truncated_by_length,
1254    })
1255}
1256
1257#[cfg(test)]
1258mod tests {
1259    use super::*;
1260
1261    fn runner() -> OpenCodeRunner {
1262        OpenCodeRunner::new()
1263    }
1264
1265    #[test]
1266    fn parse_opencode_ndjson_success() {
1267        let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start","snapshot":"..."}}
1268{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"status\":\"done\",\"summary\":\"hello\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}}
1269{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":17512,"input":17509,"output":3}}}"#;
1270
1271        let parsed = runner().parse_response(raw).unwrap();
1272        assert_eq!(parsed.response.status, "done");
1273        assert_eq!(parsed.response.summary, "hello");
1274        assert_eq!(parsed.input_tokens, Some(17509));
1275        assert_eq!(parsed.output_tokens, Some(3));
1276    }
1277
1278    /// Newer opencode versions emit text directly in the event (no "part" wrapper).
1279    /// The extract_text method must handle both formats.
1280    #[test]
1281    fn parse_opencode_ndjson_direct_text_format() {
1282        let raw = r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}
1283{"type":"text","timestamp":1001,"sessionID":"ses_abc","text":"{\"status\":\"done\",\"summary\":\"fixed\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}
1284{"type":"step_finish","timestamp":1002,"sessionID":"ses_abc","part":{"type":"step-finish","reason":"stop","tokens":{"input":200,"output":10}}}"#;
1285
1286        let parsed = runner().parse_response(raw).unwrap();
1287        assert_eq!(parsed.response.status, "done");
1288        assert_eq!(parsed.response.summary, "fixed");
1289        assert_eq!(parsed.input_tokens, Some(200));
1290        assert_eq!(parsed.output_tokens, Some(10));
1291    }
1292
1293    #[test]
1294    fn parse_opencode_concatenated_text() {
1295        let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1296{"type":"text","timestamp":1001,"part":{"type":"text","text":"Working on "}}
1297{"type":"text","timestamp":1002,"part":{"type":"text","text":"the task. "}}
1298{"type":"text","timestamp":1003,"part":{"type":"text","text":"{\"status\":\"done\",\"summary\":\"finished\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}}
1299{"type":"step_finish","timestamp":1004,"part":{"type":"step-finish","reason":"stop","tokens":{"input":100,"output":50}}}"#;
1300
1301        let parsed = runner().parse_response(raw).unwrap();
1302        assert_eq!(parsed.response.status, "done");
1303        assert_eq!(parsed.input_tokens, Some(100));
1304        assert_eq!(parsed.output_tokens, Some(50));
1305    }
1306
1307    #[test]
1308    fn extract_router_text_prefers_json_payload() {
1309        let raw = r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"Thinking..."}}
1310{"type":"text","timestamp":1002,"part":{"type":"text","text":"{\"executor\":\"claude\",\"complexity\":\"medium\",\"reason\":\"fit\"}"}}"#;
1311
1312        let text = extract_router_text(raw).unwrap();
1313        let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
1314        assert_eq!(parsed["executor"], "claude");
1315    }
1316
1317    #[test]
1318    fn parse_opencode_embedded_json_fragment_without_executor_is_ignored() {
1319        // NDJSON with a text event that contains a JSON-like fragment which does
1320        // NOT include routing keys (executor/status). This should NOT be
1321        // interpreted as a routing payload; parse_response should fail with
1322        // InvalidResponse rather than accidentally accepting the fragment.
1323        let raw = r#"{"type":"step_start","timestamp":1}
1324{"type":"text","timestamp":2,"part":{"type":"text","text":"Here is a debug trace: {\"trace\":{\"duration\":123,\"id\":\"abc\"}}"}}
1325{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
1326
1327        let err = runner().parse_response(raw).unwrap_err();
1328        match err {
1329            AgentError::InvalidResponse { raw: _ } => {
1330                // expected
1331            }
1332            other => panic!("expected InvalidResponse, got: {other:?}"),
1333        }
1334    }
1335
1336    #[test]
1337    fn parse_opencode_error_event() {
1338        let raw = r#"{"type":"error","message":"rate limit exceeded for this model"}"#;
1339
1340        let err = runner().parse_response(raw).unwrap_err();
1341        assert!(matches!(err, AgentError::RateLimit { .. }));
1342    }
1343
1344    /// Regression: opencode runs that emit only tool_use / step_start / step_finish
1345    /// events (no `text` events) must synthesize a clean needs_review response from
1346    /// the event stream instead of returning InvalidResponse with the raw NDJSON
1347    /// blob, which would otherwise be passed to synthesize_response_from_text and
1348    /// leak as 500 bytes of raw JSON into the notification summary.
1349    #[test]
1350    fn parse_opencode_tool_only_stream_synthesizes_needs_review() {
1351        let raw = r#"{"type":"step_start","timestamp":1,"part":{"type":"step-start"}}
1352{"type":"tool_use","timestamp":2,"name":"bash","input":{"cmd":"ls"}}
1353{"type":"tool_use","timestamp":3,"name":"read","input":{"path":"src/main.rs"}}
1354{"type":"tool_use","timestamp":4,"name":"bash","input":{"cmd":"cargo check"}}
1355{"type":"step_finish","timestamp":5,"part":{"type":"step-finish","reason":"stop","tokens":{"input":500,"output":0}}}"#;
1356
1357        let parsed = runner()
1358            .parse_response(raw)
1359            .expect("tool-only NDJSON must synthesize a response, not error");
1360        assert_eq!(parsed.response.status, "needs_review");
1361        assert!(
1362            !parsed.response.summary.contains("\"type\":\"step_start\""),
1363            "summary must not contain raw NDJSON; got: {}",
1364            parsed.response.summary
1365        );
1366        assert!(
1367            parsed.response.summary.contains("bash") && parsed.response.summary.contains("read"),
1368            "summary should mention tools used; got: {}",
1369            parsed.response.summary
1370        );
1371        assert!(
1372            parsed.response.summary.contains("stop"),
1373            "summary should mention step_finish reason; got: {}",
1374            parsed.response.summary
1375        );
1376        // Tokens from step_finish must still be propagated.
1377        assert_eq!(parsed.input_tokens, Some(500));
1378        assert_eq!(parsed.output_tokens, Some(0));
1379    }
1380
1381    /// Regression test for issue #3467: when opencode's `text` event contains
1382    /// a plain-prose completion summary (not JSON), parser::parse fails but
1383    /// synthesize_response_from_text must rescue it instead of returning
1384    /// InvalidResponse, mirroring the fallback claude.rs already has.
1385    #[test]
1386    fn parse_opencode_plain_prose_completion_synthesizes_response() {
1387        let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1388{"type":"text","timestamp":1001,"part":{"type":"text","text":"Fixed the typo and corrected the deadline reference in the review."}}
1389{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","tokens":{"input":300,"output":40}}}"#;
1390
1391        let parsed = runner()
1392            .parse_response(raw)
1393            .expect("plain-prose completion text must synthesize successfully");
1394        assert_eq!(parsed.response.status, "done");
1395        assert_eq!(parsed.input_tokens, Some(300));
1396        assert_eq!(parsed.output_tokens, Some(40));
1397    }
1398
1399    #[test]
1400    fn parse_opencode_step_finish_error() {
1401        let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1402{"type":"step_finish","timestamp":1001,"part":{"type":"step-finish","reason":"error","error":"context length exceeded"}}"#;
1403
1404        let err = runner().parse_response(raw).unwrap_err();
1405        assert!(matches!(err, AgentError::ContextOverflow { .. }));
1406    }
1407
1408    #[test]
1409    fn parse_opencode_empty_returns_silent_failure() {
1410        let err = runner().parse_response("").unwrap_err();
1411        // Empty output with exit 0 is treated as silent failure so fallback.rs
1412        // can apply model cooldown and retry with free models
1413        assert!(matches!(err, AgentError::Unknown { exit_code: 0, .. }));
1414    }
1415
1416    #[test]
1417    fn extract_tokens_from_step_finish() {
1418        let r = runner();
1419        let events: Vec<serde_json::Value> = vec![
1420            serde_json::json!({"type":"step_finish","part":{"type":"step-finish","tokens":{"input":1000,"output":500,"total":1500}}}),
1421        ];
1422        let (input, output) = r.extract_tokens(&events);
1423        assert_eq!(input, Some(1000));
1424        assert_eq!(output, Some(500));
1425    }
1426
1427    #[test]
1428    fn classify_opencode_rate_limit() {
1429        let err = classify_opencode_message("429 Too Many Requests");
1430        assert!(matches!(err, AgentError::RateLimit { .. }));
1431    }
1432
1433    #[test]
1434    fn classify_opencode_model_not_found() {
1435        let err = classify_opencode_message("model not found: gpt-5");
1436        assert!(matches!(err, AgentError::ModelUnavailable { .. }));
1437    }
1438
1439    #[test]
1440    fn classify_opencode_model_not_found_with_suggestion() {
1441        // Issue #1934: opencode returns "Model not found: X. Did you mean: Y?"
1442        // The parser must extract "X", not "X. Did you mean"
1443        let err = classify_opencode_message(
1444            "Model not found: github-copilot/gemini-3.1-pro. Did you mean: gemini-3.1-pro-preview?",
1445        );
1446        match err {
1447            AgentError::ModelUnavailable { model, message } => {
1448                assert_eq!(model, "github-copilot/gemini-3.1-pro");
1449                assert!(message.contains("Did you mean"));
1450            }
1451            other => panic!("expected ModelUnavailable, got: {other:?}"),
1452        }
1453    }
1454
1455    #[test]
1456    fn classify_opencode_model_deprecated() {
1457        // Issue #2228: "The free model has been deprecated." should be ModelUnavailable
1458        // with the model extracted from "Transition to X" so the cooldown key is non-empty.
1459        let err = classify_opencode_message(
1460            "The free model has been deprecated. Transition to qwen/qwen3.6-plus for continued paid access.",
1461        );
1462        match err {
1463            AgentError::ModelUnavailable { model, .. } => {
1464                assert_eq!(
1465                    model, "qwen/qwen3.6-plus",
1466                    "expected model extracted from 'Transition to X', got: {model:?}"
1467                );
1468            }
1469            other => panic!("expected ModelUnavailable, got: {other:?}"),
1470        }
1471    }
1472
1473    #[test]
1474    fn classify_opencode_model_unavailable_for_free() {
1475        // Issue #3475: "This model is unavailable for free ... use this slug instead: X"
1476        // must classify as ModelUnavailable, not fall through to the transient
1477        // network-error path via the "Upstream request failed" substring.
1478        let err = classify_opencode_message(
1479            "network error: Error from provider (Console): Upstream request failed: [404] This model is unavailable for free. The paid version is available now - use this slug instead: inclusionai/ling-3.0-flash",
1480        );
1481        match err {
1482            AgentError::ModelUnavailable { model, .. } => {
1483                assert_eq!(
1484                    model, "inclusionai/ling-3.0-flash",
1485                    "expected model extracted from 'use this slug instead: X', got: {model:?}"
1486                );
1487            }
1488            other => panic!("expected ModelUnavailable, got: {other:?}"),
1489        }
1490    }
1491
1492    #[test]
1493    fn classify_opencode_model_not_available_in_country() {
1494        // Issue #3535: "This model is not available in your country." must classify as
1495        // ModelUnavailable (persistent per-model cooldown), not fall through to the
1496        // generic AgentFailed bucket which applies a weaker cooldown plus an
1497        // unwarranted agent-wide penalty.
1498        let err =
1499            classify_opencode_message("agent failed: This model is not available in your country.");
1500        match err {
1501            AgentError::ModelUnavailable { .. } => {}
1502            other => panic!("expected ModelUnavailable, got: {other:?}"),
1503        }
1504    }
1505
1506    #[test]
1507    fn classify_opencode_no_endpoints_found() {
1508        // Issue #2228: "No endpoints found for X." should be ModelUnavailable with model extracted
1509        let err = classify_opencode_message("No endpoints found for qwen/qwen3.6-plus:free.");
1510        match err {
1511            AgentError::ModelUnavailable { model, .. } => {
1512                assert_eq!(model, "qwen/qwen3.6-plus:free");
1513            }
1514            other => panic!("expected ModelUnavailable, got: {other:?}"),
1515        }
1516    }
1517
1518    #[test]
1519    fn build_command_opencode() {
1520        let r = runner();
1521        let perms = PermissionRules::default();
1522        let cmd = r.build_command(
1523            Some("anthropic/claude-sonnet-4-20250514"),
1524            "timeout 1800",
1525            "/tmp/sys.txt",
1526            "/tmp/msg.txt",
1527            &perms,
1528        );
1529        assert!(cmd.contains("opencode run"));
1530        assert!(cmd.contains("--model 'anthropic/claude-sonnet-4-20250514'"));
1531        assert!(cmd.contains("--format json"));
1532        assert!(
1533            cmd.starts_with(
1534                r#"OPENCODE_CONFIG_CONTENT='{"snapshot":false}' timeout 1800 opencode run"#
1535            ),
1536            "snapshots must be off for orch runs, got: {cmd}"
1537        );
1538        // Autonomous mode passes --dangerously-skip-permissions instead of
1539        // writing a config file or overriding XDG_CONFIG_HOME.
1540        assert!(
1541            cmd.contains("--dangerously-skip-permissions"),
1542            "expected --dangerously-skip-permissions, got: {cmd}"
1543        );
1544        assert!(
1545            !cmd.contains("opencode.json"),
1546            "should not write a config file, got: {cmd}"
1547        );
1548        assert!(
1549            !cmd.contains("XDG_CONFIG_HOME"),
1550            "should not override XDG_CONFIG_HOME, got: {cmd}"
1551        );
1552        assert!(
1553            !cmd.contains(".orch-opencode"),
1554            "should not reference .orch-opencode, got: {cmd}"
1555        );
1556        // Regression: the command must not start with a `cat sys msg |` pipe
1557        // stage. `build_runner_script` captures the agent's exit status via
1558        // `PIPESTATUS[0]` of `{agent_cmd} | tee ...` — a leading `cat |` would
1559        // make PIPESTATUS[0] always report cat's (always-zero) exit code
1560        // instead of opencode's real exit code. sys/msg files must be fed via
1561        // input redirection instead.
1562        assert!(
1563            !cmd.trim_start().starts_with("cat "),
1564            "opencode command must not start with a `cat |` pipe stage, got: {cmd}"
1565        );
1566        assert!(
1567            cmd.contains("< <(cat \"/tmp/sys.txt\" \"/tmp/msg.txt\")"),
1568            "sys/msg files must be fed via process substitution, got: {cmd}"
1569        );
1570    }
1571
1572    #[test]
1573    fn build_command_opencode_supervised_no_skip_flag() {
1574        let r = runner();
1575        let perms = PermissionRules {
1576            autonomous: false,
1577            ..PermissionRules::default()
1578        };
1579        let cmd = r.build_command(
1580            Some("anthropic/claude-sonnet-4-20250514"),
1581            "timeout 1800",
1582            "/tmp/sys.txt",
1583            "/tmp/msg.txt",
1584            &perms,
1585        );
1586        assert!(
1587            !cmd.contains("--dangerously-skip-permissions"),
1588            "supervised mode must not pass --dangerously-skip-permissions, got: {cmd}"
1589        );
1590    }
1591
1592    #[test]
1593    fn split_model_variant_no_at() {
1594        let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5");
1595        assert_eq!(base, "openai/gpt-5.5");
1596        assert!(variant.is_none());
1597    }
1598
1599    #[test]
1600    fn split_model_variant_with_variant() {
1601        let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5@xhigh");
1602        assert_eq!(base, "openai/gpt-5.5");
1603        assert_eq!(variant, Some("xhigh"));
1604    }
1605
1606    #[test]
1607    fn split_model_variant_trailing_at_empty() {
1608        let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5@");
1609        assert_eq!(base, "openai/gpt-5.5");
1610        assert_eq!(variant, Some(""));
1611    }
1612
1613    #[test]
1614    fn split_model_variant_multiple_ats_last_wins() {
1615        let (base, variant) = OpenCodeRunner::split_model_variant("a@b@c@v");
1616        assert_eq!(base, "a@b@c");
1617        assert_eq!(variant, Some("v"));
1618    }
1619
1620    #[test]
1621    fn build_command_opencode_with_variant() {
1622        let r = runner();
1623        let perms = PermissionRules::default();
1624        let cmd = r.build_command(
1625            Some("openai/gpt-5.5@high"),
1626            "timeout 1800",
1627            "/tmp/sys.txt",
1628            "/tmp/msg.txt",
1629            &perms,
1630        );
1631        assert!(cmd.contains("--model 'openai/gpt-5.5'"));
1632        assert!(cmd.contains("--variant 'high'"));
1633    }
1634
1635    #[test]
1636    fn classify_opencode_permission_denied() {
1637        let err = classify_opencode_message("user rejected permission for tool bash: cargo test");
1638        assert!(
1639            matches!(err, AgentError::PermissionDenied { .. }),
1640            "expected PermissionDenied, got: {err:?}"
1641        );
1642    }
1643
1644    #[test]
1645    fn classify_opencode_provider_returned_error() {
1646        // Issue #2478: "Provider returned error" is an opaque upstream failure from the
1647        // opencode CLI. Classify as RateLimit so fallback records a rate-limit signal
1648        // and applies cooldown behavior tuned for transient provider instability.
1649        let err = classify_opencode_message("Provider returned error");
1650        assert!(
1651            matches!(err, AgentError::RateLimit { .. }),
1652            "expected RateLimit, got: {err:?}"
1653        );
1654        let err_with_detail =
1655            classify_opencode_message("Provider returned error: upstream connection timed out");
1656        assert!(
1657            matches!(err_with_detail, AgentError::RateLimit { .. }),
1658            "expected RateLimit, got: {err_with_detail:?}"
1659        );
1660    }
1661
1662    #[test]
1663    fn classify_opencode_upstream_idle_timeout() {
1664        // Issue #3301: "Upstream idle timeout exceeded" must produce NetworkError so
1665        // fallback.rs applies a model-level cooldown instead of retrying immediately.
1666        let err = classify_opencode_message("Upstream idle timeout exceeded");
1667        assert!(
1668            matches!(err, AgentError::NetworkError { .. }),
1669            "expected NetworkError, got: {err:?}"
1670        );
1671    }
1672
1673    #[test]
1674    fn free_models_discovery_returns_vec() {
1675        // discover_free_opencode_models returns empty in test/debug builds
1676        let models = discover_free_opencode_models();
1677        assert!(models.is_empty());
1678    }
1679
1680    #[test]
1681    fn live_opencode_discovery_is_skipped_in_test_builds() {
1682        assert!(skip_live_opencode_discovery());
1683        assert!(discover_opencode_models().is_empty());
1684        assert!(discover_free_opencode_models().is_empty());
1685        prime_free_model_cache();
1686        assert!(discover_opencode_models().is_empty());
1687    }
1688
1689    /// The stub stands in for opencode 2.2.1, which starts `opencode serve --service`
1690    /// as a child that outlives it
1691    #[cfg(unix)]
1692    #[tokio::test]
1693    async fn models_cli_reaps_orphaned_process_group() {
1694        use std::os::unix::fs::PermissionsExt;
1695
1696        let dir = tempfile::tempdir().unwrap();
1697        let sleeper_pid_path = dir.path().join("sleeper.pid");
1698        let script = dir.path().join("opencode");
1699        std::fs::write(
1700            &script,
1701            format!(
1702                "#!/bin/sh\n/bin/sleep 60 </dev/null >/dev/null 2>&1 &\necho $! > '{}'\necho test-model-free\n",
1703                sleeper_pid_path.display()
1704            ),
1705        )
1706        .unwrap();
1707        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1708
1709        // The production discovery timeout, so a loaded machine does not turn this into
1710        // a timeout run, which reaps the group too and would assert nothing
1711        let (models, reason) = run_opencode_models_cli_inner(
1712            script.to_str().unwrap(),
1713            std::time::Duration::from_secs(30),
1714        )
1715        .await;
1716        assert_eq!(reason, "ok");
1717        assert_eq!(models, vec!["test-model-free".to_string()]);
1718
1719        let sleeper: u32 = std::fs::read_to_string(&sleeper_pid_path)
1720            .unwrap()
1721            .trim()
1722            .parse()
1723            .expect("sleeper pid");
1724        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1725        while pid_is_alive(sleeper) && std::time::Instant::now() < deadline {
1726            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1727        }
1728        assert!(
1729            !pid_is_alive(sleeper),
1730            "sleeper {sleeper} must die with the process group when opencode models returns"
1731        );
1732    }
1733
1734    #[cfg(unix)]
1735    fn pid_is_alive(pid: u32) -> bool {
1736        // Signal 0 is the POSIX existence probe
1737        super::raw_kill(pid as i32, 0) == 0
1738    }
1739
1740    #[test]
1741    fn empty_free_discovery_preserves_existing_cache_contents() {
1742        reset_model_caches_for_test();
1743        let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1744        {
1745            let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
1746            *guard = (123, vec!["opencode/model-free".to_string()]);
1747        }
1748
1749        update_discovered_models_cache(cache, Vec::new(), "free", "empty_output");
1750
1751        let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
1752        // Timestamp must advance on a failed refresh so the TTL provides a
1753        // real backoff window instead of retrying on every subsequent call.
1754        assert!(guard.0 > 123);
1755        assert_eq!(guard.1, vec!["opencode/model-free".to_string()]);
1756    }
1757
1758    #[test]
1759    fn empty_all_models_discovery_preserves_existing_cache_contents() {
1760        reset_model_caches_for_test();
1761        let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1762        {
1763            let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
1764            *guard = (
1765                456,
1766                vec![
1767                    "opencode/model-free".to_string(),
1768                    "opencode/model-paid".to_string(),
1769                ],
1770            );
1771        }
1772
1773        update_discovered_models_cache(cache, Vec::new(), "all", "exit_status_failure");
1774
1775        let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
1776        assert!(guard.0 > 456);
1777        assert_eq!(
1778            guard.1,
1779            vec![
1780                "opencode/model-free".to_string(),
1781                "opencode/model-paid".to_string(),
1782            ]
1783        );
1784    }
1785
1786    #[test]
1787    fn empty_discovery_populates_empty_cache() {
1788        reset_model_caches_for_test();
1789        let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1790
1791        update_discovered_models_cache(cache, Vec::new(), "free", "command_not_found");
1792
1793        let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
1794        assert!(guard.0 > 0);
1795        assert!(guard.1.is_empty());
1796    }
1797
1798    // ── Fixture-based tests ─────────────────────────────────────
1799
1800    #[test]
1801    fn fixture_opencode_success() {
1802        let raw = include_str!("../../../../tests/fixtures/opencode_success.jsonl");
1803        let parsed = runner().parse_response(raw).unwrap();
1804        assert_eq!(parsed.response.status, "done");
1805        assert!(parsed.response.summary.contains("Fixed"));
1806        assert_eq!(parsed.response.accomplished.len(), 2);
1807        assert_eq!(parsed.input_tokens, Some(17500));
1808        assert_eq!(parsed.output_tokens, Some(500));
1809    }
1810
1811    /// Real failure: OpenCode rejects a tool permission in non-interactive mode.
1812    /// The error has `{"error":{"name":"PermissionError","data":{"message":"user rejected permission..."}}}`.
1813    /// Parser must extract the nested message and classify as PermissionDenied.
1814    #[test]
1815    fn fixture_opencode_permission_denied() {
1816        let raw = include_str!("../../../../tests/fixtures/opencode_permission_denied.jsonl");
1817        let err = runner().parse_response(raw).unwrap_err();
1818        assert!(
1819            matches!(err, AgentError::PermissionDenied { .. }),
1820            "expected PermissionDenied, got: {err:?}"
1821        );
1822    }
1823
1824    /// Real failure: OpenCode rate limit via top-level message field.
1825    #[test]
1826    fn fixture_opencode_rate_limit() {
1827        let raw = include_str!("../../../../tests/fixtures/opencode_rate_limit.jsonl");
1828        let err = runner().parse_response(raw).unwrap_err();
1829        assert!(
1830            matches!(err, AgentError::RateLimit { .. }),
1831            "expected RateLimit, got: {err:?}"
1832        );
1833    }
1834
1835    /// Real failure: OpenCode returns nested error for unknown model.
1836    /// The error object is `{"name":"UnknownError","data":{"message":"Model not found: X"}}`.
1837    /// Parser must extract the nested message and classify as ModelUnavailable.
1838    #[test]
1839    fn fixture_opencode_model_not_found() {
1840        let raw = include_str!("../../../../tests/fixtures/opencode_model_not_found.jsonl");
1841        let err = runner().parse_response(raw).unwrap_err();
1842        assert!(
1843            matches!(err, AgentError::ModelUnavailable { .. }),
1844            "expected ModelUnavailable, got: {err:?}"
1845        );
1846        if let AgentError::ModelUnavailable { model, .. } = &err {
1847            assert_eq!(model, "anthropic/claude-sonnet-4-6");
1848        }
1849    }
1850
1851    /// Real failure: OpenCode returns "Provider returned error" as an opaque upstream
1852    /// provider failure. Classified as RateLimit so degraded/rate-limit tracking applies.
1853    #[test]
1854    fn fixture_opencode_provider_returned_error() {
1855        let raw = include_str!("../../../../tests/fixtures/opencode_provider_returned_error.jsonl");
1856        let err = runner().parse_response(raw).unwrap_err();
1857        assert!(
1858            matches!(err, AgentError::RateLimit { .. }),
1859            "expected RateLimit, got: {err:?}"
1860        );
1861    }
1862
1863    // ── extract_text ─────────────────────────────────────────────
1864
1865    /// OpenCode: extract_text concatenates text events for review parsing.
1866    #[test]
1867    fn extract_text_returns_text_events() {
1868        let raw = concat!(
1869            r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1870            "\n",
1871            r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
1872            "\n",
1873            r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":100,"input":90,"output":10}}}"#,
1874        );
1875        let text = runner().extract_text(raw).unwrap();
1876        assert_eq!(
1877            text,
1878            r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#
1879        );
1880    }
1881
1882    /// OpenCode: extract_text with newer direct-text format.
1883    #[test]
1884    fn extract_text_direct_text_event_format() {
1885        let raw = concat!(
1886            r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1887            "\n",
1888            r#"{"type":"text","timestamp":1001,"sessionID":"ses_abc","text":"Here is my review:\n\n```json\n{\"decision\":\"request_changes\",\"notes\":\"Fix the bug\",\"test_results\":\"fail\",\"issues\":[]}\n```\n"}"#,
1889            "\n",
1890            r#"{"type":"step_finish","timestamp":1002,"sessionID":"ses_abc"}"#,
1891        );
1892        let text = runner().extract_text(raw).unwrap();
1893        assert!(
1894            text.contains("request_changes"),
1895            "expected review JSON, got: {text}"
1896        );
1897    }
1898
1899    /// OpenCode: extract_text propagates RateLimit for terminal errors.
1900    #[test]
1901    fn extract_text_rate_limit_propagates() {
1902        let raw = concat!(
1903            r#"{"type":"step_start","timestamp":1000}"#,
1904            "\n",
1905            r#"{"type":"error","message":"rate limit exceeded: 429 Too Many Requests"}"#,
1906        );
1907        let err = runner().extract_text(raw).unwrap_err();
1908        assert!(
1909            matches!(err, AgentError::RateLimit { .. }),
1910            "expected RateLimit, got: {err:?}"
1911        );
1912    }
1913
1914    /// OpenCode: extract_text propagates Auth error.
1915    #[test]
1916    fn extract_text_auth_error_propagates() {
1917        let raw = concat!(
1918            r#"{"type":"step_start","timestamp":1000}"#,
1919            "\n",
1920            r#"{"type":"error","message":"401 Unauthorized: invalid api key"}"#,
1921        );
1922        let err = runner().extract_text(raw).unwrap_err();
1923        assert!(
1924            matches!(err, AgentError::Auth { .. }),
1925            "expected Auth, got: {err:?}"
1926        );
1927    }
1928
1929    /// OpenCode: extract_text on empty input returns empty string (not an error).
1930    #[test]
1931    fn extract_text_empty_input_returns_empty_ok() {
1932        let text = runner().extract_text("").unwrap();
1933        assert!(text.is_empty());
1934    }
1935
1936    // ── find_opencode_result ────────────────────────────────────
1937
1938    #[test]
1939    fn find_opencode_result_success_with_tokens() {
1940        let ndjson = concat!(
1941            r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1942            "\n",
1943            r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\"}"}}"#,
1944            "\n",
1945            r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0.05,"tokens":{"input":5000,"output":200}}}"#,
1946        );
1947        let result = find_opencode_result(ndjson).expect("should find result");
1948        assert!(!result.is_error);
1949        assert!(result.result_text.contains("approve"));
1950        assert_eq!(result.input_tokens, Some(5000));
1951        assert_eq!(result.output_tokens, Some(200));
1952        assert_eq!(result.cost_usd, Some(0.05));
1953        assert!(!result.truncated_by_length);
1954    }
1955
1956    #[test]
1957    fn find_opencode_result_error_event() {
1958        let ndjson = r#"{"type":"error","message":"rate limit exceeded: 429"}"#;
1959        let result = find_opencode_result(ndjson).expect("should find error result");
1960        assert!(result.is_error);
1961        assert!(result.result_text.contains("rate limit"));
1962        assert!(!result.truncated_by_length);
1963    }
1964
1965    /// step_finish reason=length signals the model was cut off by its
1966    /// output/reasoning token budget, not malformed output — the flag must
1967    /// be set so callers avoid the persistent-format-failure cooldown.
1968    #[test]
1969    fn find_opencode_result_length_truncation() {
1970        let ndjson = concat!(
1971            r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1972            "\n",
1973            r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":"}}"#,
1974            "\n",
1975            r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"length","tokens":{"input":5485,"output":759,"reasoning":31241}}}"#,
1976        );
1977        let result = find_opencode_result(ndjson).expect("should find result");
1978        assert!(result.truncated_by_length);
1979        assert!(!result.is_error, "length truncation is not a hard error");
1980    }
1981
1982    /// A reasoning model can burn its entire budget on hidden reasoning and
1983    /// emit zero text before step_finish reason=length. The old early return
1984    /// on empty result_text bailed out with None before truncated_by_length
1985    /// was ever computed, so this got misclassified as parse_error and hit
1986    /// the 4h-7d persistent-model cooldown instead of standard backoff.
1987    #[test]
1988    fn find_opencode_result_zero_output_length_truncation() {
1989        let ndjson = concat!(
1990            r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1991            "\n",
1992            r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"length","tokens":{"total":71482,"input":39482,"output":0,"reasoning":40000}}}"#,
1993        );
1994        let result = find_opencode_result(ndjson).expect("should find result, not None");
1995        assert!(result.truncated_by_length);
1996        assert!(!result.is_error);
1997        assert!(result.result_text.is_empty());
1998    }
1999
2000    /// opencode also emits reason="unknown" with all-zero tokens for the same
2001    /// zero-output failure mode — must be treated as an equivalent signal.
2002    #[test]
2003    fn find_opencode_result_zero_output_unknown_reason_truncation() {
2004        let ndjson = concat!(
2005            r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
2006            "\n",
2007            r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"unknown","tokens":{"input":0,"output":0,"reasoning":0}}}"#,
2008        );
2009        let result = find_opencode_result(ndjson).expect("should find result, not None");
2010        assert!(result.truncated_by_length);
2011        assert!(!result.is_error);
2012        assert!(result.result_text.is_empty());
2013    }
2014
2015    /// reason="unknown" with nonzero output tokens means the model did
2016    /// produce something and the step just wasn't cleanly terminated — not
2017    /// the same "wrote nothing" signal, so it must not be flagged.
2018    #[test]
2019    fn find_opencode_result_unknown_reason_with_output_not_truncated() {
2020        let ndjson = concat!(
2021            r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
2022            "\n",
2023            r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"partial answer"}}"#,
2024            "\n",
2025            r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"unknown","tokens":{"input":100,"output":50,"reasoning":0}}}"#,
2026        );
2027        let result = find_opencode_result(ndjson).expect("should find result");
2028        assert!(!result.truncated_by_length);
2029        assert!(!result.is_error);
2030    }
2031
2032    #[test]
2033    fn find_opencode_result_empty_returns_none() {
2034        assert!(find_opencode_result("").is_none());
2035        assert!(find_opencode_result("   ").is_none());
2036    }
2037
2038    #[test]
2039    fn find_opencode_result_direct_text_format() {
2040        let ndjson = concat!(
2041            r#"{"type":"step_start","timestamp":1000}"#,
2042            "\n",
2043            r#"{"type":"text","timestamp":1001,"text":"Review complete. All tests passed."}"#,
2044            "\n",
2045            r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","tokens":{"input":100,"output":10}}}"#,
2046        );
2047        let result = find_opencode_result(ndjson).expect("should find result");
2048        assert!(!result.is_error);
2049        assert!(result.result_text.contains("All tests passed"));
2050    }
2051
2052    #[test]
2053    fn find_opencode_result_plain_text_returns_none() {
2054        assert!(find_opencode_result("just some plain text").is_none());
2055    }
2056}
2057
2058#[cfg(test)]
2059mod error_text_tests {
2060    use super::*;
2061    use serde_json::json;
2062
2063    #[test]
2064    fn unrecognized_error_shape_keeps_raw_json() {
2065        let e = json!({"type":"error","error":{"code":429,"detail":"slow down"}});
2066        assert!(opencode_error_text(&e).contains("slow down"));
2067        let e = json!({"type":"error"});
2068        assert!(opencode_error_text(&e).contains("\"type\""));
2069    }
2070
2071    #[test]
2072    fn known_shapes_still_extract() {
2073        assert_eq!(opencode_error_text(&json!({"message":"a"})), "a");
2074        assert_eq!(opencode_error_text(&json!({"error":"b"})), "b");
2075        assert_eq!(opencode_error_text(&json!({"error":{"message":"c"}})), "c");
2076        assert_eq!(
2077            opencode_error_text(&json!({"error":{"name":"N","data":{"message":"d"}}})),
2078            "d"
2079        );
2080    }
2081}
2082
2083/// OpenCode `--format json`: a `tool_use` event carries both the call and its finished state
2084fn stream_events(v: &serde_json::Value) -> Vec<StreamEvent> {
2085    let text = || {
2086        v.pointer("/part/text")
2087            .or_else(|| v.get("text"))
2088            .and_then(serde_json::Value::as_str)
2089            .unwrap_or("")
2090            .to_string()
2091    };
2092    match json_str(v, "/type") {
2093        "text" => vec![StreamEvent::Text(text())],
2094        "reasoning" => vec![StreamEvent::Thinking(text())],
2095        "tool_use" => {
2096            let part = &v["part"];
2097            let state = &part["state"];
2098            let id = json_str(part, "/callID").to_string();
2099            let name = json_str(part, "/tool").to_string();
2100            let mut out = vec![StreamEvent::ToolCall {
2101                id: id.clone(),
2102                name: name.clone(),
2103                input: state["input"].clone(),
2104            }];
2105            let result = match json_str(state, "/status") {
2106                "completed" => Some((json_str(state, "/output").to_string(), false)),
2107                "error" => Some((json_error_message(state), true)),
2108                _ => None,
2109            };
2110            if let Some((output, is_error)) = result {
2111                out.push(StreamEvent::ToolResult {
2112                    id,
2113                    name,
2114                    output,
2115                    is_error,
2116                });
2117            }
2118
2119            out
2120        }
2121        "step_finish" if json_str(v, "/part/reason") == "stop" => {
2122            vec![StreamEvent::Done(String::new())]
2123        }
2124        "error" => vec![StreamEvent::Error(json_error_message(v))],
2125        _ => vec![],
2126    }
2127}
2128
2129#[cfg(test)]
2130mod stream_tests {
2131    use super::*;
2132    use serde_json::json;
2133
2134    fn events(line: &str) -> Vec<StreamEvent> {
2135        OpenCodeRunner::new().stream_events(&serde_json::from_str(line).unwrap())
2136    }
2137
2138    #[test]
2139    fn text_in_part_or_top_level() {
2140        assert_eq!(
2141            events(r#"{"type":"text","part":{"type":"text","text":"Reviewing."}}"#),
2142            vec![StreamEvent::Text("Reviewing.".into())]
2143        );
2144        assert_eq!(
2145            events(r#"{"type":"text","text":"Plain."}"#),
2146            vec![StreamEvent::Text("Plain.".into())]
2147        );
2148    }
2149
2150    #[test]
2151    fn completed_tool_has_call_and_result() {
2152        let line = r#"{"type":"tool_use","part":{"type":"tool","tool":"read","callID":"c1","state":{"status":"completed","input":{"filePath":"src/lib.rs"},"output":"a\nb"}}}"#;
2153        assert_eq!(
2154            events(line),
2155            vec![
2156                StreamEvent::ToolCall {
2157                    id: "c1".into(),
2158                    name: "read".into(),
2159                    input: json!({"filePath": "src/lib.rs"}),
2160                },
2161                StreamEvent::ToolResult {
2162                    id: "c1".into(),
2163                    name: "read".into(),
2164                    output: "a\nb".into(),
2165                    is_error: false,
2166                },
2167            ]
2168        );
2169    }
2170
2171    #[test]
2172    fn failed_tool_result_is_error() {
2173        let line = r#"{"type":"tool_use","part":{"tool":"bash","callID":"c2","state":{"status":"error","input":{"command":"x"},"error":"not found"}}}"#;
2174        assert_eq!(
2175            events(line)[1],
2176            StreamEvent::ToolResult {
2177                id: "c2".into(),
2178                name: "bash".into(),
2179                output: "not found".into(),
2180                is_error: true,
2181            }
2182        );
2183    }
2184
2185    #[test]
2186    fn only_final_step_is_done() {
2187        assert!(events(r#"{"type":"step_finish","part":{"reason":"tool-calls"}}"#).is_empty());
2188        assert_eq!(
2189            events(r#"{"type":"step_finish","part":{"reason":"stop"}}"#),
2190            vec![StreamEvent::Done(String::new())]
2191        );
2192        assert!(events(r#"{"type":"step_start"}"#).is_empty());
2193    }
2194
2195    #[test]
2196    fn error_event() {
2197        let line = r#"{"type":"error","error":{"name":"UnknownError","data":{"message":"Provider returned error"}}}"#;
2198        assert_eq!(
2199            events(line),
2200            vec![StreamEvent::Error("Provider returned error".into())]
2201        );
2202    }
2203}