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        let combined = match (&error_result, &system_error) {
490            (Some(r), Some(s)) => format!("{s}\n{r}\n{stdout}\n{stderr}"),
491            (Some(r), None) => format!("{r}\n{stdout}\n{stderr}"),
492            (None, Some(s)) => format!("{s}\n{stdout}\n{stderr}"),
493            (None, None) => format!("{stdout}\n{stderr}"),
494        };
495        // Envelope billing text is prepended into the same 3000-byte head as hook
496        // UUIDs, so classify the envelope before the combined scan
497        if let Some(message) = &error_result {
498            if let Some(e) = super::patterns::detect_auth_error(message) {
499                return e;
500            }
501        }
502        let classified = super::patterns::classify_from_text(exit_code, &combined);
503
504        // `classify_from_text`'s generic catch-all truncates to the last 300
505        // bytes of the combined text, which can cut a perfectly well-formed
506        // `error_result` line off mid-field-name (e.g. `is_error` becomes
507        // `i_error`) once enough NDJSON precedes it (issue #3577). When we
508        // already extracted a clean, complete error message from the result
509        // envelope but none of the specific pattern detectors matched it,
510        // prefer that clean message over the byte-truncated fallback.
511        if let (AgentError::Unknown { .. }, Some(message)) = (&classified, &error_result) {
512            if !message.is_empty() {
513                return AgentError::AgentFailed {
514                    message: message.clone(),
515                };
516            }
517        }
518        classified
519    }
520
521    fn router_command(
522        &self,
523        prompt: &str,
524        model: Option<&str>,
525    ) -> anyhow::Result<tokio::process::Command> {
526        let mut cmd = tokio::process::Command::new(&self.binary);
527        cmd.env_remove("CLAUDECODE"); // allow nested invocation
528        cmd.arg("--output-format")
529            .arg("stream-json")
530            .arg("--verbose")
531            .arg("--print");
532        if let Some(m) = model.map(str::trim).filter(|m| !m.is_empty()) {
533            cmd.arg("--model").arg(m);
534        }
535        cmd.arg(prompt);
536        Ok(cmd)
537    }
538}
539
540/// Known Claude Code native tool names.
541/// These are passed as-is to `--allowedTools`.
542const CLAUDE_NATIVE_TOOLS: &[&str] = &[
543    "Edit",
544    "Write",
545    "Read",
546    "Bash",
547    "Grep",
548    "Glob",
549    "WebFetch",
550    "WebSearch",
551    "Task",
552    "Skill",
553    "NotebookEdit",
554    "EnterPlanMode",
555    "ExitPlanMode",
556    "EnterWorktree",
557    "AskUserQuestion",
558    "TodoWrite",
559    "TodoRead",
560];
561
562/// Translate a unified allowed_tools list into Claude `--allowedTools` patterns.
563///
564/// - Items matching known Claude tool names pass through as-is
565/// - Edit/Write are scoped to `allowed_edit_paths` when set
566/// - Other items are treated as CLI commands and become `Bash(<cmd> *)` patterns
567fn translate_allowed_tools(
568    allowed: &[String],
569    allowed_edit_paths: &[std::path::PathBuf],
570) -> Vec<String> {
571    let mut tools = Vec::new();
572    for item in allowed {
573        if (item == "Edit" || item == "Write") && !allowed_edit_paths.is_empty() {
574            // Scope Edit/Write to allowed paths only
575            for path in allowed_edit_paths {
576                tools.push(format!("{}({}/*)", item, path.display()));
577            }
578        } else if CLAUDE_NATIVE_TOOLS.contains(&item.as_str()) {
579            tools.push(item.clone());
580        } else {
581            // CLI command → Bash(<cmd> *) pattern
582            tools.push(format!("Bash({item} *)"));
583        }
584    }
585    tools
586}
587
588/// Find the best NDJSON result line and its token usage.
589///
590/// Returns `(line, tokens)` where `tokens` is `Some((input, output))` or `None`
591/// if no usage data was found.
592///
593/// Selection priority:
594/// 1. JSON result fields (object/array or string that parses as JSON) — always preferred
595/// 2. Among plain-text results: the one with the most total tokens (preferring actual
596///    work over outer-session confirmation summaries)
597/// 3. Error results (is_error=true) — included even with no tokens, so the caller can
598///    classify them properly
599///
600/// This skips outer-session plain-text summaries in favor of inner task results
601/// with meaningful token counts (issue #1387).
602pub(crate) fn find_ndjson_result_line(text: &str) -> Option<(&str, Option<(u64, u64)>)> {
603    let lines: Vec<_> = text.lines().filter(|l| !l.trim().is_empty()).collect();
604
605    // Pass 1: find a result line with a JSON result field.
606    // JSON results are always preferred (inner task delegate output).
607    for line in lines.iter().rev() {
608        if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
609            if let Some(obj) = val.as_object() {
610                if obj.get("type").and_then(|t| t.as_str()) == Some("result") {
611                    if let Some(result) = obj.get("result") {
612                        let is_json_result = match result {
613                            serde_json::Value::Object(_) | serde_json::Value::Array(_) => true,
614                            serde_json::Value::String(s) => {
615                                serde_json::from_str::<serde_json::Value>(s).is_ok()
616                            }
617                            _ => false,
618                        };
619                        if is_json_result {
620                            let tokens = extract_result_tokens(obj);
621                            return Some((line, tokens));
622                        }
623                    }
624                }
625            }
626        }
627    }
628
629    // Pass 2: no JSON result found — choose the plain-text result with the most tokens.
630    // This handles outer-session confirmation summaries (e.g. "Already retrieved the result")
631    // vs. actual work summaries: prefer the one with more tokens.
632    // Error results (is_error=true) are included even with zero tokens, so the caller
633    // can classify them properly.
634    let mut best_line: Option<&str> = None;
635    let mut best_total = 0u64;
636
637    for line in &lines {
638        if let Ok(val) = serde_json::from_str::<serde_json::Value>(line) {
639            if let Some(obj) = val.as_object() {
640                if obj.get("type").and_then(|t| t.as_str()) == Some("result") {
641                    let tokens = extract_result_tokens(obj);
642                    let total = tokens.map(|(i, o)| i.saturating_add(o)).unwrap_or(0);
643                    if total >= best_total {
644                        best_total = total;
645                        best_line = Some(*line);
646                    }
647                }
648            }
649        }
650    }
651
652    best_line.map(|line| (line, extract_result_tokens_from_line(line)))
653}
654
655/// Extract input/output token counts from a parsed result envelope object.
656fn extract_result_tokens(obj: &serde_json::Map<String, serde_json::Value>) -> Option<(u64, u64)> {
657    // Try top-level `usage` first.
658    if let Some(usage) = obj.get("usage").and_then(|v| v.as_object()) {
659        let input = usage.get("input_tokens").and_then(|v| v.as_u64());
660        let output = usage.get("output_tokens").and_then(|v| v.as_u64());
661        if let (Some(i), Some(o)) = (input, output) {
662            return Some((i, o));
663        }
664    }
665    // Try modelUsage (aggregated across models).
666    if let Some(model_usage) = obj.get("modelUsage").and_then(|v| v.as_object()) {
667        let mut total_input = 0u64;
668        let mut total_output = 0u64;
669        let mut found = false;
670        for (_model_name, model_stats) in model_usage {
671            if let Some(stats) = model_stats.as_object() {
672                if let Some(i) = stats
673                    .get("inputTokens")
674                    .or_else(|| stats.get("input_tokens"))
675                    .and_then(|v| v.as_u64())
676                {
677                    total_input += i;
678                    found = true;
679                }
680                if let Some(o) = stats
681                    .get("outputTokens")
682                    .or_else(|| stats.get("output_tokens"))
683                    .and_then(|v| v.as_u64())
684                {
685                    total_output += o;
686                    found = true;
687                }
688            }
689        }
690        if found {
691            return Some((total_input, total_output));
692        }
693    }
694    None
695}
696
697/// Extract token counts from a raw NDJSON result line string.
698fn extract_result_tokens_from_line(line: &str) -> Option<(u64, u64)> {
699    let val = serde_json::from_str::<serde_json::Value>(line).ok()?;
700    let obj = val.as_object()?;
701    extract_result_tokens(obj)
702}
703
704/// Extract input/output tokens from the Claude envelope's usage objects.
705fn extract_usage(
706    envelope: &serde_json::Map<String, serde_json::Value>,
707) -> (Option<u64>, Option<u64>) {
708    // Try top-level `usage` first
709    if let Some(usage) = envelope.get("usage").and_then(|v| v.as_object()) {
710        let input = usage.get("input_tokens").and_then(|v| v.as_u64());
711        let output = usage.get("output_tokens").and_then(|v| v.as_u64());
712        if input.is_some() || output.is_some() {
713            return (input, output);
714        }
715    }
716
717    // Try modelUsage (aggregated across models)
718    if let Some(model_usage) = envelope.get("modelUsage").and_then(|v| v.as_object()) {
719        let mut total_input = 0u64;
720        let mut total_output = 0u64;
721        let mut found = false;
722
723        for (_model_name, model_stats) in model_usage {
724            if let Some(stats) = model_stats.as_object() {
725                if let Some(i) = stats
726                    .get("inputTokens")
727                    .or_else(|| stats.get("input_tokens"))
728                    .and_then(|v| v.as_u64())
729                {
730                    total_input += i;
731                    found = true;
732                }
733                if let Some(o) = stats
734                    .get("outputTokens")
735                    .or_else(|| stats.get("output_tokens"))
736                    .and_then(|v| v.as_u64())
737                {
738                    total_output += o;
739                    found = true;
740                }
741            }
742        }
743
744        if found {
745            return (Some(total_input), Some(total_output));
746        }
747    }
748
749    (None, None)
750}
751
752const SHOW_CURSOR: &str = "\x1b[?25h";
753const HIDE_CURSOR: &str = "\x1b[?25l";
754
755/// Drop `script` tty junk: show-cursor (`ESC[?25h`) and `^D`/backspace redraw lines
756pub fn strip_tty_control_lines(raw: &str) -> String {
757    raw.lines()
758        .filter(|line| !is_tty_control_line(line))
759        .map(strip_trailing_cursor)
760        .collect::<Vec<_>>()
761        .join("\n")
762}
763
764fn strip_trailing_cursor(line: &str) -> &str {
765    let mut s = line.trim_end();
766    loop {
767        if let Some(next) = s
768            .strip_suffix(SHOW_CURSOR)
769            .or_else(|| s.strip_suffix(HIDE_CURSOR))
770        {
771            s = next.trim_end();
772            continue;
773        }
774        return s;
775    }
776}
777
778fn is_tty_control_line(line: &str) -> bool {
779    let trimmed = strip_trailing_cursor(line).trim();
780    if trimmed.is_empty() {
781        return !line.trim().is_empty();
782    }
783    trimmed.chars().all(|c| matches!(c, '^' | 'D' | '\u{0008}'))
784}
785
786/// Type of the last content block on the last assistant NDJSON message
787fn last_assistant_content_type(ndjson: &str) -> Option<String> {
788    let mut last = None;
789    for line in ndjson.lines() {
790        let line = line.trim();
791        if line.is_empty() {
792            continue;
793        }
794        let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
795            continue;
796        };
797        if val.get("type").and_then(|v| v.as_str()) != Some("assistant") {
798            continue;
799        }
800        let Some(content) = val
801            .get("message")
802            .and_then(|m| m.get("content"))
803            .and_then(|c| c.as_array())
804        else {
805            continue;
806        };
807        if let Some(item) = content.last() {
808            if let Some(t) = item.get("type").and_then(|v| v.as_str()) {
809                last = Some(t.to_string());
810            }
811        }
812    }
813    last
814}
815
816/// True when the stream has no result envelope and ended on a thinking block
817/// or a tool call, so the model never produced a finished review decision
818pub fn review_session_exited_mid_thought(ndjson: &str) -> bool {
819    if find_ndjson_result_line(ndjson).is_some() {
820        return false;
821    }
822    matches!(
823        last_assistant_content_type(ndjson).as_deref(),
824        Some("thinking" | "redacted_thinking" | "tool_use")
825    )
826}
827
828/// Extract a structured `AgentResult` from Claude/Kimi/MiniMax NDJSON output.
829///
830/// Finds the last `"type":"result"` line whose `result` field contains valid JSON.
831/// This skips outer-session plain-text summaries (issue #1387) and finds the
832/// actual task-delegate result with structured JSON.
833///
834/// Extracts:
835/// - `result` text (the inner content)
836/// - `is_error` flag
837/// - token usage from `usage` or `modelUsage`
838/// - `duration_ms`
839///
840/// If no JSON result line exists (common with MiniMax), falls back to
841/// extracting the last assistant text message from the stream.
842///
843/// Returns `None` only if the input contains no recognizable NDJSON events.
844pub fn find_claude_result(ndjson: &str) -> Option<super::AgentResult> {
845    let stripped = strip_tty_control_lines(ndjson);
846    let trimmed = stripped.trim();
847    if trimmed.is_empty() {
848        return None;
849    }
850
851    // Try to find the result envelope first
852    if let Some((result_line, _tokens)) = find_ndjson_result_line(trimmed) {
853        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(result_line) {
854            if let Some(envelope) = parsed.as_object() {
855                let is_error = envelope
856                    .get("is_error")
857                    .and_then(|v| v.as_bool())
858                    .unwrap_or(false);
859
860                let result_text = if is_error {
861                    // Error envelopes put the real message in errors[] (billing,
862                    // rate limits) with `result` empty — don't lose it (issue #3713)
863                    envelope_error_text(envelope)
864                } else {
865                    envelope
866                        .get("result")
867                        .and_then(|v| v.as_str())
868                        .unwrap_or("")
869                        .to_string()
870                };
871
872                let (input_tokens, output_tokens) = extract_usage(envelope);
873                let duration_ms = envelope.get("duration_ms").and_then(|v| v.as_u64());
874
875                let cost_usd = envelope
876                    .get("costUSD")
877                    .or_else(|| envelope.get("cost_usd"))
878                    .and_then(|v| v.as_f64());
879
880                return Some(super::AgentResult {
881                    is_error,
882                    result_text,
883                    input_tokens,
884                    output_tokens,
885                    cost_usd,
886                    duration_ms,
887                    truncated_by_length: false,
888                });
889            }
890        }
891    }
892
893    // Fallback for MiniMax/Kimi: extract last assistant text message
894    let mut last_text = None;
895    let mut input_tokens = None;
896    let mut output_tokens = None;
897
898    for line in trimmed.lines() {
899        let line = line.trim();
900        if line.is_empty() {
901            continue;
902        }
903        let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
904            continue;
905        };
906        if val.get("type").and_then(|v| v.as_str()) != Some("assistant") {
907            continue;
908        }
909        // Extract text from content array
910        if let Some(content) = val
911            .get("message")
912            .and_then(|m| m.get("content"))
913            .and_then(|c| c.as_array())
914        {
915            for item in content {
916                if item.get("type").and_then(|v| v.as_str()) == Some("text") {
917                    if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
918                        let t = text.trim();
919                        if !t.is_empty() {
920                            last_text = Some(t.to_string());
921                        }
922                    }
923                }
924            }
925        }
926        // Extract usage (take max, as Claude reports cumulative)
927        if let Some(usage) = val.get("message").and_then(|m| m.get("usage")) {
928            if let Some(inp) = usage.get("input_tokens").and_then(|v| v.as_u64()) {
929                input_tokens = Some(input_tokens.unwrap_or(0u64).max(inp));
930            }
931            if let Some(out) = usage.get("output_tokens").and_then(|v| v.as_u64()) {
932                output_tokens = Some(output_tokens.unwrap_or(0u64).max(out));
933            }
934        }
935    }
936
937    last_text.map(|text| super::AgentResult {
938        is_error: false,
939        result_text: text,
940        input_tokens,
941        output_tokens,
942        cost_usd: None,
943        duration_ms: None,
944        truncated_by_length: false,
945    })
946}
947
948/// Concatenate text content from all `type:assistant` NDJSON messages in
949/// the stream.  Used as a fallback when the `type:result` envelope text
950/// does not contain valid AgentResponse JSON but an earlier assistant turn
951/// might have emitted it.
952pub fn collect_assistant_messages_text(ndjson: &str) -> String {
953    let mut parts: Vec<String> = Vec::new();
954    for line in ndjson.trim().lines() {
955        let line = line.trim();
956        if line.is_empty() {
957            continue;
958        }
959        let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
960            continue;
961        };
962        if val.get("type").and_then(|v| v.as_str()) != Some("assistant") {
963            continue;
964        }
965        if let Some(content) = val
966            .get("message")
967            .and_then(|m| m.get("content"))
968            .and_then(|c| c.as_array())
969        {
970            for item in content {
971                if item.get("type").and_then(|v| v.as_str()) == Some("text") {
972                    if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
973                        let t = text.trim();
974                        if !t.is_empty() {
975                            parts.push(t.to_string());
976                        }
977                    }
978                }
979            }
980        }
981    }
982    parts.join("\n")
983}
984
985#[cfg(test)]
986mod tests {
987    use super::*;
988
989    fn runner() -> ClaudeRunner {
990        ClaudeRunner::new("claude")
991    }
992
993    #[test]
994    fn error_envelope_uses_errors_array_when_result_missing() {
995        let raw = r#"{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["API error (status 402 Payment Required): usage balance exhausted"]}"#;
996        match runner().parse_response(raw) {
997            Err(AgentError::AgentFailed { message } | AgentError::Auth { message }) => {
998                assert!(crate::engine::cooldown::detect_credit_exhaustion(&message).is_some());
999            }
1000            other => panic!("unexpected: {other:?}"),
1001        }
1002    }
1003
1004    #[test]
1005    fn error_envelope_falls_back_to_subtype() {
1006        let raw = r#"{"type":"result","subtype":"error_during_execution","is_error":true}"#;
1007        match runner().parse_response(raw) {
1008            Err(AgentError::AgentFailed { message }) => {
1009                assert_eq!(message, "error_during_execution")
1010            }
1011            other => panic!("unexpected: {other:?}"),
1012        }
1013    }
1014
1015    #[test]
1016    fn parse_claude_envelope_success() {
1017        let raw = r#"{
1018            "type": "result",
1019            "subtype": "success",
1020            "is_error": false,
1021            "duration_ms": 2006,
1022            "result": "{\"status\":\"done\",\"summary\":\"hello\",\"accomplished\":[],\"remaining\":[],\"files\":[]}",
1023            "usage": {"input_tokens": 10, "output_tokens": 78},
1024            "permission_denials": []
1025        }"#;
1026
1027        let parsed = runner().parse_response(raw).unwrap();
1028        assert_eq!(parsed.response.status, "done");
1029        assert_eq!(parsed.response.summary, "hello");
1030        assert_eq!(parsed.input_tokens, Some(10));
1031        assert_eq!(parsed.output_tokens, Some(78));
1032        assert_eq!(parsed.duration_ms, Some(2006));
1033    }
1034
1035    #[test]
1036    fn parse_claude_envelope_with_markdown_result() {
1037        let raw = r#"{
1038            "type": "result",
1039            "subtype": "success",
1040            "is_error": false,
1041            "duration_ms": 5000,
1042            "result": "```json\n{\"status\": \"done\", \"summary\": \"fixed bug\"}\n```",
1043            "usage": {"input_tokens": 100, "output_tokens": 50},
1044            "permission_denials": ["Bash(rm *)"]
1045        }"#;
1046
1047        let parsed = runner().parse_response(raw).unwrap();
1048        assert_eq!(parsed.response.status, "done");
1049        assert_eq!(parsed.response.summary, "fixed bug");
1050    }
1051
1052    #[test]
1053    fn parse_claude_envelope_error() {
1054        let raw = r#"{
1055            "type": "result",
1056            "subtype": "error",
1057            "is_error": true,
1058            "result": "credit balance too low",
1059            "usage": {},
1060            "permission_denials": []
1061        }"#;
1062
1063        let err = runner().parse_response(raw).unwrap_err();
1064        assert!(matches!(err, AgentError::Auth { .. }));
1065    }
1066
1067    #[test]
1068    fn parse_claude_envelope_rate_limit_error() {
1069        let raw = r#"{
1070            "type": "result",
1071            "subtype": "error",
1072            "is_error": true,
1073            "result": "rate limit exceeded, please try again later",
1074            "usage": {},
1075            "permission_denials": []
1076        }"#;
1077
1078        let err = runner().parse_response(raw).unwrap_err();
1079        assert!(matches!(err, AgentError::RateLimit { .. }));
1080    }
1081
1082    #[test]
1083    fn parse_envelope_no_false_positive_from_work_product_result() {
1084        // Regression test for issue #1292: when is_error=false but the result
1085        // text contains rate-limit keywords (agent describing work it did),
1086        // parse_response must NOT return a RateLimit error.
1087        //
1088        // Fix for issue #1377: when parser::parse fails on plain-text output,
1089        // synthesize_response_from_text is tried and tokens are preserved.
1090        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();
1091        // Plain-text result with rate-limit keywords: parser::parse fails, but
1092        // synthesis succeeds — result must be Ok (tokens preserved) not RateLimit.
1093        let parsed = runner()
1094            .parse_response(&raw)
1095            .expect("plain-text work product must synthesize successfully");
1096        // Tokens must be preserved from the envelope (issue #1377)
1097        assert_eq!(
1098            parsed.input_tokens,
1099            Some(10),
1100            "input_tokens must be preserved"
1101        );
1102        assert_eq!(
1103            parsed.output_tokens,
1104            Some(5),
1105            "output_tokens must be preserved"
1106        );
1107    }
1108
1109    #[test]
1110    fn parse_direct_json_no_envelope() {
1111        let raw =
1112            r#"{"status":"done","summary":"hello","accomplished":[],"remaining":[],"files":[]}"#;
1113
1114        let parsed = runner().parse_response(raw).unwrap();
1115        assert_eq!(parsed.response.status, "done");
1116        assert!(parsed.input_tokens.is_none());
1117    }
1118
1119    #[test]
1120    fn parse_empty_response() {
1121        let err = runner().parse_response("").unwrap_err();
1122        assert!(matches!(err, AgentError::InvalidResponse { .. }));
1123    }
1124
1125    #[test]
1126    fn classify_error_rate_limit() {
1127        let err = runner().classify_error(1, "", "429 Too Many Requests");
1128        assert!(matches!(err, AgentError::RateLimit { .. }));
1129    }
1130
1131    #[test]
1132    fn classify_error_detects_session_limit_from_mid_stream_system_event() {
1133        // Regression test for issue #3272: Claude session limit errors appear as
1134        // `type:system` NDJSON events mid-stream, not as `type:result` lines with
1135        // `is_error:true`. When significant work product exists before and after
1136        // the system event, it is outside both head and tail scan windows in
1137        // `classify_from_text`. The `classify_error` function must extract the
1138        // error message from the system event and prepend it to the combined text.
1139        let header_events = "{\"type\":\"conversation\",\"subtype\":\"init\"}\n\
1140             {\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"I'll work on this task.\"}]}}\n"
1141            .repeat(80);
1142        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)"}}"#;
1143        let work_product = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Here is the implementation...\"}]}}\n"
1144            .repeat(80);
1145        let stdout = format!("{header_events}{system_event}\n{work_product}");
1146        // Verify the system event is deep enough to miss head and tail scans.
1147        assert!(
1148            stdout.len() > 6000,
1149            "test stdout must exceed combined head+tail windows, got {} bytes",
1150            stdout.len()
1151        );
1152        let err = runner().classify_error(1, &stdout, "");
1153        assert!(
1154            matches!(err, AgentError::RateLimit { .. }),
1155            "session limit system event mid-stream must be detected, got: {err:?}"
1156        );
1157        let msg = err.to_string();
1158        assert!(
1159            msg.contains("session limit"),
1160            "error message should contain the rate limit reason, got: {msg}"
1161        );
1162    }
1163
1164    #[test]
1165    fn classify_error_402_envelope_not_masked_by_429_in_hook_uuid() {
1166        // Regression for issue #3760: a 4295 hook UUID in the first 3000 bytes
1167        // classified a 402 envelope as a rate limit whose message was hook prose
1168        let hook = r#"{"type":"system","subtype":"init","hook":"SessionStart","uuid":"a1b2c3d4-4295-4e5f-8a9b-0c1d2e3f4a5b","outputTokens":429}"#;
1169        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";
1170        let padding = hook_prose.repeat(40);
1171        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)"}"#;
1172        let stdout = format!("{hook}\n{padding}\n{result_line}\n");
1173        assert!(
1174            stdout.len() > 3000,
1175            "fixture must put the 4295 UUID inside the 3000-byte head, got {} bytes",
1176            stdout.len()
1177        );
1178        let err = runner().classify_error(1, &stdout, "");
1179        assert!(
1180            matches!(err, AgentError::Auth { .. }),
1181            "402 envelope must classify as Auth, got: {err:?}"
1182        );
1183        let msg = err.to_string();
1184        assert!(
1185            msg.contains("insufficient balance"),
1186            "stored error must keep the 402 text, got: {msg}"
1187        );
1188    }
1189
1190    #[test]
1191    fn classify_error_prefers_clean_result_text_over_byte_truncated_tail() {
1192        // Regression test for issue #3577: a well-formed `type:result`
1193        // envelope with `is_error:true` was correctly extracted into
1194        // `error_result`, but `classify_from_text`'s generic Unknown
1195        // fallback then re-truncates the combined stdout+stderr to its last
1196        // 300 bytes — which, once enough preceding NDJSON pushes the result
1197        // line's start past that window, cuts it off mid-field-name (e.g.
1198        // `"is_error"` becomes `"i_error"`). The stored error ends up a
1199        // garbled JSON fragment instead of the clean message, and (in the
1200        // fallback.rs non-zero-exit path) skips the model cooldown that the
1201        // AgentFailed branch applies.
1202        let padding = "{\"type\":\"system\",\"subtype\":\"init\"}\n".repeat(50);
1203        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"}"#;
1204        let stdout = format!("{padding}{result_line}\n");
1205        // Sanity check: the message text itself is not caught by any of the
1206        // specific keyword detectors (auth/rate_limit/context_overflow), so
1207        // without the fix this must fall to the generic Unknown branch.
1208        assert!(
1209            stdout.len() > 300,
1210            "test stdout must exceed the tail-truncation window"
1211        );
1212
1213        let err = runner().classify_error(1, &stdout, "");
1214        assert!(
1215            matches!(err, AgentError::AgentFailed { .. }),
1216            "expected AgentFailed with the clean message, got: {err:?}"
1217        );
1218        let msg = err.to_string();
1219        assert_eq!(
1220            msg, "agent failed: Failed to authenticate: OAuth session expired and could not be refreshed",
1221            "stored error must be the clean result text, not a byte-truncated JSON fragment"
1222        );
1223        assert!(
1224            !msg.contains("i_error"),
1225            "stored error must not contain the byte-truncated JSON fragment, got: {msg}"
1226        );
1227    }
1228
1229    #[test]
1230    fn classify_error_timeout() {
1231        let err = runner().classify_error(124, "", "");
1232        assert!(matches!(err, AgentError::Timeout { .. }));
1233    }
1234
1235    #[test]
1236    fn extract_usage_from_model_usage() {
1237        let response_json: serde_json::Value = serde_json::json!({
1238            "modelUsage": {
1239                "claude-haiku-4-5-20251001": {
1240                    "inputTokens": 1000,
1241                    "outputTokens": 500
1242                }
1243            }
1244        });
1245        let envelope = response_json.as_object().unwrap();
1246        let (input, output) = extract_usage(envelope);
1247        assert_eq!(input, Some(1000));
1248        assert_eq!(output, Some(500));
1249    }
1250
1251    #[test]
1252    fn build_command_skips_an_empty_model() {
1253        let r = runner();
1254        let perms = PermissionRules {
1255            autonomous: true,
1256            sandbox: SandboxLevel::WorkspaceWrite,
1257            disallowed_tools: vec![],
1258            allowed_tools: vec![],
1259            allowed_edit_paths: vec![],
1260            deny_read_only: false,
1261            extra_writable_dirs: vec![],
1262        };
1263        for m in ["", "  "] {
1264            let cmd = r.build_command(Some(m), "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1265            assert!(!cmd.contains("--model"), "{cmd}");
1266        }
1267    }
1268
1269    #[test]
1270    fn build_command_with_model() {
1271        let r = runner();
1272        let perms = PermissionRules {
1273            autonomous: true,
1274            sandbox: SandboxLevel::WorkspaceWrite,
1275            disallowed_tools: vec!["Bash(rm *)".to_string()],
1276            allowed_tools: vec![],
1277            allowed_edit_paths: vec![],
1278            deny_read_only: false,
1279            extra_writable_dirs: vec![],
1280        };
1281        let cmd = r.build_command(
1282            Some("opus"),
1283            "timeout 1800",
1284            "/tmp/sys.txt",
1285            "/tmp/msg.txt",
1286            &perms,
1287        );
1288        assert!(cmd.contains("--model 'opus'"));
1289        assert!(cmd.contains("claude -p"));
1290        assert!(cmd.contains("--output-format stream-json"));
1291        assert!(cmd.contains("--permission-mode bypassPermissions"));
1292        assert!(cmd.contains("--disallowedTools 'Bash(rm *)'"));
1293    }
1294
1295    #[test]
1296    fn build_command_supervised_mode() {
1297        let r = runner();
1298        let perms = PermissionRules {
1299            autonomous: false,
1300            sandbox: SandboxLevel::WorkspaceWrite,
1301            disallowed_tools: vec![],
1302            allowed_tools: vec![],
1303            allowed_edit_paths: vec![],
1304            deny_read_only: false,
1305            extra_writable_dirs: vec![],
1306        };
1307        let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1308        assert!(cmd.contains("--permission-mode acceptEdits"));
1309    }
1310
1311    #[test]
1312    fn build_command_allowed_edit_paths_scopes_edit_write() {
1313        let r = runner();
1314        let perms = PermissionRules {
1315            autonomous: true,
1316            sandbox: SandboxLevel::WorkspaceWrite,
1317            disallowed_tools: vec![],
1318            allowed_tools: vec![
1319                "Edit".to_string(),
1320                "Write".to_string(),
1321                "Read".to_string(),
1322                "Bash".to_string(),
1323            ],
1324            allowed_edit_paths: vec![std::path::PathBuf::from("/home/user/worktree")],
1325            deny_read_only: false,
1326            extra_writable_dirs: vec![],
1327        };
1328        let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1329
1330        // Edit/Write should be scoped to the worktree path
1331        assert!(
1332            cmd.contains("Edit(/home/user/worktree/*)"),
1333            "expected scoped Edit, got: {cmd}"
1334        );
1335        assert!(
1336            cmd.contains("Write(/home/user/worktree/*)"),
1337            "expected scoped Write, got: {cmd}"
1338        );
1339
1340        // Read and Bash should not be scoped
1341        assert!(
1342            cmd.contains(",Read,") || cmd.contains("'Read,") || cmd.contains(",Read'"),
1343            "expected unrestricted Read, got: {cmd}"
1344        );
1345        assert!(
1346            cmd.contains(",Bash,") || cmd.contains("'Bash,") || cmd.contains(",Bash'"),
1347            "expected unrestricted Bash, got: {cmd}"
1348        );
1349    }
1350
1351    #[test]
1352    fn kimi_runner_name() {
1353        let r = ClaudeRunner::new("kimi");
1354        assert_eq!(r.name(), "kimi");
1355    }
1356
1357    // ── Fixture-based tests ─────────────────────────────────────
1358
1359    #[test]
1360    fn fixture_claude_success() {
1361        let raw = include_str!("../../../../tests/fixtures/claude_success.json");
1362        let parsed = runner().parse_response(raw).unwrap();
1363        assert_eq!(parsed.response.status, "done");
1364        assert!(parsed.response.summary.contains("Implemented"));
1365        assert_eq!(parsed.response.accomplished.len(), 2);
1366        assert_eq!(parsed.input_tokens, Some(15000));
1367        assert_eq!(parsed.output_tokens, Some(3500));
1368        assert_eq!(parsed.duration_ms, Some(45000));
1369    }
1370
1371    #[test]
1372    fn fixture_claude_rate_limit() {
1373        let raw = include_str!("../../../../tests/fixtures/claude_error_rate_limit.json");
1374        let err = runner().parse_response(raw).unwrap_err();
1375        assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
1376    }
1377
1378    // ── NDJSON / stream-json fixtures ────────────────────────────
1379
1380    #[test]
1381    fn fixture_claude_stream_success() {
1382        let raw = include_str!("../../../../tests/fixtures/claude_stream_success.jsonl");
1383        let parsed = runner().parse_response(raw).unwrap();
1384        assert_eq!(parsed.response.status, "done");
1385        assert!(parsed.response.summary.contains("Implemented"));
1386        assert_eq!(parsed.response.accomplished.len(), 2);
1387        assert_eq!(parsed.input_tokens, Some(15000));
1388        assert_eq!(parsed.output_tokens, Some(3500));
1389        assert_eq!(parsed.duration_ms, Some(45000));
1390    }
1391
1392    #[test]
1393    fn fixture_claude_stream_error() {
1394        let raw = include_str!("../../../../tests/fixtures/claude_stream_error.jsonl");
1395        let err = runner().parse_response(raw).unwrap_err();
1396        assert!(matches!(err, AgentError::Auth { .. }), "got: {err:?}");
1397    }
1398
1399    #[test]
1400    fn fixture_claude_stream_rate_limit() {
1401        let raw = include_str!("../../../../tests/fixtures/claude_stream_rate_limit.jsonl");
1402        let err = runner().parse_response(raw).unwrap_err();
1403        assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
1404    }
1405
1406    #[test]
1407    fn fixture_claude_stream_thinking_block_conflict() {
1408        let raw =
1409            include_str!("../../../../tests/fixtures/claude_stream_thinking_block_conflict.jsonl");
1410        let err = runner().parse_response(raw).unwrap_err();
1411        assert!(
1412            matches!(err, AgentError::ThinkingBlockConflict { .. }),
1413            "expected ThinkingBlockConflict, got: {err:?}"
1414        );
1415    }
1416
1417    #[test]
1418    fn classify_error_thinking_block_conflict_from_stderr() {
1419        // The error can also surface through classify_error() when the claude
1420        // process exits non-zero with the message in stdout/stderr.
1421        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.";
1422        let err = runner().classify_error(1, "", stderr);
1423        assert!(
1424            matches!(err, AgentError::ThinkingBlockConflict { .. }),
1425            "expected ThinkingBlockConflict, got: {err:?}"
1426        );
1427    }
1428
1429    #[test]
1430    fn thinking_block_conflict_does_not_match_unrelated_text() {
1431        // Ensure the pattern is specific enough to not false-positive on
1432        // work product that mentions "thinking" or "blocks".
1433        let unrelated = "I was thinking about how to handle blocks of code in the response.";
1434        let err = runner().classify_error(1, "", unrelated);
1435        assert!(
1436            !matches!(err, AgentError::ThinkingBlockConflict { .. }),
1437            "should not classify unrelated text as ThinkingBlockConflict, got: {err:?}"
1438        );
1439    }
1440
1441    #[test]
1442    fn parse_ndjson_with_intermediate_events() {
1443        // Parser must ignore intermediate events and only look at the result line
1444        let raw = concat!(
1445            r#"{"type":"system","subtype":"init"}"#,
1446            "\n",
1447            r#"{"type":"assistant","message":{"role":"assistant","content":[]}}"#,
1448            "\n",
1449            r#"{"type":"tool_use","tool":{"name":"Read","input":{}}}"#,
1450            "\n",
1451            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}}"#,
1452            "\n"
1453        );
1454        let parsed = runner().parse_response(raw).unwrap();
1455        assert_eq!(parsed.response.status, "done");
1456        assert_eq!(parsed.response.summary, "streamed");
1457        assert_eq!(parsed.input_tokens, Some(5));
1458        assert_eq!(parsed.output_tokens, Some(10));
1459    }
1460
1461    #[test]
1462    fn parse_ndjson_skips_malformed_lines() {
1463        // Malformed intermediate lines should be ignored; result line still parsed
1464        let raw = concat!(
1465            "not valid json\n",
1466            r#"{"type":"partial_update","data":"something"}"#,
1467            "\n",
1468            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}}"#,
1469            "\n"
1470        );
1471        let parsed = runner().parse_response(raw).unwrap();
1472        assert_eq!(parsed.response.status, "done");
1473        assert_eq!(parsed.response.summary, "ok");
1474    }
1475
1476    #[test]
1477    fn parse_single_line_json_backward_compat() {
1478        // Single-line JSON (--output-format json) still works
1479        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}}"#;
1480        let parsed = runner().parse_response(raw).unwrap();
1481        assert_eq!(parsed.response.status, "done");
1482        assert_eq!(parsed.response.summary, "backward compat");
1483        assert_eq!(parsed.input_tokens, Some(50));
1484        assert_eq!(parsed.output_tokens, Some(25));
1485    }
1486
1487    // ── Kimi / MiniMax (Claude-compatible wrappers) ───────────────
1488
1489    #[test]
1490    fn kimi_parses_claude_envelope() {
1491        let r = ClaudeRunner::new("kimi");
1492        let raw = r#"{
1493            "type": "result",
1494            "subtype": "success",
1495            "is_error": false,
1496            "duration_ms": 3000,
1497            "result": "{\"status\":\"done\",\"summary\":\"kimi did it\",\"accomplished\":[\"task\"],\"remaining\":[],\"files\":[\"a.rs\"]}",
1498            "usage": {"input_tokens": 200, "output_tokens": 100}
1499        }"#;
1500
1501        let parsed = r.parse_response(raw).unwrap();
1502        assert_eq!(parsed.response.status, "done");
1503        assert_eq!(parsed.response.summary, "kimi did it");
1504    }
1505
1506    #[test]
1507    fn minimax_parses_claude_envelope() {
1508        let r = ClaudeRunner::new("minimax");
1509        assert_eq!(r.name(), "minimax");
1510        let raw = r#"{
1511            "type": "result",
1512            "subtype": "success",
1513            "is_error": false,
1514            "result": "{\"status\":\"done\",\"summary\":\"minimax did it\",\"accomplished\":[],\"remaining\":[],\"files\":[]}",
1515            "usage": {}
1516        }"#;
1517
1518        let parsed = r.parse_response(raw).unwrap();
1519        assert_eq!(parsed.response.status, "done");
1520    }
1521
1522    #[test]
1523    fn kimi_build_command_uses_kimi_binary() {
1524        let r = ClaudeRunner::new("kimi");
1525        let perms = PermissionRules {
1526            autonomous: true,
1527            sandbox: SandboxLevel::WorkspaceWrite,
1528            disallowed_tools: vec![],
1529            allowed_tools: vec![],
1530            allowed_edit_paths: vec![],
1531            deny_read_only: false,
1532            extra_writable_dirs: vec![],
1533        };
1534        let cmd = r.build_command(
1535            Some("sonnet"),
1536            "timeout 1800",
1537            "/tmp/sys.txt",
1538            "/tmp/msg.txt",
1539            &perms,
1540        );
1541        assert!(cmd.contains("kimi -p"), "expected kimi binary, got: {cmd}");
1542        assert!(cmd.contains("--model 'sonnet'"));
1543    }
1544
1545    #[test]
1546    fn build_command_with_allowed_tools() {
1547        let r = runner();
1548        let perms = PermissionRules {
1549            autonomous: true,
1550            sandbox: SandboxLevel::WorkspaceWrite,
1551            disallowed_tools: vec!["Bash(rm *)".to_string()], // should be ignored
1552            allowed_tools: vec![
1553                "Edit".to_string(),
1554                "Write".to_string(),
1555                "Read".to_string(),
1556                "Bash".to_string(),
1557                "Grep".to_string(),
1558                "git".to_string(),
1559                "npm".to_string(),
1560            ],
1561            allowed_edit_paths: vec![],
1562            deny_read_only: false,
1563            extra_writable_dirs: vec![],
1564        };
1565        let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1566
1567        // Should use --allowedTools instead of --disallowedTools
1568        assert!(
1569            cmd.contains("--allowedTools"),
1570            "expected --allowedTools flag, got: {cmd}"
1571        );
1572        assert!(
1573            !cmd.contains("--disallowedTools"),
1574            "should not have --disallowedTools when allowed_tools is set, got: {cmd}"
1575        );
1576
1577        // Native tools pass through as-is
1578        assert!(cmd.contains("Edit"), "missing Edit tool");
1579        assert!(cmd.contains("Write"), "missing Write tool");
1580        assert!(cmd.contains("Bash"), "missing Bash tool");
1581
1582        // CLI commands become Bash(<cmd> *) patterns
1583        assert!(
1584            cmd.contains("Bash(git *)"),
1585            "missing Bash(git *) pattern, got: {cmd}"
1586        );
1587        assert!(
1588            cmd.contains("Bash(npm *)"),
1589            "missing Bash(npm *) pattern, got: {cmd}"
1590        );
1591    }
1592
1593    #[test]
1594    fn build_command_allowed_tools_empty_falls_back_to_disallowed() {
1595        let r = runner();
1596        let perms = PermissionRules {
1597            autonomous: true,
1598            sandbox: SandboxLevel::WorkspaceWrite,
1599            disallowed_tools: vec!["Bash(rm *)".to_string()],
1600            allowed_tools: vec![], // empty = use disallowed_tools
1601            allowed_edit_paths: vec![],
1602            deny_read_only: false,
1603            extra_writable_dirs: vec![],
1604        };
1605        let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1606
1607        assert!(
1608            cmd.contains("--disallowedTools"),
1609            "should fall back to --disallowedTools when allowed_tools is empty, got: {cmd}"
1610        );
1611        assert!(
1612            !cmd.contains("--allowedTools"),
1613            "should not have --allowedTools when empty, got: {cmd}"
1614        );
1615    }
1616
1617    #[test]
1618    fn translate_allowed_tools_categorizes_correctly() {
1619        let allowed = vec![
1620            "Edit".to_string(),
1621            "Bash".to_string(),
1622            "Grep".to_string(),
1623            "git".to_string(),
1624            "cargo".to_string(),
1625            "WebFetch".to_string(),
1626        ];
1627        // No allowed_edit_paths → Edit passes through as-is
1628        let result = translate_allowed_tools(&allowed, &[]);
1629        assert_eq!(
1630            result,
1631            vec![
1632                "Edit",
1633                "Bash",
1634                "Grep",
1635                "Bash(git *)",
1636                "Bash(cargo *)",
1637                "WebFetch"
1638            ]
1639        );
1640    }
1641
1642    #[test]
1643    fn translate_allowed_tools_scopes_edit_write() {
1644        let allowed = vec!["Edit".to_string(), "Write".to_string(), "Read".to_string()];
1645        let paths = vec![std::path::PathBuf::from("/tmp/worktree")];
1646        let result = translate_allowed_tools(&allowed, &paths);
1647        assert_eq!(
1648            result,
1649            vec!["Edit(/tmp/worktree/*)", "Write(/tmp/worktree/*)", "Read"]
1650        );
1651    }
1652
1653    #[test]
1654    fn classify_error_overloaded_529() {
1655        // Claude 529 overloaded error
1656        let err = runner().classify_error(1, "", "overloaded_error: 529");
1657        assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
1658    }
1659
1660    #[test]
1661    fn classify_error_context_length() {
1662        let err = runner().classify_error(1, "", "context_length_exceeded: max 200000 tokens");
1663        assert!(
1664            matches!(err, AgentError::ContextOverflow { .. }),
1665            "got: {err:?}"
1666        );
1667    }
1668
1669    // ── extract_text ─────────────────────────────────────────────
1670
1671    /// Claude stream-json: extract_text returns the inner result text for review parsing.
1672    #[test]
1673    fn extract_text_stream_json_returns_result_field() {
1674        let raw = concat!(
1675            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1676            "\n",
1677            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Reviewing..."}]}}"#,
1678            "\n",
1679            r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}","usage":{"input_tokens":100,"output_tokens":20}}"#,
1680        );
1681        let text = runner().extract_text(raw).unwrap();
1682        assert_eq!(
1683            text,
1684            r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#
1685        );
1686    }
1687
1688    /// Claude stream-json with markdown-wrapped ReviewResponse in result field.
1689    #[test]
1690    fn extract_text_stream_json_markdown_result() {
1691        let review_json =
1692            r#"```json\n{"decision":"request_changes","notes":"Fix it","issues":[]}\n```"#;
1693        let result_line = format!(
1694            r#"{{"type":"result","subtype":"success","is_error":false,"result":"{review_json}","usage":{{"input_tokens":10,"output_tokens":5}}}}"#
1695        );
1696        let raw = format!(
1697            "{}\n{}",
1698            r#"{"type":"system","subtype":"init","session_id":"s1"}"#, result_line
1699        );
1700        let text = runner().extract_text(&raw).unwrap();
1701        assert!(
1702            text.contains("request_changes"),
1703            "expected review JSON in result, got: {text}"
1704        );
1705    }
1706
1707    /// Claude stream-json error (is_error=true, rate limit): extract_text propagates RateLimit.
1708    #[test]
1709    fn extract_text_stream_json_rate_limit_propagates() {
1710        let raw = concat!(
1711            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1712            "\n",
1713            r#"{"type":"result","subtype":"error","is_error":true,"result":"rate limit exceeded: 429 Too Many Requests","usage":{"input_tokens":0,"output_tokens":0}}"#,
1714        );
1715        let err = runner().extract_text(raw).unwrap_err();
1716        assert!(
1717            matches!(err, AgentError::RateLimit { .. }),
1718            "expected RateLimit, got: {err:?}"
1719        );
1720    }
1721
1722    #[test]
1723    fn extract_stream_json_fallbacks_to_opencode_extractor_on_leading_init() {
1724        // Some Claude wrappers emit a leading system/init envelope and then
1725        // place the real JSON payload inside an assistant message content array
1726        // (no final "type":"result" line). Ensure our extractor falls back
1727        // to the opencode-style NDJSON scanner and returns the inner JSON.
1728        let raw = concat!(
1729            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1730            "\n",
1731            // The inner JSON must be escaped inside the outer JSON string
1732            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"{\"status\":\"done\",\"summary\":\"fallback\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}]}}"#,
1733        );
1734
1735        let text = extract_stream_json_result_text(raw).expect("should fallback and extract JSON");
1736        let parsed: serde_json::Value =
1737            serde_json::from_str(&text).expect("extracted text must be JSON");
1738        assert_eq!(parsed["status"], "done");
1739        assert_eq!(parsed["summary"], "fallback");
1740    }
1741
1742    /// Claude stream-json error (is_error=true, auth): extract_text propagates Auth.
1743    #[test]
1744    fn extract_text_stream_json_auth_error_propagates() {
1745        let raw = concat!(
1746            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1747            "\n",
1748            r#"{"type":"result","subtype":"error","is_error":true,"result":"401 Unauthorized: invalid api key","usage":{"input_tokens":0,"output_tokens":0}}"#,
1749        );
1750        let err = runner().extract_text(raw).unwrap_err();
1751        assert!(
1752            matches!(err, AgentError::Auth { .. }),
1753            "expected Auth, got: {err:?}"
1754        );
1755    }
1756
1757    /// extract_text on empty input returns empty string (not an error).
1758    #[test]
1759    fn extract_text_empty_input_returns_empty_ok() {
1760        let text = runner().extract_text("").unwrap();
1761        assert!(text.is_empty());
1762    }
1763
1764    // ── find_claude_result ──────────────────────────────────────
1765
1766    #[test]
1767    fn find_claude_result_success_with_tokens() {
1768        let ndjson = concat!(
1769            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1770            "\n",
1771            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"working..."}]}}"#,
1772            "\n",
1773            r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\"}","usage":{"input_tokens":1500,"output_tokens":200},"duration_ms":5000}"#,
1774        );
1775        let result = find_claude_result(ndjson).expect("should find result");
1776        assert!(!result.is_error);
1777        assert!(result.result_text.contains("approve"));
1778        assert_eq!(result.input_tokens, Some(1500));
1779        assert_eq!(result.output_tokens, Some(200));
1780        assert_eq!(result.duration_ms, Some(5000));
1781    }
1782
1783    #[test]
1784    fn find_claude_result_error_flag() {
1785        let ndjson = r#"{"type":"result","subtype":"error","is_error":true,"result":"rate limit exceeded","usage":{}}"#;
1786        let result = find_claude_result(ndjson).expect("should find result");
1787        assert!(result.is_error);
1788        assert!(result.result_text.contains("rate limit"));
1789    }
1790
1791    #[test]
1792    fn find_claude_result_error_envelope_with_empty_result_uses_errors() {
1793        // Grok 402: no `result` field, message only in errors[] — must not
1794        // become an empty AgentFailed (issue #3713)
1795        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}"]}"#;
1796        let result = find_claude_result(ndjson).expect("should find result");
1797        assert!(result.is_error);
1798        assert!(result.result_text.contains("usage balance exhausted"));
1799        assert!(crate::engine::cooldown::detect_credit_exhaustion(&result.result_text).is_some());
1800    }
1801
1802    #[test]
1803    fn find_claude_result_empty_returns_none() {
1804        assert!(find_claude_result("").is_none());
1805        assert!(find_claude_result("   ").is_none());
1806    }
1807
1808    #[test]
1809    fn find_claude_result_no_result_line_falls_back_to_assistant() {
1810        // MiniMax/Kimi pattern: no type:result, just assistant messages
1811        let ndjson = concat!(
1812            r#"{"type":"system","subtype":"init","model":"MiniMax-M2.7"}"#,
1813            "\n",
1814            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"analysis complete"}],"usage":{"input_tokens":800,"output_tokens":150}}}"#,
1815        );
1816        let result = find_claude_result(ndjson).expect("should fall back to assistant text");
1817        assert!(!result.is_error);
1818        assert_eq!(result.result_text, "analysis complete");
1819        assert_eq!(result.input_tokens, Some(800));
1820        assert_eq!(result.output_tokens, Some(150));
1821    }
1822
1823    #[test]
1824    fn find_claude_result_plain_text_returns_none() {
1825        // Plain text (not NDJSON) should return None
1826        assert!(find_claude_result("just some plain text output").is_none());
1827    }
1828
1829    #[test]
1830    fn strip_tty_control_lines_drops_show_cursor_and_ctrl_d_redraw() {
1831        let redraw = "^D\u{0008}".repeat(20);
1832        let raw = format!(
1833            "{}\n{}\n{}\n{}\n{}",
1834            r#"{"type":"system","subtype":"init"}"#,
1835            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"hello"}]}}"#,
1836            SHOW_CURSOR,
1837            redraw,
1838            r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}"#,
1839        );
1840        let cleaned = strip_tty_control_lines(&raw);
1841        assert!(
1842            !cleaned.contains(SHOW_CURSOR),
1843            "show-cursor sequence must be stripped"
1844        );
1845        assert!(
1846            !cleaned.contains('\u{0008}'),
1847            "backspace redraw line must be stripped"
1848        );
1849        assert!(cleaned.contains("\"type\":\"result\""));
1850        assert!(cleaned.contains("hello"));
1851    }
1852
1853    #[test]
1854    fn strip_tty_control_lines_strips_trailing_cursor_on_json_line() {
1855        let line = format!(
1856            r#"{{"type":"result","subtype":"success","is_error":false,"result":"ok"}}{SHOW_CURSOR}"#
1857        );
1858        let cleaned = strip_tty_control_lines(&line);
1859        assert_eq!(
1860            cleaned,
1861            r#"{"type":"result","subtype":"success","is_error":false,"result":"ok"}"#
1862        );
1863    }
1864
1865    #[test]
1866    fn find_claude_result_ignores_trailing_tty_junk() {
1867        let ndjson = format!(
1868            "{}\n{}\n{}",
1869            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"working"}]}}"#,
1870            r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}","usage":{"input_tokens":10,"output_tokens":4}}"#,
1871            SHOW_CURSOR,
1872        );
1873        let result = find_claude_result(&ndjson).expect("result envelope should still parse");
1874        assert!(result.result_text.contains("approve"));
1875        assert_eq!(result.input_tokens, Some(10));
1876    }
1877
1878    #[test]
1879    fn review_session_exited_mid_thought_detects_thinking_without_envelope() {
1880        let ndjson = concat!(
1881            r#"{"type":"system","subtype":"thinking_tokens","tokens":12}"#,
1882            "\n",
1883            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"CI passes. Now verifying callers:"}],"stop_reason":null,"usage":{"output_tokens":0}}}"#,
1884            "\n",
1885            r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"checking lease edge cases"}],"stop_reason":null,"usage":{"output_tokens":0}}}"#,
1886        );
1887        assert!(review_session_exited_mid_thought(ndjson));
1888        assert!(find_ndjson_result_line(ndjson).is_none());
1889    }
1890
1891    #[test]
1892    fn review_session_exited_mid_thought_detects_tool_use_without_envelope() {
1893        let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs"}}]}}"#;
1894        assert!(review_session_exited_mid_thought(ndjson));
1895    }
1896
1897    #[test]
1898    fn review_session_exited_mid_thought_false_when_envelope_present() {
1899        let ndjson = concat!(
1900            r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"done"}]}}"#,
1901            "\n",
1902            r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}"#,
1903        );
1904        assert!(!review_session_exited_mid_thought(ndjson));
1905    }
1906
1907    #[test]
1908    fn review_session_exited_mid_thought_false_for_finished_text() {
1909        let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"I have finished looking at the code."}]}}"#;
1910        assert!(!review_session_exited_mid_thought(ndjson));
1911    }
1912
1913    #[test]
1914    fn find_claude_result_model_usage() {
1915        let ndjson = r#"{"type":"result","subtype":"success","is_error":false,"result":"done","modelUsage":{"claude-sonnet-4-20250514":{"inputTokens":5000,"outputTokens":1000}},"duration_ms":3000}"#;
1916        let result = find_claude_result(ndjson).expect("should extract modelUsage");
1917        assert_eq!(result.input_tokens, Some(5000));
1918        assert_eq!(result.output_tokens, Some(1000));
1919    }
1920
1921    // ── Regression tests for issue #1387 ──────────────────────────────────────
1922
1923    /// Regression test: when an NDJSON stream has multiple type:result lines —
1924    /// one with JSON in the result field (inner task delegate) and one with
1925    /// plain text (outer session summary) — find_ndjson_result_line must
1926    /// skip the plain-text one and return the JSON one.
1927    #[test]
1928    fn find_ndjson_result_line_skips_plain_text_result() {
1929        // Build test NDJSON using serde_json to avoid string-escaping confusion.
1930        let inner_result = serde_json::json!({"status": "done", "summary": "fix applied"});
1931        let json_result_line = serde_json::json!({
1932            "type": "result",
1933            "subtype": "success",
1934            "is_error": false,
1935            "duration_ms": 273044,
1936            "result": inner_result.to_string(),
1937            "usage": {"input_tokens": 12, "output_tokens": 1577}
1938        })
1939        .to_string();
1940        let plain_result_line = serde_json::json!({
1941            "type": "result",
1942            "subtype": "success",
1943            "is_error": false,
1944            "duration_ms": 2352,
1945            "result": "All 2352 tests passed.",
1946            "usage": {"input_tokens": 3, "output_tokens": 16}
1947        })
1948        .to_string();
1949        // Concatenate without using concat! to avoid string-escaping headaches.
1950        let mut ndjson = json_result_line.clone();
1951        ndjson.push('\n');
1952        ndjson.push_str(r#"{"type":"system","subtype":"task_notification"}"#);
1953        ndjson.push('\n');
1954        ndjson.push_str(r#"{"type":"system","subtype":"init"}"#);
1955        ndjson.push('\n');
1956        ndjson.push_str(
1957            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"summary here"}]}}"#,
1958        );
1959        ndjson.push('\n');
1960        ndjson.push_str(&plain_result_line);
1961
1962        let line = find_ndjson_result_line(&ndjson)
1963            .expect("should find a result line")
1964            .0;
1965        // Must be the JSON result (inner), not the plain-text one (outer).
1966        let parsed: serde_json::Value =
1967            serde_json::from_str(line).expect("returned line must be valid JSON");
1968        let result_val = parsed.get("result").expect("result field must exist");
1969        assert!(
1970            result_val.is_string(),
1971            "result field must be a JSON string (not plain text), got: {result_val}"
1972        );
1973        let result_str = result_val.as_str().unwrap();
1974        assert!(
1975            result_str.starts_with('{'),
1976            "result must be JSON (object), got: {result_str}"
1977        );
1978        assert!(
1979            !result_str.contains("All 2352 tests passed"),
1980            "must NOT return outer plain-text result: {result_str}"
1981        );
1982    }
1983
1984    /// Regression test: when BOTH result lines have plain-text results (no JSON),
1985    /// find_ndjson_result_line must return the one with more tokens — not the last.
1986    /// This is the actual task 1384 scenario: inner result has 12+1577 tokens,
1987    /// outer confirmation has 3+16 tokens. Must pick the inner one (issue #1387).
1988    #[test]
1989    fn find_ndjson_result_line_prefers_more_tokens_on_plain_text() {
1990        // Build NDJSON where both results are plain text.
1991        let inner_result_line = serde_json::json!({
1992            "type": "result",
1993            "subtype": "success",
1994            "is_error": false,
1995            "duration_ms": 273044,
1996            "result": "All 2352 tests pass. The fix is clean and correct.",
1997            "usage": {"input_tokens": 12, "output_tokens": 1577}
1998        })
1999        .to_string();
2000        let outer_result_line = serde_json::json!({
2001            "type": "result",
2002            "subtype": "success",
2003            "is_error": false,
2004            "duration_ms": 2352,
2005            "result": "Already retrieved the result — all 2352 tests passed.",
2006            "usage": {"input_tokens": 3, "output_tokens": 16}
2007        })
2008        .to_string();
2009
2010        // Inner result FIRST, outer result LAST (the actual task 1384 ordering).
2011        let ndjson = format!(
2012            "{0}\n{1}\n{2}",
2013            inner_result_line, r#"{"type":"system","subtype":"init"}"#, outer_result_line
2014        );
2015
2016        let (line, tokens) = find_ndjson_result_line(&ndjson).expect("should find a result line");
2017
2018        // Must return the inner result (more tokens), not the outer one.
2019        assert!(
2020            line.contains("The fix is clean"),
2021            "expected inner result (more tokens), got: {line}"
2022        );
2023        // Tokens must match the inner result.
2024        assert_eq!(
2025            tokens,
2026            Some((12, 1577)),
2027            "tokens must be from the inner result"
2028        );
2029    }
2030
2031    /// Regression test: when parse_response encounters plain-text results
2032    /// and falls back to synthesis, tokens from the envelope must be preserved.
2033    #[test]
2034    fn parse_response_preserves_tokens_on_synthesis_fallback() {
2035        // Both results are plain text (no JSON in result field).
2036        // parse_response should synthesize but preserve the envelope tokens.
2037        let ndjson = concat!(
2038            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}}"#,
2039            "\n",
2040            r#"{"type":"system","subtype":"init"}"#,
2041            "\n",
2042            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}}"#,
2043        );
2044
2045        let parsed = runner()
2046            .parse_response(ndjson)
2047            .expect("should parse successfully");
2048
2049        // Must preserve tokens from the inner (higher-token) result.
2050        assert_eq!(
2051            parsed.input_tokens,
2052            Some(12),
2053            "input_tokens must be preserved (not lost on synthesis fallback)"
2054        );
2055        assert_eq!(
2056            parsed.output_tokens,
2057            Some(1577),
2058            "output_tokens must be preserved (not lost on synthesis fallback)"
2059        );
2060    }
2061
2062    #[test]
2063    fn parse_response_uses_inner_json_result_preserving_tokens() {
2064        // Simulates task 1384: outer session says "tests passed" (plain text),
2065        // but inner task delegate result is JSON with tokens.
2066        let ndjson = concat!(
2067            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}}"#,
2068            "\n",
2069            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
2070            "\n",
2071            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"tests passed"}]}}"#,
2072            "\n",
2073            r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":2352,"result":"All 2352 tests passed.","usage":{"input_tokens":3,"output_tokens":16}}"#,
2074        );
2075
2076        let parsed = runner()
2077            .parse_response(ndjson)
2078            .expect("should parse successfully");
2079
2080        // Must use the inner JSON result (fix applied), not outer plain text.
2081        assert_eq!(
2082            parsed.response.status, "done",
2083            "expected inner JSON result status, got: {:?}",
2084            parsed.response.status
2085        );
2086        assert_eq!(
2087            parsed.response.summary.as_str(),
2088            "fix applied",
2089            "expected inner JSON result summary, got: {:?}",
2090            parsed.response.summary
2091        );
2092        // Tokens must be from the inner result with tokens, not the outer plain-text one.
2093        assert_eq!(
2094            parsed.input_tokens,
2095            Some(12),
2096            "input_tokens must be from inner result (not outer plain-text result)"
2097        );
2098        assert_eq!(
2099            parsed.output_tokens,
2100            Some(1577),
2101            "output_tokens must be from inner result (not outer plain-text result)"
2102        );
2103    }
2104
2105    /// Regression test: when find_ndjson_result_line returns None (no JSON
2106    /// result found), extract_stream_json_result_text must fall back to
2107    /// opencode-style text extraction, not return raw NDJSON.
2108    #[test]
2109    fn extract_text_falls_back_to_opencode_style_when_no_json_result() {
2110        // An NDJSON stream with no type:result at all — just a text event.
2111        let ndjson = concat!(
2112            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
2113            "\n",
2114            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"All tasks complete"}]}}"#,
2115        );
2116
2117        let text = runner().extract_text(ndjson).expect("should extract text");
2118        // Must extract the assistant message text, not raw NDJSON.
2119        assert!(
2120            text.contains("All tasks complete"),
2121            "expected extracted assistant text, got: {text}"
2122        );
2123        assert!(!text.contains("type"), "must not return raw NDJSON: {text}");
2124    }
2125}
2126
2127/// Claude `stream-json`: calls and text are blocks in `assistant` messages, results in `user` ones
2128pub(crate) fn stream_events(v: &serde_json::Value) -> Vec<StreamEvent> {
2129    let blocks = || v["message"]["content"].as_array().into_iter().flatten();
2130    match json_str(v, "/type") {
2131        "system" if json_str(v, "/subtype") == "init" => vec![StreamEvent::Started {
2132            model: json_str(v, "/model").to_string(),
2133        }],
2134        "assistant" => blocks()
2135            .filter_map(|b| match json_str(b, "/type") {
2136                "text" => Some(StreamEvent::Text(json_str(b, "/text").to_string())),
2137                "thinking" => Some(StreamEvent::Thinking(json_str(b, "/thinking").to_string())),
2138                "tool_use" => Some(StreamEvent::ToolCall {
2139                    id: json_str(b, "/id").to_string(),
2140                    name: json_str(b, "/name").to_string(),
2141                    input: b["input"].clone(),
2142                }),
2143                _ => None,
2144            })
2145            .collect(),
2146        "user" => blocks()
2147            .filter(|b| json_str(b, "/type") == "tool_result")
2148            .map(|b| StreamEvent::ToolResult {
2149                id: json_str(b, "/tool_use_id").to_string(),
2150                name: String::new(),
2151                output: match &b["content"] {
2152                    serde_json::Value::String(s) => s.clone(),
2153                    serde_json::Value::Array(parts) => parts
2154                        .iter()
2155                        .filter_map(|p| p["text"].as_str())
2156                        .collect::<Vec<_>>()
2157                        .join("\n"),
2158                    _ => String::new(),
2159                },
2160                is_error: b["is_error"].as_bool() == Some(true),
2161            })
2162            .collect(),
2163        "error" => vec![StreamEvent::Error(json_error_message(v))],
2164        "result" => {
2165            let failed = v["is_error"].as_bool() == Some(true)
2166                || json_str(v, "/subtype").starts_with("error");
2167            if failed {
2168                return vec![StreamEvent::Error(json_str(v, "/result").to_string())];
2169            }
2170
2171            let turns = v["num_turns"].as_u64().unwrap_or(0);
2172            let secs = v["duration_ms"].as_u64().unwrap_or(0) / 1000;
2173            let time = if secs >= 60 {
2174                format!("{}m {}s", secs / 60, secs % 60)
2175            } else {
2176                format!("{secs}s")
2177            };
2178            vec![StreamEvent::Done(format!("{turns} turns · {time}"))]
2179        }
2180        _ => vec![],
2181    }
2182}
2183
2184#[cfg(test)]
2185mod stream_tests {
2186    use super::*;
2187    use serde_json::json;
2188
2189    fn events(line: &str) -> Vec<StreamEvent> {
2190        ClaudeRunner::new("claude").stream_events(&serde_json::from_str(line).unwrap())
2191    }
2192
2193    #[test]
2194    fn init_reports_model() {
2195        let line = r#"{"type":"system","subtype":"init","model":"claude-sonnet-5","cwd":"/w"}"#;
2196        assert_eq!(
2197            events(line),
2198            vec![StreamEvent::Started {
2199                model: "claude-sonnet-5".into()
2200            }]
2201        );
2202    }
2203
2204    #[test]
2205    fn assistant_blocks_in_order() {
2206        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"}}]}}"#;
2207        assert_eq!(
2208            events(line),
2209            vec![
2210                StreamEvent::Thinking("plan".into()),
2211                StreamEvent::Text("Reading.".into()),
2212                StreamEvent::ToolCall {
2213                    id: "t1".into(),
2214                    name: "Read".into(),
2215                    input: json!({"file_path": "a.rs"}),
2216                },
2217            ]
2218        );
2219    }
2220
2221    #[test]
2222    fn tool_result_string_and_block_content() {
2223        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}]}}"#;
2224        assert_eq!(
2225            events(line),
2226            vec![
2227                StreamEvent::ToolResult {
2228                    id: "t1".into(),
2229                    name: String::new(),
2230                    output: "ok".into(),
2231                    is_error: false,
2232                },
2233                StreamEvent::ToolResult {
2234                    id: "t2".into(),
2235                    name: String::new(),
2236                    output: "a\nb".into(),
2237                    is_error: true,
2238                },
2239            ]
2240        );
2241    }
2242
2243    #[test]
2244    fn result_success_and_error() {
2245        let ok = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":66,"duration_ms":580214}"#;
2246        assert_eq!(
2247            events(ok),
2248            vec![StreamEvent::Done("66 turns · 9m 40s".into())]
2249        );
2250        let err = r#"{"type":"result","subtype":"error_max_turns","is_error":true,"result":"too many turns"}"#;
2251        assert_eq!(
2252            events(err),
2253            vec![StreamEvent::Error("too many turns".into())]
2254        );
2255    }
2256
2257    #[test]
2258    fn noise_events_are_dropped() {
2259        for line in [
2260            r#"{"type":"system","subtype":"thinking_tokens","tokens":12}"#,
2261            r#"{"type":"system","subtype":"task_started"}"#,
2262            r#"{"type":"rate_limit_event","rate_limit_info":{}}"#,
2263            r#"{"type":"tool_progress"}"#,
2264        ] {
2265            assert!(events(line).is_empty(), "{line}");
2266        }
2267    }
2268}