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