Skip to main content

orch/engine/runner/agents/
opencode.rs

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