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
743/// Extract a structured `AgentResult` from Claude/Kimi/MiniMax NDJSON output.
744///
745/// Finds the last `"type":"result"` line whose `result` field contains valid JSON.
746/// This skips outer-session plain-text summaries (issue #1387) and finds the
747/// actual task-delegate result with structured JSON.
748///
749/// Extracts:
750/// - `result` text (the inner content)
751/// - `is_error` flag
752/// - token usage from `usage` or `modelUsage`
753/// - `duration_ms`
754///
755/// If no JSON result line exists (common with MiniMax), falls back to
756/// extracting the last assistant text message from the stream.
757///
758/// Returns `None` only if the input contains no recognizable NDJSON events.
759pub fn find_claude_result(ndjson: &str) -> Option<super::AgentResult> {
760    let trimmed = ndjson.trim();
761    if trimmed.is_empty() {
762        return None;
763    }
764
765    // Try to find the result envelope first
766    if let Some((result_line, _tokens)) = find_ndjson_result_line(trimmed) {
767        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(result_line) {
768            if let Some(envelope) = parsed.as_object() {
769                let is_error = envelope
770                    .get("is_error")
771                    .and_then(|v| v.as_bool())
772                    .unwrap_or(false);
773
774                let result_text = envelope
775                    .get("result")
776                    .and_then(|v| v.as_str())
777                    .unwrap_or("")
778                    .to_string();
779
780                let (input_tokens, output_tokens) = extract_usage(envelope);
781                let duration_ms = envelope.get("duration_ms").and_then(|v| v.as_u64());
782
783                let cost_usd = envelope
784                    .get("costUSD")
785                    .or_else(|| envelope.get("cost_usd"))
786                    .and_then(|v| v.as_f64());
787
788                return Some(super::AgentResult {
789                    is_error,
790                    result_text,
791                    input_tokens,
792                    output_tokens,
793                    cost_usd,
794                    duration_ms,
795                    truncated_by_length: false,
796                });
797            }
798        }
799    }
800
801    // Fallback for MiniMax/Kimi: extract last assistant text message
802    let mut last_text = None;
803    let mut input_tokens = None;
804    let mut output_tokens = None;
805
806    for line in trimmed.lines() {
807        let line = line.trim();
808        if line.is_empty() {
809            continue;
810        }
811        let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
812            continue;
813        };
814        if val.get("type").and_then(|v| v.as_str()) != Some("assistant") {
815            continue;
816        }
817        // Extract text from content array
818        if let Some(content) = val
819            .get("message")
820            .and_then(|m| m.get("content"))
821            .and_then(|c| c.as_array())
822        {
823            for item in content {
824                if item.get("type").and_then(|v| v.as_str()) == Some("text") {
825                    if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
826                        let t = text.trim();
827                        if !t.is_empty() {
828                            last_text = Some(t.to_string());
829                        }
830                    }
831                }
832            }
833        }
834        // Extract usage (take max, as Claude reports cumulative)
835        if let Some(usage) = val.get("message").and_then(|m| m.get("usage")) {
836            if let Some(inp) = usage.get("input_tokens").and_then(|v| v.as_u64()) {
837                input_tokens = Some(input_tokens.unwrap_or(0u64).max(inp));
838            }
839            if let Some(out) = usage.get("output_tokens").and_then(|v| v.as_u64()) {
840                output_tokens = Some(output_tokens.unwrap_or(0u64).max(out));
841            }
842        }
843    }
844
845    last_text.map(|text| super::AgentResult {
846        is_error: false,
847        result_text: text,
848        input_tokens,
849        output_tokens,
850        cost_usd: None,
851        duration_ms: None,
852        truncated_by_length: false,
853    })
854}
855
856/// Concatenate text content from all `type:assistant` NDJSON messages in
857/// the stream.  Used as a fallback when the `type:result` envelope text
858/// does not contain valid AgentResponse JSON but an earlier assistant turn
859/// might have emitted it.
860pub fn collect_assistant_messages_text(ndjson: &str) -> String {
861    let mut parts: Vec<String> = Vec::new();
862    for line in ndjson.trim().lines() {
863        let line = line.trim();
864        if line.is_empty() {
865            continue;
866        }
867        let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
868            continue;
869        };
870        if val.get("type").and_then(|v| v.as_str()) != Some("assistant") {
871            continue;
872        }
873        if let Some(content) = val
874            .get("message")
875            .and_then(|m| m.get("content"))
876            .and_then(|c| c.as_array())
877        {
878            for item in content {
879                if item.get("type").and_then(|v| v.as_str()) == Some("text") {
880                    if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
881                        let t = text.trim();
882                        if !t.is_empty() {
883                            parts.push(t.to_string());
884                        }
885                    }
886                }
887            }
888        }
889    }
890    parts.join("\n")
891}
892
893#[cfg(test)]
894mod tests {
895    use super::*;
896
897    fn runner() -> ClaudeRunner {
898        ClaudeRunner::new("claude")
899    }
900
901    #[test]
902    fn error_envelope_uses_errors_array_when_result_missing() {
903        let raw = r#"{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["API error (status 402 Payment Required): usage balance exhausted"]}"#;
904        match runner().parse_response(raw) {
905            Err(AgentError::AgentFailed { message } | AgentError::Auth { message }) => {
906                assert!(crate::engine::cooldown::detect_credit_exhaustion(&message).is_some());
907            }
908            other => panic!("unexpected: {other:?}"),
909        }
910    }
911
912    #[test]
913    fn error_envelope_falls_back_to_subtype() {
914        let raw = r#"{"type":"result","subtype":"error_during_execution","is_error":true}"#;
915        match runner().parse_response(raw) {
916            Err(AgentError::AgentFailed { message }) => {
917                assert_eq!(message, "error_during_execution")
918            }
919            other => panic!("unexpected: {other:?}"),
920        }
921    }
922
923    #[test]
924    fn parse_claude_envelope_success() {
925        let raw = r#"{
926            "type": "result",
927            "subtype": "success",
928            "is_error": false,
929            "duration_ms": 2006,
930            "result": "{\"status\":\"done\",\"summary\":\"hello\",\"accomplished\":[],\"remaining\":[],\"files\":[]}",
931            "usage": {"input_tokens": 10, "output_tokens": 78},
932            "permission_denials": []
933        }"#;
934
935        let parsed = runner().parse_response(raw).unwrap();
936        assert_eq!(parsed.response.status, "done");
937        assert_eq!(parsed.response.summary, "hello");
938        assert_eq!(parsed.input_tokens, Some(10));
939        assert_eq!(parsed.output_tokens, Some(78));
940        assert_eq!(parsed.duration_ms, Some(2006));
941    }
942
943    #[test]
944    fn parse_claude_envelope_with_markdown_result() {
945        let raw = r#"{
946            "type": "result",
947            "subtype": "success",
948            "is_error": false,
949            "duration_ms": 5000,
950            "result": "```json\n{\"status\": \"done\", \"summary\": \"fixed bug\"}\n```",
951            "usage": {"input_tokens": 100, "output_tokens": 50},
952            "permission_denials": ["Bash(rm *)"]
953        }"#;
954
955        let parsed = runner().parse_response(raw).unwrap();
956        assert_eq!(parsed.response.status, "done");
957        assert_eq!(parsed.response.summary, "fixed bug");
958    }
959
960    #[test]
961    fn parse_claude_envelope_error() {
962        let raw = r#"{
963            "type": "result",
964            "subtype": "error",
965            "is_error": true,
966            "result": "credit balance too low",
967            "usage": {},
968            "permission_denials": []
969        }"#;
970
971        let err = runner().parse_response(raw).unwrap_err();
972        assert!(matches!(err, AgentError::Auth { .. }));
973    }
974
975    #[test]
976    fn parse_claude_envelope_rate_limit_error() {
977        let raw = r#"{
978            "type": "result",
979            "subtype": "error",
980            "is_error": true,
981            "result": "rate limit exceeded, please try again later",
982            "usage": {},
983            "permission_denials": []
984        }"#;
985
986        let err = runner().parse_response(raw).unwrap_err();
987        assert!(matches!(err, AgentError::RateLimit { .. }));
988    }
989
990    #[test]
991    fn parse_envelope_no_false_positive_from_work_product_result() {
992        // Regression test for issue #1292: when is_error=false but the result
993        // text contains rate-limit keywords (agent describing work it did),
994        // parse_response must NOT return a RateLimit error.
995        //
996        // Fix for issue #1377: when parser::parse fails on plain-text output,
997        // synthesize_response_from_text is tried and tokens are preserved.
998        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();
999        // Plain-text result with rate-limit keywords: parser::parse fails, but
1000        // synthesis succeeds — result must be Ok (tokens preserved) not RateLimit.
1001        let parsed = runner()
1002            .parse_response(&raw)
1003            .expect("plain-text work product must synthesize successfully");
1004        // Tokens must be preserved from the envelope (issue #1377)
1005        assert_eq!(
1006            parsed.input_tokens,
1007            Some(10),
1008            "input_tokens must be preserved"
1009        );
1010        assert_eq!(
1011            parsed.output_tokens,
1012            Some(5),
1013            "output_tokens must be preserved"
1014        );
1015    }
1016
1017    #[test]
1018    fn parse_direct_json_no_envelope() {
1019        let raw =
1020            r#"{"status":"done","summary":"hello","accomplished":[],"remaining":[],"files":[]}"#;
1021
1022        let parsed = runner().parse_response(raw).unwrap();
1023        assert_eq!(parsed.response.status, "done");
1024        assert!(parsed.input_tokens.is_none());
1025    }
1026
1027    #[test]
1028    fn parse_empty_response() {
1029        let err = runner().parse_response("").unwrap_err();
1030        assert!(matches!(err, AgentError::InvalidResponse { .. }));
1031    }
1032
1033    #[test]
1034    fn classify_error_rate_limit() {
1035        let err = runner().classify_error(1, "", "429 Too Many Requests");
1036        assert!(matches!(err, AgentError::RateLimit { .. }));
1037    }
1038
1039    #[test]
1040    fn classify_error_detects_session_limit_from_mid_stream_system_event() {
1041        // Regression test for issue #3272: Claude session limit errors appear as
1042        // `type:system` NDJSON events mid-stream, not as `type:result` lines with
1043        // `is_error:true`. When significant work product exists before and after
1044        // the system event, it is outside both head and tail scan windows in
1045        // `classify_from_text`. The `classify_error` function must extract the
1046        // error message from the system event and prepend it to the combined text.
1047        let header_events = "{\"type\":\"conversation\",\"subtype\":\"init\"}\n\
1048             {\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"I'll work on this task.\"}]}}\n"
1049            .repeat(80);
1050        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)"}}"#;
1051        let work_product = "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Here is the implementation...\"}]}}\n"
1052            .repeat(80);
1053        let stdout = format!("{header_events}{system_event}\n{work_product}");
1054        // Verify the system event is deep enough to miss head and tail scans.
1055        assert!(
1056            stdout.len() > 6000,
1057            "test stdout must exceed combined head+tail windows, got {} bytes",
1058            stdout.len()
1059        );
1060        let err = runner().classify_error(1, &stdout, "");
1061        assert!(
1062            matches!(err, AgentError::RateLimit { .. }),
1063            "session limit system event mid-stream must be detected, got: {err:?}"
1064        );
1065        let msg = err.to_string();
1066        assert!(
1067            msg.contains("session limit"),
1068            "error message should contain the rate limit reason, got: {msg}"
1069        );
1070    }
1071
1072    #[test]
1073    fn classify_error_prefers_clean_result_text_over_byte_truncated_tail() {
1074        // Regression test for issue #3577: a well-formed `type:result`
1075        // envelope with `is_error:true` was correctly extracted into
1076        // `error_result`, but `classify_from_text`'s generic Unknown
1077        // fallback then re-truncates the combined stdout+stderr to its last
1078        // 300 bytes — which, once enough preceding NDJSON pushes the result
1079        // line's start past that window, cuts it off mid-field-name (e.g.
1080        // `"is_error"` becomes `"i_error"`). The stored error ends up a
1081        // garbled JSON fragment instead of the clean message, and (in the
1082        // fallback.rs non-zero-exit path) skips the model cooldown that the
1083        // AgentFailed branch applies.
1084        let padding = "{\"type\":\"system\",\"subtype\":\"init\"}\n".repeat(50);
1085        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"}"#;
1086        let stdout = format!("{padding}{result_line}\n");
1087        // Sanity check: the message text itself is not caught by any of the
1088        // specific keyword detectors (auth/rate_limit/context_overflow), so
1089        // without the fix this must fall to the generic Unknown branch.
1090        assert!(
1091            stdout.len() > 300,
1092            "test stdout must exceed the tail-truncation window"
1093        );
1094
1095        let err = runner().classify_error(1, &stdout, "");
1096        assert!(
1097            matches!(err, AgentError::AgentFailed { .. }),
1098            "expected AgentFailed with the clean message, got: {err:?}"
1099        );
1100        let msg = err.to_string();
1101        assert_eq!(
1102            msg, "agent failed: Failed to authenticate: OAuth session expired and could not be refreshed",
1103            "stored error must be the clean result text, not a byte-truncated JSON fragment"
1104        );
1105        assert!(
1106            !msg.contains("i_error"),
1107            "stored error must not contain the byte-truncated JSON fragment, got: {msg}"
1108        );
1109    }
1110
1111    #[test]
1112    fn classify_error_timeout() {
1113        let err = runner().classify_error(124, "", "");
1114        assert!(matches!(err, AgentError::Timeout { .. }));
1115    }
1116
1117    #[test]
1118    fn extract_usage_from_model_usage() {
1119        let response_json: serde_json::Value = serde_json::json!({
1120            "modelUsage": {
1121                "claude-haiku-4-5-20251001": {
1122                    "inputTokens": 1000,
1123                    "outputTokens": 500
1124                }
1125            }
1126        });
1127        let envelope = response_json.as_object().unwrap();
1128        let (input, output) = extract_usage(envelope);
1129        assert_eq!(input, Some(1000));
1130        assert_eq!(output, Some(500));
1131    }
1132
1133    #[test]
1134    fn build_command_with_model() {
1135        let r = runner();
1136        let perms = PermissionRules {
1137            autonomous: true,
1138            sandbox: SandboxLevel::WorkspaceWrite,
1139            disallowed_tools: vec!["Bash(rm *)".to_string()],
1140            allowed_tools: vec![],
1141            allowed_edit_paths: vec![],
1142            deny_read_only: false,
1143            extra_writable_dirs: vec![],
1144        };
1145        let cmd = r.build_command(
1146            Some("opus"),
1147            "timeout 1800",
1148            "/tmp/sys.txt",
1149            "/tmp/msg.txt",
1150            &perms,
1151        );
1152        assert!(cmd.contains("--model 'opus'"));
1153        assert!(cmd.contains("claude -p"));
1154        assert!(cmd.contains("--output-format stream-json"));
1155        assert!(cmd.contains("--permission-mode bypassPermissions"));
1156        assert!(cmd.contains("--disallowedTools 'Bash(rm *)'"));
1157    }
1158
1159    #[test]
1160    fn build_command_supervised_mode() {
1161        let r = runner();
1162        let perms = PermissionRules {
1163            autonomous: false,
1164            sandbox: SandboxLevel::WorkspaceWrite,
1165            disallowed_tools: vec![],
1166            allowed_tools: vec![],
1167            allowed_edit_paths: vec![],
1168            deny_read_only: false,
1169            extra_writable_dirs: vec![],
1170        };
1171        let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1172        assert!(cmd.contains("--permission-mode acceptEdits"));
1173    }
1174
1175    #[test]
1176    fn build_command_allowed_edit_paths_scopes_edit_write() {
1177        let r = runner();
1178        let perms = PermissionRules {
1179            autonomous: true,
1180            sandbox: SandboxLevel::WorkspaceWrite,
1181            disallowed_tools: vec![],
1182            allowed_tools: vec![
1183                "Edit".to_string(),
1184                "Write".to_string(),
1185                "Read".to_string(),
1186                "Bash".to_string(),
1187            ],
1188            allowed_edit_paths: vec![std::path::PathBuf::from("/home/user/worktree")],
1189            deny_read_only: false,
1190            extra_writable_dirs: vec![],
1191        };
1192        let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1193
1194        // Edit/Write should be scoped to the worktree path
1195        assert!(
1196            cmd.contains("Edit(/home/user/worktree/*)"),
1197            "expected scoped Edit, got: {cmd}"
1198        );
1199        assert!(
1200            cmd.contains("Write(/home/user/worktree/*)"),
1201            "expected scoped Write, got: {cmd}"
1202        );
1203
1204        // Read and Bash should not be scoped
1205        assert!(
1206            cmd.contains(",Read,") || cmd.contains("'Read,") || cmd.contains(",Read'"),
1207            "expected unrestricted Read, got: {cmd}"
1208        );
1209        assert!(
1210            cmd.contains(",Bash,") || cmd.contains("'Bash,") || cmd.contains(",Bash'"),
1211            "expected unrestricted Bash, got: {cmd}"
1212        );
1213    }
1214
1215    #[test]
1216    fn kimi_runner_name() {
1217        let r = ClaudeRunner::new("kimi");
1218        assert_eq!(r.name(), "kimi");
1219    }
1220
1221    // ── Fixture-based tests ─────────────────────────────────────
1222
1223    #[test]
1224    fn fixture_claude_success() {
1225        let raw = include_str!("../../../../tests/fixtures/claude_success.json");
1226        let parsed = runner().parse_response(raw).unwrap();
1227        assert_eq!(parsed.response.status, "done");
1228        assert!(parsed.response.summary.contains("Implemented"));
1229        assert_eq!(parsed.response.accomplished.len(), 2);
1230        assert_eq!(parsed.input_tokens, Some(15000));
1231        assert_eq!(parsed.output_tokens, Some(3500));
1232        assert_eq!(parsed.duration_ms, Some(45000));
1233    }
1234
1235    #[test]
1236    fn fixture_claude_rate_limit() {
1237        let raw = include_str!("../../../../tests/fixtures/claude_error_rate_limit.json");
1238        let err = runner().parse_response(raw).unwrap_err();
1239        assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
1240    }
1241
1242    // ── NDJSON / stream-json fixtures ────────────────────────────
1243
1244    #[test]
1245    fn fixture_claude_stream_success() {
1246        let raw = include_str!("../../../../tests/fixtures/claude_stream_success.jsonl");
1247        let parsed = runner().parse_response(raw).unwrap();
1248        assert_eq!(parsed.response.status, "done");
1249        assert!(parsed.response.summary.contains("Implemented"));
1250        assert_eq!(parsed.response.accomplished.len(), 2);
1251        assert_eq!(parsed.input_tokens, Some(15000));
1252        assert_eq!(parsed.output_tokens, Some(3500));
1253        assert_eq!(parsed.duration_ms, Some(45000));
1254    }
1255
1256    #[test]
1257    fn fixture_claude_stream_error() {
1258        let raw = include_str!("../../../../tests/fixtures/claude_stream_error.jsonl");
1259        let err = runner().parse_response(raw).unwrap_err();
1260        assert!(matches!(err, AgentError::Auth { .. }), "got: {err:?}");
1261    }
1262
1263    #[test]
1264    fn fixture_claude_stream_rate_limit() {
1265        let raw = include_str!("../../../../tests/fixtures/claude_stream_rate_limit.jsonl");
1266        let err = runner().parse_response(raw).unwrap_err();
1267        assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
1268    }
1269
1270    #[test]
1271    fn fixture_claude_stream_thinking_block_conflict() {
1272        let raw =
1273            include_str!("../../../../tests/fixtures/claude_stream_thinking_block_conflict.jsonl");
1274        let err = runner().parse_response(raw).unwrap_err();
1275        assert!(
1276            matches!(err, AgentError::ThinkingBlockConflict { .. }),
1277            "expected ThinkingBlockConflict, got: {err:?}"
1278        );
1279    }
1280
1281    #[test]
1282    fn classify_error_thinking_block_conflict_from_stderr() {
1283        // The error can also surface through classify_error() when the claude
1284        // process exits non-zero with the message in stdout/stderr.
1285        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.";
1286        let err = runner().classify_error(1, "", stderr);
1287        assert!(
1288            matches!(err, AgentError::ThinkingBlockConflict { .. }),
1289            "expected ThinkingBlockConflict, got: {err:?}"
1290        );
1291    }
1292
1293    #[test]
1294    fn thinking_block_conflict_does_not_match_unrelated_text() {
1295        // Ensure the pattern is specific enough to not false-positive on
1296        // work product that mentions "thinking" or "blocks".
1297        let unrelated = "I was thinking about how to handle blocks of code in the response.";
1298        let err = runner().classify_error(1, "", unrelated);
1299        assert!(
1300            !matches!(err, AgentError::ThinkingBlockConflict { .. }),
1301            "should not classify unrelated text as ThinkingBlockConflict, got: {err:?}"
1302        );
1303    }
1304
1305    #[test]
1306    fn parse_ndjson_with_intermediate_events() {
1307        // Parser must ignore intermediate events and only look at the result line
1308        let raw = concat!(
1309            r#"{"type":"system","subtype":"init"}"#,
1310            "\n",
1311            r#"{"type":"assistant","message":{"role":"assistant","content":[]}}"#,
1312            "\n",
1313            r#"{"type":"tool_use","tool":{"name":"Read","input":{}}}"#,
1314            "\n",
1315            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}}"#,
1316            "\n"
1317        );
1318        let parsed = runner().parse_response(raw).unwrap();
1319        assert_eq!(parsed.response.status, "done");
1320        assert_eq!(parsed.response.summary, "streamed");
1321        assert_eq!(parsed.input_tokens, Some(5));
1322        assert_eq!(parsed.output_tokens, Some(10));
1323    }
1324
1325    #[test]
1326    fn parse_ndjson_skips_malformed_lines() {
1327        // Malformed intermediate lines should be ignored; result line still parsed
1328        let raw = concat!(
1329            "not valid json\n",
1330            r#"{"type":"partial_update","data":"something"}"#,
1331            "\n",
1332            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}}"#,
1333            "\n"
1334        );
1335        let parsed = runner().parse_response(raw).unwrap();
1336        assert_eq!(parsed.response.status, "done");
1337        assert_eq!(parsed.response.summary, "ok");
1338    }
1339
1340    #[test]
1341    fn parse_single_line_json_backward_compat() {
1342        // Single-line JSON (--output-format json) still works
1343        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}}"#;
1344        let parsed = runner().parse_response(raw).unwrap();
1345        assert_eq!(parsed.response.status, "done");
1346        assert_eq!(parsed.response.summary, "backward compat");
1347        assert_eq!(parsed.input_tokens, Some(50));
1348        assert_eq!(parsed.output_tokens, Some(25));
1349    }
1350
1351    // ── Kimi / MiniMax (Claude-compatible wrappers) ───────────────
1352
1353    #[test]
1354    fn kimi_parses_claude_envelope() {
1355        let r = ClaudeRunner::new("kimi");
1356        let raw = r#"{
1357            "type": "result",
1358            "subtype": "success",
1359            "is_error": false,
1360            "duration_ms": 3000,
1361            "result": "{\"status\":\"done\",\"summary\":\"kimi did it\",\"accomplished\":[\"task\"],\"remaining\":[],\"files\":[\"a.rs\"]}",
1362            "usage": {"input_tokens": 200, "output_tokens": 100}
1363        }"#;
1364
1365        let parsed = r.parse_response(raw).unwrap();
1366        assert_eq!(parsed.response.status, "done");
1367        assert_eq!(parsed.response.summary, "kimi did it");
1368    }
1369
1370    #[test]
1371    fn minimax_parses_claude_envelope() {
1372        let r = ClaudeRunner::new("minimax");
1373        assert_eq!(r.name(), "minimax");
1374        let raw = r#"{
1375            "type": "result",
1376            "subtype": "success",
1377            "is_error": false,
1378            "result": "{\"status\":\"done\",\"summary\":\"minimax did it\",\"accomplished\":[],\"remaining\":[],\"files\":[]}",
1379            "usage": {}
1380        }"#;
1381
1382        let parsed = r.parse_response(raw).unwrap();
1383        assert_eq!(parsed.response.status, "done");
1384    }
1385
1386    #[test]
1387    fn kimi_build_command_uses_kimi_binary() {
1388        let r = ClaudeRunner::new("kimi");
1389        let perms = PermissionRules {
1390            autonomous: true,
1391            sandbox: SandboxLevel::WorkspaceWrite,
1392            disallowed_tools: vec![],
1393            allowed_tools: vec![],
1394            allowed_edit_paths: vec![],
1395            deny_read_only: false,
1396            extra_writable_dirs: vec![],
1397        };
1398        let cmd = r.build_command(
1399            Some("sonnet"),
1400            "timeout 1800",
1401            "/tmp/sys.txt",
1402            "/tmp/msg.txt",
1403            &perms,
1404        );
1405        assert!(cmd.contains("kimi -p"), "expected kimi binary, got: {cmd}");
1406        assert!(cmd.contains("--model 'sonnet'"));
1407    }
1408
1409    #[test]
1410    fn build_command_with_allowed_tools() {
1411        let r = runner();
1412        let perms = PermissionRules {
1413            autonomous: true,
1414            sandbox: SandboxLevel::WorkspaceWrite,
1415            disallowed_tools: vec!["Bash(rm *)".to_string()], // should be ignored
1416            allowed_tools: vec![
1417                "Edit".to_string(),
1418                "Write".to_string(),
1419                "Read".to_string(),
1420                "Bash".to_string(),
1421                "Grep".to_string(),
1422                "git".to_string(),
1423                "npm".to_string(),
1424            ],
1425            allowed_edit_paths: vec![],
1426            deny_read_only: false,
1427            extra_writable_dirs: vec![],
1428        };
1429        let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1430
1431        // Should use --allowedTools instead of --disallowedTools
1432        assert!(
1433            cmd.contains("--allowedTools"),
1434            "expected --allowedTools flag, got: {cmd}"
1435        );
1436        assert!(
1437            !cmd.contains("--disallowedTools"),
1438            "should not have --disallowedTools when allowed_tools is set, got: {cmd}"
1439        );
1440
1441        // Native tools pass through as-is
1442        assert!(cmd.contains("Edit"), "missing Edit tool");
1443        assert!(cmd.contains("Write"), "missing Write tool");
1444        assert!(cmd.contains("Bash"), "missing Bash tool");
1445
1446        // CLI commands become Bash(<cmd> *) patterns
1447        assert!(
1448            cmd.contains("Bash(git *)"),
1449            "missing Bash(git *) pattern, got: {cmd}"
1450        );
1451        assert!(
1452            cmd.contains("Bash(npm *)"),
1453            "missing Bash(npm *) pattern, got: {cmd}"
1454        );
1455    }
1456
1457    #[test]
1458    fn build_command_allowed_tools_empty_falls_back_to_disallowed() {
1459        let r = runner();
1460        let perms = PermissionRules {
1461            autonomous: true,
1462            sandbox: SandboxLevel::WorkspaceWrite,
1463            disallowed_tools: vec!["Bash(rm *)".to_string()],
1464            allowed_tools: vec![], // empty = use disallowed_tools
1465            allowed_edit_paths: vec![],
1466            deny_read_only: false,
1467            extra_writable_dirs: vec![],
1468        };
1469        let cmd = r.build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &perms);
1470
1471        assert!(
1472            cmd.contains("--disallowedTools"),
1473            "should fall back to --disallowedTools when allowed_tools is empty, got: {cmd}"
1474        );
1475        assert!(
1476            !cmd.contains("--allowedTools"),
1477            "should not have --allowedTools when empty, got: {cmd}"
1478        );
1479    }
1480
1481    #[test]
1482    fn translate_allowed_tools_categorizes_correctly() {
1483        let allowed = vec![
1484            "Edit".to_string(),
1485            "Bash".to_string(),
1486            "Grep".to_string(),
1487            "git".to_string(),
1488            "cargo".to_string(),
1489            "WebFetch".to_string(),
1490        ];
1491        // No allowed_edit_paths → Edit passes through as-is
1492        let result = translate_allowed_tools(&allowed, &[]);
1493        assert_eq!(
1494            result,
1495            vec![
1496                "Edit",
1497                "Bash",
1498                "Grep",
1499                "Bash(git *)",
1500                "Bash(cargo *)",
1501                "WebFetch"
1502            ]
1503        );
1504    }
1505
1506    #[test]
1507    fn translate_allowed_tools_scopes_edit_write() {
1508        let allowed = vec!["Edit".to_string(), "Write".to_string(), "Read".to_string()];
1509        let paths = vec![std::path::PathBuf::from("/tmp/worktree")];
1510        let result = translate_allowed_tools(&allowed, &paths);
1511        assert_eq!(
1512            result,
1513            vec!["Edit(/tmp/worktree/*)", "Write(/tmp/worktree/*)", "Read"]
1514        );
1515    }
1516
1517    #[test]
1518    fn classify_error_overloaded_529() {
1519        // Claude 529 overloaded error
1520        let err = runner().classify_error(1, "", "overloaded_error: 529");
1521        assert!(matches!(err, AgentError::RateLimit { .. }), "got: {err:?}");
1522    }
1523
1524    #[test]
1525    fn classify_error_context_length() {
1526        let err = runner().classify_error(1, "", "context_length_exceeded: max 200000 tokens");
1527        assert!(
1528            matches!(err, AgentError::ContextOverflow { .. }),
1529            "got: {err:?}"
1530        );
1531    }
1532
1533    // ── extract_text ─────────────────────────────────────────────
1534
1535    /// Claude stream-json: extract_text returns the inner result text for review parsing.
1536    #[test]
1537    fn extract_text_stream_json_returns_result_field() {
1538        let raw = concat!(
1539            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1540            "\n",
1541            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Reviewing..."}]}}"#,
1542            "\n",
1543            r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"test_results\":\"pass\",\"issues\":[]}","usage":{"input_tokens":100,"output_tokens":20}}"#,
1544        );
1545        let text = runner().extract_text(raw).unwrap();
1546        assert_eq!(
1547            text,
1548            r#"{"decision":"approve","notes":"LGTM","test_results":"pass","issues":[]}"#
1549        );
1550    }
1551
1552    /// Claude stream-json with markdown-wrapped ReviewResponse in result field.
1553    #[test]
1554    fn extract_text_stream_json_markdown_result() {
1555        let review_json =
1556            r#"```json\n{"decision":"request_changes","notes":"Fix it","issues":[]}\n```"#;
1557        let result_line = format!(
1558            r#"{{"type":"result","subtype":"success","is_error":false,"result":"{review_json}","usage":{{"input_tokens":10,"output_tokens":5}}}}"#
1559        );
1560        let raw = format!(
1561            "{}\n{}",
1562            r#"{"type":"system","subtype":"init","session_id":"s1"}"#, result_line
1563        );
1564        let text = runner().extract_text(&raw).unwrap();
1565        assert!(
1566            text.contains("request_changes"),
1567            "expected review JSON in result, got: {text}"
1568        );
1569    }
1570
1571    /// Claude stream-json error (is_error=true, rate limit): extract_text propagates RateLimit.
1572    #[test]
1573    fn extract_text_stream_json_rate_limit_propagates() {
1574        let raw = concat!(
1575            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1576            "\n",
1577            r#"{"type":"result","subtype":"error","is_error":true,"result":"rate limit exceeded: 429 Too Many Requests","usage":{"input_tokens":0,"output_tokens":0}}"#,
1578        );
1579        let err = runner().extract_text(raw).unwrap_err();
1580        assert!(
1581            matches!(err, AgentError::RateLimit { .. }),
1582            "expected RateLimit, got: {err:?}"
1583        );
1584    }
1585
1586    #[test]
1587    fn extract_stream_json_fallbacks_to_opencode_extractor_on_leading_init() {
1588        // Some Claude wrappers emit a leading system/init envelope and then
1589        // place the real JSON payload inside an assistant message content array
1590        // (no final "type":"result" line). Ensure our extractor falls back
1591        // to the opencode-style NDJSON scanner and returns the inner JSON.
1592        let raw = concat!(
1593            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1594            "\n",
1595            // The inner JSON must be escaped inside the outer JSON string
1596            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"{\"status\":\"done\",\"summary\":\"fallback\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}]}}"#,
1597        );
1598
1599        let text = extract_stream_json_result_text(raw).expect("should fallback and extract JSON");
1600        let parsed: serde_json::Value =
1601            serde_json::from_str(&text).expect("extracted text must be JSON");
1602        assert_eq!(parsed["status"], "done");
1603        assert_eq!(parsed["summary"], "fallback");
1604    }
1605
1606    /// Claude stream-json error (is_error=true, auth): extract_text propagates Auth.
1607    #[test]
1608    fn extract_text_stream_json_auth_error_propagates() {
1609        let raw = concat!(
1610            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1611            "\n",
1612            r#"{"type":"result","subtype":"error","is_error":true,"result":"401 Unauthorized: invalid api key","usage":{"input_tokens":0,"output_tokens":0}}"#,
1613        );
1614        let err = runner().extract_text(raw).unwrap_err();
1615        assert!(
1616            matches!(err, AgentError::Auth { .. }),
1617            "expected Auth, got: {err:?}"
1618        );
1619    }
1620
1621    /// extract_text on empty input returns empty string (not an error).
1622    #[test]
1623    fn extract_text_empty_input_returns_empty_ok() {
1624        let text = runner().extract_text("").unwrap();
1625        assert!(text.is_empty());
1626    }
1627
1628    // ── find_claude_result ──────────────────────────────────────
1629
1630    #[test]
1631    fn find_claude_result_success_with_tokens() {
1632        let ndjson = concat!(
1633            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1634            "\n",
1635            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"working..."}]}}"#,
1636            "\n",
1637            r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\"}","usage":{"input_tokens":1500,"output_tokens":200},"duration_ms":5000}"#,
1638        );
1639        let result = find_claude_result(ndjson).expect("should find result");
1640        assert!(!result.is_error);
1641        assert!(result.result_text.contains("approve"));
1642        assert_eq!(result.input_tokens, Some(1500));
1643        assert_eq!(result.output_tokens, Some(200));
1644        assert_eq!(result.duration_ms, Some(5000));
1645    }
1646
1647    #[test]
1648    fn find_claude_result_error_flag() {
1649        let ndjson = r#"{"type":"result","subtype":"error","is_error":true,"result":"rate limit exceeded","usage":{}}"#;
1650        let result = find_claude_result(ndjson).expect("should find result");
1651        assert!(result.is_error);
1652        assert!(result.result_text.contains("rate limit"));
1653    }
1654
1655    #[test]
1656    fn find_claude_result_empty_returns_none() {
1657        assert!(find_claude_result("").is_none());
1658        assert!(find_claude_result("   ").is_none());
1659    }
1660
1661    #[test]
1662    fn find_claude_result_no_result_line_falls_back_to_assistant() {
1663        // MiniMax/Kimi pattern: no type:result, just assistant messages
1664        let ndjson = concat!(
1665            r#"{"type":"system","subtype":"init","model":"MiniMax-M2.7"}"#,
1666            "\n",
1667            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"analysis complete"}],"usage":{"input_tokens":800,"output_tokens":150}}}"#,
1668        );
1669        let result = find_claude_result(ndjson).expect("should fall back to assistant text");
1670        assert!(!result.is_error);
1671        assert_eq!(result.result_text, "analysis complete");
1672        assert_eq!(result.input_tokens, Some(800));
1673        assert_eq!(result.output_tokens, Some(150));
1674    }
1675
1676    #[test]
1677    fn find_claude_result_plain_text_returns_none() {
1678        // Plain text (not NDJSON) should return None
1679        assert!(find_claude_result("just some plain text output").is_none());
1680    }
1681
1682    #[test]
1683    fn find_claude_result_model_usage() {
1684        let ndjson = r#"{"type":"result","subtype":"success","is_error":false,"result":"done","modelUsage":{"claude-sonnet-4-20250514":{"inputTokens":5000,"outputTokens":1000}},"duration_ms":3000}"#;
1685        let result = find_claude_result(ndjson).expect("should extract modelUsage");
1686        assert_eq!(result.input_tokens, Some(5000));
1687        assert_eq!(result.output_tokens, Some(1000));
1688    }
1689
1690    // ── Regression tests for issue #1387 ──────────────────────────────────────
1691
1692    /// Regression test: when an NDJSON stream has multiple type:result lines —
1693    /// one with JSON in the result field (inner task delegate) and one with
1694    /// plain text (outer session summary) — find_ndjson_result_line must
1695    /// skip the plain-text one and return the JSON one.
1696    #[test]
1697    fn find_ndjson_result_line_skips_plain_text_result() {
1698        // Build test NDJSON using serde_json to avoid string-escaping confusion.
1699        let inner_result = serde_json::json!({"status": "done", "summary": "fix applied"});
1700        let json_result_line = serde_json::json!({
1701            "type": "result",
1702            "subtype": "success",
1703            "is_error": false,
1704            "duration_ms": 273044,
1705            "result": inner_result.to_string(),
1706            "usage": {"input_tokens": 12, "output_tokens": 1577}
1707        })
1708        .to_string();
1709        let plain_result_line = serde_json::json!({
1710            "type": "result",
1711            "subtype": "success",
1712            "is_error": false,
1713            "duration_ms": 2352,
1714            "result": "All 2352 tests passed.",
1715            "usage": {"input_tokens": 3, "output_tokens": 16}
1716        })
1717        .to_string();
1718        // Concatenate without using concat! to avoid string-escaping headaches.
1719        let mut ndjson = json_result_line.clone();
1720        ndjson.push('\n');
1721        ndjson.push_str(r#"{"type":"system","subtype":"task_notification"}"#);
1722        ndjson.push('\n');
1723        ndjson.push_str(r#"{"type":"system","subtype":"init"}"#);
1724        ndjson.push('\n');
1725        ndjson.push_str(
1726            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"summary here"}]}}"#,
1727        );
1728        ndjson.push('\n');
1729        ndjson.push_str(&plain_result_line);
1730
1731        let line = find_ndjson_result_line(&ndjson)
1732            .expect("should find a result line")
1733            .0;
1734        // Must be the JSON result (inner), not the plain-text one (outer).
1735        let parsed: serde_json::Value =
1736            serde_json::from_str(line).expect("returned line must be valid JSON");
1737        let result_val = parsed.get("result").expect("result field must exist");
1738        assert!(
1739            result_val.is_string(),
1740            "result field must be a JSON string (not plain text), got: {result_val}"
1741        );
1742        let result_str = result_val.as_str().unwrap();
1743        assert!(
1744            result_str.starts_with('{'),
1745            "result must be JSON (object), got: {result_str}"
1746        );
1747        assert!(
1748            !result_str.contains("All 2352 tests passed"),
1749            "must NOT return outer plain-text result: {result_str}"
1750        );
1751    }
1752
1753    /// Regression test: when BOTH result lines have plain-text results (no JSON),
1754    /// find_ndjson_result_line must return the one with more tokens — not the last.
1755    /// This is the actual task 1384 scenario: inner result has 12+1577 tokens,
1756    /// outer confirmation has 3+16 tokens. Must pick the inner one (issue #1387).
1757    #[test]
1758    fn find_ndjson_result_line_prefers_more_tokens_on_plain_text() {
1759        // Build NDJSON where both results are plain text.
1760        let inner_result_line = serde_json::json!({
1761            "type": "result",
1762            "subtype": "success",
1763            "is_error": false,
1764            "duration_ms": 273044,
1765            "result": "All 2352 tests pass. The fix is clean and correct.",
1766            "usage": {"input_tokens": 12, "output_tokens": 1577}
1767        })
1768        .to_string();
1769        let outer_result_line = serde_json::json!({
1770            "type": "result",
1771            "subtype": "success",
1772            "is_error": false,
1773            "duration_ms": 2352,
1774            "result": "Already retrieved the result — all 2352 tests passed.",
1775            "usage": {"input_tokens": 3, "output_tokens": 16}
1776        })
1777        .to_string();
1778
1779        // Inner result FIRST, outer result LAST (the actual task 1384 ordering).
1780        let ndjson = format!(
1781            "{0}\n{1}\n{2}",
1782            inner_result_line, r#"{"type":"system","subtype":"init"}"#, outer_result_line
1783        );
1784
1785        let (line, tokens) = find_ndjson_result_line(&ndjson).expect("should find a result line");
1786
1787        // Must return the inner result (more tokens), not the outer one.
1788        assert!(
1789            line.contains("The fix is clean"),
1790            "expected inner result (more tokens), got: {line}"
1791        );
1792        // Tokens must match the inner result.
1793        assert_eq!(
1794            tokens,
1795            Some((12, 1577)),
1796            "tokens must be from the inner result"
1797        );
1798    }
1799
1800    /// Regression test: when parse_response encounters plain-text results
1801    /// and falls back to synthesis, tokens from the envelope must be preserved.
1802    #[test]
1803    fn parse_response_preserves_tokens_on_synthesis_fallback() {
1804        // Both results are plain text (no JSON in result field).
1805        // parse_response should synthesize but preserve the envelope tokens.
1806        let ndjson = concat!(
1807            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}}"#,
1808            "\n",
1809            r#"{"type":"system","subtype":"init"}"#,
1810            "\n",
1811            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}}"#,
1812        );
1813
1814        let parsed = runner()
1815            .parse_response(ndjson)
1816            .expect("should parse successfully");
1817
1818        // Must preserve tokens from the inner (higher-token) result.
1819        assert_eq!(
1820            parsed.input_tokens,
1821            Some(12),
1822            "input_tokens must be preserved (not lost on synthesis fallback)"
1823        );
1824        assert_eq!(
1825            parsed.output_tokens,
1826            Some(1577),
1827            "output_tokens must be preserved (not lost on synthesis fallback)"
1828        );
1829    }
1830
1831    #[test]
1832    fn parse_response_uses_inner_json_result_preserving_tokens() {
1833        // Simulates task 1384: outer session says "tests passed" (plain text),
1834        // but inner task delegate result is JSON with tokens.
1835        let ndjson = concat!(
1836            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}}"#,
1837            "\n",
1838            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1839            "\n",
1840            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"tests passed"}]}}"#,
1841            "\n",
1842            r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":2352,"result":"All 2352 tests passed.","usage":{"input_tokens":3,"output_tokens":16}}"#,
1843        );
1844
1845        let parsed = runner()
1846            .parse_response(ndjson)
1847            .expect("should parse successfully");
1848
1849        // Must use the inner JSON result (fix applied), not outer plain text.
1850        assert_eq!(
1851            parsed.response.status, "done",
1852            "expected inner JSON result status, got: {:?}",
1853            parsed.response.status
1854        );
1855        assert_eq!(
1856            parsed.response.summary.as_str(),
1857            "fix applied",
1858            "expected inner JSON result summary, got: {:?}",
1859            parsed.response.summary
1860        );
1861        // Tokens must be from the inner result with tokens, not the outer plain-text one.
1862        assert_eq!(
1863            parsed.input_tokens,
1864            Some(12),
1865            "input_tokens must be from inner result (not outer plain-text result)"
1866        );
1867        assert_eq!(
1868            parsed.output_tokens,
1869            Some(1577),
1870            "output_tokens must be from inner result (not outer plain-text result)"
1871        );
1872    }
1873
1874    /// Regression test: when find_ndjson_result_line returns None (no JSON
1875    /// result found), extract_stream_json_result_text must fall back to
1876    /// opencode-style text extraction, not return raw NDJSON.
1877    #[test]
1878    fn extract_text_falls_back_to_opencode_style_when_no_json_result() {
1879        // An NDJSON stream with no type:result at all — just a text event.
1880        let ndjson = concat!(
1881            r#"{"type":"system","subtype":"init","session_id":"s1"}"#,
1882            "\n",
1883            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"All tasks complete"}]}}"#,
1884        );
1885
1886        let text = runner().extract_text(ndjson).expect("should extract text");
1887        // Must extract the assistant message text, not raw NDJSON.
1888        assert!(
1889            text.contains("All tasks complete"),
1890            "expected extracted assistant text, got: {text}"
1891        );
1892        assert!(!text.contains("type"), "must not return raw NDJSON: {text}");
1893    }
1894}
1895
1896/// Claude `stream-json`: calls and text are blocks in `assistant` messages, results in `user` ones
1897pub(crate) fn stream_events(v: &serde_json::Value) -> Vec<StreamEvent> {
1898    let blocks = || v["message"]["content"].as_array().into_iter().flatten();
1899    match json_str(v, "/type") {
1900        "system" if json_str(v, "/subtype") == "init" => vec![StreamEvent::Started {
1901            model: json_str(v, "/model").to_string(),
1902        }],
1903        "assistant" => blocks()
1904            .filter_map(|b| match json_str(b, "/type") {
1905                "text" => Some(StreamEvent::Text(json_str(b, "/text").to_string())),
1906                "thinking" => Some(StreamEvent::Thinking(json_str(b, "/thinking").to_string())),
1907                "tool_use" => Some(StreamEvent::ToolCall {
1908                    id: json_str(b, "/id").to_string(),
1909                    name: json_str(b, "/name").to_string(),
1910                    input: b["input"].clone(),
1911                }),
1912                _ => None,
1913            })
1914            .collect(),
1915        "user" => blocks()
1916            .filter(|b| json_str(b, "/type") == "tool_result")
1917            .map(|b| StreamEvent::ToolResult {
1918                id: json_str(b, "/tool_use_id").to_string(),
1919                name: String::new(),
1920                output: match &b["content"] {
1921                    serde_json::Value::String(s) => s.clone(),
1922                    serde_json::Value::Array(parts) => parts
1923                        .iter()
1924                        .filter_map(|p| p["text"].as_str())
1925                        .collect::<Vec<_>>()
1926                        .join("\n"),
1927                    _ => String::new(),
1928                },
1929                is_error: b["is_error"].as_bool() == Some(true),
1930            })
1931            .collect(),
1932        "error" => vec![StreamEvent::Error(json_error_message(v))],
1933        "result" => {
1934            let failed = v["is_error"].as_bool() == Some(true)
1935                || json_str(v, "/subtype").starts_with("error");
1936            if failed {
1937                return vec![StreamEvent::Error(json_str(v, "/result").to_string())];
1938            }
1939
1940            let turns = v["num_turns"].as_u64().unwrap_or(0);
1941            let secs = v["duration_ms"].as_u64().unwrap_or(0) / 1000;
1942            let time = if secs >= 60 {
1943                format!("{}m {}s", secs / 60, secs % 60)
1944            } else {
1945                format!("{secs}s")
1946            };
1947            vec![StreamEvent::Done(format!("{turns} turns · {time}"))]
1948        }
1949        _ => vec![],
1950    }
1951}
1952
1953#[cfg(test)]
1954mod stream_tests {
1955    use super::*;
1956    use serde_json::json;
1957
1958    fn events(line: &str) -> Vec<StreamEvent> {
1959        ClaudeRunner::new("claude").stream_events(&serde_json::from_str(line).unwrap())
1960    }
1961
1962    #[test]
1963    fn init_reports_model() {
1964        let line = r#"{"type":"system","subtype":"init","model":"claude-sonnet-5","cwd":"/w"}"#;
1965        assert_eq!(
1966            events(line),
1967            vec![StreamEvent::Started {
1968                model: "claude-sonnet-5".into()
1969            }]
1970        );
1971    }
1972
1973    #[test]
1974    fn assistant_blocks_in_order() {
1975        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"}}]}}"#;
1976        assert_eq!(
1977            events(line),
1978            vec![
1979                StreamEvent::Thinking("plan".into()),
1980                StreamEvent::Text("Reading.".into()),
1981                StreamEvent::ToolCall {
1982                    id: "t1".into(),
1983                    name: "Read".into(),
1984                    input: json!({"file_path": "a.rs"}),
1985                },
1986            ]
1987        );
1988    }
1989
1990    #[test]
1991    fn tool_result_string_and_block_content() {
1992        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}]}}"#;
1993        assert_eq!(
1994            events(line),
1995            vec![
1996                StreamEvent::ToolResult {
1997                    id: "t1".into(),
1998                    name: String::new(),
1999                    output: "ok".into(),
2000                    is_error: false,
2001                },
2002                StreamEvent::ToolResult {
2003                    id: "t2".into(),
2004                    name: String::new(),
2005                    output: "a\nb".into(),
2006                    is_error: true,
2007                },
2008            ]
2009        );
2010    }
2011
2012    #[test]
2013    fn result_success_and_error() {
2014        let ok = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":66,"duration_ms":580214}"#;
2015        assert_eq!(
2016            events(ok),
2017            vec![StreamEvent::Done("66 turns · 9m 40s".into())]
2018        );
2019        let err = r#"{"type":"result","subtype":"error_max_turns","is_error":true,"result":"too many turns"}"#;
2020        assert_eq!(
2021            events(err),
2022            vec![StreamEvent::Error("too many turns".into())]
2023        );
2024    }
2025
2026    #[test]
2027    fn noise_events_are_dropped() {
2028        for line in [
2029            r#"{"type":"system","subtype":"thinking_tokens","tokens":12}"#,
2030            r#"{"type":"system","subtype":"task_started"}"#,
2031            r#"{"type":"rate_limit_event","rate_limit_info":{}}"#,
2032            r#"{"type":"tool_progress"}"#,
2033        ] {
2034            assert!(events(line).is_empty(), "{line}");
2035        }
2036    }
2037}