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                    duration_ms: None,
577                });
578            }
579            return Err(AgentError::InvalidResponse {
580                raw: trimmed.to_string(),
581            });
582        }
583
584        // Check for errors first
585        if let Some(err) = Self::detect_error(&events) {
586            return Err(err);
587        }
588
589        // Extract tokens (needed for both the text path and the tool-only fallback)
590        let (input_tokens, output_tokens) = Self::extract_tokens(&events);
591
592        // Extract text — when opencode emits only tool_use/step events with no
593        // text content, synthesize a human-readable summary from the event
594        // stream instead of falling through to InvalidResponse. Otherwise the
595        // upstream `synthesize_response_from_text` heuristic gets handed the
596        // entire raw NDJSON, matches a "completed" substring inside tool state
597        // JSON, and emits 500 bytes of raw NDJSON as the task summary.
598        let Some(text) = Self::extract_text_from_events(&events) else {
599            let response = synthesize_response_from_events(&events);
600            return Ok(ParsedResponse {
601                response,
602                input_tokens,
603                output_tokens,
604                duration_ms: None,
605            });
606        };
607
608        // Parse the text through standard parser
609        let response = match parser::parse(&text) {
610            Ok(r) => r,
611            Err(_) => {
612                // Structured parse failed — try plain-text synthesis before
613                // giving up, same rescue path claude.rs uses (issue #3467).
614                super::synthesize_response_from_text(&text)
615                    .ok_or_else(|| AgentError::InvalidResponse { raw: text.clone() })?
616            }
617        };
618
619        Ok(ParsedResponse {
620            response,
621            input_tokens,
622            output_tokens,
623            duration_ms: None,
624        })
625    }
626
627    fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
628        // Try parsing NDJSON events from stdout for structured errors
629        let events = super::parse_ndjson(stdout);
630        if let Some(err) = Self::detect_error(&events) {
631            return err;
632        }
633
634        let combined = format!("{stdout}\n{stderr}");
635        super::patterns::classify_from_text(exit_code, &combined)
636    }
637
638    fn free_models(&self) -> Vec<String> {
639        discover_free_opencode_models()
640    }
641
642    fn available_models(&self) -> Vec<String> {
643        // Known default models; could be extended with `opencode models` discovery
644        vec![
645            "anthropic/claude-sonnet-4-20250514".to_string(),
646            "openai/gpt-4.1".to_string(),
647        ]
648    }
649
650    fn router_command(&self, prompt: &str, model: Option<&str>) -> anyhow::Result<Command> {
651        // Routing only needs text output — no tools fire.
652        // `--dangerously-skip-permissions` prevents the user's global
653        // ~/.config/opencode/opencode.json interactive defaults (e.g.
654        // "edit":"ask") from producing PermissionError events that the router
655        // would misclassify as model failures.
656        let mut cmd = Command::new("opencode");
657        cmd.env("OPENCODE_CONFIG_CONTENT", NO_SNAPSHOT_CONFIG)
658            .arg("run")
659            .arg("--format")
660            .arg("json")
661            .arg("--dangerously-skip-permissions");
662        if let Some(m) = model {
663            let (base, variant) = Self::split_model_variant(m);
664            cmd.arg("--model").arg(base);
665            if let Some(v) = variant {
666                // pass variant even if empty (last-token-wins semantics)
667                cmd.arg("--variant").arg(v);
668            }
669        }
670        cmd.arg(prompt);
671        Ok(cmd)
672    }
673}
674
675/// Classify an OpenCode error message.
676fn classify_opencode_message(message: &str) -> AgentError {
677    let lower = message.to_lowercase();
678
679    // Rate limit — delegate to shared helper
680    if let Some(e) = super::patterns::detect_rate_limit(message) {
681        return e;
682    }
683
684    // Context overflow — delegate to shared helper (covers "context overflow", "context length", etc.)
685    if let Some(e) = super::patterns::detect_context_overflow(message) {
686        return e;
687    }
688
689    // Auth errors — delegate to shared helper (includes HTTP 401/403, billing, etc.)
690    if let Some(e) = super::patterns::detect_auth_error(message) {
691        return e;
692    }
693
694    // Permission denied — delegate to shared helper
695    if let Some(e) = super::patterns::detect_permission_denied(message) {
696        return e;
697    }
698
699    // Model not available (opencode-specific extraction pattern)
700    //
701    // Patterns handled:
702    //   "Model not found: anthropic/claude-sonnet-4-6."
703    //   "Model not found: github-copilot/gemini-3.1-pro. Did you mean: gemini-3.1-pro-preview?"
704    //   "model not supported: X"
705    //   "The free model has been deprecated. Transition to qwen/qwen3.6-plus for continued paid access."
706    //   "No endpoints found for qwen/qwen3.6-plus:free."
707    //   "This model is unavailable for free. The paid version is available now - use this slug instead: X"
708    //   "This model is not available in your country."
709    let is_model_unavailable = (lower.contains("model")
710        && (lower.contains("not found")
711            || lower.contains("not supported")
712            || lower.contains("deprecated")
713            || lower.contains("unavailable for free")
714            || lower.contains("not available in your country")
715            || lower.contains("not available in your region")))
716        || lower.contains("no endpoints found");
717
718    if is_model_unavailable {
719        // Try to extract model name from patterns like "Model not found: anthropic/claude-sonnet-4-6."
720        // or "No endpoints found for qwen/qwen3.6-plus:free."
721        let model = if lower.contains("no endpoints found") {
722            // "No endpoints found for X." — extract X after "for "
723            message
724                .split(" for ")
725                .nth(1)
726                .map(|s| s.trim_end_matches('.').to_string())
727                .unwrap_or_default()
728        } else {
729            // Try "Model not found: X" or "model not supported: X" patterns
730            let from_colon = message
731                .split(": ")
732                .nth(1)
733                .map(|s| {
734                    // Strip opencode's "Did you mean: X?" suggestion suffix if present
735                    s.split(". Did you mean")
736                        .next()
737                        .unwrap_or(s)
738                        .trim_end_matches('.')
739                        .to_string()
740                })
741                .filter(|s| !s.is_empty());
742
743            // For deprecated messages like "The free model has been deprecated. Transition to X for..."
744            // extract the model from "Transition to X" when colon-based extraction yields nothing.
745            let from_transition = if lower.contains("deprecated") {
746                message
747                    .split("Transition to ")
748                    .nth(1)
749                    .and_then(|s| s.split(" for ").next())
750                    .map(|s| s.trim_end_matches('.').to_string())
751                    .filter(|s| !s.is_empty())
752            } else {
753                None
754            };
755
756            // For "unavailable for free" messages like "... use this slug instead: X"
757            // extract the suggested replacement slug.
758            let from_slug_suggestion = if lower.contains("unavailable for free") {
759                message
760                    .split("use this slug instead: ")
761                    .nth(1)
762                    .map(|s| s.trim_end_matches('.').trim().to_string())
763                    .filter(|s| !s.is_empty())
764            } else {
765                None
766            };
767
768            // Transition/slug extractions are tied to specific message shapes and take
769            // priority over the generic colon split, which would otherwise grab the
770            // wrong substring (e.g. the "[404] ..." text before the real slug).
771            from_transition
772                .or(from_slug_suggestion)
773                .or(from_colon)
774                .unwrap_or_default()
775        };
776        return AgentError::ModelUnavailable {
777            message: message.to_string(),
778            model,
779        };
780    }
781
782    // OpenCode surfaces opaque upstream provider failures as "Provider returned error".
783    // Treat this as a usage-limit class signal so fallback/error tracking records a
784    // rate-limit event (instead of a generic failure), allowing router health/degraded
785    // checks to react faster to unstable provider/model endpoints.
786    if lower.contains("provider returned error") {
787        return AgentError::RateLimit {
788            message: message.to_string(),
789        };
790    }
791
792    // Transient network/transport errors (e.g. "Upstream idle timeout exceeded")
793    if let Some(e) = super::patterns::detect_network_error(message) {
794        return e;
795    }
796
797    AgentError::AgentFailed {
798        message: message.to_string(),
799    }
800}
801
802/// Discover free opencode models, with 1-hour cache and background refresh.
803///
804/// This is the single source of truth for free model discovery. Called by
805/// the router (pool expansion, model_map), the runner (failover), and
806/// startup priming.
807///
808/// Non-blocking: returns cached data immediately. When the cache is cold
809/// or expired, spawns a background thread to refresh via `opencode models`.
810pub fn discover_free_opencode_models() -> Vec<String> {
811    if skip_live_opencode_discovery() {
812        return Vec::new();
813    }
814    let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
815
816    let now = chrono::Utc::now().timestamp();
817    // Fast path: return cached data if still fresh (1-hour TTL).
818    {
819        let guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
820        let (ts, models) = &*guard;
821        if *ts != 0 && now.saturating_sub(*ts) < 3600 {
822            return models.clone();
823        }
824    }
825
826    // Cache is cold or expired. Spawn a background task on the current runtime to refresh.
827    // Only one refresh runs at a time.
828    if !FREE_MODELS_REFRESH_IN_PROGRESS.swap(true, Ordering::AcqRel) {
829        let refresh = async {
830            let _guard = scopeguard::guard((), |()| {
831                FREE_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
832            });
833            let (models, empty_reason) = run_opencode_models_discovery_async().await;
834            let raw_count = models.len();
835            let discovered = models
836                .into_iter()
837                .filter(|m| m.to_lowercase().contains("free"))
838                .collect::<Vec<_>>();
839            let empty_reason = if discovered.is_empty() && raw_count > 0 {
840                "no_free_models_in_catalog"
841            } else {
842                empty_reason
843            };
844            let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
845            update_discovered_models_cache(cache, discovered, "free", empty_reason);
846        };
847        match Handle::try_current() {
848            Ok(handle) => {
849                // Use the current tokio runtime's handle to spawn the async discovery task,
850                // avoiding the overhead of a std::thread + new tokio runtime.
851                handle.spawn(refresh);
852            }
853            _ => {
854                // No tokio runtime (e.g. called from a plain unit test). Spawn a thread
855                // with a dedicated runtime — this is the fallback path.
856                thread::spawn(move || {
857                    if let Ok(rt) = Runtime::new() {
858                        rt.block_on(refresh);
859                    }
860                });
861            }
862        }
863    }
864
865    // Return whatever is in cache (may be empty on first call).
866    let guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
867    guard.1.clone()
868}
869
870/// Discover all opencode models, with 1-hour cache and background refresh.
871///
872/// Non-blocking: returns cached data immediately. When the cache is cold
873/// or expired, spawns a background refresh via `opencode models`.
874pub fn discover_opencode_models() -> Vec<String> {
875    if skip_live_opencode_discovery() {
876        return Vec::new();
877    }
878    let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
879    let now = chrono::Utc::now().timestamp();
880
881    {
882        let guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
883        let (ts, models) = &*guard;
884        if *ts != 0 && now.saturating_sub(*ts) < 3600 {
885            return models.clone();
886        }
887    }
888
889    if !ALL_MODELS_REFRESH_IN_PROGRESS.swap(true, Ordering::AcqRel) {
890        let refresh = async {
891            let _guard = scopeguard::guard((), |()| {
892                ALL_MODELS_REFRESH_IN_PROGRESS.store(false, Ordering::Release);
893            });
894            let (discovered, empty_reason) = run_opencode_models_discovery_async().await;
895            let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
896            update_discovered_models_cache(cache, discovered, "all", empty_reason);
897        };
898        match Handle::try_current() {
899            Ok(handle) => {
900                handle.spawn(refresh);
901            }
902            _ => {
903                thread::spawn(move || {
904                    if let Ok(rt) = Runtime::new() {
905                        rt.block_on(refresh);
906                    }
907                });
908            }
909        }
910    }
911
912    let guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
913    guard.1.clone()
914}
915
916/// Prime the free-model cache synchronously at startup.
917///
918/// Called from `Router::new()` via `spawn_blocking` so the initial
919/// blocking subprocess doesn't stall the Tokio runtime.
920/// Runs `run_opencode_models_discovery_async()` in a dedicated thread with its
921/// own tokio runtime, avoiding `block_on` restrictions when called from within
922/// an existing runtime (e.g. `#[tokio::test]` or a nested runtime context).
923pub fn prime_free_model_cache() {
924    if skip_live_opencode_discovery() {
925        return;
926    }
927    let discovered_all = thread::spawn(|| match Runtime::new() {
928        Ok(rt) => rt.block_on(run_opencode_models_discovery_async()).0,
929        _ => {
930            vec![]
931        }
932    })
933    .join()
934    .unwrap_or_default();
935    let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
936    let now = chrono::Utc::now().timestamp();
937    if let Ok(mut guard) = cache.lock() {
938        *guard = (
939            now,
940            discovered_all
941                .iter()
942                .filter(|m| m.to_lowercase().contains("free"))
943                .cloned()
944                .collect(),
945        );
946    }
947    let all_cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
948    if let Ok(mut guard) = all_cache.lock() {
949        *guard = (now, discovered_all);
950    }
951}
952
953/// Async version of `run_opencode_models_discovery` that uses `tokio::time::sleep`
954/// instead of blocking `std::thread::sleep`.
955///
956/// Returns the discovered models plus a reason code describing why the list
957/// is empty (`"ok"` when discovery actually succeeded with a non-empty
958/// result). The reason is threaded into `update_discovered_models_cache` so
959/// the "returned empty" warning says which of the failure branches fired,
960/// without requiring `RUST_LOG=debug` in production.
961async fn run_opencode_models_discovery_async() -> (Vec<String>, &'static str) {
962    run_opencode_models_cli(Duration::from_secs(30)).await
963}
964
965/// Run `opencode models` and reap any process-group leftovers when it returns
966pub(crate) async fn run_opencode_models_cli(timeout: Duration) -> (Vec<String>, &'static str) {
967    run_opencode_models_cli_inner("opencode", timeout).await
968}
969
970fn opencode_program_available(program: &str) -> bool {
971    let path = Path::new(program);
972    if path.is_absolute() || program.contains('/') {
973        path.is_file()
974    } else {
975        cmd_cache::command_exists(program)
976    }
977}
978
979/// `kill(2)`. A negative `pid` addresses the process group with that id
980#[cfg(unix)]
981fn raw_kill(pid: i32, sig: i32) -> i32 {
982    unsafe extern "C" {
983        fn kill(pid: i32, sig: i32) -> i32;
984    }
985    // SAFETY: both arguments are plain integers and kill has no other effect on this process
986    unsafe { kill(pid, sig) }
987}
988
989/// SIGKILL the group that `opencode models` leads, because the `opencode serve --service`
990/// child it starts outlives it and the group is the only handle on that grandchild
991#[cfg(unix)]
992fn kill_process_group(pgid: u32) {
993    const SIGKILL: i32 = 9;
994    let Ok(pgid) = i32::try_from(pgid) else {
995        return;
996    };
997    let _ = raw_kill(-pgid, SIGKILL);
998}
999
1000async fn run_opencode_models_cli_inner(
1001    program: &str,
1002    timeout: Duration,
1003) -> (Vec<String>, &'static str) {
1004    if !opencode_program_available(program) {
1005        tracing::warn!("opencode not in PATH, skipping model discovery");
1006        return (vec![], "command_not_found");
1007    }
1008
1009    let mut cmd = Command::new(program);
1010    cmd.args(["models"])
1011        .stdout(Stdio::piped())
1012        .stderr(Stdio::piped())
1013        .kill_on_drop(true);
1014    #[cfg(unix)]
1015    cmd.process_group(0);
1016
1017    let child = match cmd.spawn() {
1018        Ok(c) => c,
1019        Err(e) => {
1020            tracing::warn!(error = %e, "failed to spawn opencode models");
1021            return (vec![], "spawn_failed");
1022        }
1023    };
1024    let pgid = child.id();
1025    let wait = time::timeout(timeout, child.wait_with_output()).await;
1026
1027    #[cfg(unix)]
1028    if let Some(pid) = pgid {
1029        kill_process_group(pid);
1030    }
1031
1032    match wait {
1033        Ok(Ok(output)) if output.status.success() => {
1034            let models: Vec<String> = String::from_utf8_lossy(&output.stdout)
1035                .lines()
1036                .map(|l| l.trim().to_string())
1037                .filter(|l| !l.is_empty())
1038                .collect();
1039            let reason = if models.is_empty() {
1040                "empty_output"
1041            } else {
1042                "ok"
1043            };
1044            (models, reason)
1045        }
1046        Ok(Ok(output)) => {
1047            let stderr = String::from_utf8_lossy(&output.stderr);
1048            let stderr = stderr.trim();
1049            let stderr_display = if stderr.len() > 500 {
1050                format!("{}...", &stderr[..stderr.floor_char_boundary(500)])
1051            } else {
1052                stderr.to_string()
1053            };
1054            tracing::warn!(
1055                status = ?output.status,
1056                stderr = %stderr_display,
1057                "opencode models command failed"
1058            );
1059            (vec![], "exit_status_failure")
1060        }
1061        Ok(Err(e)) => {
1062            tracing::warn!(error = %e, "failed to wait on opencode models");
1063            (vec![], "wait_failed")
1064        }
1065        Err(_) => {
1066            tracing::warn!(
1067                secs = timeout.as_secs(),
1068                "opencode models timed out, killing process group"
1069            );
1070            (vec![], "timeout")
1071        }
1072    }
1073}
1074
1075/// Build a synthetic `AgentResponse` summarizing an opencode NDJSON event
1076/// stream when no `text` events are present.
1077///
1078/// Opencode runs that produced tool calls but never emitted a text event
1079/// (model exited mid-tool, stop_reason without a final reply, etc.) would
1080/// otherwise be passed to `synthesize_response_from_text` as raw NDJSON.
1081/// That heuristic matches "completed" substrings inside tool state JSON and
1082/// emits the first 500 bytes of NDJSON as the notification summary — what
1083/// the user sees as garbage in Telegram/Discord/Slack.
1084///
1085/// Status is `needs_review` because there is no text confirming completion;
1086/// the agent did work but never reported on it.
1087fn synthesize_response_from_events(events: &[serde_json::Value]) -> AgentResponse {
1088    let mut counts: BTreeMap<String, u32> = BTreeMap::new();
1089    let mut tool_names: Vec<String> = Vec::new();
1090    let mut step_finish_reason: Option<String> = None;
1091
1092    for event in events {
1093        let event_type = event
1094            .get("type")
1095            .and_then(|v| v.as_str())
1096            .unwrap_or("unknown");
1097        *counts.entry(event_type.to_string()).or_insert(0) += 1;
1098
1099        if event_type == "step_finish"
1100            && let Some(reason) = event
1101                .get("part")
1102                .and_then(|p| p.get("reason"))
1103                .and_then(|r| r.as_str())
1104        {
1105            step_finish_reason = Some(reason.to_string());
1106        }
1107
1108        // Capture tool names from common opencode/claude-style shapes.
1109        if event_type == "tool_use"
1110            && let Some(name) = event
1111                .get("name")
1112                .and_then(|v| v.as_str())
1113                .or_else(|| event.get("tool").and_then(|v| v.as_str()))
1114                .or_else(|| {
1115                    event
1116                        .get("part")
1117                        .and_then(|p| p.get("name"))
1118                        .and_then(|v| v.as_str())
1119                })
1120                .or_else(|| {
1121                    event
1122                        .get("tool")
1123                        .and_then(|t| t.get("name"))
1124                        .and_then(|v| v.as_str())
1125                })
1126        {
1127            tool_names.push(name.to_string());
1128        }
1129    }
1130
1131    let total_events = events.len();
1132    let reason = step_finish_reason.as_deref().unwrap_or("unknown");
1133
1134    let tool_summary = if tool_names.is_empty() {
1135        String::new()
1136    } else {
1137        let mut tool_counts: BTreeMap<String, u32> = BTreeMap::new();
1138        for n in &tool_names {
1139            *tool_counts.entry(n.clone()).or_insert(0) += 1;
1140        }
1141        let parts: Vec<String> = tool_counts
1142            .iter()
1143            .map(|(k, v)| {
1144                if *v > 1 {
1145                    format!("{k}×{v}")
1146                } else {
1147                    k.clone()
1148                }
1149            })
1150            .collect();
1151        format!(" (tools: {})", parts.join(", "))
1152    };
1153
1154    let summary = format!(
1155        "opencode emitted {total_events} event(s) with no text output{tool_summary}; \
1156         step_finish reason: {reason}"
1157    );
1158
1159    AgentResponse {
1160        status: "needs_review".to_string(),
1161        summary,
1162        error: Some("agent emitted no text response — only tool/step events".to_string()),
1163        ..Default::default()
1164    }
1165}
1166
1167/// Extract a structured `AgentResult` from OpenCode NDJSON output.
1168///
1169/// Concatenates text from `type:text` events (both `part.text` and direct
1170/// `text` field formats), extracts tokens from the last `step_finish` event,
1171/// and checks for `error` / `step_finish.reason=error` events.
1172///
1173/// Returns `None` only if no parseable NDJSON events are found.
1174pub fn find_opencode_result(ndjson: &str) -> Option<super::AgentResult> {
1175    let events = super::parse_ndjson(ndjson.trim());
1176    if events.is_empty() {
1177        return None;
1178    }
1179
1180    // Check for errors first
1181    let is_error;
1182    let error_text;
1183    if let Some(err) = OpenCodeRunner::detect_error(&events) {
1184        is_error = true;
1185        error_text = Some(err.to_string());
1186    } else {
1187        is_error = false;
1188        error_text = None;
1189    }
1190
1191    // Extract text content
1192    let result_text = if is_error {
1193        error_text.unwrap_or_default()
1194    } else {
1195        extract_ndjson_text(&events).unwrap_or_default()
1196    };
1197
1198    // Extract tokens from step_finish
1199    let (input_tokens, output_tokens) = OpenCodeRunner::extract_tokens(&events);
1200
1201    // The model was cut off for exceeding its output/reasoning token budget,
1202    // not because it produced malformed prose. Distinguish this from a
1203    // generic parse failure so callers don't apply the long
1204    // format-incapable-model cooldown for a token-budget fluke. A reasoning
1205    // model that spends its entire budget on hidden reasoning emits zero
1206    // text and a final step_finish of reason="length" (or opencode's
1207    // "unknown" with all-zero tokens) — same failure mode, must be computed
1208    // before the empty-text early return below or it never gets checked.
1209    let last_step_finish_reason = events
1210        .iter()
1211        .rev()
1212        .find(|event| event.get("type").and_then(|v| v.as_str()) == Some("step_finish"))
1213        .and_then(|event| {
1214            event
1215                .get("part")?
1216                .get("reason")?
1217                .as_str()
1218                .map(str::to_string)
1219        });
1220    let truncated_by_length = match last_step_finish_reason.as_deref() {
1221        Some("length") => true,
1222        Some("unknown") => output_tokens == Some(0),
1223        _ => false,
1224    };
1225
1226    if result_text.is_empty() && !is_error && !truncated_by_length {
1227        return None;
1228    }
1229
1230    // Extract cost from step_finish if available
1231    let cost_usd = events.iter().rev().find_map(|event| {
1232        if event.get("type").and_then(|v| v.as_str()) == Some("step_finish") {
1233            event
1234                .get("part")
1235                .and_then(|p| p.get("cost"))
1236                .and_then(serde_json::Value::as_f64)
1237                .filter(|&c| c > 0.0)
1238        } else {
1239            None
1240        }
1241    });
1242
1243    Some(super::AgentResult {
1244        is_error,
1245        result_text,
1246        input_tokens,
1247        output_tokens,
1248        cost_usd,
1249        duration_ms: None,
1250        truncated_by_length,
1251    })
1252}
1253
1254#[cfg(test)]
1255mod tests {
1256    #[cfg(unix)]
1257    use std::fs;
1258    #[cfg(unix)]
1259    use std::fs::Permissions;
1260    #[cfg(unix)]
1261    use std::os::unix::fs::PermissionsExt;
1262    #[cfg(unix)]
1263    use std::time::Instant;
1264
1265    use super::*;
1266
1267    fn runner() -> OpenCodeRunner {
1268        OpenCodeRunner::new()
1269    }
1270
1271    #[test]
1272    fn parse_opencode_ndjson_success() {
1273        let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start","snapshot":"..."}}
1274{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"status\":\"done\",\"summary\":\"hello\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}}
1275{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":17512,"input":17509,"output":3}}}"#;
1276
1277        let parsed = runner().parse_response(raw).unwrap();
1278        assert_eq!(parsed.response.status, "done");
1279        assert_eq!(parsed.response.summary, "hello");
1280        assert_eq!(parsed.input_tokens, Some(17509));
1281        assert_eq!(parsed.output_tokens, Some(3));
1282    }
1283
1284    /// Newer opencode versions emit text directly in the event (no "part" wrapper).
1285    /// The extract_text method must handle both formats.
1286    #[test]
1287    fn parse_opencode_ndjson_direct_text_format() {
1288        let raw = r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}
1289{"type":"text","timestamp":1001,"sessionID":"ses_abc","text":"{\"status\":\"done\",\"summary\":\"fixed\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}
1290{"type":"step_finish","timestamp":1002,"sessionID":"ses_abc","part":{"type":"step-finish","reason":"stop","tokens":{"input":200,"output":10}}}"#;
1291
1292        let parsed = runner().parse_response(raw).unwrap();
1293        assert_eq!(parsed.response.status, "done");
1294        assert_eq!(parsed.response.summary, "fixed");
1295        assert_eq!(parsed.input_tokens, Some(200));
1296        assert_eq!(parsed.output_tokens, Some(10));
1297    }
1298
1299    #[test]
1300    fn parse_opencode_concatenated_text() {
1301        let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1302{"type":"text","timestamp":1001,"part":{"type":"text","text":"Working on "}}
1303{"type":"text","timestamp":1002,"part":{"type":"text","text":"the task. "}}
1304{"type":"text","timestamp":1003,"part":{"type":"text","text":"{\"status\":\"done\",\"summary\":\"finished\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}}
1305{"type":"step_finish","timestamp":1004,"part":{"type":"step-finish","reason":"stop","tokens":{"input":100,"output":50}}}"#;
1306
1307        let parsed = runner().parse_response(raw).unwrap();
1308        assert_eq!(parsed.response.status, "done");
1309        assert_eq!(parsed.input_tokens, Some(100));
1310        assert_eq!(parsed.output_tokens, Some(50));
1311    }
1312
1313    #[test]
1314    fn extract_router_text_prefers_json_payload() {
1315        let raw = r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"Thinking..."}}
1316{"type":"text","timestamp":1002,"part":{"type":"text","text":"{\"executor\":\"claude\",\"complexity\":\"medium\",\"reason\":\"fit\"}"}}"#;
1317
1318        let text = extract_router_text(raw).unwrap();
1319        let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
1320        assert_eq!(parsed["executor"], "claude");
1321    }
1322
1323    #[test]
1324    fn parse_opencode_embedded_json_fragment_without_executor_is_ignored() {
1325        // NDJSON with a text event that contains a JSON-like fragment which does
1326        // NOT include routing keys (executor/status). This should NOT be
1327        // interpreted as a routing payload; parse_response should fail with
1328        // InvalidResponse rather than accidentally accepting the fragment.
1329        let raw = r#"{"type":"step_start","timestamp":1}
1330{"type":"text","timestamp":2,"part":{"type":"text","text":"Here is a debug trace: {\"trace\":{\"duration\":123,\"id\":\"abc\"}}"}}
1331{"type":"step_finish","timestamp":3,"part":{"type":"step-finish","reason":"stop"}}"#;
1332
1333        let err = runner().parse_response(raw).unwrap_err();
1334        match err {
1335            AgentError::InvalidResponse { raw: _ } => {
1336                // expected
1337            }
1338            other => panic!("expected InvalidResponse, got: {other:?}"),
1339        }
1340    }
1341
1342    #[test]
1343    fn parse_opencode_error_event() {
1344        let raw = r#"{"type":"error","message":"rate limit exceeded for this model"}"#;
1345
1346        let err = runner().parse_response(raw).unwrap_err();
1347        assert!(matches!(err, AgentError::RateLimit { .. }));
1348    }
1349
1350    /// Regression: opencode runs that emit only tool_use / step_start / step_finish
1351    /// events (no `text` events) must synthesize a clean needs_review response from
1352    /// the event stream instead of returning InvalidResponse with the raw NDJSON
1353    /// blob, which would otherwise be passed to synthesize_response_from_text and
1354    /// leak as 500 bytes of raw JSON into the notification summary.
1355    #[test]
1356    fn parse_opencode_tool_only_stream_synthesizes_needs_review() {
1357        let raw = r#"{"type":"step_start","timestamp":1,"part":{"type":"step-start"}}
1358{"type":"tool_use","timestamp":2,"name":"bash","input":{"cmd":"ls"}}
1359{"type":"tool_use","timestamp":3,"name":"read","input":{"path":"src/main.rs"}}
1360{"type":"tool_use","timestamp":4,"name":"bash","input":{"cmd":"cargo check"}}
1361{"type":"step_finish","timestamp":5,"part":{"type":"step-finish","reason":"stop","tokens":{"input":500,"output":0}}}"#;
1362
1363        let parsed = runner()
1364            .parse_response(raw)
1365            .expect("tool-only NDJSON must synthesize a response, not error");
1366        assert_eq!(parsed.response.status, "needs_review");
1367        assert!(
1368            !parsed.response.summary.contains("\"type\":\"step_start\""),
1369            "summary must not contain raw NDJSON; got: {}",
1370            parsed.response.summary
1371        );
1372        assert!(
1373            parsed.response.summary.contains("bash") && parsed.response.summary.contains("read"),
1374            "summary should mention tools used; got: {}",
1375            parsed.response.summary
1376        );
1377        assert!(
1378            parsed.response.summary.contains("stop"),
1379            "summary should mention step_finish reason; got: {}",
1380            parsed.response.summary
1381        );
1382        // Tokens from step_finish must still be propagated.
1383        assert_eq!(parsed.input_tokens, Some(500));
1384        assert_eq!(parsed.output_tokens, Some(0));
1385    }
1386
1387    /// Regression test for issue #3467: when opencode's `text` event contains
1388    /// a plain-prose completion summary (not JSON), parser::parse fails but
1389    /// synthesize_response_from_text must rescue it instead of returning
1390    /// InvalidResponse, mirroring the fallback claude.rs already has.
1391    #[test]
1392    fn parse_opencode_plain_prose_completion_synthesizes_response() {
1393        let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1394{"type":"text","timestamp":1001,"part":{"type":"text","text":"Fixed the typo and corrected the deadline reference in the review."}}
1395{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","tokens":{"input":300,"output":40}}}"#;
1396
1397        let parsed = runner()
1398            .parse_response(raw)
1399            .expect("plain-prose completion text must synthesize successfully");
1400        assert_eq!(parsed.response.status, "done");
1401        assert_eq!(parsed.input_tokens, Some(300));
1402        assert_eq!(parsed.output_tokens, Some(40));
1403    }
1404
1405    #[test]
1406    fn parse_opencode_step_finish_error() {
1407        let raw = r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}
1408{"type":"step_finish","timestamp":1001,"part":{"type":"step-finish","reason":"error","error":"context length exceeded"}}"#;
1409
1410        let err = runner().parse_response(raw).unwrap_err();
1411        assert!(matches!(err, AgentError::ContextOverflow { .. }));
1412    }
1413
1414    #[test]
1415    fn parse_opencode_empty_returns_silent_failure() {
1416        let err = runner().parse_response("").unwrap_err();
1417        // Empty output with exit 0 is treated as silent failure so fallback.rs
1418        // can apply model cooldown and retry with free models
1419        assert!(matches!(err, AgentError::Unknown { exit_code: 0, .. }));
1420    }
1421
1422    #[test]
1423    fn extract_tokens_from_step_finish() {
1424        let events: Vec<serde_json::Value> = vec![
1425            serde_json::json!({"type":"step_finish","part":{"type":"step-finish","tokens":{"input":1000,"output":500,"total":1500}}}),
1426        ];
1427        let (input, output) = OpenCodeRunner::extract_tokens(&events);
1428        assert_eq!(input, Some(1000));
1429        assert_eq!(output, Some(500));
1430    }
1431
1432    #[test]
1433    fn classify_opencode_rate_limit() {
1434        let err = classify_opencode_message("429 Too Many Requests");
1435        assert!(matches!(err, AgentError::RateLimit { .. }));
1436    }
1437
1438    #[test]
1439    fn classify_opencode_model_not_found() {
1440        let err = classify_opencode_message("model not found: gpt-5");
1441        assert!(matches!(err, AgentError::ModelUnavailable { .. }));
1442    }
1443
1444    #[test]
1445    fn classify_opencode_model_not_found_with_suggestion() {
1446        // Issue #1934: opencode returns "Model not found: X. Did you mean: Y?"
1447        // The parser must extract "X", not "X. Did you mean"
1448        let err = classify_opencode_message(
1449            "Model not found: github-copilot/gemini-3.1-pro. Did you mean: gemini-3.1-pro-preview?",
1450        );
1451        match err {
1452            AgentError::ModelUnavailable { model, message } => {
1453                assert_eq!(model, "github-copilot/gemini-3.1-pro");
1454                assert!(message.contains("Did you mean"));
1455            }
1456            other => panic!("expected ModelUnavailable, got: {other:?}"),
1457        }
1458    }
1459
1460    #[test]
1461    fn classify_opencode_model_deprecated() {
1462        // Issue #2228: "The free model has been deprecated." should be ModelUnavailable
1463        // with the model extracted from "Transition to X" so the cooldown key is non-empty.
1464        let err = classify_opencode_message(
1465            "The free model has been deprecated. Transition to qwen/qwen3.6-plus for continued paid access.",
1466        );
1467        match err {
1468            AgentError::ModelUnavailable { model, .. } => {
1469                assert_eq!(
1470                    model, "qwen/qwen3.6-plus",
1471                    "expected model extracted from 'Transition to X', got: {model:?}"
1472                );
1473            }
1474            other => panic!("expected ModelUnavailable, got: {other:?}"),
1475        }
1476    }
1477
1478    #[test]
1479    fn classify_opencode_model_unavailable_for_free() {
1480        // Issue #3475: "This model is unavailable for free ... use this slug instead: X"
1481        // must classify as ModelUnavailable, not fall through to the transient
1482        // network-error path via the "Upstream request failed" substring.
1483        let err = classify_opencode_message(
1484            "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",
1485        );
1486        match err {
1487            AgentError::ModelUnavailable { model, .. } => {
1488                assert_eq!(
1489                    model, "inclusionai/ling-3.0-flash",
1490                    "expected model extracted from 'use this slug instead: X', got: {model:?}"
1491                );
1492            }
1493            other => panic!("expected ModelUnavailable, got: {other:?}"),
1494        }
1495    }
1496
1497    #[test]
1498    fn classify_opencode_model_not_available_in_country() {
1499        // Issue #3535: "This model is not available in your country." must classify as
1500        // ModelUnavailable (persistent per-model cooldown), not fall through to the
1501        // generic AgentFailed bucket which applies a weaker cooldown plus an
1502        // unwarranted agent-wide penalty.
1503        let err =
1504            classify_opencode_message("agent failed: This model is not available in your country.");
1505        match err {
1506            AgentError::ModelUnavailable { .. } => {}
1507            other => panic!("expected ModelUnavailable, got: {other:?}"),
1508        }
1509    }
1510
1511    #[test]
1512    fn classify_opencode_no_endpoints_found() {
1513        // Issue #2228: "No endpoints found for X." should be ModelUnavailable with model extracted
1514        let err = classify_opencode_message("No endpoints found for qwen/qwen3.6-plus:free.");
1515        match err {
1516            AgentError::ModelUnavailable { model, .. } => {
1517                assert_eq!(model, "qwen/qwen3.6-plus:free");
1518            }
1519            other => panic!("expected ModelUnavailable, got: {other:?}"),
1520        }
1521    }
1522
1523    #[test]
1524    fn build_command_opencode() {
1525        let r = runner();
1526        let perms = PermissionRules::default();
1527        let cmd = r.build_command(
1528            Some("anthropic/claude-sonnet-4-20250514"),
1529            "timeout 1800",
1530            "/tmp/sys.txt",
1531            "/tmp/msg.txt",
1532            &perms,
1533        );
1534        assert!(cmd.contains("opencode run"));
1535        assert!(cmd.contains("--model 'anthropic/claude-sonnet-4-20250514'"));
1536        assert!(cmd.contains("--format json"));
1537        assert!(
1538            cmd.starts_with(
1539                r#"OPENCODE_CONFIG_CONTENT='{"snapshot":false}' timeout 1800 opencode run"#
1540            ),
1541            "snapshots must be off for orch runs, got: {cmd}"
1542        );
1543        // Autonomous mode passes --dangerously-skip-permissions instead of
1544        // writing a config file or overriding XDG_CONFIG_HOME.
1545        assert!(
1546            cmd.contains("--dangerously-skip-permissions"),
1547            "expected --dangerously-skip-permissions, got: {cmd}"
1548        );
1549        assert!(
1550            !cmd.contains("opencode.json"),
1551            "should not write a config file, got: {cmd}"
1552        );
1553        assert!(
1554            !cmd.contains("XDG_CONFIG_HOME"),
1555            "should not override XDG_CONFIG_HOME, got: {cmd}"
1556        );
1557        assert!(
1558            !cmd.contains(".orch-opencode"),
1559            "should not reference .orch-opencode, got: {cmd}"
1560        );
1561        // Regression: the command must not start with a `cat sys msg |` pipe
1562        // stage. `build_runner_script` captures the agent's exit status via
1563        // `PIPESTATUS[0]` of `{agent_cmd} | tee ...` — a leading `cat |` would
1564        // make PIPESTATUS[0] always report cat's (always-zero) exit code
1565        // instead of opencode's real exit code. sys/msg files must be fed via
1566        // input redirection instead.
1567        assert!(
1568            !cmd.trim_start().starts_with("cat "),
1569            "opencode command must not start with a `cat |` pipe stage, got: {cmd}"
1570        );
1571        assert!(
1572            cmd.contains("< <(cat \"/tmp/sys.txt\" \"/tmp/msg.txt\")"),
1573            "sys/msg files must be fed via process substitution, got: {cmd}"
1574        );
1575    }
1576
1577    #[test]
1578    fn build_command_opencode_supervised_no_skip_flag() {
1579        let r = runner();
1580        let perms = PermissionRules {
1581            autonomous: false,
1582            ..PermissionRules::default()
1583        };
1584        let cmd = r.build_command(
1585            Some("anthropic/claude-sonnet-4-20250514"),
1586            "timeout 1800",
1587            "/tmp/sys.txt",
1588            "/tmp/msg.txt",
1589            &perms,
1590        );
1591        assert!(
1592            !cmd.contains("--dangerously-skip-permissions"),
1593            "supervised mode must not pass --dangerously-skip-permissions, got: {cmd}"
1594        );
1595    }
1596
1597    #[test]
1598    fn split_model_variant_no_at() {
1599        let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5");
1600        assert_eq!(base, "openai/gpt-5.5");
1601        assert!(variant.is_none());
1602    }
1603
1604    #[test]
1605    fn split_model_variant_with_variant() {
1606        let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5@xhigh");
1607        assert_eq!(base, "openai/gpt-5.5");
1608        assert_eq!(variant, Some("xhigh"));
1609    }
1610
1611    #[test]
1612    fn split_model_variant_trailing_at_empty() {
1613        let (base, variant) = OpenCodeRunner::split_model_variant("openai/gpt-5.5@");
1614        assert_eq!(base, "openai/gpt-5.5");
1615        assert_eq!(variant, Some(""));
1616    }
1617
1618    #[test]
1619    fn split_model_variant_multiple_ats_last_wins() {
1620        let (base, variant) = OpenCodeRunner::split_model_variant("a@b@c@v");
1621        assert_eq!(base, "a@b@c");
1622        assert_eq!(variant, Some("v"));
1623    }
1624
1625    #[test]
1626    fn build_command_opencode_with_variant() {
1627        let r = runner();
1628        let perms = PermissionRules::default();
1629        let cmd = r.build_command(
1630            Some("openai/gpt-5.5@high"),
1631            "timeout 1800",
1632            "/tmp/sys.txt",
1633            "/tmp/msg.txt",
1634            &perms,
1635        );
1636        assert!(cmd.contains("--model 'openai/gpt-5.5'"));
1637        assert!(cmd.contains("--variant 'high'"));
1638    }
1639
1640    #[test]
1641    fn classify_opencode_permission_denied() {
1642        let err = classify_opencode_message("user rejected permission for tool bash: cargo test");
1643        assert!(
1644            matches!(err, AgentError::PermissionDenied { .. }),
1645            "expected PermissionDenied, got: {err:?}"
1646        );
1647    }
1648
1649    #[test]
1650    fn classify_opencode_provider_returned_error() {
1651        // Issue #2478: "Provider returned error" is an opaque upstream failure from the
1652        // opencode CLI. Classify as RateLimit so fallback records a rate-limit signal
1653        // and applies cooldown behavior tuned for transient provider instability.
1654        let err = classify_opencode_message("Provider returned error");
1655        assert!(
1656            matches!(err, AgentError::RateLimit { .. }),
1657            "expected RateLimit, got: {err:?}"
1658        );
1659        let err_with_detail =
1660            classify_opencode_message("Provider returned error: upstream connection timed out");
1661        assert!(
1662            matches!(err_with_detail, AgentError::RateLimit { .. }),
1663            "expected RateLimit, got: {err_with_detail:?}"
1664        );
1665    }
1666
1667    #[test]
1668    fn classify_opencode_upstream_idle_timeout() {
1669        // Issue #3301: "Upstream idle timeout exceeded" must produce NetworkError so
1670        // fallback.rs applies a model-level cooldown instead of retrying immediately.
1671        let err = classify_opencode_message("Upstream idle timeout exceeded");
1672        assert!(
1673            matches!(err, AgentError::NetworkError { .. }),
1674            "expected NetworkError, got: {err:?}"
1675        );
1676    }
1677
1678    #[test]
1679    fn free_models_discovery_returns_vec() {
1680        // discover_free_opencode_models returns empty in test/debug builds
1681        let models = discover_free_opencode_models();
1682        assert!(models.is_empty(), "{models:?}");
1683    }
1684
1685    #[test]
1686    fn live_opencode_discovery_is_skipped_in_test_builds() {
1687        assert!(skip_live_opencode_discovery());
1688        let models = discover_opencode_models();
1689        assert!(models.is_empty(), "{models:?}");
1690        let models = discover_free_opencode_models();
1691        assert!(models.is_empty(), "{models:?}");
1692        prime_free_model_cache();
1693        let models = discover_opencode_models();
1694        assert!(models.is_empty(), "{models:?}");
1695    }
1696
1697    /// The stub stands in for opencode 2.2.1, which starts `opencode serve --service`
1698    /// as a child that outlives it
1699    #[cfg(unix)]
1700    #[tokio::test]
1701    async fn models_cli_reaps_orphaned_process_group() {
1702        let dir = tempfile::tempdir().unwrap();
1703        let sleeper_pid_path = dir.path().join("sleeper.pid");
1704        let script = dir.path().join("opencode");
1705        fs::write(
1706            &script,
1707            format!(
1708                "#!/bin/sh\n/bin/sleep 60 </dev/null >/dev/null 2>&1 &\necho $! > '{}'\necho test-model-free\n",
1709                sleeper_pid_path.display()
1710            ),
1711        )
1712        .unwrap();
1713        fs::set_permissions(&script, Permissions::from_mode(0o755)).unwrap();
1714
1715        // The production discovery timeout, so a loaded machine does not turn this into
1716        // a timeout run, which reaps the group too and would assert nothing
1717        let (models, reason) =
1718            run_opencode_models_cli_inner(script.to_str().unwrap(), Duration::from_secs(30)).await;
1719        assert_eq!(reason, "ok");
1720        assert_eq!(models, vec!["test-model-free".to_string()]);
1721
1722        let sleeper: u32 = fs::read_to_string(&sleeper_pid_path)
1723            .unwrap()
1724            .trim()
1725            .parse()
1726            .expect("sleeper pid");
1727        let deadline = Instant::now() + Duration::from_secs(10);
1728        while pid_is_alive(sleeper) && Instant::now() < deadline {
1729            time::sleep(Duration::from_millis(20)).await;
1730        }
1731        assert!(
1732            !pid_is_alive(sleeper),
1733            "sleeper {sleeper} must die with the process group when opencode models returns"
1734        );
1735    }
1736
1737    #[cfg(unix)]
1738    fn pid_is_alive(pid: u32) -> bool {
1739        // Signal 0 is the POSIX existence probe
1740        i32::try_from(pid).is_ok_and(|pid| super::raw_kill(pid, 0) == 0)
1741    }
1742
1743    #[test]
1744    fn empty_free_discovery_preserves_existing_cache_contents() {
1745        reset_model_caches_for_test();
1746        let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1747        {
1748            let mut guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
1749            *guard = (123, vec!["opencode/model-free".to_string()]);
1750        }
1751
1752        update_discovered_models_cache(cache, Vec::new(), "free", "empty_output");
1753
1754        let guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
1755        // Timestamp must advance on a failed refresh so the TTL provides a
1756        // real backoff window instead of retrying on every subsequent call.
1757        assert!(guard.0 > 123);
1758        assert_eq!(guard.1, vec!["opencode/model-free".to_string()]);
1759    }
1760
1761    #[test]
1762    fn empty_all_models_discovery_preserves_existing_cache_contents() {
1763        reset_model_caches_for_test();
1764        let cache = ALL_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1765        {
1766            let mut guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
1767            *guard = (
1768                456,
1769                vec![
1770                    "opencode/model-free".to_string(),
1771                    "opencode/model-paid".to_string(),
1772                ],
1773            );
1774        }
1775
1776        update_discovered_models_cache(cache, Vec::new(), "all", "exit_status_failure");
1777
1778        let guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
1779        assert!(guard.0 > 456);
1780        assert_eq!(
1781            guard.1,
1782            vec![
1783                "opencode/model-free".to_string(),
1784                "opencode/model-paid".to_string(),
1785            ]
1786        );
1787    }
1788
1789    #[test]
1790    fn empty_discovery_populates_empty_cache() {
1791        reset_model_caches_for_test();
1792        let cache = FREE_MODELS_CACHE.get_or_init(|| Mutex::new((0, Vec::new())));
1793
1794        update_discovered_models_cache(cache, Vec::new(), "free", "command_not_found");
1795
1796        let guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
1797        assert!(guard.0 > 0);
1798        assert!(guard.1.is_empty(), "{:?}", guard.1);
1799    }
1800
1801    // ── Fixture-based tests ─────────────────────────────────────
1802
1803    #[test]
1804    fn fixture_opencode_success() {
1805        let raw = include_str!("../../../../tests/fixtures/opencode_success.jsonl");
1806        let parsed = runner().parse_response(raw).unwrap();
1807        assert_eq!(parsed.response.status, "done");
1808        assert!(parsed.response.summary.contains("Fixed"));
1809        assert_eq!(parsed.response.accomplished.len(), 2);
1810        assert_eq!(parsed.input_tokens, Some(17500));
1811        assert_eq!(parsed.output_tokens, Some(500));
1812    }
1813
1814    /// Real failure: OpenCode rejects a tool permission in non-interactive mode.
1815    /// The error has `{"error":{"name":"PermissionError","data":{"message":"user rejected permission..."}}}`.
1816    /// Parser must extract the nested message and classify as PermissionDenied.
1817    #[test]
1818    fn fixture_opencode_permission_denied() {
1819        let raw = include_str!("../../../../tests/fixtures/opencode_permission_denied.jsonl");
1820        let err = runner().parse_response(raw).unwrap_err();
1821        assert!(
1822            matches!(err, AgentError::PermissionDenied { .. }),
1823            "expected PermissionDenied, got: {err:?}"
1824        );
1825    }
1826
1827    /// Real failure: OpenCode rate limit via top-level message field.
1828    #[test]
1829    fn fixture_opencode_rate_limit() {
1830        let raw = include_str!("../../../../tests/fixtures/opencode_rate_limit.jsonl");
1831        let err = runner().parse_response(raw).unwrap_err();
1832        assert!(
1833            matches!(err, AgentError::RateLimit { .. }),
1834            "expected RateLimit, got: {err:?}"
1835        );
1836    }
1837
1838    /// Real failure: OpenCode returns nested error for unknown model.
1839    /// The error object is `{"name":"UnknownError","data":{"message":"Model not found: X"}}`.
1840    /// Parser must extract the nested message and classify as ModelUnavailable.
1841    #[test]
1842    fn fixture_opencode_model_not_found() {
1843        let raw = include_str!("../../../../tests/fixtures/opencode_model_not_found.jsonl");
1844        let err = runner().parse_response(raw).unwrap_err();
1845        assert!(
1846            matches!(err, AgentError::ModelUnavailable { .. }),
1847            "expected ModelUnavailable, got: {err:?}"
1848        );
1849        if let AgentError::ModelUnavailable { model, .. } = &err {
1850            assert_eq!(model, "anthropic/claude-sonnet-4-6");
1851        }
1852    }
1853
1854    /// Real failure: OpenCode returns "Provider returned error" as an opaque upstream
1855    /// provider failure. Classified as RateLimit so degraded/rate-limit tracking applies.
1856    #[test]
1857    fn fixture_opencode_provider_returned_error() {
1858        let raw = include_str!("../../../../tests/fixtures/opencode_provider_returned_error.jsonl");
1859        let err = runner().parse_response(raw).unwrap_err();
1860        assert!(
1861            matches!(err, AgentError::RateLimit { .. }),
1862            "expected RateLimit, got: {err:?}"
1863        );
1864    }
1865
1866    // ── extract_text ─────────────────────────────────────────────
1867
1868    /// OpenCode: extract_text concatenates text events for review parsing.
1869    #[test]
1870    fn extract_text_returns_text_events() {
1871        let raw = concat!(
1872            r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1873            "\n",
1874            r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}"}}"#,
1875            "\n",
1876            r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":100,"input":90,"output":10}}}"#,
1877        );
1878        let text = runner().extract_text(raw).unwrap();
1879        assert_eq!(
1880            text,
1881            r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#
1882        );
1883    }
1884
1885    /// OpenCode: extract_text with newer direct-text format.
1886    #[test]
1887    fn extract_text_direct_text_event_format() {
1888        let raw = concat!(
1889            r#"{"type":"step_start","timestamp":1000,"sessionID":"ses_abc"}"#,
1890            "\n",
1891            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"}"#,
1892            "\n",
1893            r#"{"type":"step_finish","timestamp":1002,"sessionID":"ses_abc"}"#,
1894        );
1895        let text = runner().extract_text(raw).unwrap();
1896        assert!(
1897            text.contains("request_changes"),
1898            "expected review JSON, got: {text}"
1899        );
1900    }
1901
1902    /// OpenCode: extract_text propagates RateLimit for terminal errors.
1903    #[test]
1904    fn extract_text_rate_limit_propagates() {
1905        let raw = concat!(
1906            r#"{"type":"step_start","timestamp":1000}"#,
1907            "\n",
1908            r#"{"type":"error","message":"rate limit exceeded: 429 Too Many Requests"}"#,
1909        );
1910        let err = runner().extract_text(raw).unwrap_err();
1911        assert!(
1912            matches!(err, AgentError::RateLimit { .. }),
1913            "expected RateLimit, got: {err:?}"
1914        );
1915    }
1916
1917    /// OpenCode: extract_text propagates Auth error.
1918    #[test]
1919    fn extract_text_auth_error_propagates() {
1920        let raw = concat!(
1921            r#"{"type":"step_start","timestamp":1000}"#,
1922            "\n",
1923            r#"{"type":"error","message":"401 Unauthorized: invalid api key"}"#,
1924        );
1925        let err = runner().extract_text(raw).unwrap_err();
1926        assert!(
1927            matches!(err, AgentError::Auth { .. }),
1928            "expected Auth, got: {err:?}"
1929        );
1930    }
1931
1932    /// OpenCode: extract_text on empty input returns empty string (not an error).
1933    #[test]
1934    fn extract_text_empty_input_returns_empty_ok() {
1935        let text = runner().extract_text("").unwrap();
1936        assert_eq!(text, "");
1937    }
1938
1939    // ── find_opencode_result ────────────────────────────────────
1940
1941    #[test]
1942    fn find_opencode_result_success_with_tokens() {
1943        let ndjson = concat!(
1944            r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1945            "\n",
1946            r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\"}"}}"#,
1947            "\n",
1948            r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","cost":0.05,"tokens":{"input":5000,"output":200}}}"#,
1949        );
1950        let result = find_opencode_result(ndjson).expect("should find result");
1951        assert!(!result.is_error);
1952        assert!(result.result_text.contains("approve"));
1953        assert_eq!(result.input_tokens, Some(5000));
1954        assert_eq!(result.output_tokens, Some(200));
1955        assert_eq!(result.cost_usd, Some(0.05));
1956        assert!(!result.truncated_by_length);
1957    }
1958
1959    #[test]
1960    fn find_opencode_result_error_event() {
1961        let ndjson = r#"{"type":"error","message":"rate limit exceeded: 429"}"#;
1962        let result = find_opencode_result(ndjson).expect("should find error result");
1963        assert!(result.is_error);
1964        assert!(result.result_text.contains("rate limit"));
1965        assert!(!result.truncated_by_length);
1966    }
1967
1968    /// step_finish reason=length signals the model was cut off by its
1969    /// output/reasoning token budget, not malformed output — the flag must
1970    /// be set so callers avoid the persistent-format-failure cooldown.
1971    #[test]
1972    fn find_opencode_result_length_truncation() {
1973        let ndjson = concat!(
1974            r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1975            "\n",
1976            r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"{\"decision\":"}}"#,
1977            "\n",
1978            r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"length","tokens":{"input":5485,"output":759,"reasoning":31241}}}"#,
1979        );
1980        let result = find_opencode_result(ndjson).expect("should find result");
1981        assert!(result.truncated_by_length);
1982        assert!(!result.is_error, "length truncation is not a hard error");
1983    }
1984
1985    /// A reasoning model can burn its entire budget on hidden reasoning and
1986    /// emit zero text before step_finish reason=length. The old early return
1987    /// on empty result_text bailed out with None before truncated_by_length
1988    /// was ever computed, so this got misclassified as parse_error and hit
1989    /// the 4h-7d persistent-model cooldown instead of standard backoff.
1990    #[test]
1991    fn find_opencode_result_zero_output_length_truncation() {
1992        let ndjson = concat!(
1993            r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
1994            "\n",
1995            r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"length","tokens":{"total":71482,"input":39482,"output":0,"reasoning":40000}}}"#,
1996        );
1997        let result = find_opencode_result(ndjson).expect("should find result, not None");
1998        assert!(result.truncated_by_length);
1999        assert!(!result.is_error);
2000        assert_eq!(result.result_text, "");
2001    }
2002
2003    /// opencode also emits reason="unknown" with all-zero tokens for the same
2004    /// zero-output failure mode — must be treated as an equivalent signal.
2005    #[test]
2006    fn find_opencode_result_zero_output_unknown_reason_truncation() {
2007        let ndjson = concat!(
2008            r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
2009            "\n",
2010            r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"unknown","tokens":{"input":0,"output":0,"reasoning":0}}}"#,
2011        );
2012        let result = find_opencode_result(ndjson).expect("should find result, not None");
2013        assert!(result.truncated_by_length);
2014        assert!(!result.is_error);
2015        assert_eq!(result.result_text, "");
2016    }
2017
2018    /// reason="unknown" with nonzero output tokens means the model did
2019    /// produce something and the step just wasn't cleanly terminated — not
2020    /// the same "wrote nothing" signal, so it must not be flagged.
2021    #[test]
2022    fn find_opencode_result_unknown_reason_with_output_not_truncated() {
2023        let ndjson = concat!(
2024            r#"{"type":"step_start","timestamp":1000,"part":{"type":"step-start"}}"#,
2025            "\n",
2026            r#"{"type":"text","timestamp":1001,"part":{"type":"text","text":"partial answer"}}"#,
2027            "\n",
2028            r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"unknown","tokens":{"input":100,"output":50,"reasoning":0}}}"#,
2029        );
2030        let result = find_opencode_result(ndjson).expect("should find result");
2031        assert!(!result.truncated_by_length);
2032        assert!(!result.is_error);
2033    }
2034
2035    #[test]
2036    fn find_opencode_result_empty_returns_none() {
2037        assert!(find_opencode_result("").is_none());
2038        assert!(find_opencode_result("   ").is_none());
2039    }
2040
2041    #[test]
2042    fn find_opencode_result_direct_text_format() {
2043        let ndjson = concat!(
2044            r#"{"type":"step_start","timestamp":1000}"#,
2045            "\n",
2046            r#"{"type":"text","timestamp":1001,"text":"Review complete. All tests passed."}"#,
2047            "\n",
2048            r#"{"type":"step_finish","timestamp":1002,"part":{"type":"step-finish","reason":"stop","tokens":{"input":100,"output":10}}}"#,
2049        );
2050        let result = find_opencode_result(ndjson).expect("should find result");
2051        assert!(!result.is_error);
2052        assert!(result.result_text.contains("All tests passed"));
2053    }
2054
2055    #[test]
2056    fn find_opencode_result_plain_text_returns_none() {
2057        assert!(find_opencode_result("just some plain text").is_none());
2058    }
2059}
2060
2061#[cfg(test)]
2062mod error_text_tests {
2063    use super::*;
2064    use serde_json::json;
2065
2066    #[test]
2067    fn unrecognized_error_shape_keeps_raw_json() {
2068        let e = json!({"type":"error","error":{"code":429,"detail":"slow down"}});
2069        assert!(opencode_error_text(&e).contains("slow down"));
2070        let e = json!({"type":"error"});
2071        assert!(opencode_error_text(&e).contains("\"type\""));
2072    }
2073
2074    #[test]
2075    fn known_shapes_still_extract() {
2076        assert_eq!(opencode_error_text(&json!({"message":"a"})), "a");
2077        assert_eq!(opencode_error_text(&json!({"error":"b"})), "b");
2078        assert_eq!(opencode_error_text(&json!({"error":{"message":"c"}})), "c");
2079        assert_eq!(
2080            opencode_error_text(&json!({"error":{"name":"N","data":{"message":"d"}}})),
2081            "d"
2082        );
2083    }
2084}
2085
2086/// OpenCode `--format json`: a `tool_use` event carries both the call and its finished state
2087fn stream_events(v: &serde_json::Value) -> Vec<StreamEvent> {
2088    let text = || {
2089        v.pointer("/part/text")
2090            .or_else(|| v.get("text"))
2091            .and_then(serde_json::Value::as_str)
2092            .unwrap_or("")
2093            .to_string()
2094    };
2095    match json_str(v, "/type") {
2096        "text" => vec![StreamEvent::Text(text())],
2097        "reasoning" => vec![StreamEvent::Thinking(text())],
2098        "tool_use" => {
2099            let part = &v["part"];
2100            let state = &part["state"];
2101            let id = json_str(part, "/callID").to_string();
2102            let name = json_str(part, "/tool").to_string();
2103            let mut out = vec![StreamEvent::ToolCall {
2104                id: id.clone(),
2105                name: name.clone(),
2106                input: state["input"].clone(),
2107            }];
2108            let result = match json_str(state, "/status") {
2109                "completed" => Some((json_str(state, "/output").to_string(), false)),
2110                "error" => Some((json_error_message(state), true)),
2111                _ => None,
2112            };
2113            if let Some((output, is_error)) = result {
2114                out.push(StreamEvent::ToolResult {
2115                    id,
2116                    name,
2117                    output,
2118                    is_error,
2119                });
2120            }
2121
2122            out
2123        }
2124        "step_finish" if json_str(v, "/part/reason") == "stop" => {
2125            vec![StreamEvent::Done(String::new())]
2126        }
2127        "error" => vec![StreamEvent::Error(json_error_message(v))],
2128        _ => vec![],
2129    }
2130}
2131
2132#[cfg(test)]
2133mod stream_tests {
2134    use super::*;
2135    use serde_json::json;
2136
2137    fn events(line: &str) -> Vec<StreamEvent> {
2138        OpenCodeRunner::new().stream_events(&serde_json::from_str(line).unwrap())
2139    }
2140
2141    #[test]
2142    fn text_in_part_or_top_level() {
2143        assert_eq!(
2144            events(r#"{"type":"text","part":{"type":"text","text":"Reviewing."}}"#),
2145            vec![StreamEvent::Text("Reviewing.".into())]
2146        );
2147        assert_eq!(
2148            events(r#"{"type":"text","text":"Plain."}"#),
2149            vec![StreamEvent::Text("Plain.".into())]
2150        );
2151    }
2152
2153    #[test]
2154    fn completed_tool_has_call_and_result() {
2155        let line = r#"{"type":"tool_use","part":{"type":"tool","tool":"read","callID":"c1","state":{"status":"completed","input":{"filePath":"src/lib.rs"},"output":"a\nb"}}}"#;
2156        assert_eq!(
2157            events(line),
2158            vec![
2159                StreamEvent::ToolCall {
2160                    id: "c1".into(),
2161                    name: "read".into(),
2162                    input: json!({"filePath": "src/lib.rs"}),
2163                },
2164                StreamEvent::ToolResult {
2165                    id: "c1".into(),
2166                    name: "read".into(),
2167                    output: "a\nb".into(),
2168                    is_error: false,
2169                },
2170            ]
2171        );
2172    }
2173
2174    #[test]
2175    fn failed_tool_result_is_error() {
2176        let line = r#"{"type":"tool_use","part":{"tool":"bash","callID":"c2","state":{"status":"error","input":{"command":"x"},"error":"not found"}}}"#;
2177        assert_eq!(
2178            events(line)[1],
2179            StreamEvent::ToolResult {
2180                id: "c2".into(),
2181                name: "bash".into(),
2182                output: "not found".into(),
2183                is_error: true,
2184            }
2185        );
2186    }
2187
2188    #[test]
2189    fn only_final_step_is_done() {
2190        let dropped = events(r#"{"type":"step_finish","part":{"reason":"tool-calls"}}"#);
2191        assert!(dropped.is_empty(), "{dropped:?}");
2192        assert_eq!(
2193            events(r#"{"type":"step_finish","part":{"reason":"stop"}}"#),
2194            vec![StreamEvent::Done(String::new())]
2195        );
2196        let dropped = events(r#"{"type":"step_start"}"#);
2197        assert!(dropped.is_empty(), "{dropped:?}");
2198    }
2199
2200    #[test]
2201    fn error_event() {
2202        let line = r#"{"type":"error","error":{"name":"UnknownError","data":{"message":"Provider returned error"}}}"#;
2203        assert_eq!(
2204            events(line),
2205            vec![StreamEvent::Error("Provider returned error".into())]
2206        );
2207    }
2208}