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