acp.rs

  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}