acp.rs

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