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    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}