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