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