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