Skip to main content

orch/engine/runner/agents/
opencode.rs

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