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