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