Skip to main content

orch/engine/runner/agents/
claude.rs

1//! Claude-compatible agent runner.
2//!
3//! Handles Claude, Kimi, and MiniMax agents. All three use the same
4//! `claude` binary (or a shell wrapper that sets `ANTHROPIC_BASE_URL`).
5//!
6//! ## CLI invocation
7//!
8//! ```bash
9//! claude -p --model {model} \
10//!   --permission-mode bypassPermissions \
11//!   --output-format stream-json \
12//!   --disallowedTools '...' \
13//!   --append-system-prompt "{sys_file}" \
14//!   < "{msg_file}"
15//! ```
16//!
17//! ## Output format (`--output-format stream-json`)
18//!
19//! NDJSON event stream; the final event is always `"type": "result"`:
20//! ```jsonl
21//! {"type":"system","subtype":"init",...}
22//! {"type":"assistant","message":{...}}
23//! {"type":"tool_use","tool":{...}}
24//! {"type":"tool_result","result":{...}}
25//! {"type":"result","subtype":"success","is_error":false,"duration_ms":2006,"result":"...","usage":{"input_tokens":10,"output_tokens":78},"modelUsage":{...}}
26//! ```
27//!
28//! Backward compatible with `--output-format json` (single JSON blob) — the
29//! parser finds the last `"type":"result"` line whether it is NDJSON or a
30//! pretty-printed single object.
31//!
32//! ## Kimi/MiniMax wrappers
33//!
34//! Both are shell scripts that `exec` the claude binary with custom env:
35//! ```bash
36//! exec env ANTHROPIC_BASE_URL=... ANTHROPIC_API_KEY=... claude "$@"
37//! ```
38//!
39//! ## Known error patterns
40//!
41//! - `is_error: true` with various message strings
42//! - "credit balance too low" — billing/auth
43//! - "overloaded" — capacity / rate limit (HTTP 529)
44//! - "rate limit" / "429" — standard rate limiting
45//! - "context_length_exceeded" — context overflow
46
47#[cfg(test)]
48use super::SandboxLevel;
49use super::{
50    json_error_message, json_str, AgentError, AgentRunner, ParsedResponse, PermissionRules,
51    StreamEvent,
52};
53use crate::parser;
54
55/// Runner for Claude-compatible agents (Claude, Kimi, MiniMax).
56pub struct ClaudeRunner {
57    /// The agent binary name (claude, kimi, minimax, or custom).
58    binary: String,
59}
60
61impl ClaudeRunner {
62    pub fn new(agent_name: &str) -> Self {
63        Self {
64            binary: agent_name.to_string(),
65        }
66    }
67
68    /// Parse the Claude `--output-format json` envelope.
69    ///
70    /// Extracts `result`, `usage`, and `is_error`.
71    fn parse_envelope(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
72        let parsed_json: serde_json::Value = serde_json::from_str(raw).map_err(|_| {
73            // Not valid JSON at all — try as raw text
74            AgentError::InvalidResponse {
75                raw: raw.to_string(),
76            }
77        })?;
78
79        let envelope = parsed_json
80            .as_object()
81            .ok_or_else(|| AgentError::InvalidResponse {
82                raw: raw.to_string(),
83            })?;
84
85        // Check if this is a Claude envelope (has "type" field)
86        let is_envelope = envelope.get("type").and_then(|v| v.as_str()) == Some("result");
87
88        if !is_envelope {
89            // Not a Claude envelope — try direct parsing via generic parser
90            return self.parse_direct(raw);
91        }
92
93        // Check is_error flag
94        let is_error = envelope
95            .get("is_error")
96            .and_then(|v| v.as_bool())
97            .unwrap_or(false);
98
99        // Extract the result text
100        let error_text = if is_error {
101            envelope_error_text(envelope)
102        } else {
103            String::new()
104        };
105        let result_text = if is_error {
106            error_text.as_str()
107        } else {
108            envelope
109                .get("result")
110                .and_then(|v| v.as_str())
111                .unwrap_or("")
112        };
113
114        if is_error {
115            // Classify the error from the result text.
116            // Check stale session first — it requires a targeted recovery action
117            // (reset UUID + retry) that is distinct from generic agent failures.
118            if let Some(e) = super::patterns::detect_stale_session(result_text) {
119                return Err(e);
120            }
121            // Check thinking block conflict before auth — it's a specific 400
122            // that must not be counted as a model failure.
123            if let Some(e) = super::patterns::detect_thinking_block_conflict(result_text) {
124                return Err(e);
125            }
126            // Check auth before rate_limit — billing errors (credit balance)
127            // are auth issues, not transient rate limits.
128            // Use result_text only (not combined with raw JSON) so the error
129            // message stays clean — the raw NDJSON stats blob must not leak
130            // into the logged reason string.
131            if let Some(e) = super::patterns::detect_auth_error(result_text) {
132                return Err(e);
133            }
134            if let Some(e) = super::patterns::detect_rate_limit(result_text) {
135                return Err(e);
136            }
137            if let Some(e) = super::patterns::detect_context_overflow(result_text) {
138                return Err(e);
139            }
140            return Err(AgentError::AgentFailed {
141                message: result_text.to_string(),
142            });
143        }
144
145        // Extract token usage from top-level `usage` object
146        let (input_tokens, output_tokens) = extract_usage(envelope);
147
148        // Extract duration
149        let duration_ms = envelope.get("duration_ms").and_then(|v| v.as_u64());
150
151        // Parse the inner result text into AgentResponse.
152        // Do NOT check for rate limits here — result_text is agent work product
153        // (summary, code descriptions) when is_error=false. Rate-limit keywords
154        // in work product cause false positives (see issue #1292).
155        let response = match parser::parse(result_text) {
156            Ok(r) => r,
157            Err(_) => {
158                // Structured parse failed — try plain-text synthesis.
159                // Preserve already-extracted tokens rather than discarding them (issue #1387).
160                super::synthesize_response_from_text(result_text).ok_or_else(|| {
161                    AgentError::InvalidResponse {
162                        raw: result_text.to_string(),
163                    }
164                })?
165            }
166        };
167
168        Ok(ParsedResponse {
169            response,
170            input_tokens,
171            output_tokens,
172            duration_ms,
173        })
174    }
175
176    /// Parse raw text directly (non-envelope response).
177    fn parse_direct(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
178        let response = parser::parse(raw).map_err(|_| AgentError::InvalidResponse {
179            raw: raw.to_string(),
180        })?;
181
182        Ok(ParsedResponse {
183            response,
184            input_tokens: None,
185            output_tokens: None,
186            duration_ms: None,
187        })
188    }
189}
190
191/// Error text of an `is_error` envelope: `result`, else the joined `errors[]`, else `subtype`.
192/// Some agents (e.g. billing failures) put the message only in `errors[]`
193fn envelope_error_text(obj: &serde_json::Map<String, serde_json::Value>) -> String {
194    let result = obj.get("result").and_then(|v| v.as_str()).unwrap_or("");
195    if !result.is_empty() {
196        return result.to_string();
197    }
198    let errors = obj
199        .get("errors")
200        .and_then(|v| v.as_array())
201        .map(|a| {
202            a.iter()
203                .filter_map(|e| e.as_str())
204                .collect::<Vec<_>>()
205                .join("\n")
206        })
207        .unwrap_or_default();
208    if !errors.is_empty() {
209        return errors;
210    }
211    obj.get("subtype")
212        .and_then(|v| v.as_str())
213        .unwrap_or("")
214        .to_string()
215}
216
217/// Helper: extract error from a JSON object if it is a `type:result` envelope
218/// with `is_error=true`. Uses pattern detection for proper error classification
219/// (auth, rate limit, context overflow) rather than always returning AgentFailed.
220fn extract_error_from_envelope(
221    obj: &serde_json::Map<String, serde_json::Value>,
222    _raw: &str,
223) -> Option<AgentError> {
224    if obj.get("type").and_then(|v| v.as_str()) == Some("result")
225        && obj
226            .get("is_error")
227            .and_then(|v| v.as_bool())
228            .unwrap_or(false)
229    {
230        let result_text = envelope_error_text(obj);
231        let result_text = result_text.as_str();
232        // Use result_text only (not combined with raw JSON) so the error
233        // message stays clean — the raw NDJSON stats blob must not leak
234        // into the logged reason string.
235        if let Some(e) = super::patterns::detect_thinking_block_conflict(result_text) {
236            return Some(e);
237        }
238        if let Some(e) = super::patterns::detect_auth_error(result_text) {
239            return Some(e);
240        }
241        if let Some(e) = super::patterns::detect_rate_limit(result_text) {
242            return Some(e);
243        }
244        if let Some(e) = super::patterns::detect_context_overflow(result_text) {
245            return Some(e);
246        }
247        return Some(AgentError::AgentFailed {
248            message: result_text.to_string(),
249        });
250    }
251    None
252}
253
254/// Helper: scan NDJSON lines in `text` for a `type:result` envelope with
255/// `is_error=true` and extract the error message. Returns `None` if no error
256/// envelope is found.
257fn detect_error_envelope(text: &str) -> Option<AgentError> {
258    for line in text.lines().filter(|l| !l.trim().is_empty()) {
259        if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
260            if let Some(obj) = val.as_object() {
261                if let Some(err) = extract_error_from_envelope(obj, line) {
262                    return Some(err);
263                }
264            }
265        }
266    }
267    None
268}
269
270pub(crate) fn extract_stream_json_result_text(raw: &str) -> Result<String, AgentError> {
271    let trimmed = raw.trim();
272    if trimmed.is_empty() {
273        return Err(AgentError::InvalidResponse { raw: String::new() });
274    }
275
276    // Prefer the final result envelope whose result field is valid JSON.
277    // This skips outer-session plain-text summaries (issue #1387).
278    let envelope_line_opt = find_ndjson_result_line(trimmed).map(|(line, _)| line);
279
280    if let Some(line) = envelope_line_opt {
281        // Parse the found result line.
282        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(line) {
283            if let Some(obj) = parsed.as_object() {
284                // Check is_error FIRST.
285                if let Some(err) = extract_error_from_envelope(obj, line) {
286                    return Err(err);
287                }
288                // Extract result text from the JSON object.
289                if let Some(result) = obj.get("result") {
290                    if let Some(text) = result.as_str() {
291                        return Ok(text.to_string());
292                    }
293                    if result.is_object() || result.is_array() {
294                        return Ok(result.to_string());
295                    }
296                }
297            }
298        }
299        // Not an error, but result field is not extractable: fall through to fallback.
300    }
301
302    // No JSON result line found (or it wasn't an error). Try opencode-style
303    // text extraction which scans NDJSON for text/result events.
304    if let Some(fallback) = crate::engine::runner::agents::opencode::extract_router_text(trimmed) {
305        return Ok(fallback);
306    }
307
308    // No opencode-style text found. Try parsing the whole trimmed string as a
309    // single JSON object (handles `--output-format json` plain output).
310    if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
311        if let Some(obj) = parsed.as_object() {
312            // Check for error envelope in the single-line JSON.
313            if let Some(err) = extract_error_from_envelope(obj, trimmed) {
314                return Err(err);
315            }
316            // Extract result text from the JSON object.
317            if let Some(result) = obj.get("result") {
318                if let Some(text) = result.as_str() {
319                    return Ok(text.to_string());
320                }
321                if result.is_object() || result.is_array() {
322                    return Ok(result.to_string());
323                }
324            }
325            return Ok(trimmed.to_string());
326        }
327    }
328
329    // Not valid JSON at all. Before returning raw text, scan for error envelopes
330    // (handles the case where find_ndjson_result_line skipped error-only output).
331    if let Some(err) = detect_error_envelope(trimmed) {
332        return Err(err);
333    }
334
335    Ok(trimmed.to_string())
336}
337
338impl AgentRunner for ClaudeRunner {
339    #[cfg(test)]
340    fn name(&self) -> &str {
341        &self.binary
342    }
343
344    fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent> {
345        stream_events(event)
346    }
347
348    fn router_transcript(&self) -> super::RouterTranscript {
349        super::RouterTranscript::Messages
350    }
351
352    fn extract_text(&self, raw: &str) -> Result<String, super::AgentError> {
353        // Empty input: return empty string (not an error), matching historical behavior.
354        if raw.trim().is_empty() {
355            return Ok(String::new());
356        }
357        extract_stream_json_result_text(raw)
358    }
359
360    fn build_command(
361        &self,
362        model: Option<&str>,
363        timeout_cmd: &str,
364        sys_file: &str,
365        msg_file: &str,
366        permissions: &PermissionRules,
367    ) -> String {
368        let model_flag = model
369            .map(str::trim)
370            .filter(|m| !m.is_empty())
371            .map(|m| format!("--model {}", super::shell_single_quote(m)))
372            .unwrap_or_default();
373
374        // Claude permission mode: autonomous → bypassPermissions, supervised → acceptEdits
375        let permission_mode = if permissions.autonomous {
376            "bypassPermissions"
377        } else {
378            "acceptEdits"
379        };
380
381        // Build tool restriction flags.
382        // If allowed_tools is set (whitelist), use --allowedTools and ignore disallowed_tools.
383        // Otherwise fall back to --disallowedTools (deny list).
384        let tool_flag = if !permissions.allowed_tools.is_empty() {
385            let tools = translate_allowed_tools(
386                &permissions.allowed_tools,
387                &permissions.allowed_edit_paths,
388            );
389            format!("--allowedTools '{}'", tools.join(","))
390        } else if !permissions.disallowed_tools.is_empty() {
391            format!(
392                "--disallowedTools '{}'",
393                permissions.disallowed_tools.join(",")
394            )
395        } else {
396            String::new()
397        };
398
399        format!(
400            r#"{timeout_cmd} {binary} -p --verbose {model_flag} \
401  --permission-mode {permission_mode} \
402  --output-format stream-json \
403  {tool_flag} \
404  --append-system-prompt "{sys_file}" \
405  < "{msg_file}""#,
406            timeout_cmd = timeout_cmd,
407            binary = self.binary,
408            model_flag = model_flag,
409            permission_mode = permission_mode,
410            tool_flag = tool_flag,
411            sys_file = sys_file,
412            msg_file = msg_file,
413        )
414    }
415
416    fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
417        let trimmed = raw.trim();
418        if trimmed.is_empty() {
419            return Err(AgentError::InvalidResponse { raw: String::new() });
420        }
421
422        // For NDJSON (--output-format stream-json): find the best result line and its
423        // token count. For single-line JSON (--output-format json), returns None.
424        if let Some((result_line, tokens)) = find_ndjson_result_line(trimmed) {
425            let result = self.parse_envelope(result_line)?;
426            // If the envelope parse preserved meaningful tokens, use those.
427            // Otherwise, fall back to the tokens from find_ndjson_result_line
428            // (which selected the best result line by token count — issue #1387).
429            let input_tokens = result.input_tokens.or(tokens.map(|(i, _)| i));
430            let output_tokens = result.output_tokens.or(tokens.map(|(_, o)| o));
431            if input_tokens != result.input_tokens || output_tokens != result.output_tokens {
432                return Ok(ParsedResponse {
433                    input_tokens,
434                    output_tokens,
435                    ..result
436                });
437            }
438            return Ok(result);
439        }
440
441        // Fallback: treat the whole string as the envelope (e.g. pretty-printed JSON)
442        self.parse_envelope(trimmed)
443    }
444
445    fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
446        // For claude-protocol agents the real error string lives in the `result`
447        // field of the NDJSON result line (when is_error=true). Prepend it to the
448        // combined text so the classifier sees it before the raw metadata tail.
449        let error_result: Option<String> = (|| {
450            let trimmed = stdout.trim();
451            let (line, _) = find_ndjson_result_line(trimmed)?;
452            let val: serde_json::Value = serde_json::from_str(line).ok()?;
453            let obj = val.as_object()?;
454            if obj.get("is_error").and_then(|v| v.as_bool()) != Some(true) {
455                return None;
456            }
457            Some(envelope_error_text(obj)).filter(|t| !t.is_empty())
458        })();
459
460        // Also check for system-level error events (e.g., session limit mid-stream).
461        // These appear as `type:system` NDJSON events with an error object, not as
462        // `type:result` lines with `is_error:true`. This catches errors like session
463        // limits, credential issues, and API errors that interrupt the agent mid-run.
464        //
465        // Example:
466        //   {"type":"system","subtype":"error","error":{"type":"session_limit","message":"..."}}
467        let system_error: Option<String> = (|| {
468            let trimmed = stdout.trim();
469            for line in trimmed.lines() {
470                let line = line.trim();
471                if line.is_empty() {
472                    continue;
473                }
474                if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
475                    if let Some(obj) = val.as_object() {
476                        if obj.get("type").and_then(|t| t.as_str()) == Some("system") {
477                            if let Some(error) = obj.get("error") {
478                                if let Some(msg) = error.get("message").and_then(|m| m.as_str()) {
479                                    return Some(msg.to_string());
480                                }
481                            }
482                        }
483                    }
484                }
485            }
486            None
487        })();
488
489        // A kill signal (SIGTERM or SIGKILL) with no result envelope and no error event
490        // is an interrupted run, not a provider failure, so skip the text scan
491        let has_result_line = find_ndjson_result_line(stdout.trim()).is_some();
492        if matches!(exit_code, 143 | 137)
493            && !has_result_line
494            && error_result.is_none()
495            && system_error.is_none()
496        {
497            return AgentError::Interrupted {
498                message: format!("agent killed by signal (exit {exit_code})"),
499            };
500        }
501
502        let combined = match (&error_result, &system_error) {
503            (Some(r), Some(s)) => format!("{s}\n{r}\n{stdout}\n{stderr}"),
504            (Some(r), None) => format!("{r}\n{stdout}\n{stderr}"),
505            (None, Some(s)) => format!("{s}\n{stdout}\n{stderr}"),
506            (None, None) => format!("{stdout}\n{stderr}"),
507        };
508        // Envelope billing text is prepended into the same 3000-byte head as hook
509        // UUIDs, so classify the envelope before the combined scan
510        if let Some(message) = &error_result {
511            if let Some(e) = super::patterns::detect_auth_error(message) {
512                return e;
513            }
514        }
515        let classified = super::patterns::classify_from_text(exit_code, &combined);
516
517        // `classify_from_text`'s generic catch-all truncates to the last 300
518        // bytes of the combined text, which can cut a perfectly well-formed
519        // `error_result` line off mid-field-name (e.g. `is_error` becomes
520        // `i_error`) once enough NDJSON precedes it (issue #3577). When we
521        // already extracted a clean, complete error message from the result
522        // envelope but none of the specific pattern detectors matched it,
523        // prefer that clean message over the byte-truncated fallback.
524        if let (AgentError::Unknown { .. }, Some(message)) = (&classified, &error_result) {
525            if !message.is_empty() {
526                return AgentError::AgentFailed {
527                    message: message.clone(),
528                };
529            }
530        }
531        classified
532    }
533
534    fn router_command(
535        &self,
536        prompt: &str,
537        model: Option<&str>,
538    ) -> anyhow::Result<tokio::process::Command> {
539        let mut cmd = tokio::process::Command::new(&self.binary);
540        cmd.env_remove("CLAUDECODE"); // allow nested invocation
541        cmd.arg("--output-format")
542            .arg("stream-json")
543            .arg("--verbose")
544            .arg("--print");
545        if let Some(m) = model.map(str::trim).filter(|m| !m.is_empty()) {
546            cmd.arg("--model").arg(m);
547        }
548        cmd.arg(prompt);
549        Ok(cmd)
550    }
551}
552
553/// Known Claude Code native tool names.
554/// These are passed as-is to `--allowedTools`.
555const CLAUDE_NATIVE_TOOLS: &[&str] = &[
556    "Edit",
557    "Write",
558    "Read",
559    "Bash",
560    "Grep",
561    "Glob",
562    "WebFetch",
563    "WebSearch",
564    "Task",
565    "Skill",
566    "NotebookEdit",
567    "EnterPlanMode",
568    "ExitPlanMode",
569    "EnterWorktree",
570    "AskUserQuestion",
571    "TodoWrite",
572    "TodoRead",
573];
574
575/// Translate a unified allowed_tools list into Claude `--allowedTools` patterns.
576///
577/// - Items matching known Claude tool names pass through as-is
578/// - Edit/Write are scoped to `allowed_edit_paths` when set
579/// - Other items are treated as CLI commands and become `Bash(<cmd> *)` patterns
580fn translate_allowed_tools(
581    allowed: &[String],
582    allowed_edit_paths: &[std::path::PathBuf],
583) -> Vec<String> {
584    let mut tools = Vec::new();
585    for item in allowed {
586        if (item == "Edit" || item == "Write") && !allowed_edit_paths.is_empty() {
587            // Scope Edit/Write to allowed paths only
588            for path in allowed_edit_paths {
589                tools.push(format!("{}({}/*)", item, path.display()));
590            }
591        } else if CLAUDE_NATIVE_TOOLS.contains(&item.as_str()) {
592            tools.push(item.clone());
593        } else {
594            // CLI command → Bash(<cmd> *) pattern
595            tools.push(format!("Bash({item} *)"));
596        }
597    }
598    tools
599}
600
601/// Find the best NDJSON result line and its token usage.
602///
603/// Returns `(line, tokens)` where `tokens` is `Some((input, output))` or `None`
604/// if no usage data was found.
605///
606/// Selection priority:
607/// 1. JSON result fields (object/array or string that parses as JSON) — always preferred
608/// 2. Among plain-text results: the one with the most total tokens (preferring actual
609///    work over outer-session confirmation summaries)
610/// 3. Error results (is_error=true) — included even with no tokens, so the caller can
611///    classify them properly
612///
613/// This skips outer-session plain-text summaries in favor of inner task results
614/// with meaningful token counts (issue #1387).
615pub(crate) fn find_ndjson_result_line(text: &str) -> Option<(&str, Option<(u64, u64)>)> {
616    let lines: Vec<_> = text.lines().filter(|l| !l.trim().is_empty()).collect();
617
618    // Pass 1: find a result line with a JSON result field.
619    // JSON results are always preferred (inner task delegate output).
620    for line in lines.iter().rev() {
621        if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
622            if let Some(obj) = val.as_object() {
623                if obj.get("type").and_then(|t| t.as_str()) == Some("result") {
624                    if let Some(result) = obj.get("result") {
625                        let is_json_result = match result {
626                            serde_json::Value::Object(_) | serde_json::Value::Array(_) => true,
627                            serde_json::Value::String(s) => {
628                                serde_json::from_str::<serde_json::Value>(s).is_ok()
629                            }
630                            _ => false,
631                        };
632                        if is_json_result {
633                            let tokens = extract_result_tokens(obj);
634                            return Some((line, tokens));
635                        }
636                    }
637                }
638            }
639        }
640    }
641
642    // Pass 2: no JSON result found — choose the plain-text result with the most tokens.
643    // This handles outer-session confirmation summaries (e.g. "Already retrieved the result")
644    // vs. actual work summaries: prefer the one with more tokens.
645    // Error results (is_error=true) are included even with zero tokens, so the caller
646    // can classify them properly.
647    let mut best_line: Option<&str> = None;
648    let mut best_total = 0u64;
649
650    for line in &lines {
651        if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
652            if let Some(obj) = val.as_object() {
653                if obj.get("type").and_then(|t| t.as_str()) == Some("result") {
654                    let tokens = extract_result_tokens(obj);
655                    let total = tokens.map(|(i, o)| i.saturating_add(o)).unwrap_or(0);
656                    if total >= best_total {
657                        best_total = total;
658                        best_line = Some(*line);
659                    }
660                }
661            }
662        }
663    }
664
665    best_line.map(|line| (line, extract_result_tokens_from_line(line)))
666}
667
668/// Extract input/output token counts from a parsed result envelope object.
669fn extract_result_tokens(obj: &serde_json::Map<String, serde_json::Value>) -> Option<(u64, u64)> {
670    // Try top-level `usage` first.
671    if let Some(usage) = obj.get("usage").and_then(|v| v.as_object()) {
672        let input = usage.get("input_tokens").and_then(|v| v.as_u64());
673        let output = usage.get("output_tokens").and_then(|v| v.as_u64());
674        if let (Some(i), Some(o)) = (input, output) {
675            return Some((i, o));
676        }
677    }
678    // Try modelUsage (aggregated across models).
679    if let Some(model_usage) = obj.get("modelUsage").and_then(|v| v.as_object()) {
680        let mut total_input = 0u64;
681        let mut total_output = 0u64;
682        let mut found = false;
683        for (_model_name, model_stats) in model_usage {
684            if let Some(stats) = model_stats.as_object() {
685                if let Some(i) = stats
686                    .get("inputTokens")
687                    .or_else(|| stats.get("input_tokens"))
688                    .and_then(|v| v.as_u64())
689                {
690                    total_input += i;
691                    found = true;
692                }
693                if let Some(o) = stats
694                    .get("outputTokens")
695                    .or_else(|| stats.get("output_tokens"))
696                    .and_then(|v| v.as_u64())
697                {
698                    total_output += o;
699                    found = true;
700                }
701            }
702        }
703        if found {
704            return Some((total_input, total_output));
705        }
706    }
707    None
708}
709
710/// Extract token counts from a raw NDJSON result line string.
711fn extract_result_tokens_from_line(line: &str) -> Option<(u64, u64)> {
712    let val = serde_json::from_str::<serde_json::Value>(line).ok()?;
713    let obj = val.as_object()?;
714    extract_result_tokens(obj)
715}
716
717/// Extract input/output tokens from the Claude envelope's usage objects.
718fn extract_usage(
719    envelope: &serde_json::Map<String, serde_json::Value>,
720) -> (Option<u64>, Option<u64>) {
721    // Try top-level `usage` first
722    if let Some(usage) = envelope.get("usage").and_then(|v| v.as_object()) {
723        let input = usage.get("input_tokens").and_then(|v| v.as_u64());
724        let output = usage.get("output_tokens").and_then(|v| v.as_u64());
725        if input.is_some() || output.is_some() {
726            return (input, output);
727        }
728    }
729
730    // Try modelUsage (aggregated across models)
731    if let Some(model_usage) = envelope.get("modelUsage").and_then(|v| v.as_object()) {
732        let mut total_input = 0u64;
733        let mut total_output = 0u64;
734        let mut found = false;
735
736        for (_model_name, model_stats) in model_usage {
737            if let Some(stats) = model_stats.as_object() {
738                if let Some(i) = stats
739                    .get("inputTokens")
740                    .or_else(|| stats.get("input_tokens"))
741                    .and_then(|v| v.as_u64())
742                {
743                    total_input += i;
744                    found = true;
745                }
746                if let Some(o) = stats
747                    .get("outputTokens")
748                    .or_else(|| stats.get("output_tokens"))
749                    .and_then(|v| v.as_u64())
750                {
751                    total_output += o;
752                    found = true;
753                }
754            }
755        }
756
757        if found {
758            return (Some(total_input), Some(total_output));
759        }
760    }
761
762    (None, None)
763}
764
765const SHOW_CURSOR: &str = "\x1b[?25h";
766const HIDE_CURSOR: &str = "\x1b[?25l";
767
768/// Drop `script` tty junk: show-cursor (`ESC[?25h`) and `^D`/backspace redraw lines
769pub fn strip_tty_control_lines(raw: &str) -> String {
770    raw.lines()
771        .filter(|line| !is_tty_control_line(line))
772        .map(strip_trailing_cursor)
773        .collect::<Vec<_>>()
774        .join("\n")
775}
776
777fn strip_trailing_cursor(line: &str) -> &str {
778    let mut s = line.trim_end();
779    loop {
780        if let Some(next) = s
781            .strip_suffix(SHOW_CURSOR)
782            .or_else(|| s.strip_suffix(HIDE_CURSOR))
783        {
784            s = next.trim_end();
785            continue;
786        }
787        return s;
788    }
789}
790
791fn is_tty_control_line(line: &str) -> bool {
792    let trimmed = strip_trailing_cursor(line).trim();
793    if trimmed.is_empty() {
794        return !line.trim().is_empty();
795    }
796    trimmed.chars().all(|c| matches!(c, '^' | 'D' | '\u{0008}'))
797}
798
799/// Type of the last content block on the last assistant NDJSON message
800fn last_assistant_content_type(ndjson: &str) -> Option<String> {
801    let mut last = None;
802    for line in ndjson.lines() {
803        let line = line.trim();
804        if line.is_empty() {
805            continue;
806        }
807        let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
808            continue;
809        };
810        if val.get("type").and_then(|v| v.as_str()) != Some("assistant") {
811            continue;
812        }
813        let Some(content) = val
814            .get("message")
815            .and_then(|m| m.get("content"))
816            .and_then(|c| c.as_array())
817        else {
818            continue;
819        };
820        if let Some(item) = content.last() {
821            if let Some(t) = item.get("type").and_then(|v| v.as_str()) {
822                last = Some(t.to_string());
823            }
824        }
825    }
826    last
827}
828
829/// True when the stream has no result envelope and ended on a thinking block
830/// or a tool call, so the model never produced a finished review decision
831pub fn review_session_exited_mid_thought(ndjson: &str) -> bool {
832    if find_ndjson_result_line(ndjson).is_some() {
833        return false;
834    }
835    matches!(
836        last_assistant_content_type(ndjson).as_deref(),
837        Some("thinking" | "redacted_thinking" | "tool_use")
838    )
839}
840
841/// Extract a structured `AgentResult` from Claude/Kimi/MiniMax NDJSON output.
842///
843/// Finds the last `"type":"result"` line whose `result` field contains valid JSON.
844/// This skips outer-session plain-text summaries (issue #1387) and finds the
845/// actual task-delegate result with structured JSON.
846///
847/// Extracts:
848/// - `result` text (the inner content)
849/// - `is_error` flag
850/// - token usage from `usage` or `modelUsage`
851/// - `duration_ms`
852///
853/// If no JSON result line exists (common with MiniMax), falls back to
854/// extracting the last assistant text message from the stream.
855///
856/// Returns `None` only if the input contains no recognizable NDJSON events.
857pub fn find_claude_result(ndjson: &str) -> Option<super::AgentResult> {
858    let stripped = strip_tty_control_lines(ndjson);
859    let trimmed = stripped.trim();
860    if trimmed.is_empty() {
861        return None;
862    }
863
864    // Try to find the result envelope first
865    if let Some((result_line, _tokens)) = find_ndjson_result_line(trimmed) {
866        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(result_line) {
867            if let Some(envelope) = parsed.as_object() {
868                let is_error = envelope
869                    .get("is_error")
870                    .and_then(|v| v.as_bool())
871                    .unwrap_or(false);
872
873                let result_text = if is_error {
874                    // Error envelopes put the real message in errors[] (billing,
875                    // rate limits) with `result` empty — don't lose it (issue #3713)
876                    envelope_error_text(envelope)
877                } else {
878                    envelope
879                        .get("result")
880                        .and_then(|v| v.as_str())
881                        .unwrap_or("")
882                        .to_string()
883                };
884
885                let (input_tokens, output_tokens) = extract_usage(envelope);
886                let duration_ms = envelope.get("duration_ms").and_then(|v| v.as_u64());
887
888                let cost_usd = envelope
889                    .get("costUSD")
890                    .or_else(|| envelope.get("cost_usd"))
891                    .and_then(|v| v.as_f64());
892
893                return Some(super::AgentResult {
894                    is_error,
895                    result_text,
896                    input_tokens,
897                    output_tokens,
898                    cost_usd,
899                    duration_ms,
900                    truncated_by_length: false,
901                });
902            }
903        }
904    }
905
906    // Fallback for MiniMax/Kimi: extract last assistant text message
907    let mut last_text = None;
908    let mut input_tokens = None;
909    let mut output_tokens = None;
910
911    for line in trimmed.lines() {
912        let line = line.trim();
913        if line.is_empty() {
914            continue;
915        }
916        let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
917            continue;
918        };
919        if val.get("type").and_then(|v| v.as_str()) != Some("assistant") {
920            continue;
921        }
922        // Extract text from content array
923        if let Some(content) = val
924            .get("message")
925            .and_then(|m| m.get("content"))
926            .and_then(|c| c.as_array())
927        {
928            for item in content {
929                if item.get("type").and_then(|v| v.as_str()) == Some("text") {
930                    if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
931                        let t = text.trim();
932                        if !t.is_empty() {
933                            last_text = Some(t.to_string());
934                        }
935                    }
936                }
937            }
938        }
939        // Extract usage (take max, as Claude reports cumulative)
940        if let Some(usage) = val.get("message").and_then(|m| m.get("usage")) {
941            if let Some(inp) = usage.get("input_tokens").and_then(|v| v.as_u64()) {
942                input_tokens = Some(input_tokens.unwrap_or(0u64).max(inp));
943            }
944            if let Some(out) = usage.get("output_tokens").and_then(|v| v.as_u64()) {
945                output_tokens = Some(output_tokens.unwrap_or(0u64).max(out));
946            }
947        }
948    }
949
950    last_text.map(|text| super::AgentResult {
951        is_error: false,
952        result_text: text,
953        input_tokens,
954        output_tokens,
955        cost_usd: None,
956        duration_ms: None,
957        truncated_by_length: false,
958    })
959}
960
961/// Concatenate text content from all `type:assistant` NDJSON messages in
962/// the stream.  Used as a fallback when the `type:result` envelope text
963/// does not contain valid AgentResponse JSON but an earlier assistant turn
964/// might have emitted it.
965pub fn collect_assistant_messages_text(ndjson: &str) -> String {
966    let mut parts: Vec<String> = Vec::new();
967    for line in ndjson.trim().lines() {
968        let line = line.trim();
969        if line.is_empty() {
970            continue;
971        }
972        let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
973            continue;
974        };
975        if val.get("type").and_then(|v| v.as_str()) != Some("assistant") {
976            continue;
977        }
978        if let Some(content) = val
979            .get("message")
980            .and_then(|m| m.get("content"))
981            .and_then(|c| c.as_array())
982        {
983            for item in content {
984                if item.get("type").and_then(|v| v.as_str()) == Some("text") {
985                    if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
986                        let t = text.trim();
987                        if !t.is_empty() {
988                            parts.push(t.to_string());
989                        }
990                    }
991                }
992            }
993        }
994    }
995    parts.join("\n")
996}
997
998#[cfg(test)]
999mod tests {
1000    use super::*;
1001
1002    fn runner() -> ClaudeRunner {
1003        ClaudeRunner::new("claude")
1004    }
1005
1006    #[test]
1007    fn error_envelope_uses_errors_array_when_result_missing() {
1008        let raw = r#"{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["API error (status 402 Payment Required): usage balance exhausted"]}"#;
1009        match runner().parse_response(raw) {
1010            Err(AgentError::AgentFailed { message } | AgentError::Auth { message }) => {
1011                assert!(crate::engine::cooldown::detect_credit_exhaustion(&message).is_some());
1012            }
1013            other => panic!("unexpected: {other:?}"),
1014        }
1015    }
1016
1017    #[test]
1018    fn error_envelope_falls_back_to_subtype() {
1019        let raw = r#"{"type":"result","subtype":"error_during_execution","is_error":true}"#;
1020        match runner().parse_response(raw) {
1021            Err(AgentError::AgentFailed { message }) => {
1022                assert_eq!(message, "error_during_execution")
1023            }
1024            other => panic!("unexpected: {other:?}"),
1025        }
1026    }
1027
1028    #[test]
1029    fn parse_claude_envelope_success() {
1030        let raw = r#"{
1031            "type": "result",
1032            "subtype": "success",
1033            "is_error": false,
1034            "duration_ms": 2006,
1035            "result": "{\"status\":\"done\",\"summary\":\"hello\",\"accomplished\":[],\"remaining\":[],\"files\":[]}",
1036            "usage": {"input_tokens": 10, "output_tokens": 78},
1037            "permission_denials": []
1038        }"#;
1039
1040        let parsed = runner().parse_response(raw).unwrap();
1041        assert_eq!(parsed.response.status, "done");
1042        assert_eq!(parsed.response.summary, "hello");
1043        assert_eq!(parsed.input_tokens, Some(10));
1044        assert_eq!(parsed.output_tokens, Some(78));
1045        assert_eq!(parsed.duration_ms, Some(2006));
1046    }
1047
1048    #[test]
1049    fn parse_claude_envelope_with_markdown_result() {
1050        let raw = r#"{
1051            "type": "result",
1052            "subtype": "success",
1053            "is_error": false,
1054            "duration_ms": 5000,
1055            "result": "```json\n{\"status\": \"done\", \"summary\": \"fixed bug\"}\n```",
1056            "usage": {"input_tokens": 100, "output_tokens": 50},
1057            "permission_denials": ["Bash(rm *)"]
1058        }"#;
1059
1060        let parsed = runner().parse_response(raw).unwrap();
1061        assert_eq!(parsed.response.status, "done");
1062        assert_eq!(parsed.response.summary, "fixed bug");
1063    }
1064
1065    #[test]
1066    fn parse_claude_envelope_error() {
1067        let raw = r#"{
1068            "type": "result",
1069            "subtype": "error",
1070            "is_error": true,
1071            "result": "credit balance too low",
1072            "usage": {},
1073            "permission_denials": []
1074        }"#;
1075
1076        let err = runner().parse_response(raw).unwrap_err();
1077        assert!(matches!(err, AgentError::Auth { .. }));
1078    }
1079
1080    #[test]
1081    fn parse_claude_envelope_rate_limit_error() {
1082        let raw = r#"{
1083            "type": "result",
1084            "subtype": "error",
1085            "is_error": true,
1086            "result": "rate limit exceeded, please try again later",
1087            "usage": {},
1088            "permission_denials": []
1089        }"#;
1090
1091        let err = runner().parse_response(raw).unwrap_err();
1092        assert!(matches!(err, AgentError::RateLimit { .. }));
1093    }
1094
1095    #[test]
1096    fn parse_envelope_no_false_positive_from_work_product_result() {
1097        // Regression test for issue #1292: when is_error=false but the result
1098        // text contains rate-limit keywords (agent describing work it did),
1099        // parse_response must NOT return a RateLimit error.
1100        //
1101        // Fix for issue #1377: when parser::parse fails on plain-text output,
1102        // synthesize_response_from_text is tried and tokens are preserved.
1103        let raw = r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":1000,"result":"Added detect_rate_limit and quota exceeded patterns","usage":{"input_tokens":10,"output_tokens":5}}"#.to_string();
1104        // Plain-text result with rate-limit keywords: parser::parse fails, but
1105        // synthesis succeeds — result must be Ok (tokens preserved) not RateLimit.
1106        let parsed = runner()
1107            .parse_response(&raw)
1108            .expect("plain-text work product must synthesize successfully");
1109        // Tokens must be preserved from the envelope (issue #1377)
1110        assert_eq!(
1111            parsed.input_tokens,
1112            Some(10),
1113            "input_tokens must be preserved"
1114        );
1115        assert_eq!(
1116            parsed.output_tokens,
1117            Some(5),
1118            "output_tokens must be preserved"
1119        );
1120    }
1121
1122    #[test]
1123    fn parse_direct_json_no_envelope() {
1124        let raw =
1125            r#"{"status":"done","summary":"hello","accomplished":[],"remaining":[],"files":[]}"#;
1126
1127        let parsed = runner().parse_response(raw).unwrap();
1128        assert_eq!(parsed.response.status, "done");
1129        assert!(parsed.input_tokens.is_none());
1130    }
1131
1132    #[test]
1133    fn parse_empty_response() {
1134        let err = runner().parse_response("").unwrap_err();
1135        assert!(matches!(err, AgentError::InvalidResponse { .. }));
1136    }
1137
1138    #[test]
1139    fn classify_error_rate_limit() {
1140        let err = runner().classify_error(1, "", "429 Too Many Requests");
1141        assert!(matches!(err, AgentError::RateLimit { .. }));
1142    }
1143
1144    #[test]
1145    fn classify_error_detects_session_limit_from_mid_stream_system_event() {
1146        // Regression test for issue #3272: Claude session limit errors appear as
1147        // `type:system` NDJSON events mid-stream, not as `type:result` lines with
1148        // `is_error:true`. When significant work product exists before and after
1149        // the system event, it is outside both head and tail scan windows in
1150        // `classify_from_text`. The `classify_error` function must extract the
1151        // error message from the system event and prepend it to the combined text.
1152        let header_events = "{\"type\":\"conversation\",\"subtype\":\"init\"}\n\
1153             {\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"I'll work on this task.\"}]}}\n"
1154            .repeat(80);
1155        let system_event = r#"{"type":"system","subtype":"error","error":{"type":"session_limit","message":"You've hit your session limit · resets 4:10am (America/Sao_Paulo)"}}"#;
1156        let work_product = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Here is the implementation...\"}]}}\n"
1157            .repeat(80);
1158        let stdout = format!("{header_events}{system_event}\n{work_product}");
1159        // Verify the system event is deep enough to miss head and tail scans.
1160        assert!(
1161            stdout.len() > 6000,
1162            "test stdout must exceed combined head+tail windows, got {} bytes",
1163            stdout.len()
1164        );
1165        let err = runner().classify_error(1, &stdout, "");
1166        assert!(
1167            matches!(err, AgentError::RateLimit { .. }),
1168            "session limit system event mid-stream must be detected, got: {err:?}"
1169        );
1170        let msg = err.to_string();
1171        assert!(
1172            msg.contains("session limit"),
1173            "error message should contain the rate limit reason, got: {msg}"
1174        );
1175    }
1176
1177    #[test]
1178    fn classify_error_killed_run_with_429_in_uuid_hex_group_is_not_rate_limit() {
1179        // Regression for issue #3956: `-429c-` in a hook id labelled a SIGTERM run RateLimit
1180        let hook = r#"{"type":"system","subtype":"hook_response","hook_id":"18db61b6-7b72-429c-8279-fae3089bf565"}"#;
1181        let prose = "the first lazy solution that works is the right one.\n".repeat(80);
1182        let stdout = format!("{hook}\n{prose}");
1183        assert!(stdout.len() > 3000);
1184        for code in [143, 137, 1] {
1185            let err = runner().classify_error(code, &stdout, "");
1186            assert!(
1187                !matches!(err, AgentError::RateLimit { .. }),
1188                "exit {code} must not classify as RateLimit, got: {err:?}"
1189            );
1190        }
1191        let err = runner().classify_error(143, &stdout, "");
1192        assert!(
1193            matches!(err, AgentError::Interrupted { .. }),
1194            "got: {err:?}"
1195        );
1196    }
1197
1198    #[test]
1199    fn classify_error_402_envelope_not_masked_by_429_in_hook_uuid() {
1200        // Regression for issue #3760: a 4295 hook UUID in the first 3000 bytes
1201        // classified a 402 envelope as a rate limit whose message was hook prose
1202        let hook = r#"{"type":"system","subtype":"init","hook":"SessionStart","uuid":"a1b2c3d4-4295-4e5f-8a9b-0c1d2e3f4a5b","outputTokens":429}"#;
1203        let hook_prose = "on. The first lazy solution that works is the\nright one once you actually know what the change has to touch.\n\n**Build the smallest thing that can fail.**\n";
1204        let padding = hook_prose.repeat(40);
1205        let result_line = r#"{"type":"result","is_error":true,"terminal_reason":"api_error","api_error_status":402,"subtype":"success","result":"API Error: 402 insufficient balance (1008)"}"#;
1206        let stdout = format!("{hook}\n{padding}\n{result_line}\n");
1207        assert!(
1208            stdout.len() > 3000,
1209            "fixture must put the 4295 UUID inside the 3000-byte head, got {} bytes",
1210            stdout.len()
1211        );
1212        let err = runner().classify_error(1, &stdout, "");
1213        assert!(
1214            matches!(err, AgentError::Auth { .. }),
1215            "402 envelope must classify as Auth, got: {err:?}"
1216        );
1217        let msg = err.to_string();
1218        assert!(
1219            msg.contains("insufficient balance"),
1220            "stored error must keep the 402 text, got: {msg}"
1221        );
1222    }
1223
1224    #[test]
1225    fn classify_error_prefers_clean_result_text_over_byte_truncated_tail() {
1226        // Regression test for issue #3577: a well-formed `type:result`
1227        // envelope with `is_error:true` was correctly extracted into
1228        // `error_result`, but `classify_from_text`'s generic Unknown
1229        // fallback then re-truncates the combined stdout+stderr to its last
1230        // 300 bytes — which, once enough preceding NDJSON pushes the result
1231        // line's start past that window, cuts it off mid-field-name (e.g.
1232        // `"is_error"` becomes `"i_error"`). The stored error ends up a
1233        // garbled JSON fragment instead of the clean message, and (in the
1234        // fallback.rs non-zero-exit path) skips the model cooldown that the
1235        // AgentFailed branch applies.
1236        let padding = "{\"type\":\"system\",\"subtype\":\"init\"}\n".repeat(50);
1237        let result_line = r#"{"is_error":true,"duration_api_ms":0,"num_turns":1,"stop_reason":"stop_sequence","session_id":"32a7c67c","total_cost_usd":0,"usage":{},"modelUsage":{},"permission_denials":[],"terminal_reason":"api_error","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subtype":"success","api_error_status":null,"result":"Failed to authenticate: OAuth session expired and could not be refreshed","type":"result","duration_ms":141,"uuid":"fe0ccff2-54d3-47cd-b5ba-a82f99a72b45"}"#;
1238        let stdout = format!("{padding}{result_line}\n");
1239        // Sanity check: the message text itself is not caught by any of the
1240        // specific keyword detectors (auth/rate_limit/context_overflow), so
1241        // without the fix this must fall to the generic Unknown branch.
1242        assert!(
1243            stdout.len() > 300,
1244            "test stdout must exceed the tail-truncation window"
1245        );
1246
1247        let err = runner().classify_error(1, &stdout, "");
1248        assert!(
1249            matches!(err, AgentError::AgentFailed { .. }),
1250            "expected AgentFailed with the clean message, got: {err:?}"
1251        );
1252        let msg = err.to_string();
1253        assert_eq!(
1254            msg, "agent failed: Failed to authenticate: OAuth session expired and could not be refreshed",
1255            "stored error must be the clean result text, not a byte-truncated JSON fragment"
1256        );
1257        assert!(
1258            !msg.contains("i_error"),
1259            "stored error must not contain the byte-truncated JSON fragment, got: {msg}"
1260        );
1261    }
1262
1263    #[test]
1264    fn classify_error_timeout() {
1265        let err = runner().classify_error(124, "", "");
1266        assert!(matches!(err, AgentError::Timeout { .. }));
1267    }
1268
1269    #[test]
1270    fn extract_usage_from_model_usage() {
1271        let response_json: serde_json::Value = serde_json::json!({
1272            "modelUsage": {
1273                "claude-haiku-4-5-20251001": {
1274                    "inputTokens": 1000,
1275                    "outputTokens": 500
1276                }
1277            }
1278        });
1279        let envelope = response_json.as_object().unwrap();
1280        let (input, output) = extract_usage(envelope);
1281        assert_eq!(input, Some(1000));
1282        assert_eq!(output, Some(500));
1283    }
1284
1285    #[test]
1286    fn build_command_skips_an_empty_model() {
1287        let r = runner();
1288        let perms = PermissionRules {
1289            autonomous: true,
1290            sandbox: SandboxLevel::WorkspaceWrite,
1291            disallowed_tools: vec![],
1292            allowed_tools: vec![],
1293            allowed_edit_paths: vec![],
1294            deny_read_only: false,
1295            extra_writable_dirs: vec![],
1296        };
1297        for m in ["", "  "] {
1298            let cmd = r.build_command(Some(m), "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1299            assert!(!cmd.contains("--model"), "{cmd}");
1300        }
1301    }
1302
1303    #[test]
1304    fn build_command_with_model() {
1305        let r = runner();
1306        let perms = PermissionRules {
1307            autonomous: true,
1308            sandbox: SandboxLevel::WorkspaceWrite,
1309            disallowed_tools: vec!["Bash(rm *)".to_string()],
1310            allowed_tools: vec![],
1311            allowed_edit_paths: vec![],
1312            deny_read_only: false,
1313            extra_writable_dirs: vec![],
1314        };
1315        let cmd = r.build_command(
1316            Some("opus"),
1317            "timeout 1800",
1318            "/tmp/sys.txt",
1319            "/tmp/msg.txt",
1320            &perms,
1321        );
1322        assert!(cmd.contains("--model 'opus'"));
1323        assert!(cmd.contains("claude -p"));
1324        assert!(cmd.contains("--output-format stream-json"));
1325        assert!(cmd.contains("--permission-mode bypassPermissions"));
1326        assert!(cmd.contains("--disallowedTools 'Bash(rm *)'"));
1327    }
1328
1329    #[test]
1330    fn build_command_supervised_mode() {
1331        let r = runner();
1332        let perms = PermissionRules {
1333            autonomous: false,
1334            sandbox: SandboxLevel::WorkspaceWrite,
1335            disallowed_tools: vec![],
1336            allowed_tools: vec![],
1337            allowed_edit_paths: vec![],
1338            deny_read_only: false,
1339            extra_writable_dirs: vec![],
1340        };
1341        let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1342        assert!(cmd.contains("--permission-mode acceptEdits"));
1343    }
1344
1345    #[test]
1346    fn build_command_allowed_edit_paths_scopes_edit_write() {
1347        let r = runner();
1348        let perms = PermissionRules {
1349            autonomous: true,
1350            sandbox: SandboxLevel::WorkspaceWrite,
1351            disallowed_tools: vec![],
1352            allowed_tools: vec![
1353                "Edit".to_string(),
1354                "Write".to_string(),
1355                "Read".to_string(),
1356                "Bash".to_string(),
1357            ],
1358            allowed_edit_paths: vec![std::path::PathBuf::from("/home/user/worktree")],
1359            deny_read_only: false,
1360            extra_writable_dirs: vec![],
1361        };
1362        let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1363
1364        // Edit/Write should be scoped to the worktree path
1365        assert!(
1366            cmd.contains("Edit(/home/user/worktree/*)"),
1367            "expected scoped Edit, got: {cmd}"
1368        );
1369        assert!(
1370            cmd.contains("Write(/home/user/worktree/*)"),
1371            "expected scoped Write, got: {cmd}"
1372        );
1373
1374        // Read and Bash should not be scoped
1375        assert!(
1376            cmd.contains(",Read,") || cmd.contains("'Read,") || cmd.contains(",Read'"),
1377            "expected unrestricted Read, got: {cmd}"
1378        );
1379        assert!(
1380            cmd.contains(",Bash,") || cmd.contains("'Bash,") || cmd.contains(",Bash'"),
1381            "expected unrestricted Bash, got: {cmd}"
1382        );
1383    }
1384
1385    #[test]
1386    fn kimi_runner_name() {
1387        let r = ClaudeRunner::new("kimi");
1388        assert_eq!(r.name(), "kimi");
1389    }
1390
1391    // ── Fixture-based tests ─────────────────────────────────────
1392
1393    #[test]
1394    fn fixture_claude_success() {
1395        let raw = include_str!("../../../../tests/fixtures/claude_success.json");
1396        let parsed = runner().parse_response(raw).unwrap();
1397        assert_eq!(parsed.response.status, "done");
1398        assert!(parsed.response.summary.contains("Implemented"));
1399        assert_eq!(parsed.response.accomplished.len(), 2);
1400        assert_eq!(parsed.input_tokens, Some(15000));
1401        assert_eq!(parsed.output_tokens, Some(3500));
1402        assert_eq!(parsed.duration_ms, Some(45000));
1403    }
1404
1405    #[test]
1406    fn fixture_claude_rate_limit() {
1407        let raw = include_str!("../../../../tests/fixtures/claude_error_rate_limit.json");
1408        let err = runner().parse_response(raw).unwrap_err();
1409        assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
1410    }
1411
1412    // ── NDJSON / stream-json fixtures ────────────────────────────
1413
1414    #[test]
1415    fn fixture_claude_stream_success() {
1416        let raw = include_str!("../../../../tests/fixtures/claude_stream_success.jsonl");
1417        let parsed = runner().parse_response(raw).unwrap();
1418        assert_eq!(parsed.response.status, "done");
1419        assert!(parsed.response.summary.contains("Implemented"));
1420        assert_eq!(parsed.response.accomplished.len(), 2);
1421        assert_eq!(parsed.input_tokens, Some(15000));
1422        assert_eq!(parsed.output_tokens, Some(3500));
1423        assert_eq!(parsed.duration_ms, Some(45000));
1424    }
1425
1426    #[test]
1427    fn fixture_claude_stream_error() {
1428        let raw = include_str!("../../../../tests/fixtures/claude_stream_error.jsonl");
1429        let err = runner().parse_response(raw).unwrap_err();
1430        assert!(matches!(err, AgentError::Auth { .. }), "got: {err:?}");
1431    }
1432
1433    #[test]
1434    fn fixture_claude_stream_rate_limit() {
1435        let raw = include_str!("../../../../tests/fixtures/claude_stream_rate_limit.jsonl");
1436        let err = runner().parse_response(raw).unwrap_err();
1437        assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
1438    }
1439
1440    #[test]
1441    fn fixture_claude_stream_thinking_block_conflict() {
1442        let raw =
1443            include_str!("../../../../tests/fixtures/claude_stream_thinking_block_conflict.jsonl");
1444        let err = runner().parse_response(raw).unwrap_err();
1445        assert!(
1446            matches!(err, AgentError::ThinkingBlockConflict { .. }),
1447            "expected ThinkingBlockConflict, got: {err:?}"
1448        );
1449    }
1450
1451    #[test]
1452    fn classify_error_thinking_block_conflict_from_stderr() {
1453        // The error can also surface through classify_error() when the claude
1454        // process exits non-zero with the message in stdout/stderr.
1455        let stderr = "claude failed: API Error: 400 messages.1.content.3: `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response.";
1456        let err = runner().classify_error(1, "", stderr);
1457        assert!(
1458            matches!(err, AgentError::ThinkingBlockConflict { .. }),
1459            "expected ThinkingBlockConflict, got: {err:?}"
1460        );
1461    }
1462
1463    #[test]
1464    fn thinking_block_conflict_does_not_match_unrelated_text() {
1465        // Ensure the pattern is specific enough to not false-positive on
1466        // work product that mentions "thinking" or "blocks".
1467        let unrelated = "I was thinking about how to handle blocks of code in the response.";
1468        let err = runner().classify_error(1, "", unrelated);
1469        assert!(
1470            !matches!(err, AgentError::ThinkingBlockConflict { .. }),
1471            "should not classify unrelated text as ThinkingBlockConflict, got: {err:?}"
1472        );
1473    }
1474
1475    #[test]
1476    fn parse_ndjson_with_intermediate_events() {
1477        // Parser must ignore intermediate events and only look at the result line
1478        let raw = concat!(
1479            r#"{"type":"system","subtype":"init"}"#,
1480            "\n",
1481            r#"{"type":"assistant","message":{"role":"assistant","content":[]}}"#,
1482            "\n",
1483            r#"{"type":"tool_use","tool":{"name":"Read","input":{}}}"#,
1484            "\n",
1485            r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":1000,"result":"{\"status\":\"done\",\"summary\":\"streamed\",\"accomplished\":[],\"remaining\":[],\"files\":[]}","usage":{"input_tokens":5,"output_tokens":10}}"#,
1486            "\n"
1487        );
1488        let parsed = runner().parse_response(raw).unwrap();
1489        assert_eq!(parsed.response.status, "done");
1490        assert_eq!(parsed.response.summary, "streamed");
1491        assert_eq!(parsed.input_tokens, Some(5));
1492        assert_eq!(parsed.output_tokens, Some(10));
1493    }
1494
1495    #[test]
1496    fn parse_ndjson_skips_malformed_lines() {
1497        // Malformed intermediate lines should be ignored; result line still parsed
1498        let raw = concat!(
1499            "not valid json\n",
1500            r#"{"type":"partial_update","data":"something"}"#,
1501            "\n",
1502            r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":500,"result":"{\"status\":\"done\",\"summary\":\"ok\",\"accomplished\":[],\"remaining\":[],\"files\":[]}","usage":{"input_tokens":1,"output_tokens":2}}"#,
1503            "\n"
1504        );
1505        let parsed = runner().parse_response(raw).unwrap();
1506        assert_eq!(parsed.response.status, "done");
1507        assert_eq!(parsed.response.summary, "ok");
1508    }
1509
1510    #[test]
1511    fn parse_single_line_json_backward_compat() {
1512        // Single-line JSON (--output-format json) still works
1513        let raw = r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":2000,"result":"{\"status\":\"done\",\"summary\":\"backward compat\",\"accomplished\":[],\"remaining\":[],\"files\":[]}","usage":{"input_tokens":50,"output_tokens":25}}"#;
1514        let parsed = runner().parse_response(raw).unwrap();
1515        assert_eq!(parsed.response.status, "done");
1516        assert_eq!(parsed.response.summary, "backward compat");
1517        assert_eq!(parsed.input_tokens, Some(50));
1518        assert_eq!(parsed.output_tokens, Some(25));
1519    }
1520
1521    // ── Kimi / MiniMax (Claude-compatible wrappers) ───────────────
1522
1523    #[test]
1524    fn kimi_parses_claude_envelope() {
1525        let r = ClaudeRunner::new("kimi");
1526        let raw = r#"{
1527            "type": "result",
1528            "subtype": "success",
1529            "is_error": false,
1530            "duration_ms": 3000,
1531            "result": "{\"status\":\"done\",\"summary\":\"kimi did it\",\"accomplished\":[\"task\"],\"remaining\":[],\"files\":[\"a.rs\"]}",
1532            "usage": {"input_tokens": 200, "output_tokens": 100}
1533        }"#;
1534
1535        let parsed = r.parse_response(raw).unwrap();
1536        assert_eq!(parsed.response.status, "done");
1537        assert_eq!(parsed.response.summary, "kimi did it");
1538    }
1539
1540    #[test]
1541    fn minimax_parses_claude_envelope() {
1542        let r = ClaudeRunner::new("minimax");
1543        assert_eq!(r.name(), "minimax");
1544        let raw = r#"{
1545            "type": "result",
1546            "subtype": "success",
1547            "is_error": false,
1548            "result": "{\"status\":\"done\",\"summary\":\"minimax did it\",\"accomplished\":[],\"remaining\":[],\"files\":[]}",
1549            "usage": {}
1550        }"#;
1551
1552        let parsed = r.parse_response(raw).unwrap();
1553        assert_eq!(parsed.response.status, "done");
1554    }
1555
1556    #[test]
1557    fn kimi_build_command_uses_kimi_binary() {
1558        let r = ClaudeRunner::new("kimi");
1559        let perms = PermissionRules {
1560            autonomous: true,
1561            sandbox: SandboxLevel::WorkspaceWrite,
1562            disallowed_tools: vec![],
1563            allowed_tools: vec![],
1564            allowed_edit_paths: vec![],
1565            deny_read_only: false,
1566            extra_writable_dirs: vec![],
1567        };
1568        let cmd = r.build_command(
1569            Some("sonnet"),
1570            "timeout 1800",
1571            "/tmp/sys.txt",
1572            "/tmp/msg.txt",
1573            &perms,
1574        );
1575        assert!(cmd.contains("kimi -p"), "expected kimi binary, got: {cmd}");
1576        assert!(cmd.contains("--model 'sonnet'"));
1577    }
1578
1579    #[test]
1580    fn build_command_with_allowed_tools() {
1581        let r = runner();
1582        let perms = PermissionRules {
1583            autonomous: true,
1584            sandbox: SandboxLevel::WorkspaceWrite,
1585            disallowed_tools: vec!["Bash(rm *)".to_string()], // should be ignored
1586            allowed_tools: vec![
1587                "Edit".to_string(),
1588                "Write".to_string(),
1589                "Read".to_string(),
1590                "Bash".to_string(),
1591                "Grep".to_string(),
1592                "git".to_string(),
1593                "npm".to_string(),
1594            ],
1595            allowed_edit_paths: vec![],
1596            deny_read_only: false,
1597            extra_writable_dirs: vec![],
1598        };
1599        let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1600
1601        // Should use --allowedTools instead of --disallowedTools
1602        assert!(
1603            cmd.contains("--allowedTools"),
1604            "expected --allowedTools flag, got: {cmd}"
1605        );
1606        assert!(
1607            !cmd.contains("--disallowedTools"),
1608            "should not have --disallowedTools when allowed_tools is set, got: {cmd}"
1609        );
1610
1611        // Native tools pass through as-is
1612        assert!(cmd.contains("Edit"), "missing Edit tool");
1613        assert!(cmd.contains("Write"), "missing Write tool");
1614        assert!(cmd.contains("Bash"), "missing Bash tool");
1615
1616        // CLI commands become Bash(<cmd> *) patterns
1617        assert!(
1618            cmd.contains("Bash(git *)"),
1619            "missing Bash(git *) pattern, got: {cmd}"
1620        );
1621        assert!(
1622            cmd.contains("Bash(npm *)"),
1623            "missing Bash(npm *) pattern, got: {cmd}"
1624        );
1625    }
1626
1627    #[test]
1628    fn build_command_allowed_tools_empty_falls_back_to_disallowed() {
1629        let r = runner();
1630        let perms = PermissionRules {
1631            autonomous: true,
1632            sandbox: SandboxLevel::WorkspaceWrite,
1633            disallowed_tools: vec!["Bash(rm *)".to_string()],
1634            allowed_tools: vec![], // empty = use disallowed_tools
1635            allowed_edit_paths: vec![],
1636            deny_read_only: false,
1637            extra_writable_dirs: vec![],
1638        };
1639        let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1640
1641        assert!(
1642            cmd.contains("--disallowedTools"),
1643            "should fall back to --disallowedTools when allowed_tools is empty, got: {cmd}"
1644        );
1645        assert!(
1646            !cmd.contains("--allowedTools"),
1647            "should not have --allowedTools when empty, got: {cmd}"
1648        );
1649    }
1650
1651    #[test]
1652    fn translate_allowed_tools_categorizes_correctly() {
1653        let allowed = vec![
1654            "Edit".to_string(),
1655            "Bash".to_string(),
1656            "Grep".to_string(),
1657            "git".to_string(),
1658            "cargo".to_string(),
1659            "WebFetch".to_string(),
1660        ];
1661        // No allowed_edit_paths → Edit passes through as-is
1662        let result = translate_allowed_tools(&allowed, &[]);
1663        assert_eq!(
1664            result,
1665            vec![
1666                "Edit",
1667                "Bash",
1668                "Grep",
1669                "Bash(git *)",
1670                "Bash(cargo *)",
1671                "WebFetch"
1672            ]
1673        );
1674    }
1675
1676    #[test]
1677    fn translate_allowed_tools_scopes_edit_write() {
1678        let allowed = vec!["Edit".to_string(), "Write".to_string(), "Read".to_string()];
1679        let paths = vec![std::path::PathBuf::from("/tmp/worktree")];
1680        let result = translate_allowed_tools(&allowed, &paths);
1681        assert_eq!(
1682            result,
1683            vec!["Edit(/tmp/worktree/*)", "Write(/tmp/worktree/*)", "Read"]
1684        );
1685    }
1686
1687    #[test]
1688    fn classify_error_overloaded_529() {
1689        // Claude 529 overloaded error
1690        let err = runner().classify_error(1, "", "overloaded_error: 529");
1691        assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
1692    }
1693
1694    #[test]
1695    fn classify_error_context_length() {
1696        let err = runner().classify_error(1, "", "context_length_exceeded: max 200000 tokens");
1697        assert!(
1698            matches!(err, AgentError::ContextOverflow { .. }),
1699            "got: {err:?}"
1700        );
1701    }
1702
1703    // ── extract_text ─────────────────────────────────────────────
1704
1705    /// Claude stream-json: extract_text returns the inner result text for review parsing.
1706    #[test]
1707    fn extract_text_stream_json_returns_result_field() {
1708        let raw = concat!(
1709            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1710            "\n",
1711            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Reviewing..."}]}}"#,
1712            "\n",
1713            r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}","usage":{"input_tokens":100,"output_tokens":20}}"#,
1714        );
1715        let text = runner().extract_text(raw).unwrap();
1716        assert_eq!(
1717            text,
1718            r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#
1719        );
1720    }
1721
1722    /// Claude stream-json with markdown-wrapped ReviewResponse in result field.
1723    #[test]
1724    fn extract_text_stream_json_markdown_result() {
1725        let review_json =
1726            r#"```json\n{"decision":"request_changes","notes":"Fix it","issues":[]}\n```"#;
1727        let result_line = format!(
1728            r#"{{"type":"result","subtype":"success","is_error":false,"result":"{review_json}","usage":{{"input_tokens":10,"output_tokens":5}}}}"#
1729        );
1730        let raw = format!(
1731            "{}\n{}",
1732            r#"{"type":"system","subtype":"init","session_id":"s1"}"#, result_line
1733        );
1734        let text = runner().extract_text(&raw).unwrap();
1735        assert!(
1736            text.contains("request_changes"),
1737            "expected review JSON in result, got: {text}"
1738        );
1739    }
1740
1741    /// Claude stream-json error (is_error=true, rate limit): extract_text propagates RateLimit.
1742    #[test]
1743    fn extract_text_stream_json_rate_limit_propagates() {
1744        let raw = concat!(
1745            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1746            "\n",
1747            r#"{"type":"result","subtype":"error","is_error":true,"result":"rate limit exceeded: 429 Too Many Requests","usage":{"input_tokens":0,"output_tokens":0}}"#,
1748        );
1749        let err = runner().extract_text(raw).unwrap_err();
1750        assert!(
1751            matches!(err, AgentError::RateLimit { .. }),
1752            "expected RateLimit, got: {err:?}"
1753        );
1754    }
1755
1756    #[test]
1757    fn extract_stream_json_fallbacks_to_opencode_extractor_on_leading_init() {
1758        // Some Claude wrappers emit a leading system/init envelope and then
1759        // place the real JSON payload inside an assistant message content array
1760        // (no final "type":"result" line). Ensure our extractor falls back
1761        // to the opencode-style NDJSON scanner and returns the inner JSON.
1762        let raw = concat!(
1763            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1764            "\n",
1765            // The inner JSON must be escaped inside the outer JSON string
1766            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"{\"status\":\"done\",\"summary\":\"fallback\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}]}}"#,
1767        );
1768
1769        let text = extract_stream_json_result_text(raw).expect("should fallback and extract JSON");
1770        let parsed: serde_json::Value =
1771            serde_json::from_str(&text).expect("extracted text must be JSON");
1772        assert_eq!(parsed["status"], "done");
1773        assert_eq!(parsed["summary"], "fallback");
1774    }
1775
1776    /// Claude stream-json error (is_error=true, auth): extract_text propagates Auth.
1777    #[test]
1778    fn extract_text_stream_json_auth_error_propagates() {
1779        let raw = concat!(
1780            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1781            "\n",
1782            r#"{"type":"result","subtype":"error","is_error":true,"result":"401 Unauthorized: invalid api key","usage":{"input_tokens":0,"output_tokens":0}}"#,
1783        );
1784        let err = runner().extract_text(raw).unwrap_err();
1785        assert!(
1786            matches!(err, AgentError::Auth { .. }),
1787            "expected Auth, got: {err:?}"
1788        );
1789    }
1790
1791    /// extract_text on empty input returns empty string (not an error).
1792    #[test]
1793    fn extract_text_empty_input_returns_empty_ok() {
1794        let text = runner().extract_text("").unwrap();
1795        assert!(text.is_empty());
1796    }
1797
1798    // ── find_claude_result ──────────────────────────────────────
1799
1800    #[test]
1801    fn find_claude_result_success_with_tokens() {
1802        let ndjson = concat!(
1803            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1804            "\n",
1805            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"working..."}]}}"#,
1806            "\n",
1807            r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\"}","usage":{"input_tokens":1500,"output_tokens":200},"duration_ms":5000}"#,
1808        );
1809        let result = find_claude_result(ndjson).expect("should find result");
1810        assert!(!result.is_error);
1811        assert!(result.result_text.contains("approve"));
1812        assert_eq!(result.input_tokens, Some(1500));
1813        assert_eq!(result.output_tokens, Some(200));
1814        assert_eq!(result.duration_ms, Some(5000));
1815    }
1816
1817    #[test]
1818    fn find_claude_result_error_flag() {
1819        let ndjson = r#"{"type":"result","subtype":"error","is_error":true,"result":"rate limit exceeded","usage":{}}"#;
1820        let result = find_claude_result(ndjson).expect("should find result");
1821        assert!(result.is_error);
1822        assert!(result.result_text.contains("rate limit"));
1823    }
1824
1825    #[test]
1826    fn find_claude_result_error_envelope_with_empty_result_uses_errors() {
1827        // Grok 402: no `result` field, message only in errors[] — must not
1828        // become an empty AgentFailed (issue #3713)
1829        let ndjson = r#"{"type":"result","subtype":"error_during_execution","is_error":true,"num_turns":0,"errors":["Internal error: {\"message\":\"API error (status 402 Payment Required): Grok Build usage balance exhausted\",\"http_status\":402}"]}"#;
1830        let result = find_claude_result(ndjson).expect("should find result");
1831        assert!(result.is_error);
1832        assert!(result.result_text.contains("usage balance exhausted"));
1833        assert!(crate::engine::cooldown::detect_credit_exhaustion(&result.result_text).is_some());
1834    }
1835
1836    #[test]
1837    fn find_claude_result_empty_returns_none() {
1838        assert!(find_claude_result("").is_none());
1839        assert!(find_claude_result("   ").is_none());
1840    }
1841
1842    #[test]
1843    fn find_claude_result_no_result_line_falls_back_to_assistant() {
1844        // MiniMax/Kimi pattern: no type:result, just assistant messages
1845        let ndjson = concat!(
1846            r#"{"type":"system","subtype":"init","model":"MiniMax-M2.7"}"#,
1847            "\n",
1848            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"analysis complete"}],"usage":{"input_tokens":800,"output_tokens":150}}}"#,
1849        );
1850        let result = find_claude_result(ndjson).expect("should fall back to assistant text");
1851        assert!(!result.is_error);
1852        assert_eq!(result.result_text, "analysis complete");
1853        assert_eq!(result.input_tokens, Some(800));
1854        assert_eq!(result.output_tokens, Some(150));
1855    }
1856
1857    #[test]
1858    fn find_claude_result_plain_text_returns_none() {
1859        // Plain text (not NDJSON) should return None
1860        assert!(find_claude_result("just some plain text output").is_none());
1861    }
1862
1863    #[test]
1864    fn strip_tty_control_lines_drops_show_cursor_and_ctrl_d_redraw() {
1865        let redraw = "^D\u{0008}".repeat(20);
1866        let raw = format!(
1867            "{}\n{}\n{}\n{}\n{}",
1868            r#"{"type":"system","subtype":"init"}"#,
1869            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"hello"}]}}"#,
1870            SHOW_CURSOR,
1871            redraw,
1872            r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}"#,
1873        );
1874        let cleaned = strip_tty_control_lines(&raw);
1875        assert!(
1876            !cleaned.contains(SHOW_CURSOR),
1877            "show-cursor sequence must be stripped"
1878        );
1879        assert!(
1880            !cleaned.contains('\u{0008}'),
1881            "backspace redraw line must be stripped"
1882        );
1883        assert!(cleaned.contains("\"type\":\"result\""));
1884        assert!(cleaned.contains("hello"));
1885    }
1886
1887    #[test]
1888    fn strip_tty_control_lines_strips_trailing_cursor_on_json_line() {
1889        let line = format!(
1890            r#"{{"type":"result","subtype":"success","is_error":false,"result":"ok"}}{SHOW_CURSOR}"#
1891        );
1892        let cleaned = strip_tty_control_lines(&line);
1893        assert_eq!(
1894            cleaned,
1895            r#"{"type":"result","subtype":"success","is_error":false,"result":"ok"}"#
1896        );
1897    }
1898
1899    #[test]
1900    fn find_claude_result_ignores_trailing_tty_junk() {
1901        let ndjson = format!(
1902            "{}\n{}\n{}",
1903            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"working"}]}}"#,
1904            r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}","usage":{"input_tokens":10,"output_tokens":4}}"#,
1905            SHOW_CURSOR,
1906        );
1907        let result = find_claude_result(&ndjson).expect("result envelope should still parse");
1908        assert!(result.result_text.contains("approve"));
1909        assert_eq!(result.input_tokens, Some(10));
1910    }
1911
1912    #[test]
1913    fn review_session_exited_mid_thought_detects_thinking_without_envelope() {
1914        let ndjson = concat!(
1915            r#"{"type":"system","subtype":"thinking_tokens","tokens":12}"#,
1916            "\n",
1917            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"CI passes. Now verifying callers:"}],"stop_reason":null,"usage":{"output_tokens":0}}}"#,
1918            "\n",
1919            r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"checking lease edge cases"}],"stop_reason":null,"usage":{"output_tokens":0}}}"#,
1920        );
1921        assert!(review_session_exited_mid_thought(ndjson));
1922        assert!(find_ndjson_result_line(ndjson).is_none());
1923    }
1924
1925    #[test]
1926    fn review_session_exited_mid_thought_detects_tool_use_without_envelope() {
1927        let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs"}}]}}"#;
1928        assert!(review_session_exited_mid_thought(ndjson));
1929    }
1930
1931    #[test]
1932    fn review_session_exited_mid_thought_false_when_envelope_present() {
1933        let ndjson = concat!(
1934            r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"done"}]}}"#,
1935            "\n",
1936            r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}"#,
1937        );
1938        assert!(!review_session_exited_mid_thought(ndjson));
1939    }
1940
1941    #[test]
1942    fn review_session_exited_mid_thought_false_for_finished_text() {
1943        let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"I have finished looking at the code."}]}}"#;
1944        assert!(!review_session_exited_mid_thought(ndjson));
1945    }
1946
1947    #[test]
1948    fn find_claude_result_model_usage() {
1949        let ndjson = r#"{"type":"result","subtype":"success","is_error":false,"result":"done","modelUsage":{"claude-sonnet-4-20250514":{"inputTokens":5000,"outputTokens":1000}},"duration_ms":3000}"#;
1950        let result = find_claude_result(ndjson).expect("should extract modelUsage");
1951        assert_eq!(result.input_tokens, Some(5000));
1952        assert_eq!(result.output_tokens, Some(1000));
1953    }
1954
1955    // ── Regression tests for issue #1387 ──────────────────────────────────────
1956
1957    /// Regression test: when an NDJSON stream has multiple type:result lines —
1958    /// one with JSON in the result field (inner task delegate) and one with
1959    /// plain text (outer session summary) — find_ndjson_result_line must
1960    /// skip the plain-text one and return the JSON one.
1961    #[test]
1962    fn find_ndjson_result_line_skips_plain_text_result() {
1963        // Build test NDJSON using serde_json to avoid string-escaping confusion.
1964        let inner_result = serde_json::json!({"status": "done", "summary": "fix applied"});
1965        let json_result_line = serde_json::json!({
1966            "type": "result",
1967            "subtype": "success",
1968            "is_error": false,
1969            "duration_ms": 273044,
1970            "result": inner_result.to_string(),
1971            "usage": {"input_tokens": 12, "output_tokens": 1577}
1972        })
1973        .to_string();
1974        let plain_result_line = serde_json::json!({
1975            "type": "result",
1976            "subtype": "success",
1977            "is_error": false,
1978            "duration_ms": 2352,
1979            "result": "All 2352 tests passed.",
1980            "usage": {"input_tokens": 3, "output_tokens": 16}
1981        })
1982        .to_string();
1983        // Concatenate without using concat! to avoid string-escaping headaches.
1984        let mut ndjson = json_result_line.clone();
1985        ndjson.push('\n');
1986        ndjson.push_str(r#"{"type":"system","subtype":"task_notification"}"#);
1987        ndjson.push('\n');
1988        ndjson.push_str(r#"{"type":"system","subtype":"init"}"#);
1989        ndjson.push('\n');
1990        ndjson.push_str(
1991            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"summary here"}]}}"#,
1992        );
1993        ndjson.push('\n');
1994        ndjson.push_str(&plain_result_line);
1995
1996        let line = find_ndjson_result_line(&ndjson)
1997            .expect("should find a result line")
1998            .0;
1999        // Must be the JSON result (inner), not the plain-text one (outer).
2000        let parsed: serde_json::Value =
2001            serde_json::from_str(line).expect("returned line must be valid JSON");
2002        let result_val = parsed.get("result").expect("result field must exist");
2003        assert!(
2004            result_val.is_string(),
2005            "result field must be a JSON string (not plain text), got: {result_val}"
2006        );
2007        let result_str = result_val.as_str().unwrap();
2008        assert!(
2009            result_str.starts_with('{'),
2010            "result must be JSON (object), got: {result_str}"
2011        );
2012        assert!(
2013            !result_str.contains("All 2352 tests passed"),
2014            "must NOT return outer plain-text result: {result_str}"
2015        );
2016    }
2017
2018    /// Regression test: when BOTH result lines have plain-text results (no JSON),
2019    /// find_ndjson_result_line must return the one with more tokens — not the last.
2020    /// This is the actual task 1384 scenario: inner result has 12+1577 tokens,
2021    /// outer confirmation has 3+16 tokens. Must pick the inner one (issue #1387).
2022    #[test]
2023    fn find_ndjson_result_line_prefers_more_tokens_on_plain_text() {
2024        // Build NDJSON where both results are plain text.
2025        let inner_result_line = serde_json::json!({
2026            "type": "result",
2027            "subtype": "success",
2028            "is_error": false,
2029            "duration_ms": 273044,
2030            "result": "All 2352 tests pass. The fix is clean and correct.",
2031            "usage": {"input_tokens": 12, "output_tokens": 1577}
2032        })
2033        .to_string();
2034        let outer_result_line = serde_json::json!({
2035            "type": "result",
2036            "subtype": "success",
2037            "is_error": false,
2038            "duration_ms": 2352,
2039            "result": "Already retrieved the result — all 2352 tests passed.",
2040            "usage": {"input_tokens": 3, "output_tokens": 16}
2041        })
2042        .to_string();
2043
2044        // Inner result FIRST, outer result LAST (the actual task 1384 ordering).
2045        let ndjson = format!(
2046            "{0}\n{1}\n{2}",
2047            inner_result_line, r#"{"type":"system","subtype":"init"}"#, outer_result_line
2048        );
2049
2050        let (line, tokens) = find_ndjson_result_line(&ndjson).expect("should find a result line");
2051
2052        // Must return the inner result (more tokens), not the outer one.
2053        assert!(
2054            line.contains("The fix is clean"),
2055            "expected inner result (more tokens), got: {line}"
2056        );
2057        // Tokens must match the inner result.
2058        assert_eq!(
2059            tokens,
2060            Some((12, 1577)),
2061            "tokens must be from the inner result"
2062        );
2063    }
2064
2065    /// Regression test: when parse_response encounters plain-text results
2066    /// and falls back to synthesis, tokens from the envelope must be preserved.
2067    #[test]
2068    fn parse_response_preserves_tokens_on_synthesis_fallback() {
2069        // Both results are plain text (no JSON in result field).
2070        // parse_response should synthesize but preserve the envelope tokens.
2071        let ndjson = concat!(
2072            r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":273044,"result":"All 2352 tests pass. The fix is clean and correct.","usage":{"input_tokens":12,"output_tokens":1577}}"#,
2073            "\n",
2074            r#"{"type":"system","subtype":"init"}"#,
2075            "\n",
2076            r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":2352,"result":"Already retrieved the result — all 2352 tests passed.","usage":{"input_tokens":3,"output_tokens":16}}"#,
2077        );
2078
2079        let parsed = runner()
2080            .parse_response(ndjson)
2081            .expect("should parse successfully");
2082
2083        // Must preserve tokens from the inner (higher-token) result.
2084        assert_eq!(
2085            parsed.input_tokens,
2086            Some(12),
2087            "input_tokens must be preserved (not lost on synthesis fallback)"
2088        );
2089        assert_eq!(
2090            parsed.output_tokens,
2091            Some(1577),
2092            "output_tokens must be preserved (not lost on synthesis fallback)"
2093        );
2094    }
2095
2096    #[test]
2097    fn parse_response_uses_inner_json_result_preserving_tokens() {
2098        // Simulates task 1384: outer session says "tests passed" (plain text),
2099        // but inner task delegate result is JSON with tokens.
2100        let ndjson = concat!(
2101            r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":273044,"result":"{\"status\":\"done\",\"summary\":\"fix applied\",\"accomplished\":[\"fix\"],\"remaining\":[],\"files\":[]}","usage":{"input_tokens":12,"output_tokens":1577}}"#,
2102            "\n",
2103            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
2104            "\n",
2105            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"tests passed"}]}}"#,
2106            "\n",
2107            r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":2352,"result":"All 2352 tests passed.","usage":{"input_tokens":3,"output_tokens":16}}"#,
2108        );
2109
2110        let parsed = runner()
2111            .parse_response(ndjson)
2112            .expect("should parse successfully");
2113
2114        // Must use the inner JSON result (fix applied), not outer plain text.
2115        assert_eq!(
2116            parsed.response.status, "done",
2117            "expected inner JSON result status, got: {:?}",
2118            parsed.response.status
2119        );
2120        assert_eq!(
2121            parsed.response.summary.as_str(),
2122            "fix applied",
2123            "expected inner JSON result summary, got: {:?}",
2124            parsed.response.summary
2125        );
2126        // Tokens must be from the inner result with tokens, not the outer plain-text one.
2127        assert_eq!(
2128            parsed.input_tokens,
2129            Some(12),
2130            "input_tokens must be from inner result (not outer plain-text result)"
2131        );
2132        assert_eq!(
2133            parsed.output_tokens,
2134            Some(1577),
2135            "output_tokens must be from inner result (not outer plain-text result)"
2136        );
2137    }
2138
2139    /// Regression test: when find_ndjson_result_line returns None (no JSON
2140    /// result found), extract_stream_json_result_text must fall back to
2141    /// opencode-style text extraction, not return raw NDJSON.
2142    #[test]
2143    fn extract_text_falls_back_to_opencode_style_when_no_json_result() {
2144        // An NDJSON stream with no type:result at all — just a text event.
2145        let ndjson = concat!(
2146            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
2147            "\n",
2148            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"All tasks complete"}]}}"#,
2149        );
2150
2151        let text = runner().extract_text(ndjson).expect("should extract text");
2152        // Must extract the assistant message text, not raw NDJSON.
2153        assert!(
2154            text.contains("All tasks complete"),
2155            "expected extracted assistant text, got: {text}"
2156        );
2157        assert!(!text.contains("type"), "must not return raw NDJSON: {text}");
2158    }
2159}
2160
2161/// Claude `stream-json`: calls and text are blocks in `assistant` messages, results in `user` ones
2162pub(crate) fn stream_events(v: &serde_json::Value) -> Vec<StreamEvent> {
2163    let blocks = || v["message"]["content"].as_array().into_iter().flatten();
2164    match json_str(v, "/type") {
2165        "system" if json_str(v, "/subtype") == "init" => vec![StreamEvent::Started {
2166            model: json_str(v, "/model").to_string(),
2167        }],
2168        "assistant" => blocks()
2169            .filter_map(|b| match json_str(b, "/type") {
2170                "text" => Some(StreamEvent::Text(json_str(b, "/text").to_string())),
2171                "thinking" => Some(StreamEvent::Thinking(json_str(b, "/thinking").to_string())),
2172                "tool_use" => Some(StreamEvent::ToolCall {
2173                    id: json_str(b, "/id").to_string(),
2174                    name: json_str(b, "/name").to_string(),
2175                    input: b["input"].clone(),
2176                }),
2177                _ => None,
2178            })
2179            .collect(),
2180        "user" => blocks()
2181            .filter(|b| json_str(b, "/type") == "tool_result")
2182            .map(|b| StreamEvent::ToolResult {
2183                id: json_str(b, "/tool_use_id").to_string(),
2184                name: String::new(),
2185                output: match &b["content"] {
2186                    serde_json::Value::String(s) => s.clone(),
2187                    serde_json::Value::Array(parts) => parts
2188                        .iter()
2189                        .filter_map(|p| p["text"].as_str())
2190                        .collect::<Vec<_>>()
2191                        .join("\n"),
2192                    _ => String::new(),
2193                },
2194                is_error: b["is_error"].as_bool() == Some(true),
2195            })
2196            .collect(),
2197        "error" => vec![StreamEvent::Error(json_error_message(v))],
2198        "result" => {
2199            let failed = v["is_error"].as_bool() == Some(true)
2200                || json_str(v, "/subtype").starts_with("error");
2201            if failed {
2202                return vec![StreamEvent::Error(json_str(v, "/result").to_string())];
2203            }
2204
2205            let turns = v["num_turns"].as_u64().unwrap_or(0);
2206            let secs = v["duration_ms"].as_u64().unwrap_or(0) / 1000;
2207            let time = if secs >= 60 {
2208                format!("{}m {}s", secs / 60, secs % 60)
2209            } else {
2210                format!("{secs}s")
2211            };
2212            vec![StreamEvent::Done(format!("{turns} turns · {time}"))]
2213        }
2214        _ => vec![],
2215    }
2216}
2217
2218#[cfg(test)]
2219mod stream_tests {
2220    use super::*;
2221    use serde_json::json;
2222
2223    fn events(line: &str) -> Vec<StreamEvent> {
2224        ClaudeRunner::new("claude").stream_events(&serde_json::from_str(line).unwrap())
2225    }
2226
2227    #[test]
2228    fn init_reports_model() {
2229        let line = r#"{"type":"system","subtype":"init","model":"claude-sonnet-5","cwd":"/w"}"#;
2230        assert_eq!(
2231            events(line),
2232            vec![StreamEvent::Started {
2233                model: "claude-sonnet-5".into()
2234            }]
2235        );
2236    }
2237
2238    #[test]
2239    fn assistant_blocks_in_order() {
2240        let line = r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"plan"},{"type":"text","text":"Reading."},{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"a.rs"}}]}}"#;
2241        assert_eq!(
2242            events(line),
2243            vec![
2244                StreamEvent::Thinking("plan".into()),
2245                StreamEvent::Text("Reading.".into()),
2246                StreamEvent::ToolCall {
2247                    id: "t1".into(),
2248                    name: "Read".into(),
2249                    input: json!({"file_path": "a.rs"}),
2250                },
2251            ]
2252        );
2253    }
2254
2255    #[test]
2256    fn tool_result_string_and_block_content() {
2257        let line = r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"},{"type":"tool_result","tool_use_id":"t2","content":[{"type":"text","text":"a"},{"type":"text","text":"b"}],"is_error":true}]}}"#;
2258        assert_eq!(
2259            events(line),
2260            vec![
2261                StreamEvent::ToolResult {
2262                    id: "t1".into(),
2263                    name: String::new(),
2264                    output: "ok".into(),
2265                    is_error: false,
2266                },
2267                StreamEvent::ToolResult {
2268                    id: "t2".into(),
2269                    name: String::new(),
2270                    output: "a\nb".into(),
2271                    is_error: true,
2272                },
2273            ]
2274        );
2275    }
2276
2277    #[test]
2278    fn result_success_and_error() {
2279        let ok = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":66,"duration_ms":580214}"#;
2280        assert_eq!(
2281            events(ok),
2282            vec![StreamEvent::Done("66 turns · 9m 40s".into())]
2283        );
2284        let err = r#"{"type":"result","subtype":"error_max_turns","is_error":true,"result":"too many turns"}"#;
2285        assert_eq!(
2286            events(err),
2287            vec![StreamEvent::Error("too many turns".into())]
2288        );
2289    }
2290
2291    #[test]
2292    fn noise_events_are_dropped() {
2293        for line in [
2294            r#"{"type":"system","subtype":"thinking_tokens","tokens":12}"#,
2295            r#"{"type":"system","subtype":"task_started"}"#,
2296            r#"{"type":"rate_limit_event","rate_limit_info":{}}"#,
2297            r#"{"type":"tool_progress"}"#,
2298        ] {
2299            assert!(events(line).is_empty(), "{line}");
2300        }
2301    }
2302}