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::any::Any;
10use std::cell::RefCell;
11use std::fmt::Display;
12use std::path::Path;
13use std::rc::Rc;
14use uuid::Uuid;
15
16use agent_client_protocol as acp;
17use anyhow::{Context as _, 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, debug_panic};
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 "How can I help you today?"
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
69struct ClaudeAgentConnection {
70 sessions: Rc<RefCell<HashMap<acp::SessionId, ClaudeAgentSession>>>,
71}
72
73impl AgentConnection for ClaudeAgentConnection {
74 fn new_thread(
75 self: Rc<Self>,
76 project: Entity<Project>,
77 cwd: &Path,
78 cx: &mut App,
79 ) -> Task<Result<Entity<AcpThread>>> {
80 let cwd = cwd.to_owned();
81 cx.spawn(async move |cx| {
82 let (mut thread_tx, thread_rx) = watch::channel(WeakEntity::new_invalid());
83 let permission_mcp_server = ClaudeZedMcpServer::new(thread_rx.clone(), cx).await?;
84
85 let mut mcp_servers = HashMap::default();
86 mcp_servers.insert(
87 mcp_server::SERVER_NAME.to_string(),
88 permission_mcp_server.server_config()?,
89 );
90 let mcp_config = McpConfig { mcp_servers };
91
92 let mcp_config_file = tempfile::NamedTempFile::new()?;
93 let (mcp_config_file, mcp_config_path) = mcp_config_file.into_parts();
94
95 let mut mcp_config_file = smol::fs::File::from(mcp_config_file);
96 mcp_config_file
97 .write_all(serde_json::to_string(&mcp_config)?.as_bytes())
98 .await?;
99 mcp_config_file.flush().await?;
100
101 let settings = cx.read_global(|settings: &SettingsStore, _| {
102 settings.get::<AllAgentServersSettings>(None).claude.clone()
103 })?;
104
105 let Some(command) = AgentServerCommand::resolve(
106 "claude",
107 &[],
108 Some(&util::paths::home_dir().join(".claude/local/claude")),
109 settings,
110 &project,
111 cx,
112 )
113 .await
114 else {
115 anyhow::bail!("Failed to find claude binary");
116 };
117
118 let (incoming_message_tx, mut incoming_message_rx) = mpsc::unbounded();
119 let (outgoing_tx, outgoing_rx) = mpsc::unbounded();
120
121 let session_id = acp::SessionId(Uuid::new_v4().to_string().into());
122
123 log::trace!("Starting session with id: {}", session_id);
124
125 let mut child = spawn_claude(
126 &command,
127 ClaudeSessionMode::Start,
128 session_id.clone(),
129 &mcp_config_path,
130 &cwd,
131 )?;
132
133 let stdout = child.stdout.take().context("Failed to take stdout")?;
134 let stdin = child.stdin.take().context("Failed to take stdin")?;
135 let stderr = child.stderr.take().context("Failed to take stderr")?;
136
137 let pid = child.id();
138 log::trace!("Spawned (pid: {})", pid);
139
140 cx.background_spawn(async move {
141 let mut stderr = BufReader::new(stderr);
142 let mut line = String::new();
143 while let Ok(n) = stderr.read_line(&mut line).await
144 && n > 0
145 {
146 log::warn!("agent stderr: {}", &line);
147 line.clear();
148 }
149 })
150 .detach();
151
152 cx.background_spawn(async move {
153 let mut outgoing_rx = Some(outgoing_rx);
154
155 ClaudeAgentSession::handle_io(
156 outgoing_rx.take().unwrap(),
157 incoming_message_tx.clone(),
158 stdin,
159 stdout,
160 )
161 .await?;
162
163 log::trace!("Stopped (pid: {})", pid);
164
165 drop(mcp_config_path);
166 anyhow::Ok(())
167 })
168 .detach();
169
170 let turn_state = Rc::new(RefCell::new(TurnState::None));
171
172 let handler_task = cx.spawn({
173 let turn_state = turn_state.clone();
174 let mut thread_rx = thread_rx.clone();
175 async move |cx| {
176 while let Some(message) = incoming_message_rx.next().await {
177 ClaudeAgentSession::handle_message(
178 thread_rx.clone(),
179 message,
180 turn_state.clone(),
181 cx,
182 )
183 .await
184 }
185
186 if let Some(status) = child.status().await.log_err() {
187 if let Some(thread) = thread_rx.recv().await.ok() {
188 thread
189 .update(cx, |thread, cx| {
190 thread.emit_server_exited(status, cx);
191 })
192 .ok();
193 }
194 }
195 }
196 });
197
198 let thread = cx.new(|cx| {
199 AcpThread::new("Claude Code", self.clone(), project, session_id.clone(), cx)
200 })?;
201
202 thread_tx.send(thread.downgrade())?;
203
204 let session = ClaudeAgentSession {
205 outgoing_tx,
206 turn_state,
207 _handler_task: handler_task,
208 _mcp_server: Some(permission_mcp_server),
209 };
210
211 self.sessions.borrow_mut().insert(session_id, session);
212
213 Ok(thread)
214 })
215 }
216
217 fn auth_methods(&self) -> &[acp::AuthMethod] {
218 &[]
219 }
220
221 fn authenticate(&self, _: acp::AuthMethodId, _cx: &mut App) -> Task<Result<()>> {
222 Task::ready(Err(anyhow!("Authentication not supported")))
223 }
224
225 fn prompt(
226 &self,
227 _id: Option<acp_thread::UserMessageId>,
228 params: acp::PromptRequest,
229 cx: &mut App,
230 ) -> Task<Result<acp::PromptResponse>> {
231 let sessions = self.sessions.borrow();
232 let Some(session) = sessions.get(¶ms.session_id) else {
233 return Task::ready(Err(anyhow!(
234 "Attempted to send message to nonexistent session {}",
235 params.session_id
236 )));
237 };
238
239 let (end_tx, end_rx) = oneshot::channel();
240 session.turn_state.replace(TurnState::InProgress { end_tx });
241
242 let mut content = String::new();
243 for chunk in params.prompt {
244 match chunk {
245 acp::ContentBlock::Text(text_content) => {
246 content.push_str(&text_content.text);
247 }
248 acp::ContentBlock::ResourceLink(resource_link) => {
249 content.push_str(&format!("@{}", resource_link.uri));
250 }
251 acp::ContentBlock::Audio(_)
252 | acp::ContentBlock::Image(_)
253 | acp::ContentBlock::Resource(_) => {
254 // TODO
255 }
256 }
257 }
258
259 if let Err(err) = session.outgoing_tx.unbounded_send(SdkMessage::User {
260 message: Message {
261 role: Role::User,
262 content: Content::UntaggedText(content),
263 id: None,
264 model: None,
265 stop_reason: None,
266 stop_sequence: None,
267 usage: None,
268 },
269 session_id: Some(params.session_id.to_string()),
270 }) {
271 return Task::ready(Err(anyhow!(err)));
272 }
273
274 cx.foreground_executor().spawn(async move { end_rx.await? })
275 }
276
277 fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) {
278 let sessions = self.sessions.borrow();
279 let Some(session) = sessions.get(&session_id) else {
280 log::warn!("Attempted to cancel nonexistent session {}", session_id);
281 return;
282 };
283
284 let request_id = new_request_id();
285
286 let turn_state = session.turn_state.take();
287 let TurnState::InProgress { end_tx } = turn_state else {
288 // Already cancelled or idle, put it back
289 session.turn_state.replace(turn_state);
290 return;
291 };
292
293 session.turn_state.replace(TurnState::CancelRequested {
294 end_tx,
295 request_id: request_id.clone(),
296 });
297
298 session
299 .outgoing_tx
300 .unbounded_send(SdkMessage::ControlRequest {
301 request_id,
302 request: ControlRequest::Interrupt,
303 })
304 .log_err();
305 }
306
307 fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
308 self
309 }
310}
311
312#[derive(Clone, Copy)]
313enum ClaudeSessionMode {
314 Start,
315 #[expect(dead_code)]
316 Resume,
317}
318
319fn spawn_claude(
320 command: &AgentServerCommand,
321 mode: ClaudeSessionMode,
322 session_id: acp::SessionId,
323 mcp_config_path: &Path,
324 root_dir: &Path,
325) -> Result<Child> {
326 let child = util::command::new_smol_command(&command.path)
327 .args([
328 "--input-format",
329 "stream-json",
330 "--output-format",
331 "stream-json",
332 "--print",
333 "--verbose",
334 "--mcp-config",
335 mcp_config_path.to_string_lossy().as_ref(),
336 "--permission-prompt-tool",
337 &format!(
338 "mcp__{}__{}",
339 mcp_server::SERVER_NAME,
340 mcp_server::PermissionTool::NAME,
341 ),
342 "--allowedTools",
343 &format!(
344 "mcp__{}__{},mcp__{}__{}",
345 mcp_server::SERVER_NAME,
346 mcp_server::EditTool::NAME,
347 mcp_server::SERVER_NAME,
348 mcp_server::ReadTool::NAME
349 ),
350 "--disallowedTools",
351 "Read,Edit",
352 ])
353 .args(match mode {
354 ClaudeSessionMode::Start => ["--session-id".to_string(), session_id.to_string()],
355 ClaudeSessionMode::Resume => ["--resume".to_string(), session_id.to_string()],
356 })
357 .args(command.args.iter().map(|arg| arg.as_str()))
358 .current_dir(root_dir)
359 .stdin(std::process::Stdio::piped())
360 .stdout(std::process::Stdio::piped())
361 .stderr(std::process::Stdio::piped())
362 .kill_on_drop(true)
363 .spawn()?;
364
365 Ok(child)
366}
367
368struct ClaudeAgentSession {
369 outgoing_tx: UnboundedSender<SdkMessage>,
370 turn_state: Rc<RefCell<TurnState>>,
371 _mcp_server: Option<ClaudeZedMcpServer>,
372 _handler_task: Task<()>,
373}
374
375#[derive(Debug, Default)]
376enum TurnState {
377 #[default]
378 None,
379 InProgress {
380 end_tx: oneshot::Sender<Result<acp::PromptResponse>>,
381 },
382 CancelRequested {
383 end_tx: oneshot::Sender<Result<acp::PromptResponse>>,
384 request_id: String,
385 },
386 CancelConfirmed {
387 end_tx: oneshot::Sender<Result<acp::PromptResponse>>,
388 },
389}
390
391impl TurnState {
392 fn is_cancelled(&self) -> bool {
393 matches!(self, TurnState::CancelConfirmed { .. })
394 }
395
396 fn end_tx(self) -> Option<oneshot::Sender<Result<acp::PromptResponse>>> {
397 match self {
398 TurnState::None => None,
399 TurnState::InProgress { end_tx, .. } => Some(end_tx),
400 TurnState::CancelRequested { end_tx, .. } => Some(end_tx),
401 TurnState::CancelConfirmed { end_tx } => Some(end_tx),
402 }
403 }
404
405 fn confirm_cancellation(self, id: &str) -> Self {
406 match self {
407 TurnState::CancelRequested { request_id, end_tx } if request_id == id => {
408 TurnState::CancelConfirmed { end_tx }
409 }
410 _ => self,
411 }
412 }
413}
414
415impl ClaudeAgentSession {
416 async fn handle_message(
417 mut thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
418 message: SdkMessage,
419 turn_state: Rc<RefCell<TurnState>>,
420 cx: &mut AsyncApp,
421 ) {
422 match message {
423 // we should only be sending these out, they don't need to be in the thread
424 SdkMessage::ControlRequest { .. } => {}
425 SdkMessage::User {
426 message,
427 session_id: _,
428 } => {
429 let Some(thread) = thread_rx
430 .recv()
431 .await
432 .log_err()
433 .and_then(|entity| entity.upgrade())
434 else {
435 log::error!("Received an SDK message but thread is gone");
436 return;
437 };
438
439 for chunk in message.content.chunks() {
440 match chunk {
441 ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => {
442 if !turn_state.borrow().is_cancelled() {
443 thread
444 .update(cx, |thread, cx| {
445 thread.push_user_content_block(None, text.into(), cx)
446 })
447 .log_err();
448 }
449 }
450 ContentChunk::ToolResult {
451 content,
452 tool_use_id,
453 } => {
454 let content = content.to_string();
455 thread
456 .update(cx, |thread, cx| {
457 thread.update_tool_call(
458 acp::ToolCallUpdate {
459 id: acp::ToolCallId(tool_use_id.into()),
460 fields: acp::ToolCallUpdateFields {
461 status: if turn_state.borrow().is_cancelled() {
462 // Do not set to completed if turn was cancelled
463 None
464 } else {
465 Some(acp::ToolCallStatus::Completed)
466 },
467 content: (!content.is_empty())
468 .then(|| vec![content.into()]),
469 ..Default::default()
470 },
471 },
472 cx,
473 )
474 })
475 .log_err();
476 }
477 ContentChunk::Thinking { .. }
478 | ContentChunk::RedactedThinking
479 | ContentChunk::ToolUse { .. } => {
480 debug_panic!(
481 "Should not get {:?} with role: assistant. should we handle this?",
482 chunk
483 );
484 }
485
486 ContentChunk::Image
487 | ContentChunk::Document
488 | ContentChunk::WebSearchToolResult => {
489 thread
490 .update(cx, |thread, cx| {
491 thread.push_assistant_content_block(
492 format!("Unsupported content: {:?}", chunk).into(),
493 false,
494 cx,
495 )
496 })
497 .log_err();
498 }
499 }
500 }
501 }
502 SdkMessage::Assistant {
503 message,
504 session_id: _,
505 } => {
506 let Some(thread) = thread_rx
507 .recv()
508 .await
509 .log_err()
510 .and_then(|entity| entity.upgrade())
511 else {
512 log::error!("Received an SDK message but thread is gone");
513 return;
514 };
515
516 for chunk in message.content.chunks() {
517 match chunk {
518 ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => {
519 thread
520 .update(cx, |thread, cx| {
521 thread.push_assistant_content_block(text.into(), false, cx)
522 })
523 .log_err();
524 }
525 ContentChunk::Thinking { thinking } => {
526 thread
527 .update(cx, |thread, cx| {
528 thread.push_assistant_content_block(thinking.into(), true, cx)
529 })
530 .log_err();
531 }
532 ContentChunk::RedactedThinking => {
533 thread
534 .update(cx, |thread, cx| {
535 thread.push_assistant_content_block(
536 "[REDACTED]".into(),
537 true,
538 cx,
539 )
540 })
541 .log_err();
542 }
543 ContentChunk::ToolUse { id, name, input } => {
544 let claude_tool = ClaudeTool::infer(&name, input);
545
546 thread
547 .update(cx, |thread, cx| {
548 if let ClaudeTool::TodoWrite(Some(params)) = claude_tool {
549 thread.update_plan(
550 acp::Plan {
551 entries: params
552 .todos
553 .into_iter()
554 .map(Into::into)
555 .collect(),
556 },
557 cx,
558 )
559 } else {
560 thread.upsert_tool_call(
561 claude_tool.as_acp(acp::ToolCallId(id.into())),
562 cx,
563 );
564 }
565 })
566 .log_err();
567 }
568 ContentChunk::ToolResult { .. } | ContentChunk::WebSearchToolResult => {
569 debug_panic!(
570 "Should not get tool results with role: assistant. should we handle this?"
571 );
572 }
573 ContentChunk::Image | ContentChunk::Document => {
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::Result {
588 is_error,
589 subtype,
590 result,
591 ..
592 } => {
593 let turn_state = turn_state.take();
594 let was_cancelled = turn_state.is_cancelled();
595 let Some(end_turn_tx) = turn_state.end_tx() else {
596 debug_panic!("Received `SdkMessage::Result` but there wasn't an active turn");
597 return;
598 };
599
600 if is_error || (!was_cancelled && subtype == ResultErrorType::ErrorDuringExecution)
601 {
602 end_turn_tx
603 .send(Err(anyhow!(
604 "Error: {}",
605 result.unwrap_or_else(|| subtype.to_string())
606 )))
607 .ok();
608 } else {
609 let stop_reason = match subtype {
610 ResultErrorType::Success => acp::StopReason::EndTurn,
611 ResultErrorType::ErrorMaxTurns => acp::StopReason::MaxTurnRequests,
612 ResultErrorType::ErrorDuringExecution => acp::StopReason::Cancelled,
613 };
614 end_turn_tx
615 .send(Ok(acp::PromptResponse { stop_reason }))
616 .ok();
617 }
618 }
619 SdkMessage::ControlResponse { response } => {
620 if matches!(response.subtype, ResultErrorType::Success) {
621 let new_state = turn_state.take().confirm_cancellation(&response.request_id);
622 turn_state.replace(new_state);
623 } else {
624 log::error!("Control response error: {:?}", response);
625 }
626 }
627 SdkMessage::System { .. } => {}
628 }
629 }
630
631 async fn handle_io(
632 mut outgoing_rx: UnboundedReceiver<SdkMessage>,
633 incoming_tx: UnboundedSender<SdkMessage>,
634 mut outgoing_bytes: impl Unpin + AsyncWrite,
635 incoming_bytes: impl Unpin + AsyncRead,
636 ) -> Result<UnboundedReceiver<SdkMessage>> {
637 let mut output_reader = BufReader::new(incoming_bytes);
638 let mut outgoing_line = Vec::new();
639 let mut incoming_line = String::new();
640 loop {
641 select_biased! {
642 message = outgoing_rx.next() => {
643 if let Some(message) = message {
644 outgoing_line.clear();
645 serde_json::to_writer(&mut outgoing_line, &message)?;
646 log::trace!("send: {}", String::from_utf8_lossy(&outgoing_line));
647 outgoing_line.push(b'\n');
648 outgoing_bytes.write_all(&outgoing_line).await.ok();
649 } else {
650 break;
651 }
652 }
653 bytes_read = output_reader.read_line(&mut incoming_line).fuse() => {
654 if bytes_read? == 0 {
655 break
656 }
657 log::trace!("recv: {}", &incoming_line);
658 match serde_json::from_str::<SdkMessage>(&incoming_line) {
659 Ok(message) => {
660 incoming_tx.unbounded_send(message).log_err();
661 }
662 Err(error) => {
663 log::error!("failed to parse incoming message: {error}. Raw: {incoming_line}");
664 }
665 }
666 incoming_line.clear();
667 }
668 }
669 }
670
671 Ok(outgoing_rx)
672 }
673}
674
675#[derive(Debug, Clone, Serialize, Deserialize)]
676struct Message {
677 role: Role,
678 content: Content,
679 #[serde(skip_serializing_if = "Option::is_none")]
680 id: Option<String>,
681 #[serde(skip_serializing_if = "Option::is_none")]
682 model: Option<String>,
683 #[serde(skip_serializing_if = "Option::is_none")]
684 stop_reason: Option<String>,
685 #[serde(skip_serializing_if = "Option::is_none")]
686 stop_sequence: Option<String>,
687 #[serde(skip_serializing_if = "Option::is_none")]
688 usage: Option<Usage>,
689}
690
691#[derive(Debug, Clone, Serialize, Deserialize)]
692#[serde(untagged)]
693enum Content {
694 UntaggedText(String),
695 Chunks(Vec<ContentChunk>),
696}
697
698impl Content {
699 pub fn chunks(self) -> impl Iterator<Item = ContentChunk> {
700 match self {
701 Self::Chunks(chunks) => chunks.into_iter(),
702 Self::UntaggedText(text) => vec![ContentChunk::Text { text: text.clone() }].into_iter(),
703 }
704 }
705}
706
707impl Display for Content {
708 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
709 match self {
710 Content::UntaggedText(txt) => write!(f, "{}", txt),
711 Content::Chunks(chunks) => {
712 for chunk in chunks {
713 write!(f, "{}", chunk)?;
714 }
715 Ok(())
716 }
717 }
718 }
719}
720
721#[derive(Debug, Clone, Serialize, Deserialize)]
722#[serde(tag = "type", rename_all = "snake_case")]
723enum ContentChunk {
724 Text {
725 text: String,
726 },
727 ToolUse {
728 id: String,
729 name: String,
730 input: serde_json::Value,
731 },
732 ToolResult {
733 content: Content,
734 tool_use_id: String,
735 },
736 Thinking {
737 thinking: String,
738 },
739 RedactedThinking,
740 // TODO
741 Image,
742 Document,
743 WebSearchToolResult,
744 #[serde(untagged)]
745 UntaggedText(String),
746}
747
748impl Display for ContentChunk {
749 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
750 match self {
751 ContentChunk::Text { text } => write!(f, "{}", text),
752 ContentChunk::Thinking { thinking } => write!(f, "Thinking: {}", thinking),
753 ContentChunk::RedactedThinking => write!(f, "Thinking: [REDACTED]"),
754 ContentChunk::UntaggedText(text) => write!(f, "{}", text),
755 ContentChunk::ToolResult { content, .. } => write!(f, "{}", content),
756 ContentChunk::Image
757 | ContentChunk::Document
758 | ContentChunk::ToolUse { .. }
759 | ContentChunk::WebSearchToolResult => {
760 write!(f, "\n{:?}\n", &self)
761 }
762 }
763 }
764}
765
766#[derive(Debug, Clone, Serialize, Deserialize)]
767struct Usage {
768 input_tokens: u32,
769 cache_creation_input_tokens: u32,
770 cache_read_input_tokens: u32,
771 output_tokens: u32,
772 service_tier: String,
773}
774
775#[derive(Debug, Clone, Serialize, Deserialize)]
776#[serde(rename_all = "snake_case")]
777enum Role {
778 System,
779 Assistant,
780 User,
781}
782
783#[derive(Debug, Clone, Serialize, Deserialize)]
784struct MessageParam {
785 role: Role,
786 content: String,
787}
788
789#[derive(Debug, Clone, Serialize, Deserialize)]
790#[serde(tag = "type", rename_all = "snake_case")]
791enum SdkMessage {
792 // An assistant message
793 Assistant {
794 message: Message, // from Anthropic SDK
795 #[serde(skip_serializing_if = "Option::is_none")]
796 session_id: Option<String>,
797 },
798 // A user message
799 User {
800 message: Message, // from Anthropic SDK
801 #[serde(skip_serializing_if = "Option::is_none")]
802 session_id: Option<String>,
803 },
804 // Emitted as the last message in a conversation
805 Result {
806 subtype: ResultErrorType,
807 duration_ms: f64,
808 duration_api_ms: f64,
809 is_error: bool,
810 num_turns: i32,
811 #[serde(skip_serializing_if = "Option::is_none")]
812 result: Option<String>,
813 session_id: String,
814 total_cost_usd: f64,
815 },
816 // Emitted as the first message at the start of a conversation
817 System {
818 cwd: String,
819 session_id: String,
820 tools: Vec<String>,
821 model: String,
822 mcp_servers: Vec<McpServer>,
823 #[serde(rename = "apiKeySource")]
824 api_key_source: String,
825 #[serde(rename = "permissionMode")]
826 permission_mode: PermissionMode,
827 },
828 /// Messages used to control the conversation, outside of chat messages to the model
829 ControlRequest {
830 request_id: String,
831 request: ControlRequest,
832 },
833 /// Response to a control request
834 ControlResponse { response: ControlResponse },
835}
836
837#[derive(Debug, Clone, Serialize, Deserialize)]
838#[serde(tag = "subtype", rename_all = "snake_case")]
839enum ControlRequest {
840 /// Cancel the current conversation
841 Interrupt,
842}
843
844#[derive(Debug, Clone, Serialize, Deserialize)]
845struct ControlResponse {
846 request_id: String,
847 subtype: ResultErrorType,
848}
849
850#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
851#[serde(rename_all = "snake_case")]
852enum ResultErrorType {
853 Success,
854 ErrorMaxTurns,
855 ErrorDuringExecution,
856}
857
858impl Display for ResultErrorType {
859 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
860 match self {
861 ResultErrorType::Success => write!(f, "success"),
862 ResultErrorType::ErrorMaxTurns => write!(f, "error_max_turns"),
863 ResultErrorType::ErrorDuringExecution => write!(f, "error_during_execution"),
864 }
865 }
866}
867
868fn new_request_id() -> String {
869 use rand::Rng;
870 // In the Claude Code TS SDK they just generate a random 12 character string,
871 // `Math.random().toString(36).substring(2, 15)`
872 rand::thread_rng()
873 .sample_iter(&rand::distributions::Alphanumeric)
874 .take(12)
875 .map(char::from)
876 .collect()
877}
878
879#[derive(Debug, Clone, Serialize, Deserialize)]
880struct McpServer {
881 name: String,
882 status: String,
883}
884
885#[derive(Debug, Clone, Serialize, Deserialize)]
886#[serde(rename_all = "camelCase")]
887enum PermissionMode {
888 Default,
889 AcceptEdits,
890 BypassPermissions,
891 Plan,
892}
893
894#[cfg(test)]
895pub(crate) mod tests {
896 use super::*;
897 use crate::e2e_tests;
898 use gpui::TestAppContext;
899 use serde_json::json;
900
901 crate::common_e2e_tests!(ClaudeCode, allow_option_id = "allow");
902
903 pub fn local_command() -> AgentServerCommand {
904 AgentServerCommand {
905 path: "claude".into(),
906 args: vec![],
907 env: None,
908 }
909 }
910
911 #[gpui::test]
912 #[cfg_attr(not(feature = "e2e"), ignore)]
913 async fn test_todo_plan(cx: &mut TestAppContext) {
914 let fs = e2e_tests::init_test(cx).await;
915 let project = Project::test(fs, [], cx).await;
916 let thread =
917 e2e_tests::new_test_thread(ClaudeCode, project.clone(), "/private/tmp", cx).await;
918
919 thread
920 .update(cx, |thread, cx| {
921 thread.send_raw(
922 "Create a todo plan for initializing a new React app. I'll follow it myself, do not execute on it.",
923 cx,
924 )
925 })
926 .await
927 .unwrap();
928
929 let mut entries_len = 0;
930
931 thread.read_with(cx, |thread, _| {
932 entries_len = thread.plan().entries.len();
933 assert!(thread.plan().entries.len() > 0, "Empty plan");
934 });
935
936 thread
937 .update(cx, |thread, cx| {
938 thread.send_raw(
939 "Mark the first entry status as in progress without acting on it.",
940 cx,
941 )
942 })
943 .await
944 .unwrap();
945
946 thread.read_with(cx, |thread, _| {
947 assert!(matches!(
948 thread.plan().entries[0].status,
949 acp::PlanEntryStatus::InProgress
950 ));
951 assert_eq!(thread.plan().entries.len(), entries_len);
952 });
953
954 thread
955 .update(cx, |thread, cx| {
956 thread.send_raw(
957 "Now mark the first entry as completed without acting on it.",
958 cx,
959 )
960 })
961 .await
962 .unwrap();
963
964 thread.read_with(cx, |thread, _| {
965 assert!(matches!(
966 thread.plan().entries[0].status,
967 acp::PlanEntryStatus::Completed
968 ));
969 assert_eq!(thread.plan().entries.len(), entries_len);
970 });
971 }
972
973 #[test]
974 fn test_deserialize_content_untagged_text() {
975 let json = json!("Hello, world!");
976 let content: Content = serde_json::from_value(json).unwrap();
977 match content {
978 Content::UntaggedText(text) => assert_eq!(text, "Hello, world!"),
979 _ => panic!("Expected UntaggedText variant"),
980 }
981 }
982
983 #[test]
984 fn test_deserialize_content_chunks() {
985 let json = json!([
986 {
987 "type": "text",
988 "text": "Hello"
989 },
990 {
991 "type": "tool_use",
992 "id": "tool_123",
993 "name": "calculator",
994 "input": {"operation": "add", "a": 1, "b": 2}
995 }
996 ]);
997 let content: Content = serde_json::from_value(json).unwrap();
998 match content {
999 Content::Chunks(chunks) => {
1000 assert_eq!(chunks.len(), 2);
1001 match &chunks[0] {
1002 ContentChunk::Text { text } => assert_eq!(text, "Hello"),
1003 _ => panic!("Expected Text chunk"),
1004 }
1005 match &chunks[1] {
1006 ContentChunk::ToolUse { id, name, input } => {
1007 assert_eq!(id, "tool_123");
1008 assert_eq!(name, "calculator");
1009 assert_eq!(input["operation"], "add");
1010 assert_eq!(input["a"], 1);
1011 assert_eq!(input["b"], 2);
1012 }
1013 _ => panic!("Expected ToolUse chunk"),
1014 }
1015 }
1016 _ => panic!("Expected Chunks variant"),
1017 }
1018 }
1019
1020 #[test]
1021 fn test_deserialize_tool_result_untagged_text() {
1022 let json = json!({
1023 "type": "tool_result",
1024 "content": "Result content",
1025 "tool_use_id": "tool_456"
1026 });
1027 let chunk: ContentChunk = serde_json::from_value(json).unwrap();
1028 match chunk {
1029 ContentChunk::ToolResult {
1030 content,
1031 tool_use_id,
1032 } => {
1033 match content {
1034 Content::UntaggedText(text) => assert_eq!(text, "Result content"),
1035 _ => panic!("Expected UntaggedText content"),
1036 }
1037 assert_eq!(tool_use_id, "tool_456");
1038 }
1039 _ => panic!("Expected ToolResult variant"),
1040 }
1041 }
1042
1043 #[test]
1044 fn test_deserialize_tool_result_chunks() {
1045 let json = json!({
1046 "type": "tool_result",
1047 "content": [
1048 {
1049 "type": "text",
1050 "text": "Processing complete"
1051 },
1052 {
1053 "type": "text",
1054 "text": "Result: 42"
1055 }
1056 ],
1057 "tool_use_id": "tool_789"
1058 });
1059 let chunk: ContentChunk = serde_json::from_value(json).unwrap();
1060 match chunk {
1061 ContentChunk::ToolResult {
1062 content,
1063 tool_use_id,
1064 } => {
1065 match content {
1066 Content::Chunks(chunks) => {
1067 assert_eq!(chunks.len(), 2);
1068 match &chunks[0] {
1069 ContentChunk::Text { text } => assert_eq!(text, "Processing complete"),
1070 _ => panic!("Expected Text chunk"),
1071 }
1072 match &chunks[1] {
1073 ContentChunk::Text { text } => assert_eq!(text, "Result: 42"),
1074 _ => panic!("Expected Text chunk"),
1075 }
1076 }
1077 _ => panic!("Expected Chunks content"),
1078 }
1079 assert_eq!(tool_use_id, "tool_789");
1080 }
1081 _ => panic!("Expected ToolResult variant"),
1082 }
1083 }
1084}