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