Skip to main content

orch/engine/runner/agents/
claude.rs

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