claude.rs

  1mod mcp_server;
  2pub mod tools;
  3
  4use collections::HashMap;
  5use context_server::listener::McpServerTool;
  6use project::Project;
  7use settings::SettingsStore;
  8use smol::process::Child;
  9use std::cell::RefCell;
 10use std::fmt::Display;
 11use std::path::Path;
 12use std::pin::pin;
 13use std::rc::Rc;
 14use uuid::Uuid;
 15
 16use agent_client_protocol as acp;
 17use anyhow::{Result, anyhow};
 18use futures::channel::oneshot;
 19use futures::{AsyncBufReadExt, AsyncWriteExt};
 20use futures::{
 21    AsyncRead, AsyncWrite, FutureExt, StreamExt,
 22    channel::mpsc::{self, UnboundedReceiver, UnboundedSender},
 23    io::BufReader,
 24    select_biased,
 25};
 26use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
 27use serde::{Deserialize, Serialize};
 28use util::ResultExt;
 29
 30use crate::claude::mcp_server::{ClaudeZedMcpServer, McpConfig};
 31use crate::claude::tools::ClaudeTool;
 32use crate::{AgentServer, AgentServerCommand, AllAgentServersSettings};
 33use acp_thread::{AcpThread, AgentConnection};
 34
 35#[derive(Clone)]
 36pub struct ClaudeCode;
 37
 38impl AgentServer for ClaudeCode {
 39    fn name(&self) -> &'static str {
 40        "Claude Code"
 41    }
 42
 43    fn empty_state_headline(&self) -> &'static str {
 44        self.name()
 45    }
 46
 47    fn empty_state_message(&self) -> &'static str {
 48        ""
 49    }
 50
 51    fn logo(&self) -> ui::IconName {
 52        ui::IconName::AiClaude
 53    }
 54
 55    fn connect(
 56        &self,
 57        _root_dir: &Path,
 58        _project: &Entity<Project>,
 59        _cx: &mut App,
 60    ) -> Task<Result<Rc<dyn AgentConnection>>> {
 61        let connection = ClaudeAgentConnection {
 62            sessions: Default::default(),
 63        };
 64
 65        Task::ready(Ok(Rc::new(connection) as _))
 66    }
 67}
 68
 69#[cfg(unix)]
 70fn send_interrupt(pid: libc::pid_t) -> anyhow::Result<()> {
 71    let pid = nix::unistd::Pid::from_raw(pid);
 72
 73    nix::sys::signal::kill(pid, nix::sys::signal::SIGINT)
 74        .map_err(|e| anyhow!("Failed to interrupt process: {}", e))
 75}
 76
 77#[cfg(windows)]
 78fn send_interrupt(_pid: i32) -> anyhow::Result<()> {
 79    panic!("Cancel not implemented on Windows")
 80}
 81
 82struct ClaudeAgentConnection {
 83    sessions: Rc<RefCell<HashMap<acp::SessionId, ClaudeAgentSession>>>,
 84}
 85
 86impl AgentConnection for ClaudeAgentConnection {
 87    fn name(&self) -> &'static str {
 88        ClaudeCode.name()
 89    }
 90
 91    fn new_thread(
 92        self: Rc<Self>,
 93        project: Entity<Project>,
 94        cwd: &Path,
 95        cx: &mut AsyncApp,
 96    ) -> Task<Result<Entity<AcpThread>>> {
 97        let cwd = cwd.to_owned();
 98        cx.spawn(async move |cx| {
 99            let (mut thread_tx, thread_rx) = watch::channel(WeakEntity::new_invalid());
100            let permission_mcp_server = ClaudeZedMcpServer::new(thread_rx.clone(), cx).await?;
101
102            let mut mcp_servers = HashMap::default();
103            mcp_servers.insert(
104                mcp_server::SERVER_NAME.to_string(),
105                permission_mcp_server.server_config()?,
106            );
107            let mcp_config = McpConfig { mcp_servers };
108
109            let mcp_config_file = tempfile::NamedTempFile::new()?;
110            let (mcp_config_file, mcp_config_path) = mcp_config_file.into_parts();
111
112            let mut mcp_config_file = smol::fs::File::from(mcp_config_file);
113            mcp_config_file
114                .write_all(serde_json::to_string(&mcp_config)?.as_bytes())
115                .await?;
116            mcp_config_file.flush().await?;
117
118            let settings = cx.read_global(|settings: &SettingsStore, _| {
119                settings.get::<AllAgentServersSettings>(None).claude.clone()
120            })?;
121
122            let Some(command) =
123                AgentServerCommand::resolve("claude", &[], settings, &project, cx).await
124            else {
125                anyhow::bail!("Failed to find claude binary");
126            };
127
128            let (incoming_message_tx, mut incoming_message_rx) = mpsc::unbounded();
129            let (outgoing_tx, outgoing_rx) = mpsc::unbounded();
130            let (cancel_tx, mut cancel_rx) = mpsc::unbounded::<oneshot::Sender<Result<()>>>();
131
132            let session_id = acp::SessionId(Uuid::new_v4().to_string().into());
133
134            log::trace!("Starting session with id: {}", session_id);
135
136            cx.background_spawn({
137                let session_id = session_id.clone();
138                async move {
139                    let mut outgoing_rx = Some(outgoing_rx);
140                    let mut mode = ClaudeSessionMode::Start;
141
142                    loop {
143                        let mut child = spawn_claude(
144                            &command,
145                            mode,
146                            session_id.clone(),
147                            &mcp_config_path,
148                            &cwd,
149                        )
150                        .await?;
151                        mode = ClaudeSessionMode::Resume;
152
153                        let pid = child.id();
154                        log::trace!("Spawned (pid: {})", pid);
155
156                        let mut io_fut = pin!(
157                            ClaudeAgentSession::handle_io(
158                                outgoing_rx.take().unwrap(),
159                                incoming_message_tx.clone(),
160                                child.stdin.take().unwrap(),
161                                child.stdout.take().unwrap(),
162                            )
163                            .fuse()
164                        );
165
166                        select_biased! {
167                            done_tx = cancel_rx.next() => {
168                                if let Some(done_tx) = done_tx {
169                                    log::trace!("Interrupted (pid: {})", pid);
170                                    let result = send_interrupt(pid as i32);
171                                    outgoing_rx.replace(io_fut.await?);
172                                    done_tx.send(result).log_err();
173                                    continue;
174                                }
175                            }
176                            result = io_fut => {
177                                result?;
178                            }
179                        }
180
181                        log::trace!("Stopped (pid: {})", pid);
182                        break;
183                    }
184
185                    drop(mcp_config_path);
186                    anyhow::Ok(())
187                }
188            })
189            .detach();
190
191            let end_turn_tx = Rc::new(RefCell::new(None));
192            let handler_task = cx.spawn({
193                let end_turn_tx = end_turn_tx.clone();
194                let thread_rx = thread_rx.clone();
195                async move |cx| {
196                    while let Some(message) = incoming_message_rx.next().await {
197                        ClaudeAgentSession::handle_message(
198                            thread_rx.clone(),
199                            message,
200                            end_turn_tx.clone(),
201                            cx,
202                        )
203                        .await
204                    }
205                }
206            });
207
208            let thread =
209                cx.new(|cx| AcpThread::new(self.clone(), project, session_id.clone(), cx))?;
210
211            thread_tx.send(thread.downgrade())?;
212
213            let session = ClaudeAgentSession {
214                outgoing_tx,
215                end_turn_tx,
216                cancel_tx,
217                _handler_task: handler_task,
218                _mcp_server: Some(permission_mcp_server),
219            };
220
221            self.sessions.borrow_mut().insert(session_id, session);
222
223            Ok(thread)
224        })
225    }
226
227    fn authenticate(&self, _cx: &mut App) -> Task<Result<()>> {
228        Task::ready(Err(anyhow!("Authentication not supported")))
229    }
230
231    fn prompt(&self, params: acp::PromptToolArguments, cx: &mut App) -> Task<Result<()>> {
232        let sessions = self.sessions.borrow();
233        let Some(session) = sessions.get(&params.session_id) else {
234            return Task::ready(Err(anyhow!(
235                "Attempted to send message to nonexistent session {}",
236                params.session_id
237            )));
238        };
239
240        let (tx, rx) = oneshot::channel();
241        session.end_turn_tx.borrow_mut().replace(tx);
242
243        let mut content = String::new();
244        for chunk in params.prompt {
245            match chunk {
246                acp::ContentBlock::Text(text_content) => {
247                    content.push_str(&text_content.text);
248                }
249                acp::ContentBlock::ResourceLink(resource_link) => {
250                    content.push_str(&format!("@{}", resource_link.uri));
251                }
252                acp::ContentBlock::Audio(_)
253                | acp::ContentBlock::Image(_)
254                | acp::ContentBlock::Resource(_) => {
255                    // TODO
256                }
257            }
258        }
259
260        if let Err(err) = session.outgoing_tx.unbounded_send(SdkMessage::User {
261            message: Message {
262                role: Role::User,
263                content: Content::UntaggedText(content),
264                id: None,
265                model: None,
266                stop_reason: None,
267                stop_sequence: None,
268                usage: None,
269            },
270            session_id: Some(params.session_id.to_string()),
271        }) {
272            return Task::ready(Err(anyhow!(err)));
273        }
274
275        cx.foreground_executor().spawn(async move {
276            rx.await??;
277            Ok(())
278        })
279    }
280
281    fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) {
282        let sessions = self.sessions.borrow();
283        let Some(session) = sessions.get(&session_id) else {
284            log::warn!("Attempted to cancel nonexistent session {}", session_id);
285            return;
286        };
287
288        let (done_tx, done_rx) = oneshot::channel();
289        if session
290            .cancel_tx
291            .unbounded_send(done_tx)
292            .log_err()
293            .is_some()
294        {
295            let end_turn_tx = session.end_turn_tx.clone();
296            cx.foreground_executor()
297                .spawn(async move {
298                    done_rx.await??;
299                    if let Some(end_turn_tx) = end_turn_tx.take() {
300                        end_turn_tx.send(Ok(())).ok();
301                    }
302                    anyhow::Ok(())
303                })
304                .detach_and_log_err(cx);
305        }
306    }
307}
308
309#[derive(Clone, Copy)]
310enum ClaudeSessionMode {
311    Start,
312    Resume,
313}
314
315async fn spawn_claude(
316    command: &AgentServerCommand,
317    mode: ClaudeSessionMode,
318    session_id: acp::SessionId,
319    mcp_config_path: &Path,
320    root_dir: &Path,
321) -> Result<Child> {
322    let child = util::command::new_smol_command(&command.path)
323        .args([
324            "--input-format",
325            "stream-json",
326            "--output-format",
327            "stream-json",
328            "--print",
329            "--verbose",
330            "--mcp-config",
331            mcp_config_path.to_string_lossy().as_ref(),
332            "--permission-prompt-tool",
333            &format!(
334                "mcp__{}__{}",
335                mcp_server::SERVER_NAME,
336                mcp_server::PermissionTool::NAME,
337            ),
338            "--allowedTools",
339            &format!(
340                "mcp__{}__{},mcp__{}__{}",
341                mcp_server::SERVER_NAME,
342                mcp_server::EditTool::NAME,
343                mcp_server::SERVER_NAME,
344                mcp_server::ReadTool::NAME
345            ),
346            "--disallowedTools",
347            "Read,Edit",
348        ])
349        .args(match mode {
350            ClaudeSessionMode::Start => ["--session-id".to_string(), session_id.to_string()],
351            ClaudeSessionMode::Resume => ["--resume".to_string(), session_id.to_string()],
352        })
353        .args(command.args.iter().map(|arg| arg.as_str()))
354        .current_dir(root_dir)
355        .stdin(std::process::Stdio::piped())
356        .stdout(std::process::Stdio::piped())
357        .stderr(std::process::Stdio::inherit())
358        .kill_on_drop(true)
359        .spawn()?;
360
361    Ok(child)
362}
363
364struct ClaudeAgentSession {
365    outgoing_tx: UnboundedSender<SdkMessage>,
366    end_turn_tx: Rc<RefCell<Option<oneshot::Sender<Result<()>>>>>,
367    cancel_tx: UnboundedSender<oneshot::Sender<Result<()>>>,
368    _mcp_server: Option<ClaudeZedMcpServer>,
369    _handler_task: Task<()>,
370}
371
372impl ClaudeAgentSession {
373    async fn handle_message(
374        mut thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
375        message: SdkMessage,
376        end_turn_tx: Rc<RefCell<Option<oneshot::Sender<Result<()>>>>>,
377        cx: &mut AsyncApp,
378    ) {
379        match message {
380            SdkMessage::Assistant {
381                message,
382                session_id: _,
383            }
384            | SdkMessage::User {
385                message,
386                session_id: _,
387            } => {
388                let Some(thread) = thread_rx
389                    .recv()
390                    .await
391                    .log_err()
392                    .and_then(|entity| entity.upgrade())
393                else {
394                    log::error!("Received an SDK message but thread is gone");
395                    return;
396                };
397
398                for chunk in message.content.chunks() {
399                    match chunk {
400                        ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => {
401                            thread
402                                .update(cx, |thread, cx| {
403                                    thread.push_assistant_chunk(text.into(), false, cx)
404                                })
405                                .log_err();
406                        }
407                        ContentChunk::ToolUse { id, name, input } => {
408                            let claude_tool = ClaudeTool::infer(&name, input);
409
410                            thread
411                                .update(cx, |thread, cx| {
412                                    if let ClaudeTool::TodoWrite(Some(params)) = claude_tool {
413                                        thread.update_plan(
414                                            acp::Plan {
415                                                entries: params
416                                                    .todos
417                                                    .into_iter()
418                                                    .map(Into::into)
419                                                    .collect(),
420                                            },
421                                            cx,
422                                        )
423                                    } else {
424                                        thread.upsert_tool_call(
425                                            claude_tool.as_acp(acp::ToolCallId(id.into())),
426                                            cx,
427                                        );
428                                    }
429                                })
430                                .log_err();
431                        }
432                        ContentChunk::ToolResult {
433                            content,
434                            tool_use_id,
435                        } => {
436                            let content = content.to_string();
437                            thread
438                                .update(cx, |thread, cx| {
439                                    thread.update_tool_call(
440                                        acp::ToolCallId(tool_use_id.into()),
441                                        acp::ToolCallStatus::Completed,
442                                        (!content.is_empty()).then(|| vec![content.into()]),
443                                        cx,
444                                    )
445                                })
446                                .log_err();
447                        }
448                        ContentChunk::Image
449                        | ContentChunk::Document
450                        | ContentChunk::Thinking
451                        | ContentChunk::RedactedThinking
452                        | ContentChunk::WebSearchToolResult => {
453                            thread
454                                .update(cx, |thread, cx| {
455                                    thread.push_assistant_chunk(
456                                        format!("Unsupported content: {:?}", chunk).into(),
457                                        false,
458                                        cx,
459                                    )
460                                })
461                                .log_err();
462                        }
463                    }
464                }
465            }
466            SdkMessage::Result {
467                is_error, subtype, ..
468            } => {
469                if let Some(end_turn_tx) = end_turn_tx.borrow_mut().take() {
470                    if is_error {
471                        end_turn_tx.send(Err(anyhow!("Error: {subtype}"))).ok();
472                    } else {
473                        end_turn_tx.send(Ok(())).ok();
474                    }
475                }
476            }
477            SdkMessage::System { .. } => {}
478        }
479    }
480
481    async fn handle_io(
482        mut outgoing_rx: UnboundedReceiver<SdkMessage>,
483        incoming_tx: UnboundedSender<SdkMessage>,
484        mut outgoing_bytes: impl Unpin + AsyncWrite,
485        incoming_bytes: impl Unpin + AsyncRead,
486    ) -> Result<UnboundedReceiver<SdkMessage>> {
487        let mut output_reader = BufReader::new(incoming_bytes);
488        let mut outgoing_line = Vec::new();
489        let mut incoming_line = String::new();
490        loop {
491            select_biased! {
492                message = outgoing_rx.next() => {
493                    if let Some(message) = message {
494                        outgoing_line.clear();
495                        serde_json::to_writer(&mut outgoing_line, &message)?;
496                        log::trace!("send: {}", String::from_utf8_lossy(&outgoing_line));
497                        outgoing_line.push(b'\n');
498                        outgoing_bytes.write_all(&outgoing_line).await.ok();
499                    } else {
500                        break;
501                    }
502                }
503                bytes_read = output_reader.read_line(&mut incoming_line).fuse() => {
504                    if bytes_read? == 0 {
505                        break
506                    }
507                    log::trace!("recv: {}", &incoming_line);
508                    match serde_json::from_str::<SdkMessage>(&incoming_line) {
509                        Ok(message) => {
510                            incoming_tx.unbounded_send(message).log_err();
511                        }
512                        Err(error) => {
513                            log::error!("failed to parse incoming message: {error}. Raw: {incoming_line}");
514                        }
515                    }
516                    incoming_line.clear();
517                }
518            }
519        }
520
521        Ok(outgoing_rx)
522    }
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize)]
526struct Message {
527    role: Role,
528    content: Content,
529    #[serde(skip_serializing_if = "Option::is_none")]
530    id: Option<String>,
531    #[serde(skip_serializing_if = "Option::is_none")]
532    model: Option<String>,
533    #[serde(skip_serializing_if = "Option::is_none")]
534    stop_reason: Option<String>,
535    #[serde(skip_serializing_if = "Option::is_none")]
536    stop_sequence: Option<String>,
537    #[serde(skip_serializing_if = "Option::is_none")]
538    usage: Option<Usage>,
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize)]
542#[serde(untagged)]
543enum Content {
544    UntaggedText(String),
545    Chunks(Vec<ContentChunk>),
546}
547
548impl Content {
549    pub fn chunks(self) -> impl Iterator<Item = ContentChunk> {
550        match self {
551            Self::Chunks(chunks) => chunks.into_iter(),
552            Self::UntaggedText(text) => vec![ContentChunk::Text { text: text.clone() }].into_iter(),
553        }
554    }
555}
556
557impl Display for Content {
558    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559        match self {
560            Content::UntaggedText(txt) => write!(f, "{}", txt),
561            Content::Chunks(chunks) => {
562                for chunk in chunks {
563                    write!(f, "{}", chunk)?;
564                }
565                Ok(())
566            }
567        }
568    }
569}
570
571#[derive(Debug, Clone, Serialize, Deserialize)]
572#[serde(tag = "type", rename_all = "snake_case")]
573enum ContentChunk {
574    Text {
575        text: String,
576    },
577    ToolUse {
578        id: String,
579        name: String,
580        input: serde_json::Value,
581    },
582    ToolResult {
583        content: Content,
584        tool_use_id: String,
585    },
586    // TODO
587    Image,
588    Document,
589    Thinking,
590    RedactedThinking,
591    WebSearchToolResult,
592    #[serde(untagged)]
593    UntaggedText(String),
594}
595
596impl Display for ContentChunk {
597    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
598        match self {
599            ContentChunk::Text { text } => write!(f, "{}", text),
600            ContentChunk::UntaggedText(text) => write!(f, "{}", text),
601            ContentChunk::ToolResult { content, .. } => write!(f, "{}", content),
602            ContentChunk::Image
603            | ContentChunk::Document
604            | ContentChunk::Thinking
605            | ContentChunk::RedactedThinking
606            | ContentChunk::ToolUse { .. }
607            | ContentChunk::WebSearchToolResult => {
608                write!(f, "\n{:?}\n", &self)
609            }
610        }
611    }
612}
613
614#[derive(Debug, Clone, Serialize, Deserialize)]
615struct Usage {
616    input_tokens: u32,
617    cache_creation_input_tokens: u32,
618    cache_read_input_tokens: u32,
619    output_tokens: u32,
620    service_tier: String,
621}
622
623#[derive(Debug, Clone, Serialize, Deserialize)]
624#[serde(rename_all = "snake_case")]
625enum Role {
626    System,
627    Assistant,
628    User,
629}
630
631#[derive(Debug, Clone, Serialize, Deserialize)]
632struct MessageParam {
633    role: Role,
634    content: String,
635}
636
637#[derive(Debug, Clone, Serialize, Deserialize)]
638#[serde(tag = "type", rename_all = "snake_case")]
639enum SdkMessage {
640    // An assistant message
641    Assistant {
642        message: Message, // from Anthropic SDK
643        #[serde(skip_serializing_if = "Option::is_none")]
644        session_id: Option<String>,
645    },
646
647    // A user message
648    User {
649        message: Message, // from Anthropic SDK
650        #[serde(skip_serializing_if = "Option::is_none")]
651        session_id: Option<String>,
652    },
653
654    // Emitted as the last message in a conversation
655    Result {
656        subtype: ResultErrorType,
657        duration_ms: f64,
658        duration_api_ms: f64,
659        is_error: bool,
660        num_turns: i32,
661        #[serde(skip_serializing_if = "Option::is_none")]
662        result: Option<String>,
663        session_id: String,
664        total_cost_usd: f64,
665    },
666    // Emitted as the first message at the start of a conversation
667    System {
668        cwd: String,
669        session_id: String,
670        tools: Vec<String>,
671        model: String,
672        mcp_servers: Vec<McpServer>,
673        #[serde(rename = "apiKeySource")]
674        api_key_source: String,
675        #[serde(rename = "permissionMode")]
676        permission_mode: PermissionMode,
677    },
678}
679
680#[derive(Debug, Clone, Serialize, Deserialize)]
681#[serde(rename_all = "snake_case")]
682enum ResultErrorType {
683    Success,
684    ErrorMaxTurns,
685    ErrorDuringExecution,
686}
687
688impl Display for ResultErrorType {
689    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
690        match self {
691            ResultErrorType::Success => write!(f, "success"),
692            ResultErrorType::ErrorMaxTurns => write!(f, "error_max_turns"),
693            ResultErrorType::ErrorDuringExecution => write!(f, "error_during_execution"),
694        }
695    }
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize)]
699struct McpServer {
700    name: String,
701    status: String,
702}
703
704#[derive(Debug, Clone, Serialize, Deserialize)]
705#[serde(rename_all = "camelCase")]
706enum PermissionMode {
707    Default,
708    AcceptEdits,
709    BypassPermissions,
710    Plan,
711}
712
713#[cfg(test)]
714pub(crate) mod tests {
715    use super::*;
716    use serde_json::json;
717
718    crate::common_e2e_tests!(ClaudeCode);
719
720    pub fn local_command() -> AgentServerCommand {
721        AgentServerCommand {
722            path: "claude".into(),
723            args: vec![],
724            env: None,
725        }
726    }
727
728    #[test]
729    fn test_deserialize_content_untagged_text() {
730        let json = json!("Hello, world!");
731        let content: Content = serde_json::from_value(json).unwrap();
732        match content {
733            Content::UntaggedText(text) => assert_eq!(text, "Hello, world!"),
734            _ => panic!("Expected UntaggedText variant"),
735        }
736    }
737
738    #[test]
739    fn test_deserialize_content_chunks() {
740        let json = json!([
741            {
742                "type": "text",
743                "text": "Hello"
744            },
745            {
746                "type": "tool_use",
747                "id": "tool_123",
748                "name": "calculator",
749                "input": {"operation": "add", "a": 1, "b": 2}
750            }
751        ]);
752        let content: Content = serde_json::from_value(json).unwrap();
753        match content {
754            Content::Chunks(chunks) => {
755                assert_eq!(chunks.len(), 2);
756                match &chunks[0] {
757                    ContentChunk::Text { text } => assert_eq!(text, "Hello"),
758                    _ => panic!("Expected Text chunk"),
759                }
760                match &chunks[1] {
761                    ContentChunk::ToolUse { id, name, input } => {
762                        assert_eq!(id, "tool_123");
763                        assert_eq!(name, "calculator");
764                        assert_eq!(input["operation"], "add");
765                        assert_eq!(input["a"], 1);
766                        assert_eq!(input["b"], 2);
767                    }
768                    _ => panic!("Expected ToolUse chunk"),
769                }
770            }
771            _ => panic!("Expected Chunks variant"),
772        }
773    }
774
775    #[test]
776    fn test_deserialize_tool_result_untagged_text() {
777        let json = json!({
778            "type": "tool_result",
779            "content": "Result content",
780            "tool_use_id": "tool_456"
781        });
782        let chunk: ContentChunk = serde_json::from_value(json).unwrap();
783        match chunk {
784            ContentChunk::ToolResult {
785                content,
786                tool_use_id,
787            } => {
788                match content {
789                    Content::UntaggedText(text) => assert_eq!(text, "Result content"),
790                    _ => panic!("Expected UntaggedText content"),
791                }
792                assert_eq!(tool_use_id, "tool_456");
793            }
794            _ => panic!("Expected ToolResult variant"),
795        }
796    }
797
798    #[test]
799    fn test_deserialize_tool_result_chunks() {
800        let json = json!({
801            "type": "tool_result",
802            "content": [
803                {
804                    "type": "text",
805                    "text": "Processing complete"
806                },
807                {
808                    "type": "text",
809                    "text": "Result: 42"
810                }
811            ],
812            "tool_use_id": "tool_789"
813        });
814        let chunk: ContentChunk = serde_json::from_value(json).unwrap();
815        match chunk {
816            ContentChunk::ToolResult {
817                content,
818                tool_use_id,
819            } => {
820                match content {
821                    Content::Chunks(chunks) => {
822                        assert_eq!(chunks.len(), 2);
823                        match &chunks[0] {
824                            ContentChunk::Text { text } => assert_eq!(text, "Processing complete"),
825                            _ => panic!("Expected Text chunk"),
826                        }
827                        match &chunks[1] {
828                            ContentChunk::Text { text } => assert_eq!(text, "Result: 42"),
829                            _ => panic!("Expected Text chunk"),
830                        }
831                    }
832                    _ => panic!("Expected Chunks content"),
833                }
834                assert_eq!(tool_use_id, "tool_789");
835            }
836            _ => panic!("Expected ToolResult variant"),
837        }
838    }
839}