1mod edit_tool;
2mod mcp_server;
3mod permission_tool;
4mod read_tool;
5pub mod tools;
6mod write_tool;
7
8use action_log::ActionLog;
9use collections::HashMap;
10use context_server::listener::McpServerTool;
11use language_models::provider::anthropic::AnthropicLanguageModelProvider;
12use project::Project;
13use settings::SettingsStore;
14use smol::process::Child;
15use std::any::Any;
16use std::cell::RefCell;
17use std::fmt::Display;
18use std::path::{Path, PathBuf};
19use std::rc::Rc;
20use util::command::new_smol_command;
21use uuid::Uuid;
22
23use agent_client_protocol as acp;
24use anyhow::{Context as _, Result, anyhow};
25use futures::channel::oneshot;
26use futures::{AsyncBufReadExt, AsyncWriteExt};
27use futures::{
28 AsyncRead, AsyncWrite, FutureExt, StreamExt,
29 channel::mpsc::{self, UnboundedReceiver, UnboundedSender},
30 io::BufReader,
31 select_biased,
32};
33use gpui::{App, AppContext, AsyncApp, Entity, SharedString, Task, WeakEntity};
34use serde::{Deserialize, Serialize};
35use util::{ResultExt, debug_panic};
36
37use crate::claude::mcp_server::{ClaudeZedMcpServer, McpConfig};
38use crate::claude::tools::ClaudeTool;
39use crate::{AgentServer, AgentServerCommand, AgentServerDelegate, AllAgentServersSettings};
40use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError, MentionUri};
41
42#[derive(Clone)]
43pub struct ClaudeCode;
44
45impl AgentServer for ClaudeCode {
46 fn telemetry_id(&self) -> &'static str {
47 "claude-code"
48 }
49
50 fn name(&self) -> SharedString {
51 "Claude Code".into()
52 }
53
54 fn logo(&self) -> ui::IconName {
55 ui::IconName::AiClaude
56 }
57
58 fn connect(
59 &self,
60 _root_dir: &Path,
61 _delegate: AgentServerDelegate,
62 _cx: &mut App,
63 ) -> Task<Result<Rc<dyn AgentConnection>>> {
64 let connection = ClaudeAgentConnection {
65 sessions: Default::default(),
66 };
67
68 Task::ready(Ok(Rc::new(connection) as _))
69 }
70
71 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
72 self
73 }
74}
75
76struct ClaudeAgentConnection {
77 sessions: Rc<RefCell<HashMap<acp::SessionId, ClaudeAgentSession>>>,
78}
79
80impl AgentConnection for ClaudeAgentConnection {
81 fn new_thread(
82 self: Rc<Self>,
83 project: Entity<Project>,
84 cwd: &Path,
85 cx: &mut App,
86 ) -> Task<Result<Entity<AcpThread>>> {
87 let cwd = cwd.to_owned();
88 cx.spawn(async move |cx| {
89 let settings = cx.read_global(|settings: &SettingsStore, _| {
90 settings.get::<AllAgentServersSettings>(None).claude.clone()
91 })?;
92
93 let Some(command) = AgentServerCommand::resolve(
94 "claude",
95 &[],
96 Some(&util::paths::home_dir().join(".claude/local/claude")),
97 settings,
98 &project,
99 cx,
100 )
101 .await
102 else {
103 return Err(anyhow!("Failed to find Claude Code binary"));
104 };
105
106 let api_key =
107 cx.update(AnthropicLanguageModelProvider::api_key)?
108 .await
109 .map_err(|err| {
110 if err.is::<language_model::AuthenticateError>() {
111 anyhow!(AuthRequired::new().with_language_model_provider(
112 language_model::ANTHROPIC_PROVIDER_ID
113 ))
114 } else {
115 anyhow!(err)
116 }
117 })?;
118
119 let (mut thread_tx, thread_rx) = watch::channel(WeakEntity::new_invalid());
120 let fs = project.read_with(cx, |project, _cx| project.fs().clone())?;
121 let permission_mcp_server = ClaudeZedMcpServer::new(thread_rx.clone(), fs, cx).await?;
122
123 let mut mcp_servers = HashMap::default();
124 mcp_servers.insert(
125 mcp_server::SERVER_NAME.to_string(),
126 permission_mcp_server.server_config()?,
127 );
128 let mcp_config = McpConfig { mcp_servers };
129
130 let mcp_config_file = tempfile::NamedTempFile::new()?;
131 let (mcp_config_file, mcp_config_path) = mcp_config_file.into_parts();
132
133 let mut mcp_config_file = smol::fs::File::from(mcp_config_file);
134 mcp_config_file
135 .write_all(serde_json::to_string(&mcp_config)?.as_bytes())
136 .await?;
137 mcp_config_file.flush().await?;
138
139 let (incoming_message_tx, mut incoming_message_rx) = mpsc::unbounded();
140 let (outgoing_tx, outgoing_rx) = mpsc::unbounded();
141
142 let session_id = acp::SessionId(Uuid::new_v4().to_string().into());
143
144 log::trace!("Starting session with id: {}", session_id);
145
146 let mut child = spawn_claude(
147 &command,
148 ClaudeSessionMode::Start,
149 session_id.clone(),
150 api_key,
151 &mcp_config_path,
152 &cwd,
153 )?;
154
155 let stdout = child.stdout.take().context("Failed to take stdout")?;
156 let stdin = child.stdin.take().context("Failed to take stdin")?;
157 let stderr = child.stderr.take().context("Failed to take stderr")?;
158
159 let pid = child.id();
160 log::trace!("Spawned (pid: {})", pid);
161
162 cx.background_spawn(async move {
163 let mut stderr = BufReader::new(stderr);
164 let mut line = String::new();
165 while let Ok(n) = stderr.read_line(&mut line).await
166 && n > 0
167 {
168 log::warn!("agent stderr: {}", &line);
169 line.clear();
170 }
171 })
172 .detach();
173
174 cx.background_spawn(async move {
175 let mut outgoing_rx = Some(outgoing_rx);
176
177 ClaudeAgentSession::handle_io(
178 outgoing_rx.take().unwrap(),
179 incoming_message_tx.clone(),
180 stdin,
181 stdout,
182 )
183 .await?;
184
185 log::trace!("Stopped (pid: {})", pid);
186
187 drop(mcp_config_path);
188 anyhow::Ok(())
189 })
190 .detach();
191
192 let turn_state = Rc::new(RefCell::new(TurnState::None));
193
194 let handler_task = cx.spawn({
195 let turn_state = turn_state.clone();
196 let mut thread_rx = thread_rx.clone();
197 async move |cx| {
198 while let Some(message) = incoming_message_rx.next().await {
199 ClaudeAgentSession::handle_message(
200 thread_rx.clone(),
201 message,
202 turn_state.clone(),
203 cx,
204 )
205 .await
206 }
207
208 if let Some(status) = child.status().await.log_err()
209 && let Some(thread) = thread_rx.recv().await.ok()
210 {
211 let version = claude_version(command.path.clone(), cx).await.log_err();
212 let help = claude_help(command.path.clone(), cx).await.log_err();
213 thread
214 .update(cx, |thread, cx| {
215 let error = if let Some(version) = version
216 && let Some(help) = help
217 && (!help.contains("--input-format")
218 || !help.contains("--session-id"))
219 {
220 LoadError::Unsupported {
221 command: command.path.to_string_lossy().to_string().into(),
222 current_version: version.to_string().into(),
223 minimum_version: "1.0.0".into(),
224 }
225 } else {
226 LoadError::Exited { status }
227 };
228 thread.emit_load_error(error, cx);
229 })
230 .ok();
231 }
232 }
233 });
234
235 let action_log = cx.new(|_| ActionLog::new(project.clone()))?;
236 let thread = cx.new(|cx| {
237 AcpThread::new(
238 "Claude Code",
239 self.clone(),
240 project,
241 action_log,
242 session_id.clone(),
243 watch::Receiver::constant(acp::PromptCapabilities {
244 image: true,
245 audio: false,
246 embedded_context: true,
247 }),
248 cx,
249 )
250 })?;
251
252 thread_tx.send(thread.downgrade())?;
253
254 let session = ClaudeAgentSession {
255 outgoing_tx,
256 turn_state,
257 _handler_task: handler_task,
258 _mcp_server: Some(permission_mcp_server),
259 };
260
261 self.sessions.borrow_mut().insert(session_id, session);
262
263 Ok(thread)
264 })
265 }
266
267 fn auth_methods(&self) -> &[acp::AuthMethod] {
268 &[]
269 }
270
271 fn authenticate(&self, _: acp::AuthMethodId, _cx: &mut App) -> Task<Result<()>> {
272 Task::ready(Err(anyhow!("Authentication not supported")))
273 }
274
275 fn prompt(
276 &self,
277 _id: Option<acp_thread::UserMessageId>,
278 params: acp::PromptRequest,
279 cx: &mut App,
280 ) -> Task<Result<acp::PromptResponse>> {
281 let sessions = self.sessions.borrow();
282 let Some(session) = sessions.get(¶ms.session_id) else {
283 return Task::ready(Err(anyhow!(
284 "Attempted to send message to nonexistent session {}",
285 params.session_id
286 )));
287 };
288
289 let (end_tx, end_rx) = oneshot::channel();
290 session.turn_state.replace(TurnState::InProgress { end_tx });
291
292 let content = acp_content_to_claude(params.prompt);
293
294 if let Err(err) = session.outgoing_tx.unbounded_send(SdkMessage::User {
295 message: Message {
296 role: Role::User,
297 content: Content::Chunks(content),
298 id: None,
299 model: None,
300 stop_reason: None,
301 stop_sequence: None,
302 usage: None,
303 },
304 session_id: Some(params.session_id.to_string()),
305 }) {
306 return Task::ready(Err(anyhow!(err)));
307 }
308
309 cx.foreground_executor().spawn(async move { end_rx.await? })
310 }
311
312 fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) {
313 let sessions = self.sessions.borrow();
314 let Some(session) = sessions.get(session_id) else {
315 log::warn!("Attempted to cancel nonexistent session {}", session_id);
316 return;
317 };
318
319 let request_id = new_request_id();
320
321 let turn_state = session.turn_state.take();
322 let TurnState::InProgress { end_tx } = turn_state else {
323 // Already canceled or idle, put it back
324 session.turn_state.replace(turn_state);
325 return;
326 };
327
328 session.turn_state.replace(TurnState::CancelRequested {
329 end_tx,
330 request_id: request_id.clone(),
331 });
332
333 session
334 .outgoing_tx
335 .unbounded_send(SdkMessage::ControlRequest {
336 request_id,
337 request: ControlRequest::Interrupt,
338 })
339 .log_err();
340 }
341
342 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
343 self
344 }
345}
346
347#[derive(Clone, Copy)]
348enum ClaudeSessionMode {
349 Start,
350 #[expect(dead_code)]
351 Resume,
352}
353
354fn spawn_claude(
355 command: &AgentServerCommand,
356 mode: ClaudeSessionMode,
357 session_id: acp::SessionId,
358 api_key: language_models::provider::anthropic::ApiKey,
359 mcp_config_path: &Path,
360 root_dir: &Path,
361) -> Result<Child> {
362 let child = util::command::new_smol_command(&command.path)
363 .args([
364 "--input-format",
365 "stream-json",
366 "--output-format",
367 "stream-json",
368 "--print",
369 "--verbose",
370 "--mcp-config",
371 mcp_config_path.to_string_lossy().as_ref(),
372 "--permission-prompt-tool",
373 &format!(
374 "mcp__{}__{}",
375 mcp_server::SERVER_NAME,
376 permission_tool::PermissionTool::NAME,
377 ),
378 "--allowedTools",
379 &format!(
380 "mcp__{}__{}",
381 mcp_server::SERVER_NAME,
382 read_tool::ReadTool::NAME
383 ),
384 "--disallowedTools",
385 "Read,Write,Edit,MultiEdit",
386 ])
387 .args(match mode {
388 ClaudeSessionMode::Start => ["--session-id".to_string(), session_id.to_string()],
389 ClaudeSessionMode::Resume => ["--resume".to_string(), session_id.to_string()],
390 })
391 .args(command.args.iter().map(|arg| arg.as_str()))
392 .envs(command.env.iter().flatten())
393 .env("ANTHROPIC_API_KEY", api_key.key)
394 .current_dir(root_dir)
395 .stdin(std::process::Stdio::piped())
396 .stdout(std::process::Stdio::piped())
397 .stderr(std::process::Stdio::piped())
398 .kill_on_drop(true)
399 .spawn()?;
400
401 Ok(child)
402}
403
404fn claude_version(path: PathBuf, cx: &mut AsyncApp) -> Task<Result<semver::Version>> {
405 cx.background_spawn(async move {
406 let output = new_smol_command(path).arg("--version").output().await?;
407 let output = String::from_utf8(output.stdout)?;
408 let version = output
409 .trim()
410 .strip_suffix(" (Claude Code)")
411 .context("parsing Claude version")?;
412 let version = semver::Version::parse(version)?;
413 anyhow::Ok(version)
414 })
415}
416
417fn claude_help(path: PathBuf, cx: &mut AsyncApp) -> Task<Result<String>> {
418 cx.background_spawn(async move {
419 let output = new_smol_command(path).arg("--help").output().await?;
420 let output = String::from_utf8(output.stdout)?;
421 anyhow::Ok(output)
422 })
423}
424
425struct ClaudeAgentSession {
426 outgoing_tx: UnboundedSender<SdkMessage>,
427 turn_state: Rc<RefCell<TurnState>>,
428 _mcp_server: Option<ClaudeZedMcpServer>,
429 _handler_task: Task<()>,
430}
431
432#[derive(Debug, Default)]
433enum TurnState {
434 #[default]
435 None,
436 InProgress {
437 end_tx: oneshot::Sender<Result<acp::PromptResponse>>,
438 },
439 CancelRequested {
440 end_tx: oneshot::Sender<Result<acp::PromptResponse>>,
441 request_id: String,
442 },
443 CancelConfirmed {
444 end_tx: oneshot::Sender<Result<acp::PromptResponse>>,
445 },
446}
447
448impl TurnState {
449 fn is_canceled(&self) -> bool {
450 matches!(self, TurnState::CancelConfirmed { .. })
451 }
452
453 fn end_tx(self) -> Option<oneshot::Sender<Result<acp::PromptResponse>>> {
454 match self {
455 TurnState::None => None,
456 TurnState::InProgress { end_tx, .. } => Some(end_tx),
457 TurnState::CancelRequested { end_tx, .. } => Some(end_tx),
458 TurnState::CancelConfirmed { end_tx } => Some(end_tx),
459 }
460 }
461
462 fn confirm_cancellation(self, id: &str) -> Self {
463 match self {
464 TurnState::CancelRequested { request_id, end_tx } if request_id == id => {
465 TurnState::CancelConfirmed { end_tx }
466 }
467 _ => self,
468 }
469 }
470}
471
472impl ClaudeAgentSession {
473 async fn handle_message(
474 mut thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
475 message: SdkMessage,
476 turn_state: Rc<RefCell<TurnState>>,
477 cx: &mut AsyncApp,
478 ) {
479 match message {
480 // we should only be sending these out, they don't need to be in the thread
481 SdkMessage::ControlRequest { .. } => {}
482 SdkMessage::User {
483 message,
484 session_id: _,
485 } => {
486 let Some(thread) = thread_rx
487 .recv()
488 .await
489 .log_err()
490 .and_then(|entity| entity.upgrade())
491 else {
492 log::error!("Received an SDK message but thread is gone");
493 return;
494 };
495
496 for chunk in message.content.chunks() {
497 match chunk {
498 ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => {
499 if !turn_state.borrow().is_canceled() {
500 thread
501 .update(cx, |thread, cx| {
502 thread.push_user_content_block(None, text.into(), cx)
503 })
504 .log_err();
505 }
506 }
507 ContentChunk::ToolResult {
508 content,
509 tool_use_id,
510 } => {
511 let content = content.to_string();
512 thread
513 .update(cx, |thread, cx| {
514 let id = acp::ToolCallId(tool_use_id.into());
515 let set_new_content = !content.is_empty()
516 && thread.tool_call(&id).is_none_or(|(_, tool_call)| {
517 // preserve rich diff if we have one
518 tool_call.diffs().next().is_none()
519 });
520
521 thread.update_tool_call(
522 acp::ToolCallUpdate {
523 id,
524 fields: acp::ToolCallUpdateFields {
525 status: if turn_state.borrow().is_canceled() {
526 // Do not set to completed if turn was canceled
527 None
528 } else {
529 Some(acp::ToolCallStatus::Completed)
530 },
531 content: set_new_content
532 .then(|| vec![content.into()]),
533 ..Default::default()
534 },
535 },
536 cx,
537 )
538 })
539 .log_err();
540 }
541 ContentChunk::Thinking { .. }
542 | ContentChunk::RedactedThinking
543 | ContentChunk::ToolUse { .. } => {
544 debug_panic!(
545 "Should not get {:?} with role: assistant. should we handle this?",
546 chunk
547 );
548 }
549 ContentChunk::Image { source } => {
550 if !turn_state.borrow().is_canceled() {
551 thread
552 .update(cx, |thread, cx| {
553 thread.push_user_content_block(None, source.into(), cx)
554 })
555 .log_err();
556 }
557 }
558
559 ContentChunk::Document | ContentChunk::WebSearchToolResult => {
560 thread
561 .update(cx, |thread, cx| {
562 thread.push_assistant_content_block(
563 format!("Unsupported content: {:?}", chunk).into(),
564 false,
565 cx,
566 )
567 })
568 .log_err();
569 }
570 }
571 }
572 }
573 SdkMessage::Assistant {
574 message,
575 session_id: _,
576 } => {
577 let Some(thread) = thread_rx
578 .recv()
579 .await
580 .log_err()
581 .and_then(|entity| entity.upgrade())
582 else {
583 log::error!("Received an SDK message but thread is gone");
584 return;
585 };
586
587 for chunk in message.content.chunks() {
588 match chunk {
589 ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => {
590 thread
591 .update(cx, |thread, cx| {
592 thread.push_assistant_content_block(text.into(), false, cx)
593 })
594 .log_err();
595 }
596 ContentChunk::Thinking { thinking } => {
597 thread
598 .update(cx, |thread, cx| {
599 thread.push_assistant_content_block(thinking.into(), true, cx)
600 })
601 .log_err();
602 }
603 ContentChunk::RedactedThinking => {
604 thread
605 .update(cx, |thread, cx| {
606 thread.push_assistant_content_block(
607 "[REDACTED]".into(),
608 true,
609 cx,
610 )
611 })
612 .log_err();
613 }
614 ContentChunk::ToolUse { id, name, input } => {
615 let claude_tool = ClaudeTool::infer(&name, input);
616
617 thread
618 .update(cx, |thread, cx| {
619 if let ClaudeTool::TodoWrite(Some(params)) = claude_tool {
620 thread.update_plan(
621 acp::Plan {
622 entries: params
623 .todos
624 .into_iter()
625 .map(Into::into)
626 .collect(),
627 },
628 cx,
629 )
630 } else {
631 thread.upsert_tool_call(
632 claude_tool.as_acp(acp::ToolCallId(id.into())),
633 cx,
634 )?;
635 }
636 anyhow::Ok(())
637 })
638 .log_err();
639 }
640 ContentChunk::ToolResult { .. } | ContentChunk::WebSearchToolResult => {
641 debug_panic!(
642 "Should not get tool results with role: assistant. should we handle this?"
643 );
644 }
645 ContentChunk::Image { source } => {
646 thread
647 .update(cx, |thread, cx| {
648 thread.push_assistant_content_block(source.into(), false, cx)
649 })
650 .log_err();
651 }
652 ContentChunk::Document => {
653 thread
654 .update(cx, |thread, cx| {
655 thread.push_assistant_content_block(
656 format!("Unsupported content: {:?}", chunk).into(),
657 false,
658 cx,
659 )
660 })
661 .log_err();
662 }
663 }
664 }
665 }
666 SdkMessage::Result {
667 is_error,
668 subtype,
669 result,
670 ..
671 } => {
672 let turn_state = turn_state.take();
673 let was_canceled = turn_state.is_canceled();
674 let Some(end_turn_tx) = turn_state.end_tx() else {
675 debug_panic!("Received `SdkMessage::Result` but there wasn't an active turn");
676 return;
677 };
678
679 if is_error || (!was_canceled && subtype == ResultErrorType::ErrorDuringExecution) {
680 end_turn_tx
681 .send(Err(anyhow!(
682 "Error: {}",
683 result.unwrap_or_else(|| subtype.to_string())
684 )))
685 .ok();
686 } else {
687 let stop_reason = match subtype {
688 ResultErrorType::Success => acp::StopReason::EndTurn,
689 ResultErrorType::ErrorMaxTurns => acp::StopReason::MaxTurnRequests,
690 ResultErrorType::ErrorDuringExecution => acp::StopReason::Cancelled,
691 };
692 end_turn_tx
693 .send(Ok(acp::PromptResponse { stop_reason }))
694 .ok();
695 }
696 }
697 SdkMessage::ControlResponse { response } => {
698 if matches!(response.subtype, ResultErrorType::Success) {
699 let new_state = turn_state.take().confirm_cancellation(&response.request_id);
700 turn_state.replace(new_state);
701 } else {
702 log::error!("Control response error: {:?}", response);
703 }
704 }
705 SdkMessage::System { .. } => {}
706 }
707 }
708
709 async fn handle_io(
710 mut outgoing_rx: UnboundedReceiver<SdkMessage>,
711 incoming_tx: UnboundedSender<SdkMessage>,
712 mut outgoing_bytes: impl Unpin + AsyncWrite,
713 incoming_bytes: impl Unpin + AsyncRead,
714 ) -> Result<UnboundedReceiver<SdkMessage>> {
715 let mut output_reader = BufReader::new(incoming_bytes);
716 let mut outgoing_line = Vec::new();
717 let mut incoming_line = String::new();
718 loop {
719 select_biased! {
720 message = outgoing_rx.next() => {
721 if let Some(message) = message {
722 outgoing_line.clear();
723 serde_json::to_writer(&mut outgoing_line, &message)?;
724 log::trace!("send: {}", String::from_utf8_lossy(&outgoing_line));
725 outgoing_line.push(b'\n');
726 outgoing_bytes.write_all(&outgoing_line).await.ok();
727 } else {
728 break;
729 }
730 }
731 bytes_read = output_reader.read_line(&mut incoming_line).fuse() => {
732 if bytes_read? == 0 {
733 break
734 }
735 log::trace!("recv: {}", &incoming_line);
736 match serde_json::from_str::<SdkMessage>(&incoming_line) {
737 Ok(message) => {
738 incoming_tx.unbounded_send(message).log_err();
739 }
740 Err(error) => {
741 log::error!("failed to parse incoming message: {error}. Raw: {incoming_line}");
742 }
743 }
744 incoming_line.clear();
745 }
746 }
747 }
748
749 Ok(outgoing_rx)
750 }
751}
752
753#[derive(Debug, Clone, Serialize, Deserialize)]
754struct Message {
755 role: Role,
756 content: Content,
757 #[serde(skip_serializing_if = "Option::is_none")]
758 id: Option<String>,
759 #[serde(skip_serializing_if = "Option::is_none")]
760 model: Option<String>,
761 #[serde(skip_serializing_if = "Option::is_none")]
762 stop_reason: Option<String>,
763 #[serde(skip_serializing_if = "Option::is_none")]
764 stop_sequence: Option<String>,
765 #[serde(skip_serializing_if = "Option::is_none")]
766 usage: Option<Usage>,
767}
768
769#[derive(Debug, Clone, Serialize, Deserialize)]
770#[serde(untagged)]
771enum Content {
772 UntaggedText(String),
773 Chunks(Vec<ContentChunk>),
774}
775
776impl Content {
777 pub fn chunks(self) -> impl Iterator<Item = ContentChunk> {
778 match self {
779 Self::Chunks(chunks) => chunks.into_iter(),
780 Self::UntaggedText(text) => vec![ContentChunk::Text { text }].into_iter(),
781 }
782 }
783}
784
785impl Display for Content {
786 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
787 match self {
788 Content::UntaggedText(txt) => write!(f, "{}", txt),
789 Content::Chunks(chunks) => {
790 for chunk in chunks {
791 write!(f, "{}", chunk)?;
792 }
793 Ok(())
794 }
795 }
796 }
797}
798
799#[derive(Debug, Clone, Serialize, Deserialize)]
800#[serde(tag = "type", rename_all = "snake_case")]
801enum ContentChunk {
802 Text {
803 text: String,
804 },
805 ToolUse {
806 id: String,
807 name: String,
808 input: serde_json::Value,
809 },
810 ToolResult {
811 content: Content,
812 tool_use_id: String,
813 },
814 Thinking {
815 thinking: String,
816 },
817 RedactedThinking,
818 Image {
819 source: ImageSource,
820 },
821 // TODO
822 Document,
823 WebSearchToolResult,
824 #[serde(untagged)]
825 UntaggedText(String),
826}
827
828#[derive(Debug, Clone, Serialize, Deserialize)]
829#[serde(tag = "type", rename_all = "snake_case")]
830enum ImageSource {
831 Base64 { data: String, media_type: String },
832 Url { url: String },
833}
834
835impl Into<acp::ContentBlock> for ImageSource {
836 fn into(self) -> acp::ContentBlock {
837 match self {
838 ImageSource::Base64 { data, media_type } => {
839 acp::ContentBlock::Image(acp::ImageContent {
840 annotations: None,
841 data,
842 mime_type: media_type,
843 uri: None,
844 })
845 }
846 ImageSource::Url { url } => acp::ContentBlock::Image(acp::ImageContent {
847 annotations: None,
848 data: "".to_string(),
849 mime_type: "".to_string(),
850 uri: Some(url),
851 }),
852 }
853 }
854}
855
856impl Display for ContentChunk {
857 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
858 match self {
859 ContentChunk::Text { text } => write!(f, "{}", text),
860 ContentChunk::Thinking { thinking } => write!(f, "Thinking: {}", thinking),
861 ContentChunk::RedactedThinking => write!(f, "Thinking: [REDACTED]"),
862 ContentChunk::UntaggedText(text) => write!(f, "{}", text),
863 ContentChunk::ToolResult { content, .. } => write!(f, "{}", content),
864 ContentChunk::Image { .. }
865 | ContentChunk::Document
866 | ContentChunk::ToolUse { .. }
867 | ContentChunk::WebSearchToolResult => {
868 write!(f, "\n{:?}\n", &self)
869 }
870 }
871 }
872}
873
874#[derive(Debug, Clone, Serialize, Deserialize)]
875struct Usage {
876 input_tokens: u32,
877 cache_creation_input_tokens: u32,
878 cache_read_input_tokens: u32,
879 output_tokens: u32,
880 service_tier: String,
881}
882
883#[derive(Debug, Clone, Serialize, Deserialize)]
884#[serde(rename_all = "snake_case")]
885enum Role {
886 System,
887 Assistant,
888 User,
889}
890
891#[derive(Debug, Clone, Serialize, Deserialize)]
892struct MessageParam {
893 role: Role,
894 content: String,
895}
896
897#[derive(Debug, Clone, Serialize, Deserialize)]
898#[serde(tag = "type", rename_all = "snake_case")]
899enum SdkMessage {
900 // An assistant message
901 Assistant {
902 message: Message, // from Anthropic SDK
903 #[serde(skip_serializing_if = "Option::is_none")]
904 session_id: Option<String>,
905 },
906 // A user message
907 User {
908 message: Message, // from Anthropic SDK
909 #[serde(skip_serializing_if = "Option::is_none")]
910 session_id: Option<String>,
911 },
912 // Emitted as the last message in a conversation
913 Result {
914 subtype: ResultErrorType,
915 duration_ms: f64,
916 duration_api_ms: f64,
917 is_error: bool,
918 num_turns: i32,
919 #[serde(skip_serializing_if = "Option::is_none")]
920 result: Option<String>,
921 session_id: String,
922 total_cost_usd: f64,
923 },
924 // Emitted as the first message at the start of a conversation
925 System {
926 cwd: String,
927 session_id: String,
928 tools: Vec<String>,
929 model: String,
930 mcp_servers: Vec<McpServer>,
931 #[serde(rename = "apiKeySource")]
932 api_key_source: String,
933 #[serde(rename = "permissionMode")]
934 permission_mode: PermissionMode,
935 },
936 /// Messages used to control the conversation, outside of chat messages to the model
937 ControlRequest {
938 request_id: String,
939 request: ControlRequest,
940 },
941 /// Response to a control request
942 ControlResponse { response: ControlResponse },
943}
944
945#[derive(Debug, Clone, Serialize, Deserialize)]
946#[serde(tag = "subtype", rename_all = "snake_case")]
947enum ControlRequest {
948 /// Cancel the current conversation
949 Interrupt,
950}
951
952#[derive(Debug, Clone, Serialize, Deserialize)]
953struct ControlResponse {
954 request_id: String,
955 subtype: ResultErrorType,
956}
957
958#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
959#[serde(rename_all = "snake_case")]
960enum ResultErrorType {
961 Success,
962 ErrorMaxTurns,
963 ErrorDuringExecution,
964}
965
966impl Display for ResultErrorType {
967 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
968 match self {
969 ResultErrorType::Success => write!(f, "success"),
970 ResultErrorType::ErrorMaxTurns => write!(f, "error_max_turns"),
971 ResultErrorType::ErrorDuringExecution => write!(f, "error_during_execution"),
972 }
973 }
974}
975
976fn acp_content_to_claude(prompt: Vec<acp::ContentBlock>) -> Vec<ContentChunk> {
977 let mut content = Vec::with_capacity(prompt.len());
978 let mut context = Vec::with_capacity(prompt.len());
979
980 for chunk in prompt {
981 match chunk {
982 acp::ContentBlock::Text(text_content) => {
983 content.push(ContentChunk::Text {
984 text: text_content.text,
985 });
986 }
987 acp::ContentBlock::ResourceLink(resource_link) => {
988 match MentionUri::parse(&resource_link.uri) {
989 Ok(uri) => {
990 content.push(ContentChunk::Text {
991 text: format!("{}", uri.as_link()),
992 });
993 }
994 Err(_) => {
995 content.push(ContentChunk::Text {
996 text: resource_link.uri,
997 });
998 }
999 }
1000 }
1001 acp::ContentBlock::Resource(resource) => match resource.resource {
1002 acp::EmbeddedResourceResource::TextResourceContents(resource) => {
1003 match MentionUri::parse(&resource.uri) {
1004 Ok(uri) => {
1005 content.push(ContentChunk::Text {
1006 text: format!("{}", uri.as_link()),
1007 });
1008 }
1009 Err(_) => {
1010 content.push(ContentChunk::Text {
1011 text: resource.uri.clone(),
1012 });
1013 }
1014 }
1015
1016 context.push(ContentChunk::Text {
1017 text: format!(
1018 "\n<context ref=\"{}\">\n{}\n</context>",
1019 resource.uri, resource.text
1020 ),
1021 });
1022 }
1023 acp::EmbeddedResourceResource::BlobResourceContents(_) => {
1024 // Unsupported by SDK
1025 }
1026 },
1027 acp::ContentBlock::Image(acp::ImageContent {
1028 data, mime_type, ..
1029 }) => content.push(ContentChunk::Image {
1030 source: ImageSource::Base64 {
1031 data,
1032 media_type: mime_type,
1033 },
1034 }),
1035 acp::ContentBlock::Audio(_) => {
1036 // Unsupported by SDK
1037 }
1038 }
1039 }
1040
1041 content.extend(context);
1042 content
1043}
1044
1045fn new_request_id() -> String {
1046 use rand::Rng;
1047 // In the Claude Code TS SDK they just generate a random 12 character string,
1048 // `Math.random().toString(36).substring(2, 15)`
1049 rand::thread_rng()
1050 .sample_iter(&rand::distributions::Alphanumeric)
1051 .take(12)
1052 .map(char::from)
1053 .collect()
1054}
1055
1056#[derive(Debug, Clone, Serialize, Deserialize)]
1057struct McpServer {
1058 name: String,
1059 status: String,
1060}
1061
1062#[derive(Debug, Clone, Serialize, Deserialize)]
1063#[serde(rename_all = "camelCase")]
1064enum PermissionMode {
1065 Default,
1066 AcceptEdits,
1067 BypassPermissions,
1068 Plan,
1069}
1070
1071#[cfg(test)]
1072pub(crate) mod tests {
1073 use super::*;
1074 use crate::e2e_tests;
1075 use gpui::TestAppContext;
1076 use serde_json::json;
1077
1078 crate::common_e2e_tests!(async |_, _, _| ClaudeCode, allow_option_id = "allow");
1079
1080 pub fn local_command() -> AgentServerCommand {
1081 AgentServerCommand {
1082 path: "claude".into(),
1083 args: vec![],
1084 env: None,
1085 }
1086 }
1087
1088 #[gpui::test]
1089 #[cfg_attr(not(feature = "e2e"), ignore)]
1090 async fn test_todo_plan(cx: &mut TestAppContext) {
1091 let fs = e2e_tests::init_test(cx).await;
1092 let project = Project::test(fs, [], cx).await;
1093 let thread =
1094 e2e_tests::new_test_thread(ClaudeCode, project.clone(), "/private/tmp", cx).await;
1095
1096 thread
1097 .update(cx, |thread, cx| {
1098 thread.send_raw(
1099 "Create a todo plan for initializing a new React app. I'll follow it myself, do not execute on it.",
1100 cx,
1101 )
1102 })
1103 .await
1104 .unwrap();
1105
1106 let mut entries_len = 0;
1107
1108 thread.read_with(cx, |thread, _| {
1109 entries_len = thread.plan().entries.len();
1110 assert!(!thread.plan().entries.is_empty(), "Empty plan");
1111 });
1112
1113 thread
1114 .update(cx, |thread, cx| {
1115 thread.send_raw(
1116 "Mark the first entry status as in progress without acting on it.",
1117 cx,
1118 )
1119 })
1120 .await
1121 .unwrap();
1122
1123 thread.read_with(cx, |thread, _| {
1124 assert!(matches!(
1125 thread.plan().entries[0].status,
1126 acp::PlanEntryStatus::InProgress
1127 ));
1128 assert_eq!(thread.plan().entries.len(), entries_len);
1129 });
1130
1131 thread
1132 .update(cx, |thread, cx| {
1133 thread.send_raw(
1134 "Now mark the first entry as completed without acting on it.",
1135 cx,
1136 )
1137 })
1138 .await
1139 .unwrap();
1140
1141 thread.read_with(cx, |thread, _| {
1142 assert!(matches!(
1143 thread.plan().entries[0].status,
1144 acp::PlanEntryStatus::Completed
1145 ));
1146 assert_eq!(thread.plan().entries.len(), entries_len);
1147 });
1148 }
1149
1150 #[test]
1151 fn test_deserialize_content_untagged_text() {
1152 let json = json!("Hello, world!");
1153 let content: Content = serde_json::from_value(json).unwrap();
1154 match content {
1155 Content::UntaggedText(text) => assert_eq!(text, "Hello, world!"),
1156 _ => panic!("Expected UntaggedText variant"),
1157 }
1158 }
1159
1160 #[test]
1161 fn test_deserialize_content_chunks() {
1162 let json = json!([
1163 {
1164 "type": "text",
1165 "text": "Hello"
1166 },
1167 {
1168 "type": "tool_use",
1169 "id": "tool_123",
1170 "name": "calculator",
1171 "input": {"operation": "add", "a": 1, "b": 2}
1172 }
1173 ]);
1174 let content: Content = serde_json::from_value(json).unwrap();
1175 match content {
1176 Content::Chunks(chunks) => {
1177 assert_eq!(chunks.len(), 2);
1178 match &chunks[0] {
1179 ContentChunk::Text { text } => assert_eq!(text, "Hello"),
1180 _ => panic!("Expected Text chunk"),
1181 }
1182 match &chunks[1] {
1183 ContentChunk::ToolUse { id, name, input } => {
1184 assert_eq!(id, "tool_123");
1185 assert_eq!(name, "calculator");
1186 assert_eq!(input["operation"], "add");
1187 assert_eq!(input["a"], 1);
1188 assert_eq!(input["b"], 2);
1189 }
1190 _ => panic!("Expected ToolUse chunk"),
1191 }
1192 }
1193 _ => panic!("Expected Chunks variant"),
1194 }
1195 }
1196
1197 #[test]
1198 fn test_deserialize_tool_result_untagged_text() {
1199 let json = json!({
1200 "type": "tool_result",
1201 "content": "Result content",
1202 "tool_use_id": "tool_456"
1203 });
1204 let chunk: ContentChunk = serde_json::from_value(json).unwrap();
1205 match chunk {
1206 ContentChunk::ToolResult {
1207 content,
1208 tool_use_id,
1209 } => {
1210 match content {
1211 Content::UntaggedText(text) => assert_eq!(text, "Result content"),
1212 _ => panic!("Expected UntaggedText content"),
1213 }
1214 assert_eq!(tool_use_id, "tool_456");
1215 }
1216 _ => panic!("Expected ToolResult variant"),
1217 }
1218 }
1219
1220 #[test]
1221 fn test_deserialize_tool_result_chunks() {
1222 let json = json!({
1223 "type": "tool_result",
1224 "content": [
1225 {
1226 "type": "text",
1227 "text": "Processing complete"
1228 },
1229 {
1230 "type": "text",
1231 "text": "Result: 42"
1232 }
1233 ],
1234 "tool_use_id": "tool_789"
1235 });
1236 let chunk: ContentChunk = serde_json::from_value(json).unwrap();
1237 match chunk {
1238 ContentChunk::ToolResult {
1239 content,
1240 tool_use_id,
1241 } => {
1242 match content {
1243 Content::Chunks(chunks) => {
1244 assert_eq!(chunks.len(), 2);
1245 match &chunks[0] {
1246 ContentChunk::Text { text } => assert_eq!(text, "Processing complete"),
1247 _ => panic!("Expected Text chunk"),
1248 }
1249 match &chunks[1] {
1250 ContentChunk::Text { text } => assert_eq!(text, "Result: 42"),
1251 _ => panic!("Expected Text chunk"),
1252 }
1253 }
1254 _ => panic!("Expected Chunks content"),
1255 }
1256 assert_eq!(tool_use_id, "tool_789");
1257 }
1258 _ => panic!("Expected ToolResult variant"),
1259 }
1260 }
1261
1262 #[test]
1263 fn test_acp_content_to_claude() {
1264 let acp_content = vec![
1265 acp::ContentBlock::Text(acp::TextContent {
1266 text: "Hello world".to_string(),
1267 annotations: None,
1268 }),
1269 acp::ContentBlock::Image(acp::ImageContent {
1270 data: "base64data".to_string(),
1271 mime_type: "image/png".to_string(),
1272 annotations: None,
1273 uri: None,
1274 }),
1275 acp::ContentBlock::ResourceLink(acp::ResourceLink {
1276 uri: "file:///path/to/example.rs".to_string(),
1277 name: "example.rs".to_string(),
1278 annotations: None,
1279 description: None,
1280 mime_type: None,
1281 size: None,
1282 title: None,
1283 }),
1284 acp::ContentBlock::Resource(acp::EmbeddedResource {
1285 annotations: None,
1286 resource: acp::EmbeddedResourceResource::TextResourceContents(
1287 acp::TextResourceContents {
1288 mime_type: None,
1289 text: "fn main() { println!(\"Hello!\"); }".to_string(),
1290 uri: "file:///path/to/code.rs".to_string(),
1291 },
1292 ),
1293 }),
1294 acp::ContentBlock::ResourceLink(acp::ResourceLink {
1295 uri: "invalid_uri_format".to_string(),
1296 name: "invalid.txt".to_string(),
1297 annotations: None,
1298 description: None,
1299 mime_type: None,
1300 size: None,
1301 title: None,
1302 }),
1303 ];
1304
1305 let claude_content = acp_content_to_claude(acp_content);
1306
1307 assert_eq!(claude_content.len(), 6);
1308
1309 match &claude_content[0] {
1310 ContentChunk::Text { text } => assert_eq!(text, "Hello world"),
1311 _ => panic!("Expected Text chunk"),
1312 }
1313
1314 match &claude_content[1] {
1315 ContentChunk::Image { source } => match source {
1316 ImageSource::Base64 { data, media_type } => {
1317 assert_eq!(data, "base64data");
1318 assert_eq!(media_type, "image/png");
1319 }
1320 _ => panic!("Expected Base64 image source"),
1321 },
1322 _ => panic!("Expected Image chunk"),
1323 }
1324
1325 match &claude_content[2] {
1326 ContentChunk::Text { text } => {
1327 assert!(text.contains("example.rs"));
1328 assert!(text.contains("file:///path/to/example.rs"));
1329 }
1330 _ => panic!("Expected Text chunk for ResourceLink"),
1331 }
1332
1333 match &claude_content[3] {
1334 ContentChunk::Text { text } => {
1335 assert!(text.contains("code.rs"));
1336 assert!(text.contains("file:///path/to/code.rs"));
1337 }
1338 _ => panic!("Expected Text chunk for Resource"),
1339 }
1340
1341 match &claude_content[4] {
1342 ContentChunk::Text { text } => {
1343 assert_eq!(text, "invalid_uri_format");
1344 }
1345 _ => panic!("Expected Text chunk for invalid URI"),
1346 }
1347
1348 match &claude_content[5] {
1349 ContentChunk::Text { text } => {
1350 assert!(text.contains("<context ref=\"file:///path/to/code.rs\">"));
1351 assert!(text.contains("fn main() { println!(\"Hello!\"); }"));
1352 assert!(text.contains("</context>"));
1353 }
1354 _ => panic!("Expected Text chunk for context"),
1355 }
1356 }
1357}