Skip to main content

orch/engine/runner/agents/
claude.rs

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