1mod server;
2mod thread_view;
3
4use agentic_coding_protocol::{self as acp, Role};
5use anyhow::{Context as _, Result};
6use buffer_diff::BufferDiff;
7use chrono::{DateTime, Utc};
8use editor::{MultiBuffer, PathKey};
9use futures::channel::oneshot;
10use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Task};
11use language::{Anchor, Buffer, Capability, LanguageRegistry, OffsetRangeExt as _};
12use markdown::Markdown;
13use project::Project;
14use std::{mem, ops::Range, path::PathBuf, sync::Arc};
15use ui::{App, IconName};
16use util::{ResultExt, debug_panic};
17
18pub use server::AcpServer;
19pub use thread_view::AcpThreadView;
20
21#[derive(Debug, Clone, PartialEq, Eq, Hash)]
22pub struct ThreadId(SharedString);
23
24#[derive(Copy, Clone, Debug, PartialEq, Eq)]
25pub struct FileVersion(u64);
26
27#[derive(Debug)]
28pub struct AgentThreadSummary {
29 pub id: ThreadId,
30 pub title: String,
31 pub created_at: DateTime<Utc>,
32}
33
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct FileContent {
36 pub path: PathBuf,
37 pub version: FileVersion,
38 pub content: SharedString,
39}
40
41#[derive(Clone, Debug, Eq, PartialEq)]
42pub struct Message {
43 pub role: acp::Role,
44 pub chunks: Vec<MessageChunk>,
45}
46
47impl Message {
48 fn into_acp(self, cx: &App) -> acp::Message {
49 acp::Message {
50 role: self.role,
51 chunks: self
52 .chunks
53 .into_iter()
54 .map(|chunk| chunk.into_acp(cx))
55 .collect(),
56 }
57 }
58}
59
60#[derive(Clone, Debug, Eq, PartialEq)]
61pub enum MessageChunk {
62 Text {
63 chunk: Entity<Markdown>,
64 },
65 File {
66 content: FileContent,
67 },
68 Directory {
69 path: PathBuf,
70 contents: Vec<FileContent>,
71 },
72 Symbol {
73 path: PathBuf,
74 range: Range<u64>,
75 version: FileVersion,
76 name: SharedString,
77 content: SharedString,
78 },
79 Fetch {
80 url: SharedString,
81 content: SharedString,
82 },
83}
84
85impl MessageChunk {
86 pub fn from_acp(
87 chunk: acp::MessageChunk,
88 language_registry: Arc<LanguageRegistry>,
89 cx: &mut App,
90 ) -> Self {
91 match chunk {
92 acp::MessageChunk::Text { chunk } => MessageChunk::Text {
93 chunk: cx.new(|cx| Markdown::new(chunk.into(), Some(language_registry), None, cx)),
94 },
95 }
96 }
97
98 pub fn into_acp(self, cx: &App) -> acp::MessageChunk {
99 match self {
100 MessageChunk::Text { chunk } => acp::MessageChunk::Text {
101 chunk: chunk.read(cx).source().to_string(),
102 },
103 MessageChunk::File { .. } => todo!(),
104 MessageChunk::Directory { .. } => todo!(),
105 MessageChunk::Symbol { .. } => todo!(),
106 MessageChunk::Fetch { .. } => todo!(),
107 }
108 }
109
110 pub fn from_str(chunk: &str, language_registry: Arc<LanguageRegistry>, cx: &mut App) -> Self {
111 MessageChunk::Text {
112 chunk: cx.new(|cx| {
113 Markdown::new(chunk.to_owned().into(), Some(language_registry), None, cx)
114 }),
115 }
116 }
117}
118
119#[derive(Debug)]
120pub enum AgentThreadEntryContent {
121 Message(Message),
122 ToolCall(ToolCall),
123}
124
125#[derive(Debug)]
126pub struct ToolCall {
127 id: ToolCallId,
128 label: Entity<Markdown>,
129 icon: IconName,
130 content: Option<ToolCallContent>,
131 status: ToolCallStatus,
132}
133
134#[derive(Debug)]
135pub enum ToolCallStatus {
136 WaitingForConfirmation {
137 confirmation: ToolCallConfirmation,
138 respond_tx: oneshot::Sender<acp::ToolCallConfirmationOutcome>,
139 },
140 Allowed {
141 status: acp::ToolCallStatus,
142 },
143 Rejected,
144}
145
146#[derive(Debug)]
147pub enum ToolCallConfirmation {
148 Edit {
149 description: Option<Entity<Markdown>>,
150 },
151 Execute {
152 command: String,
153 root_command: String,
154 description: Option<Entity<Markdown>>,
155 },
156 Mcp {
157 server_name: String,
158 tool_name: String,
159 tool_display_name: String,
160 description: Option<Entity<Markdown>>,
161 },
162 Fetch {
163 urls: Vec<String>,
164 description: Option<Entity<Markdown>>,
165 },
166 Other {
167 description: Entity<Markdown>,
168 },
169}
170
171impl ToolCallConfirmation {
172 pub fn from_acp(
173 confirmation: acp::ToolCallConfirmation,
174 language_registry: Arc<LanguageRegistry>,
175 cx: &mut App,
176 ) -> Self {
177 let to_md = |description: String, cx: &mut App| -> Entity<Markdown> {
178 cx.new(|cx| {
179 Markdown::new(
180 description.into(),
181 Some(language_registry.clone()),
182 None,
183 cx,
184 )
185 })
186 };
187
188 match confirmation {
189 acp::ToolCallConfirmation::Edit { description } => Self::Edit {
190 description: description.map(|description| to_md(description, cx)),
191 },
192 acp::ToolCallConfirmation::Execute {
193 command,
194 root_command,
195 description,
196 } => Self::Execute {
197 command,
198 root_command,
199 description: description.map(|description| to_md(description, cx)),
200 },
201 acp::ToolCallConfirmation::Mcp {
202 server_name,
203 tool_name,
204 tool_display_name,
205 description,
206 } => Self::Mcp {
207 server_name,
208 tool_name,
209 tool_display_name,
210 description: description.map(|description| to_md(description, cx)),
211 },
212 acp::ToolCallConfirmation::Fetch { urls, description } => Self::Fetch {
213 urls,
214 description: description.map(|description| to_md(description, cx)),
215 },
216 acp::ToolCallConfirmation::Other { description } => Self::Other {
217 description: to_md(description, cx),
218 },
219 }
220 }
221}
222
223#[derive(Debug)]
224pub enum ToolCallContent {
225 Markdown { markdown: Entity<Markdown> },
226 Diff { diff: Diff },
227}
228
229impl ToolCallContent {
230 pub fn from_acp(
231 content: acp::ToolCallContent,
232 language_registry: Arc<LanguageRegistry>,
233 cx: &mut App,
234 ) -> Self {
235 match content {
236 acp::ToolCallContent::Markdown { markdown } => Self::Markdown {
237 markdown: cx.new(|cx| Markdown::new_text(markdown.into(), cx)),
238 },
239 acp::ToolCallContent::Diff { diff } => Self::Diff {
240 diff: Diff::from_acp(diff, language_registry, cx),
241 },
242 }
243 }
244}
245
246#[derive(Debug)]
247pub struct Diff {
248 // todo! show path somewhere
249 multibuffer: Entity<MultiBuffer>,
250 _path: PathBuf,
251 _task: Task<Result<()>>,
252}
253
254impl Diff {
255 pub fn from_acp(
256 diff: acp::Diff,
257 language_registry: Arc<LanguageRegistry>,
258 cx: &mut App,
259 ) -> Self {
260 let acp::Diff {
261 path,
262 old_text,
263 new_text,
264 } = diff;
265
266 let multibuffer = cx.new(|_cx| MultiBuffer::without_headers(Capability::ReadOnly));
267
268 let new_buffer = cx.new(|cx| Buffer::local(new_text, cx));
269 let old_buffer = cx.new(|cx| Buffer::local(old_text.unwrap_or("".into()), cx));
270 let new_buffer_snapshot = new_buffer.read(cx).text_snapshot();
271 let old_buffer_snapshot = old_buffer.read(cx).snapshot();
272 let buffer_diff = cx.new(|cx| BufferDiff::new(&new_buffer_snapshot, cx));
273 let diff_task = buffer_diff.update(cx, |diff, cx| {
274 diff.set_base_text(
275 old_buffer_snapshot,
276 Some(language_registry.clone()),
277 new_buffer_snapshot,
278 cx,
279 )
280 });
281
282 let task = cx.spawn({
283 let multibuffer = multibuffer.clone();
284 let path = path.clone();
285 async move |cx| {
286 diff_task.await?;
287
288 multibuffer
289 .update(cx, |multibuffer, cx| {
290 let hunk_ranges = {
291 let buffer = new_buffer.read(cx);
292 let diff = buffer_diff.read(cx);
293 diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, cx)
294 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&buffer))
295 .collect::<Vec<_>>()
296 };
297
298 multibuffer.set_excerpts_for_path(
299 PathKey::for_buffer(&new_buffer, cx),
300 new_buffer.clone(),
301 hunk_ranges,
302 editor::DEFAULT_MULTIBUFFER_CONTEXT,
303 cx,
304 );
305 multibuffer.add_diff(buffer_diff.clone(), cx);
306 })
307 .log_err();
308
309 if let Some(language) = language_registry
310 .language_for_file_path(&path)
311 .await
312 .log_err()
313 {
314 new_buffer.update(cx, |buffer, cx| buffer.set_language(Some(language), cx))?;
315 }
316
317 anyhow::Ok(())
318 }
319 });
320
321 Self {
322 multibuffer,
323 _path: path,
324 _task: task,
325 }
326 }
327}
328
329/// A `ThreadEntryId` that is known to be a ToolCall
330#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
331pub struct ToolCallId(ThreadEntryId);
332
333impl ToolCallId {
334 pub fn as_u64(&self) -> u64 {
335 self.0.0
336 }
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
340pub struct ThreadEntryId(pub u64);
341
342impl ThreadEntryId {
343 pub fn post_inc(&mut self) -> Self {
344 let id = *self;
345 self.0 += 1;
346 id
347 }
348}
349
350#[derive(Debug)]
351pub struct ThreadEntry {
352 pub id: ThreadEntryId,
353 pub content: AgentThreadEntryContent,
354}
355
356pub struct AcpThread {
357 id: ThreadId,
358 next_entry_id: ThreadEntryId,
359 entries: Vec<ThreadEntry>,
360 server: Arc<AcpServer>,
361 title: SharedString,
362 project: Entity<Project>,
363}
364
365enum AcpThreadEvent {
366 NewEntry,
367 EntryUpdated(usize),
368}
369
370impl EventEmitter<AcpThreadEvent> for AcpThread {}
371
372impl AcpThread {
373 pub fn new(
374 server: Arc<AcpServer>,
375 thread_id: ThreadId,
376 entries: Vec<AgentThreadEntryContent>,
377 project: Entity<Project>,
378 _: &mut Context<Self>,
379 ) -> Self {
380 let mut next_entry_id = ThreadEntryId(0);
381 Self {
382 title: "A new agent2 thread".into(),
383 entries: entries
384 .into_iter()
385 .map(|entry| ThreadEntry {
386 id: next_entry_id.post_inc(),
387 content: entry,
388 })
389 .collect(),
390 server,
391 id: thread_id,
392 next_entry_id,
393 project,
394 }
395 }
396
397 pub fn title(&self) -> SharedString {
398 self.title.clone()
399 }
400
401 pub fn entries(&self) -> &[ThreadEntry] {
402 &self.entries
403 }
404
405 pub fn push_entry(
406 &mut self,
407 entry: AgentThreadEntryContent,
408 cx: &mut Context<Self>,
409 ) -> ThreadEntryId {
410 let id = self.next_entry_id.post_inc();
411 self.entries.push(ThreadEntry { id, content: entry });
412 cx.emit(AcpThreadEvent::NewEntry);
413 id
414 }
415
416 pub fn push_assistant_chunk(&mut self, chunk: acp::MessageChunk, cx: &mut Context<Self>) {
417 let entries_len = self.entries.len();
418 if let Some(last_entry) = self.entries.last_mut()
419 && let AgentThreadEntryContent::Message(Message {
420 ref mut chunks,
421 role: Role::Assistant,
422 }) = last_entry.content
423 {
424 cx.emit(AcpThreadEvent::EntryUpdated(entries_len - 1));
425
426 if let (
427 Some(MessageChunk::Text { chunk: old_chunk }),
428 acp::MessageChunk::Text { chunk: new_chunk },
429 ) = (chunks.last_mut(), &chunk)
430 {
431 old_chunk.update(cx, |old_chunk, cx| {
432 old_chunk.append(&new_chunk, cx);
433 });
434 } else {
435 chunks.push(MessageChunk::from_acp(
436 chunk,
437 self.project.read(cx).languages().clone(),
438 cx,
439 ));
440 }
441
442 return;
443 }
444
445 let chunk = MessageChunk::from_acp(chunk, self.project.read(cx).languages().clone(), cx);
446
447 self.push_entry(
448 AgentThreadEntryContent::Message(Message {
449 role: Role::Assistant,
450 chunks: vec![chunk],
451 }),
452 cx,
453 );
454 }
455
456 pub fn request_tool_call(
457 &mut self,
458 label: String,
459 icon: acp::Icon,
460 content: Option<acp::ToolCallContent>,
461 confirmation: acp::ToolCallConfirmation,
462 cx: &mut Context<Self>,
463 ) -> ToolCallRequest {
464 let (tx, rx) = oneshot::channel();
465
466 let status = ToolCallStatus::WaitingForConfirmation {
467 confirmation: ToolCallConfirmation::from_acp(
468 confirmation,
469 self.project.read(cx).languages().clone(),
470 cx,
471 ),
472 respond_tx: tx,
473 };
474
475 let id = self.insert_tool_call(label, status, icon, content, cx);
476 ToolCallRequest { id, outcome: rx }
477 }
478
479 pub fn push_tool_call(
480 &mut self,
481 label: String,
482 icon: acp::Icon,
483 content: Option<acp::ToolCallContent>,
484 cx: &mut Context<Self>,
485 ) -> ToolCallId {
486 let status = ToolCallStatus::Allowed {
487 status: acp::ToolCallStatus::Running,
488 };
489
490 self.insert_tool_call(label, status, icon, content, cx)
491 }
492
493 fn insert_tool_call(
494 &mut self,
495 label: String,
496 status: ToolCallStatus,
497 icon: acp::Icon,
498 content: Option<acp::ToolCallContent>,
499 cx: &mut Context<Self>,
500 ) -> ToolCallId {
501 let language_registry = self.project.read(cx).languages().clone();
502
503 let entry_id = self.push_entry(
504 AgentThreadEntryContent::ToolCall(ToolCall {
505 // todo! clean up id creation
506 id: ToolCallId(ThreadEntryId(self.entries.len() as u64)),
507 label: cx.new(|cx| {
508 Markdown::new(label.into(), Some(language_registry.clone()), None, cx)
509 }),
510 icon: acp_icon_to_ui_icon(icon),
511 content: content
512 .map(|content| ToolCallContent::from_acp(content, language_registry, cx)),
513 status,
514 }),
515 cx,
516 );
517
518 ToolCallId(entry_id)
519 }
520
521 pub fn authorize_tool_call(
522 &mut self,
523 id: ToolCallId,
524 outcome: acp::ToolCallConfirmationOutcome,
525 cx: &mut Context<Self>,
526 ) {
527 let Some(entry) = self.entry_mut(id.0) else {
528 return;
529 };
530
531 let AgentThreadEntryContent::ToolCall(call) = &mut entry.content else {
532 debug_panic!("expected ToolCall");
533 return;
534 };
535
536 let new_status = if outcome == acp::ToolCallConfirmationOutcome::Reject {
537 ToolCallStatus::Rejected
538 } else {
539 ToolCallStatus::Allowed {
540 status: acp::ToolCallStatus::Running,
541 }
542 };
543
544 let curr_status = mem::replace(&mut call.status, new_status);
545
546 if let ToolCallStatus::WaitingForConfirmation { respond_tx, .. } = curr_status {
547 respond_tx.send(outcome).log_err();
548 } else {
549 debug_panic!("tried to authorize an already authorized tool call");
550 }
551
552 cx.emit(AcpThreadEvent::EntryUpdated(id.as_u64() as usize));
553 }
554
555 pub fn update_tool_call(
556 &mut self,
557 id: ToolCallId,
558 new_status: acp::ToolCallStatus,
559 new_content: Option<acp::ToolCallContent>,
560 cx: &mut Context<Self>,
561 ) -> Result<()> {
562 let language_registry = self.project.read(cx).languages().clone();
563 let entry = self.entry_mut(id.0).context("Entry not found")?;
564
565 match &mut entry.content {
566 AgentThreadEntryContent::ToolCall(call) => {
567 call.content = new_content.map(|new_content| {
568 ToolCallContent::from_acp(new_content, language_registry, cx)
569 });
570
571 match &mut call.status {
572 ToolCallStatus::Allowed { status } => {
573 *status = new_status;
574 }
575 ToolCallStatus::WaitingForConfirmation { .. } => {
576 anyhow::bail!("Tool call hasn't been authorized yet")
577 }
578 ToolCallStatus::Rejected => {
579 anyhow::bail!("Tool call was rejected and therefore can't be updated")
580 }
581 }
582 }
583 _ => anyhow::bail!("Entry is not a tool call"),
584 }
585
586 cx.emit(AcpThreadEvent::EntryUpdated(id.as_u64() as usize));
587 Ok(())
588 }
589
590 fn entry_mut(&mut self, id: ThreadEntryId) -> Option<&mut ThreadEntry> {
591 let entry = self.entries.get_mut(id.0 as usize);
592 debug_assert!(
593 entry.is_some(),
594 "We shouldn't give out ids to entries that don't exist"
595 );
596 entry
597 }
598
599 /// Returns true if the last turn is awaiting tool authorization
600 pub fn waiting_for_tool_confirmation(&self) -> bool {
601 for entry in self.entries.iter().rev() {
602 match &entry.content {
603 AgentThreadEntryContent::ToolCall(call) => match call.status {
604 ToolCallStatus::WaitingForConfirmation { .. } => return true,
605 ToolCallStatus::Allowed { .. } | ToolCallStatus::Rejected => continue,
606 },
607 AgentThreadEntryContent::Message(_) => {
608 // Reached the beginning of the turn
609 return false;
610 }
611 }
612 }
613 false
614 }
615
616 pub fn send(&mut self, message: &str, cx: &mut Context<Self>) -> Task<Result<()>> {
617 let agent = self.server.clone();
618 let id = self.id.clone();
619 let chunk = MessageChunk::from_str(message, self.project.read(cx).languages().clone(), cx);
620 let message = Message {
621 role: Role::User,
622 chunks: vec![chunk],
623 };
624 self.push_entry(AgentThreadEntryContent::Message(message.clone()), cx);
625 let acp_message = message.into_acp(cx);
626 cx.spawn(async move |_, cx| {
627 agent.send_message(id, acp_message, cx).await?;
628 Ok(())
629 })
630 }
631}
632
633fn acp_icon_to_ui_icon(icon: acp::Icon) -> IconName {
634 match icon {
635 acp::Icon::FileSearch => IconName::FileSearch,
636 acp::Icon::Folder => IconName::Folder,
637 acp::Icon::Globe => IconName::Globe,
638 acp::Icon::Hammer => IconName::Hammer,
639 acp::Icon::LightBulb => IconName::LightBulb,
640 acp::Icon::Pencil => IconName::Pencil,
641 acp::Icon::Regex => IconName::Regex,
642 acp::Icon::Terminal => IconName::Terminal,
643 }
644}
645
646pub struct ToolCallRequest {
647 pub id: ToolCallId,
648 pub outcome: oneshot::Receiver<acp::ToolCallConfirmationOutcome>,
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654 use futures::{FutureExt as _, channel::mpsc, select};
655 use gpui::TestAppContext;
656 use project::FakeFs;
657 use serde_json::json;
658 use settings::SettingsStore;
659 use smol::stream::StreamExt as _;
660 use std::{env, path::Path, process::Stdio, time::Duration};
661 use util::path;
662
663 fn init_test(cx: &mut TestAppContext) {
664 env_logger::try_init().ok();
665 cx.update(|cx| {
666 let settings_store = SettingsStore::test(cx);
667 cx.set_global(settings_store);
668 Project::init_settings(cx);
669 language::init(cx);
670 });
671 }
672
673 #[gpui::test]
674 async fn test_gemini_basic(cx: &mut TestAppContext) {
675 init_test(cx);
676
677 cx.executor().allow_parking();
678
679 let fs = FakeFs::new(cx.executor());
680 let project = Project::test(fs, [], cx).await;
681 let server = gemini_acp_server(project.clone(), cx).await;
682 let thread = server.create_thread(&mut cx.to_async()).await.unwrap();
683 thread
684 .update(cx, |thread, cx| thread.send("Hello from Zed!", cx))
685 .await
686 .unwrap();
687
688 thread.read_with(cx, |thread, _| {
689 assert_eq!(thread.entries.len(), 2);
690 assert!(matches!(
691 thread.entries[0].content,
692 AgentThreadEntryContent::Message(Message {
693 role: Role::User,
694 ..
695 })
696 ));
697 assert!(matches!(
698 thread.entries[1].content,
699 AgentThreadEntryContent::Message(Message {
700 role: Role::Assistant,
701 ..
702 })
703 ));
704 });
705 }
706
707 #[gpui::test]
708 async fn test_gemini_tool_call(cx: &mut TestAppContext) {
709 init_test(cx);
710
711 cx.executor().allow_parking();
712
713 let fs = FakeFs::new(cx.executor());
714 fs.insert_tree(
715 path!("/private/tmp"),
716 json!({"foo": "Lorem ipsum dolor", "bar": "bar", "baz": "baz"}),
717 )
718 .await;
719 let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
720 let server = gemini_acp_server(project.clone(), cx).await;
721 let thread = server.create_thread(&mut cx.to_async()).await.unwrap();
722 thread
723 .update(cx, |thread, cx| {
724 thread.send(
725 "Read the '/private/tmp/foo' file and tell me what you see.",
726 cx,
727 )
728 })
729 .await
730 .unwrap();
731 thread.read_with(cx, |thread, _cx| {
732 assert!(matches!(
733 &thread.entries()[1].content,
734 AgentThreadEntryContent::ToolCall(ToolCall {
735 status: ToolCallStatus::Allowed { .. },
736 ..
737 })
738 ));
739
740 assert!(matches!(
741 thread.entries[2].content,
742 AgentThreadEntryContent::Message(Message {
743 role: Role::Assistant,
744 ..
745 })
746 ));
747 });
748 }
749
750 #[gpui::test]
751 async fn test_gemini_tool_call_with_confirmation(cx: &mut TestAppContext) {
752 init_test(cx);
753
754 cx.executor().allow_parking();
755
756 let fs = FakeFs::new(cx.executor());
757 let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
758 let server = gemini_acp_server(project.clone(), cx).await;
759 let thread = server.create_thread(&mut cx.to_async()).await.unwrap();
760 let full_turn = thread.update(cx, |thread, cx| {
761 thread.send(r#"Run `echo "Hello, world!"`"#, cx)
762 });
763
764 run_until_tool_call(&thread, cx).await;
765
766 let tool_call_id = thread.read_with(cx, |thread, _cx| {
767 let AgentThreadEntryContent::ToolCall(ToolCall {
768 id,
769 status:
770 ToolCallStatus::WaitingForConfirmation {
771 confirmation: ToolCallConfirmation::Execute { root_command, .. },
772 ..
773 },
774 ..
775 }) = &thread.entries()[1].content
776 else {
777 panic!();
778 };
779
780 assert_eq!(root_command, "echo");
781
782 *id
783 });
784
785 thread.update(cx, |thread, cx| {
786 thread.authorize_tool_call(tool_call_id, acp::ToolCallConfirmationOutcome::Allow, cx);
787
788 assert!(matches!(
789 &thread.entries()[1].content,
790 AgentThreadEntryContent::ToolCall(ToolCall {
791 status: ToolCallStatus::Allowed { .. },
792 ..
793 })
794 ));
795 });
796
797 full_turn.await.unwrap();
798
799 thread.read_with(cx, |thread, cx| {
800 let AgentThreadEntryContent::ToolCall(ToolCall {
801 content: Some(ToolCallContent::Markdown { markdown }),
802 status: ToolCallStatus::Allowed { .. },
803 ..
804 }) = &thread.entries()[1].content
805 else {
806 panic!();
807 };
808
809 markdown.read_with(cx, |md, _cx| {
810 assert!(
811 md.source().contains("Hello, world!"),
812 r#"Expected '{}' to contain "Hello, world!""#,
813 md.source()
814 );
815 });
816 });
817 }
818
819 async fn run_until_tool_call(thread: &Entity<AcpThread>, cx: &mut TestAppContext) {
820 let (mut tx, mut rx) = mpsc::channel::<()>(1);
821
822 let subscription = cx.update(|cx| {
823 cx.subscribe(thread, move |thread, _, cx| {
824 if thread
825 .read(cx)
826 .entries
827 .iter()
828 .any(|e| matches!(e.content, AgentThreadEntryContent::ToolCall(_)))
829 {
830 tx.try_send(()).unwrap();
831 }
832 })
833 });
834
835 select! {
836 _ = futures::FutureExt::fuse(smol::Timer::after(Duration::from_secs(10))) => {
837 panic!("Timeout waiting for tool call")
838 }
839 _ = rx.next().fuse() => {
840 drop(subscription);
841 }
842 }
843 }
844
845 pub async fn gemini_acp_server(
846 project: Entity<Project>,
847 cx: &mut TestAppContext,
848 ) -> Arc<AcpServer> {
849 let cli_path =
850 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../gemini-cli/packages/cli");
851 let mut command = util::command::new_smol_command("node");
852 command
853 .arg(cli_path)
854 .arg("--acp")
855 .current_dir("/private/tmp")
856 .stdin(Stdio::piped())
857 .stdout(Stdio::piped())
858 .stderr(Stdio::inherit())
859 .kill_on_drop(true);
860
861 if let Ok(gemini_key) = std::env::var("GEMINI_API_KEY") {
862 command.env("GEMINI_API_KEY", gemini_key);
863 }
864
865 let child = command.spawn().unwrap();
866 let server = cx.update(|cx| AcpServer::stdio(child, project, cx));
867 server.initialize().await.unwrap();
868 server
869 }
870}