Skip to main content

zag_agent/providers/claude/
mod.rs

1// provider-updated: 2026-04-05
2pub mod logs;
3/// Claude agent implementation.
4///
5/// This module provides the Claude agent implementation, including:
6/// - Agent trait implementation for executing Claude commands
7/// - JSON output models for parsing Claude's verbose output
8/// - Conversion to unified AgentOutput format
9pub mod models;
10
11use crate::agent::{Agent, ModelSize};
12
13/// Return the Claude projects directory: `~/.claude/projects/`.
14pub fn projects_dir() -> Option<std::path::PathBuf> {
15    dirs::home_dir().map(|h| h.join(".claude/projects"))
16}
17use crate::output::AgentOutput;
18use crate::providers::common::CommonAgentState;
19use anyhow::{Context, Result};
20use async_trait::async_trait;
21use std::process::Stdio;
22use tokio::io::{AsyncBufReadExt, BufReader};
23use tokio::process::Command;
24
25pub const DEFAULT_MODEL: &str = "default";
26
27pub const AVAILABLE_MODELS: &[&str] = &["default", "sonnet", "opus", "haiku"];
28
29/// Callback for streaming events. Set via `set_event_handler` to receive
30/// unified events as they arrive during non-interactive execution.
31pub type EventHandler = Box<dyn Fn(&crate::output::Event, bool) + Send + Sync>;
32
33pub struct Claude {
34    pub common: CommonAgentState,
35    pub session_id: Option<String>,
36    pub input_format: Option<String>,
37    pub verbose: bool,
38    pub json_schema: Option<String>,
39    pub event_handler: Option<EventHandler>,
40    pub replay_user_messages: bool,
41    pub include_partial_messages: bool,
42    pub mcp_config_path: Option<String>,
43}
44
45impl Claude {
46    pub fn new() -> Self {
47        Self {
48            common: CommonAgentState::new(DEFAULT_MODEL),
49            session_id: None,
50            input_format: None,
51            verbose: false,
52            json_schema: None,
53            event_handler: None,
54            replay_user_messages: false,
55            include_partial_messages: false,
56            mcp_config_path: None,
57        }
58    }
59
60    pub fn set_input_format(&mut self, format: Option<String>) {
61        self.input_format = format;
62    }
63
64    pub fn set_session_id(&mut self, session_id: String) {
65        self.session_id = Some(session_id);
66    }
67
68    pub fn set_verbose(&mut self, verbose: bool) {
69        self.verbose = verbose;
70    }
71
72    pub fn set_json_schema(&mut self, schema: Option<String>) {
73        self.json_schema = schema;
74    }
75
76    pub fn set_replay_user_messages(&mut self, replay: bool) {
77        self.replay_user_messages = replay;
78    }
79
80    pub fn set_include_partial_messages(&mut self, include: bool) {
81        self.include_partial_messages = include;
82    }
83
84    /// Set MCP server config: a JSON string (written to a temp file) or a file path.
85    pub fn set_mcp_config(&mut self, config: Option<String>) {
86        self.mcp_config_path = config.map(|c| {
87            if c.trim_start().starts_with('{') {
88                let path =
89                    std::env::temp_dir().join(format!("zag-mcp-{}.json", uuid::Uuid::new_v4()));
90                if let Err(e) = std::fs::write(&path, &c) {
91                    log::warn!("Failed to write MCP config temp file: {e}");
92                    return c;
93                }
94                path.to_string_lossy().into_owned()
95            } else {
96                c
97            }
98        });
99    }
100
101    /// Set a callback to receive streaming events during non-interactive execution.
102    ///
103    /// The callback receives `(event, verbose)` where `verbose` indicates whether
104    /// the user requested verbose output.
105    pub fn set_event_handler(&mut self, handler: EventHandler) {
106        self.event_handler = Some(handler);
107    }
108
109    /// Build the argument list for a run/exec invocation.
110    fn build_run_args(
111        &self,
112        interactive: bool,
113        prompt: Option<&str>,
114        effective_output_format: &Option<String>,
115    ) -> Vec<String> {
116        let mut args = Vec::new();
117        let in_sandbox = self.common.sandbox.is_some();
118
119        if !interactive {
120            args.push("--print".to_string());
121
122            match effective_output_format.as_deref() {
123                Some("json") | Some("json-pretty") => {
124                    args.extend(["--verbose", "--output-format", "json"].map(String::from));
125                }
126                Some("stream-json") | None => {
127                    args.extend(["--verbose", "--output-format", "stream-json"].map(String::from));
128                }
129                Some("native-json") => {
130                    args.extend(["--verbose", "--output-format", "json"].map(String::from));
131                }
132                Some("text") => {}
133                _ => {}
134            }
135        }
136
137        // Skip --dangerously-skip-permissions in sandbox (permissions are sandbox-default)
138        if self.common.skip_permissions && !in_sandbox {
139            args.push("--dangerously-skip-permissions".to_string());
140        }
141
142        args.extend(["--model".to_string(), self.common.model.clone()]);
143
144        if interactive && let Some(session_id) = &self.session_id {
145            args.extend(["--session-id".to_string(), session_id.clone()]);
146        }
147
148        for dir in &self.common.add_dirs {
149            args.extend(["--add-dir".to_string(), dir.clone()]);
150        }
151
152        if !self.common.system_prompt.is_empty() {
153            args.extend([
154                "--append-system-prompt".to_string(),
155                self.common.system_prompt.clone(),
156            ]);
157        }
158
159        if !interactive && let Some(ref input_fmt) = self.input_format {
160            args.extend(["--input-format".to_string(), input_fmt.clone()]);
161        }
162
163        if !interactive && self.replay_user_messages {
164            args.push("--replay-user-messages".to_string());
165        }
166
167        if !interactive && self.include_partial_messages {
168            args.push("--include-partial-messages".to_string());
169        }
170
171        if let Some(ref schema) = self.json_schema {
172            args.extend(["--json-schema".to_string(), schema.clone()]);
173        }
174
175        if let Some(turns) = self.common.max_turns {
176            args.extend(["--max-turns".to_string(), turns.to_string()]);
177        }
178
179        if let Some(ref path) = self.mcp_config_path {
180            args.extend(["--mcp-config".to_string(), path.clone()]);
181        }
182
183        if let Some(p) = prompt {
184            args.push(p.to_string());
185        }
186
187        args
188    }
189
190    /// Build the argument list for a resume invocation.
191    fn build_resume_args(&self, session_id: Option<&str>) -> Vec<String> {
192        let mut args = Vec::new();
193        let in_sandbox = self.common.sandbox.is_some();
194
195        if let Some(id) = session_id {
196            args.extend(["--resume".to_string(), id.to_string()]);
197        } else {
198            args.push("--continue".to_string());
199        }
200
201        if self.common.skip_permissions && !in_sandbox {
202            args.push("--dangerously-skip-permissions".to_string());
203        }
204
205        args.extend(["--model".to_string(), self.common.model.clone()]);
206
207        for dir in &self.common.add_dirs {
208            args.extend(["--add-dir".to_string(), dir.clone()]);
209        }
210
211        args
212    }
213
214    /// Create a `Command` either directly or wrapped in sandbox.
215    fn make_command(&self, agent_args: Vec<String>) -> Command {
216        self.common.make_command("claude", agent_args)
217    }
218
219    /// Spawn a streaming session with piped stdin/stdout.
220    ///
221    /// Automatically configures `--input-format stream-json`, `--output-format stream-json`,
222    /// and `--replay-user-messages`. Returns a `StreamingSession` for bidirectional
223    /// communication with the agent.
224    ///
225    /// # Mid-turn semantics
226    ///
227    /// User messages sent via `StreamingSession::send_user_message` while the
228    /// assistant is producing a response are **queued** by the Claude CLI: the
229    /// current turn runs to completion and the new message is delivered as the
230    /// next user turn. The in-flight turn is **not interrupted**. This
231    /// corresponds to `streaming_input.semantics == "queue"` in the capability
232    /// descriptor.
233    pub fn execute_streaming(
234        &self,
235        prompt: Option<&str>,
236    ) -> Result<crate::streaming::StreamingSession> {
237        // Build args for non-interactive streaming mode
238        let mut args = Vec::new();
239        let in_sandbox = self.common.sandbox.is_some();
240
241        args.push("--print".to_string());
242        args.extend(["--verbose", "--output-format", "stream-json"].map(String::from));
243
244        if self.common.skip_permissions && !in_sandbox {
245            args.push("--dangerously-skip-permissions".to_string());
246        }
247
248        args.extend(["--model".to_string(), self.common.model.clone()]);
249
250        for dir in &self.common.add_dirs {
251            args.extend(["--add-dir".to_string(), dir.clone()]);
252        }
253
254        if !self.common.system_prompt.is_empty() {
255            args.extend([
256                "--append-system-prompt".to_string(),
257                self.common.system_prompt.clone(),
258            ]);
259        }
260
261        args.extend(["--input-format".to_string(), "stream-json".to_string()]);
262        args.push("--replay-user-messages".to_string());
263
264        if self.include_partial_messages {
265            args.push("--include-partial-messages".to_string());
266        }
267
268        if let Some(ref schema) = self.json_schema {
269            args.extend(["--json-schema".to_string(), schema.clone()]);
270        }
271
272        if let Some(p) = prompt {
273            args.push(p.to_string());
274        }
275
276        log::debug!("Claude streaming command: claude {}", args.join(" "));
277
278        let mut cmd = self.make_command(args);
279        cmd.stdin(Stdio::piped())
280            .stdout(Stdio::piped())
281            .stderr(Stdio::piped());
282
283        let child = cmd
284            .spawn()
285            .context("Failed to execute 'claude' CLI. Is it installed and in PATH?")?;
286        crate::streaming::StreamingSession::new(child)
287    }
288
289    /// Build argument list for a streaming resume invocation.
290    fn build_streaming_resume_args(&self, session_id: &str) -> Vec<String> {
291        let mut args = Vec::new();
292        let in_sandbox = self.common.sandbox.is_some();
293
294        args.push("--print".to_string());
295        args.extend(["--resume".to_string(), session_id.to_string()]);
296        args.extend(["--verbose", "--output-format", "stream-json"].map(String::from));
297
298        if self.common.skip_permissions && !in_sandbox {
299            args.push("--dangerously-skip-permissions".to_string());
300        }
301
302        args.extend(["--model".to_string(), self.common.model.clone()]);
303
304        for dir in &self.common.add_dirs {
305            args.extend(["--add-dir".to_string(), dir.clone()]);
306        }
307
308        args.extend(["--input-format".to_string(), "stream-json".to_string()]);
309        args.push("--replay-user-messages".to_string());
310
311        if self.include_partial_messages {
312            args.push("--include-partial-messages".to_string());
313        }
314
315        args
316    }
317
318    /// Spawn a streaming session that resumes an existing session.
319    ///
320    /// Combines `--resume` with `--input-format stream-json`, `--output-format stream-json`,
321    /// and `--replay-user-messages`. Returns a `StreamingSession` for bidirectional
322    /// communication with the resumed session.
323    ///
324    /// Mid-turn `send_user_message` calls follow the same **queue** semantics
325    /// as [`Self::execute_streaming`].
326    pub fn execute_streaming_resume(
327        &self,
328        session_id: &str,
329    ) -> Result<crate::streaming::StreamingSession> {
330        let args = self.build_streaming_resume_args(session_id);
331
332        log::debug!("Claude streaming resume command: claude {}", args.join(" "));
333
334        let mut cmd = self.make_command(args);
335        cmd.stdin(Stdio::piped())
336            .stdout(Stdio::piped())
337            .stderr(Stdio::piped());
338
339        let child = cmd
340            .spawn()
341            .context("Failed to execute 'claude' CLI. Is it installed and in PATH?")?;
342        crate::streaming::StreamingSession::new(child)
343    }
344
345    async fn execute(
346        &self,
347        interactive: bool,
348        prompt: Option<&str>,
349    ) -> Result<Option<AgentOutput>> {
350        // When capture_output is set (e.g. by auto-selector), use "json" format
351        // so stdout is piped and parsed into AgentOutput
352        let effective_output_format =
353            if self.common.capture_output && self.common.output_format.is_none() {
354                Some("json".to_string())
355            } else {
356                self.common.output_format.clone()
357            };
358
359        // Determine if we should capture structured output
360        // Default to streaming unified output when no format is specified in print mode
361        let capture_json = !interactive
362            && effective_output_format
363                .as_ref()
364                .is_none_or(|f| f == "json" || f == "json-pretty" || f == "stream-json");
365
366        let agent_args = self.build_run_args(interactive, prompt, &effective_output_format);
367        log::debug!("Claude command: claude {}", agent_args.join(" "));
368        if !self.common.system_prompt.is_empty() {
369            log::debug!("Claude system prompt: {}", self.common.system_prompt);
370        }
371        if let Some(p) = prompt {
372            log::debug!("Claude user prompt: {p}");
373        }
374        log::debug!(
375            "Claude mode: interactive={interactive}, capture_json={capture_json}, output_format={effective_output_format:?}"
376        );
377        let mut cmd = self.make_command(agent_args);
378
379        // Check if we should pass through native JSON without conversion
380        let is_native_json = effective_output_format.as_deref() == Some("native-json");
381
382        if interactive {
383            // Interactive mode - inherit all stdio
384            cmd.stdin(Stdio::inherit())
385                .stdout(Stdio::inherit())
386                .stderr(Stdio::inherit());
387
388            let status = cmd
389                .status()
390                .await
391                .context("Failed to execute 'claude' CLI. Is it installed and in PATH?")?;
392            if !status.success() {
393                return Err(crate::process::ProcessError {
394                    exit_code: status.code(),
395                    stderr: String::new(),
396                    agent_name: "Claude".to_string(),
397                }
398                .into());
399            }
400            Ok(None)
401        } else if is_native_json {
402            // Native JSON mode - pass through Claude's raw JSON output, capture stderr
403            cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit());
404
405            crate::process::run_with_captured_stderr(&mut cmd).await?;
406            Ok(None)
407        } else if capture_json {
408            let output_format = effective_output_format.as_deref();
409            let is_streaming = output_format == Some("stream-json") || output_format.is_none();
410
411            if is_streaming {
412                // For stream-json or default (None), stream output and convert to unified format
413                cmd.stdin(Stdio::inherit());
414                cmd.stdout(Stdio::piped());
415
416                let mut child = crate::process::spawn_with_captured_stderr(&mut cmd).await?;
417                let stdout = child
418                    .stdout
419                    .take()
420                    .ok_or_else(|| anyhow::anyhow!("Failed to capture stdout"))?;
421
422                let reader = BufReader::new(stdout);
423                let mut lines = reader.lines();
424
425                // Determine output mode
426                let format_as_text = output_format.is_none(); // Default: beautiful text
427                let format_as_json = output_format == Some("stream-json"); // Explicit: unified JSON
428
429                // Per-line batch path uses the stateful translator so that
430                // TurnComplete events are synthesized alongside Result.
431                let mut translator = ClaudeEventTranslator::new();
432
433                // Stream each line, dispatching via event_handler if set
434                while let Some(line) = lines.next_line().await? {
435                    if format_as_text || format_as_json {
436                        match serde_json::from_str::<models::ClaudeEvent>(&line) {
437                            Ok(claude_event) => {
438                                for unified_event in translator.translate(&claude_event) {
439                                    if let Some(ref handler) = self.event_handler {
440                                        handler(&unified_event, self.verbose);
441                                    }
442                                }
443                            }
444                            Err(e) => {
445                                log::debug!(
446                                    "Failed to parse streaming Claude event: {}. Line: {}",
447                                    e,
448                                    crate::truncate_str(&line, 200)
449                                );
450                            }
451                        }
452                    }
453                }
454
455                // Signal end of streaming to handler
456                if let Some(ref handler) = self.event_handler {
457                    // Send a Result event to signal completion
458                    handler(
459                        &crate::output::Event::Result {
460                            success: true,
461                            message: None,
462                            duration_ms: None,
463                            num_turns: None,
464                        },
465                        self.verbose,
466                    );
467                }
468
469                crate::process::wait_with_stderr(child).await?;
470
471                // Return None to indicate output was streamed directly
472                Ok(None)
473            } else {
474                // For json/json-pretty, capture all output then parse
475                cmd.stdin(Stdio::inherit());
476                cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
477
478                let output = cmd.output().await?;
479
480                crate::process::handle_output(&output, "Claude")?;
481
482                // Parse JSON output
483                let json_str = String::from_utf8(output.stdout)?;
484                log::debug!("Parsing Claude JSON output ({} bytes)", json_str.len());
485                let claude_output: models::ClaudeOutput =
486                    serde_json::from_str(&json_str).map_err(|e| {
487                        log::debug!(
488                            "Failed to parse Claude JSON output: {}. First 500 chars: {}",
489                            e,
490                            crate::truncate_str(&json_str, 500)
491                        );
492                        anyhow::anyhow!("Failed to parse Claude JSON output: {e}")
493                    })?;
494                log::debug!("Parsed {} Claude events successfully", claude_output.len());
495
496                // Log any unknown event types for diagnostics.
497                if let Ok(raw_events) = serde_json::from_str::<Vec<serde_json::Value>>(&json_str) {
498                    let known = ["system", "assistant", "user", "result"];
499                    for raw in &raw_events {
500                        if let Some(t) = raw.get("type").and_then(|v| v.as_str()) {
501                            if !known.contains(&t) {
502                                log::debug!(
503                                    "Unknown Claude event type: {:?} (first 300 chars: {})",
504                                    t,
505                                    crate::truncate_str(
506                                        &serde_json::to_string(raw).unwrap_or_default(),
507                                        300
508                                    )
509                                );
510                            }
511                        }
512                    }
513                }
514
515                // Convert to unified AgentOutput
516                let agent_output: AgentOutput =
517                    models::claude_output_to_agent_output(claude_output);
518                Ok(Some(agent_output))
519            }
520        } else {
521            // Explicit text mode - inherit stdout, capture stderr
522            cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit());
523
524            crate::process::run_with_captured_stderr(&mut cmd).await?;
525            Ok(None)
526        }
527    }
528}
529
530/// Stateful translator from Claude `stream-json` events to unified
531/// [`crate::output::Event`]s.
532///
533/// Some unified events are synthesized from cross-event state —
534/// specifically [`crate::output::Event::TurnComplete`], which carries
535/// `stop_reason` and `usage` from the *last* assistant message of a turn
536/// and is emitted immediately before the corresponding per-turn
537/// [`crate::output::Event::Result`]. This translator owns that state.
538///
539/// Stateless per-event conversion still goes through
540/// [`convert_claude_event_to_unified`]; the translator is a thin stateful
541/// wrapper on top.
542#[derive(Debug, Default)]
543pub(crate) struct ClaudeEventTranslator {
544    /// `stop_reason` from the most recent `ClaudeEvent::Assistant` in the
545    /// current turn. Consumed when `TurnComplete` is emitted.
546    pending_stop_reason: Option<String>,
547    /// `usage` from the most recent `ClaudeEvent::Assistant`.
548    pending_usage: Option<crate::output::Usage>,
549    /// Zero-based turn index within the session. Incremented after each
550    /// emitted `TurnComplete`.
551    next_turn_index: u32,
552    /// Text from the most recent assistant message, used as fallback when
553    /// `Result.result` is empty.
554    last_assistant_text: Option<String>,
555    /// Maps `tool_use_id` → `tool_name` from assistant messages so that
556    /// subsequent `ToolExecution` events (which only carry the id) can be
557    /// enriched with the correct tool name.
558    tool_name_by_id: std::collections::HashMap<String, String>,
559}
560
561impl ClaudeEventTranslator {
562    pub(crate) fn new() -> Self {
563        Self::default()
564    }
565
566    /// Translate one Claude event into zero or more unified events.
567    ///
568    /// A `ClaudeEvent::Result` expands into `[TurnComplete, Result]`; all
569    /// other events pass through [`convert_claude_event_to_unified`] and
570    /// yield at most one unified event.
571    pub(crate) fn translate(&mut self, event: &models::ClaudeEvent) -> Vec<crate::output::Event> {
572        use crate::output::{Event as UnifiedEvent, Usage as UnifiedUsage};
573
574        // Observe assistant-side turn state. Every assistant message
575        // within the current turn updates the pending stop_reason / usage;
576        // the final one wins because it is the message that actually ends
577        // the turn (its `stop_reason` will be `end_turn`, `tool_use`,
578        // `max_tokens`, or `stop_sequence`).
579        if let models::ClaudeEvent::Assistant { message, .. } = event {
580            if let Some(reason) = &message.stop_reason {
581                self.pending_stop_reason = Some(reason.clone());
582            }
583            self.pending_usage = Some(UnifiedUsage {
584                input_tokens: message.usage.input_tokens,
585                output_tokens: message.usage.output_tokens,
586                cache_read_tokens: Some(message.usage.cache_read_input_tokens),
587                cache_creation_tokens: Some(message.usage.cache_creation_input_tokens),
588                web_search_requests: message
589                    .usage
590                    .server_tool_use
591                    .as_ref()
592                    .map(|s| s.web_search_requests),
593                web_fetch_requests: message
594                    .usage
595                    .server_tool_use
596                    .as_ref()
597                    .map(|s| s.web_fetch_requests),
598            });
599
600            // Track text for fallback when Result.result is empty.
601            let text_parts: Vec<&str> = message
602                .content
603                .iter()
604                .filter_map(|b| match b {
605                    models::ContentBlock::Text { text } => Some(text.as_str()),
606                    _ => None,
607                })
608                .collect();
609            if !text_parts.is_empty() {
610                self.last_assistant_text = Some(text_parts.join("\n"));
611            }
612
613            // Track tool_use_id → tool_name so ToolExecution events get the
614            // correct name instead of "unknown".
615            for block in &message.content {
616                if let models::ContentBlock::ToolUse { id, name, .. } = block {
617                    self.tool_name_by_id.insert(id.clone(), name.clone());
618                }
619            }
620        }
621
622        let unified = convert_claude_event_to_unified(event);
623
624        match unified {
625            Some(UnifiedEvent::Result {
626                success,
627                message,
628                duration_ms,
629                num_turns,
630            }) if message.as_deref() == Some("") => {
631                // Empty result — substitute last assistant text if available.
632                let fallback = self.last_assistant_text.take();
633                if fallback.is_some() {
634                    log::debug!(
635                        "Streaming Result.message is empty; using last assistant text as fallback"
636                    );
637                }
638                let result_event = UnifiedEvent::Result {
639                    success,
640                    message: fallback.or(message),
641                    duration_ms,
642                    num_turns,
643                };
644                let turn_complete = UnifiedEvent::TurnComplete {
645                    stop_reason: self.pending_stop_reason.take(),
646                    turn_index: self.next_turn_index,
647                    usage: self.pending_usage.take(),
648                };
649                self.next_turn_index = self.next_turn_index.saturating_add(1);
650                vec![turn_complete, result_event]
651            }
652            Some(UnifiedEvent::Result { .. }) => {
653                let turn_complete = UnifiedEvent::TurnComplete {
654                    stop_reason: self.pending_stop_reason.take(),
655                    turn_index: self.next_turn_index,
656                    usage: self.pending_usage.take(),
657                };
658                self.next_turn_index = self.next_turn_index.saturating_add(1);
659                vec![turn_complete, unified.unwrap()]
660            }
661            Some(UnifiedEvent::ToolExecution {
662                tool_name,
663                tool_id,
664                input,
665                result,
666                parent_tool_use_id,
667            }) => {
668                // Enrich with the real tool name if we tracked it from a
669                // prior assistant message.
670                let resolved_name = self
671                    .tool_name_by_id
672                    .get(&tool_id)
673                    .cloned()
674                    .unwrap_or(tool_name);
675                vec![UnifiedEvent::ToolExecution {
676                    tool_name: resolved_name,
677                    tool_id,
678                    input,
679                    result,
680                    parent_tool_use_id,
681                }]
682            }
683            Some(ev) => vec![ev],
684            None => Vec::new(),
685        }
686    }
687}
688
689/// Convert a single Claude event to a unified event format.
690/// Returns None if the event doesn't map to a user-visible unified event.
691///
692/// This is the stateless per-event converter. Callers that need
693/// cross-event synthesis (e.g. [`crate::output::Event::TurnComplete`])
694/// should use [`ClaudeEventTranslator`] instead.
695pub(crate) fn convert_claude_event_to_unified(
696    event: &models::ClaudeEvent,
697) -> Option<crate::output::Event> {
698    use crate::output::{
699        ContentBlock as UnifiedContentBlock, Event as UnifiedEvent, ToolResult,
700        Usage as UnifiedUsage,
701    };
702    use models::ClaudeEvent;
703
704    match event {
705        ClaudeEvent::System {
706            model, tools, cwd, ..
707        } => {
708            let mut metadata = std::collections::HashMap::new();
709            if let Some(cwd_val) = cwd {
710                metadata.insert("cwd".to_string(), serde_json::json!(cwd_val));
711            }
712
713            Some(UnifiedEvent::Init {
714                model: model.clone(),
715                tools: tools.clone(),
716                working_directory: cwd.clone(),
717                metadata,
718            })
719        }
720
721        ClaudeEvent::Assistant {
722            message,
723            parent_tool_use_id,
724            ..
725        } => {
726            // Convert content blocks
727            let content: Vec<UnifiedContentBlock> = message
728                .content
729                .iter()
730                .filter_map(|block| match block {
731                    models::ContentBlock::Text { text } => {
732                        Some(UnifiedContentBlock::Text { text: text.clone() })
733                    }
734                    models::ContentBlock::ToolUse { id, name, input } => {
735                        Some(UnifiedContentBlock::ToolUse {
736                            id: id.clone(),
737                            name: name.clone(),
738                            input: input.clone(),
739                        })
740                    }
741                    models::ContentBlock::Thinking { .. } | models::ContentBlock::Other => None,
742                })
743                .collect();
744
745            // Convert usage
746            let usage = Some(UnifiedUsage {
747                input_tokens: message.usage.input_tokens,
748                output_tokens: message.usage.output_tokens,
749                cache_read_tokens: Some(message.usage.cache_read_input_tokens),
750                cache_creation_tokens: Some(message.usage.cache_creation_input_tokens),
751                web_search_requests: message
752                    .usage
753                    .server_tool_use
754                    .as_ref()
755                    .map(|s| s.web_search_requests),
756                web_fetch_requests: message
757                    .usage
758                    .server_tool_use
759                    .as_ref()
760                    .map(|s| s.web_fetch_requests),
761            });
762
763            Some(UnifiedEvent::AssistantMessage {
764                content,
765                usage,
766                parent_tool_use_id: parent_tool_use_id.clone(),
767            })
768        }
769
770        ClaudeEvent::User {
771            message,
772            tool_use_result,
773            parent_tool_use_id,
774            ..
775        } => {
776            // For streaming, we can't easily look up tool names from previous events
777            // So we'll use "unknown" for the tool name in streaming mode
778            // Find the first tool_result block (skip text and other blocks)
779            let first_tool_result = message.content.iter().find_map(|b| {
780                if let models::UserContentBlock::ToolResult {
781                    tool_use_id,
782                    content,
783                    is_error,
784                } = b
785                {
786                    Some((tool_use_id, content, is_error))
787                } else {
788                    None
789                }
790            });
791
792            if let Some((tool_use_id, content, is_error)) = first_tool_result {
793                let tool_result = ToolResult {
794                    success: !is_error,
795                    output: if !is_error {
796                        Some(content.clone())
797                    } else {
798                        None
799                    },
800                    error: if *is_error {
801                        Some(content.clone())
802                    } else {
803                        None
804                    },
805                    data: tool_use_result.clone(),
806                };
807
808                Some(UnifiedEvent::ToolExecution {
809                    tool_name: "unknown".to_string(),
810                    tool_id: tool_use_id.clone(),
811                    input: serde_json::Value::Null,
812                    result: tool_result,
813                    parent_tool_use_id: parent_tool_use_id.clone(),
814                })
815            } else {
816                // Check for text content (replayed user messages via --replay-user-messages)
817                let text_blocks: Vec<UnifiedContentBlock> = message
818                    .content
819                    .iter()
820                    .filter_map(|b| {
821                        if let models::UserContentBlock::Text { text } = b {
822                            Some(UnifiedContentBlock::Text { text: text.clone() })
823                        } else {
824                            None
825                        }
826                    })
827                    .collect();
828
829                if !text_blocks.is_empty() {
830                    Some(UnifiedEvent::UserMessage {
831                        content: text_blocks,
832                    })
833                } else {
834                    None
835                }
836            }
837        }
838
839        ClaudeEvent::Other => {
840            log::debug!("Skipping unknown Claude event type during streaming conversion");
841            None
842        }
843
844        ClaudeEvent::Result {
845            is_error,
846            result,
847            duration_ms,
848            num_turns,
849            structured_output,
850            ..
851        } => {
852            // When result is empty but structured_output is present
853            // (Claude CLI with --json-schema), use the structured output.
854            let effective_result = if result.is_empty() {
855                if let Some(so) = structured_output {
856                    log::debug!("Streaming Result.result is empty; using structured_output");
857                    serde_json::to_string(so).unwrap_or_default()
858                } else {
859                    result.clone()
860                }
861            } else {
862                result.clone()
863            };
864            Some(UnifiedEvent::Result {
865                success: !is_error,
866                message: Some(effective_result),
867                duration_ms: Some(*duration_ms),
868                num_turns: Some(*num_turns),
869            })
870        }
871    }
872}
873
874#[cfg(test)]
875#[path = "claude_tests.rs"]
876mod tests;
877
878impl Default for Claude {
879    fn default() -> Self {
880        Self::new()
881    }
882}
883
884#[async_trait]
885impl Agent for Claude {
886    fn name(&self) -> &str {
887        "claude"
888    }
889
890    fn default_model() -> &'static str {
891        DEFAULT_MODEL
892    }
893
894    fn model_for_size(size: ModelSize) -> &'static str {
895        match size {
896            ModelSize::Small => "haiku",
897            ModelSize::Medium => "sonnet",
898            ModelSize::Large => "default",
899        }
900    }
901
902    fn available_models() -> &'static [&'static str] {
903        AVAILABLE_MODELS
904    }
905
906    crate::providers::common::impl_common_agent_setters!();
907
908    fn set_skip_permissions(&mut self, skip: bool) {
909        self.common.skip_permissions = skip;
910    }
911
912    crate::providers::common::impl_as_any!();
913
914    async fn run(&self, prompt: Option<&str>) -> Result<Option<AgentOutput>> {
915        self.execute(false, prompt).await
916    }
917
918    async fn run_interactive(&self, prompt: Option<&str>) -> Result<()> {
919        self.execute(true, prompt).await?;
920        Ok(())
921    }
922
923    async fn run_resume(&self, session_id: Option<&str>, _last: bool) -> Result<()> {
924        let agent_args = self.build_resume_args(session_id);
925        let mut cmd = self.make_command(agent_args);
926
927        cmd.stdin(Stdio::inherit())
928            .stdout(Stdio::inherit())
929            .stderr(Stdio::inherit());
930
931        let status = cmd
932            .status()
933            .await
934            .context("Failed to execute 'claude' CLI. Is it installed and in PATH?")?;
935        if !status.success() {
936            return Err(crate::process::ProcessError {
937                exit_code: status.code(),
938                stderr: String::new(),
939                agent_name: "Claude".to_string(),
940            }
941            .into());
942        }
943        Ok(())
944    }
945
946    async fn run_resume_with_prompt(
947        &self,
948        session_id: &str,
949        prompt: &str,
950    ) -> Result<Option<AgentOutput>> {
951        log::debug!("Claude resume with prompt: session={session_id}, prompt={prompt}");
952        let in_sandbox = self.common.sandbox.is_some();
953        let mut args = vec!["--print".to_string()];
954        args.extend(["--resume".to_string(), session_id.to_string()]);
955        args.extend(["--verbose", "--output-format", "json"].map(String::from));
956
957        if self.common.skip_permissions && !in_sandbox {
958            args.push("--dangerously-skip-permissions".to_string());
959        }
960
961        args.extend(["--model".to_string(), self.common.model.clone()]);
962
963        for dir in &self.common.add_dirs {
964            args.extend(["--add-dir".to_string(), dir.clone()]);
965        }
966
967        if let Some(ref schema) = self.json_schema {
968            args.extend(["--json-schema".to_string(), schema.clone()]);
969        }
970
971        args.push(prompt.to_string());
972
973        let mut cmd = self.make_command(args);
974
975        cmd.stdin(Stdio::inherit());
976        cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
977
978        let output = cmd.output().await?;
979
980        crate::process::handle_output(&output, "Claude")?;
981
982        // Parse JSON output
983        let json_str = String::from_utf8(output.stdout)?;
984        log::debug!(
985            "Parsing Claude resume JSON output ({} bytes)",
986            json_str.len()
987        );
988        let claude_output: models::ClaudeOutput = serde_json::from_str(&json_str)
989            .map_err(|e| anyhow::anyhow!("Failed to parse Claude resume JSON output: {e}"))?;
990
991        let agent_output: AgentOutput = models::claude_output_to_agent_output(claude_output);
992        Ok(Some(agent_output))
993    }
994
995    async fn cleanup(&self) -> Result<()> {
996        Ok(())
997    }
998}