acp.rs

  1mod server;
  2mod thread_view;
  3
  4use agentic_coding_protocol::{self as acp, Role};
  5use anyhow::Result;
  6use chrono::{DateTime, Utc};
  7use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Task};
  8use language::LanguageRegistry;
  9use markdown::Markdown;
 10use project::Project;
 11use std::{ops::Range, path::PathBuf, sync::Arc};
 12use ui::App;
 13
 14pub use server::AcpServer;
 15pub use thread_view::AcpThreadView;
 16
 17#[derive(Debug, Clone, PartialEq, Eq, Hash)]
 18pub struct ThreadId(SharedString);
 19
 20#[derive(Copy, Clone, Debug, PartialEq, Eq)]
 21pub struct FileVersion(u64);
 22
 23#[derive(Debug)]
 24pub struct AgentThreadSummary {
 25    pub id: ThreadId,
 26    pub title: String,
 27    pub created_at: DateTime<Utc>,
 28}
 29
 30#[derive(Clone, Debug, PartialEq, Eq)]
 31pub struct FileContent {
 32    pub path: PathBuf,
 33    pub version: FileVersion,
 34    pub content: SharedString,
 35}
 36
 37#[derive(Clone, Debug, Eq, PartialEq)]
 38pub struct Message {
 39    pub role: acp::Role,
 40    pub chunks: Vec<MessageChunk>,
 41}
 42
 43impl Message {
 44    fn into_acp(self, cx: &App) -> acp::Message {
 45        acp::Message {
 46            role: self.role,
 47            chunks: self
 48                .chunks
 49                .into_iter()
 50                .map(|chunk| chunk.into_acp(cx))
 51                .collect(),
 52        }
 53    }
 54}
 55
 56#[derive(Clone, Debug, Eq, PartialEq)]
 57pub enum MessageChunk {
 58    Text {
 59        chunk: Entity<Markdown>,
 60    },
 61    File {
 62        content: FileContent,
 63    },
 64    Directory {
 65        path: PathBuf,
 66        contents: Vec<FileContent>,
 67    },
 68    Symbol {
 69        path: PathBuf,
 70        range: Range<u64>,
 71        version: FileVersion,
 72        name: SharedString,
 73        content: SharedString,
 74    },
 75    Fetch {
 76        url: SharedString,
 77        content: SharedString,
 78    },
 79}
 80
 81impl MessageChunk {
 82    pub fn from_acp(
 83        chunk: acp::MessageChunk,
 84        language_registry: Arc<LanguageRegistry>,
 85        cx: &mut App,
 86    ) -> Self {
 87        match chunk {
 88            acp::MessageChunk::Text { chunk } => MessageChunk::Text {
 89                chunk: cx.new(|cx| Markdown::new(chunk.into(), Some(language_registry), None, cx)),
 90            },
 91        }
 92    }
 93
 94    pub fn into_acp(self, cx: &App) -> acp::MessageChunk {
 95        match self {
 96            MessageChunk::Text { chunk } => acp::MessageChunk::Text {
 97                chunk: chunk.read(cx).source().to_string(),
 98            },
 99            MessageChunk::File { .. } => todo!(),
100            MessageChunk::Directory { .. } => todo!(),
101            MessageChunk::Symbol { .. } => todo!(),
102            MessageChunk::Fetch { .. } => todo!(),
103        }
104    }
105
106    pub fn from_str(chunk: &str, language_registry: Arc<LanguageRegistry>, cx: &mut App) -> Self {
107        MessageChunk::Text {
108            chunk: cx.new(|cx| {
109                Markdown::new(chunk.to_owned().into(), Some(language_registry), None, cx)
110            }),
111        }
112    }
113}
114
115#[derive(Clone, Debug, Eq, PartialEq)]
116pub enum AgentThreadEntryContent {
117    Message(Message),
118    ReadFile { path: PathBuf, content: String },
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
122pub struct ThreadEntryId(usize);
123
124impl ThreadEntryId {
125    pub fn post_inc(&mut self) -> Self {
126        let id = *self;
127        self.0 += 1;
128        id
129    }
130}
131
132#[derive(Debug)]
133pub struct ThreadEntry {
134    pub id: ThreadEntryId,
135    pub content: AgentThreadEntryContent,
136}
137
138pub struct AcpThread {
139    id: ThreadId,
140    next_entry_id: ThreadEntryId,
141    entries: Vec<ThreadEntry>,
142    server: Arc<AcpServer>,
143    title: SharedString,
144    project: Entity<Project>,
145}
146
147enum AcpThreadEvent {
148    NewEntry,
149    LastEntryUpdated,
150}
151
152impl EventEmitter<AcpThreadEvent> for AcpThread {}
153
154impl AcpThread {
155    pub fn new(
156        server: Arc<AcpServer>,
157        thread_id: ThreadId,
158        entries: Vec<AgentThreadEntryContent>,
159        project: Entity<Project>,
160        _: &mut Context<Self>,
161    ) -> Self {
162        let mut next_entry_id = ThreadEntryId(0);
163        Self {
164            title: "A new agent2 thread".into(),
165            entries: entries
166                .into_iter()
167                .map(|entry| ThreadEntry {
168                    id: next_entry_id.post_inc(),
169                    content: entry,
170                })
171                .collect(),
172            server,
173            id: thread_id,
174            next_entry_id,
175            project,
176        }
177    }
178
179    pub fn title(&self) -> SharedString {
180        self.title.clone()
181    }
182
183    pub fn entries(&self) -> &[ThreadEntry] {
184        &self.entries
185    }
186
187    pub fn push_entry(&mut self, entry: AgentThreadEntryContent, cx: &mut Context<Self>) {
188        self.entries.push(ThreadEntry {
189            id: self.next_entry_id.post_inc(),
190            content: entry,
191        });
192        cx.emit(AcpThreadEvent::NewEntry)
193    }
194
195    pub fn push_assistant_chunk(&mut self, chunk: acp::MessageChunk, cx: &mut Context<Self>) {
196        if let Some(last_entry) = self.entries.last_mut()
197            && let AgentThreadEntryContent::Message(Message {
198                ref mut chunks,
199                role: Role::Assistant,
200            }) = last_entry.content
201        {
202            cx.emit(AcpThreadEvent::LastEntryUpdated);
203
204            if let (
205                Some(MessageChunk::Text { chunk: old_chunk }),
206                acp::MessageChunk::Text { chunk: new_chunk },
207            ) = (chunks.last_mut(), &chunk)
208            {
209                old_chunk.update(cx, |old_chunk, cx| {
210                    old_chunk.append(&new_chunk, cx);
211                });
212            } else {
213                chunks.push(MessageChunk::from_acp(
214                    chunk,
215                    self.project.read(cx).languages().clone(),
216                    cx,
217                ));
218            }
219
220            return;
221        }
222
223        let chunk = MessageChunk::from_acp(chunk, self.project.read(cx).languages().clone(), cx);
224
225        self.push_entry(
226            AgentThreadEntryContent::Message(Message {
227                role: Role::Assistant,
228                chunks: vec![chunk],
229            }),
230            cx,
231        );
232    }
233
234    pub fn send(&mut self, message: &str, cx: &mut Context<Self>) -> Task<Result<()>> {
235        let agent = self.server.clone();
236        let id = self.id.clone();
237        let chunk = MessageChunk::from_str(message, self.project.read(cx).languages().clone(), cx);
238        let message = Message {
239            role: Role::User,
240            chunks: vec![chunk],
241        };
242        self.push_entry(AgentThreadEntryContent::Message(message.clone()), cx);
243        let acp_message = message.into_acp(cx);
244        cx.spawn(async move |_, cx| {
245            agent.send_message(id, acp_message, cx).await?;
246            Ok(())
247        })
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use gpui::{AsyncApp, TestAppContext};
255    use project::FakeFs;
256    use serde_json::json;
257    use settings::SettingsStore;
258    use std::{env, path::Path, process::Stdio};
259    use util::path;
260
261    fn init_test(cx: &mut TestAppContext) {
262        env_logger::init();
263        cx.update(|cx| {
264            let settings_store = SettingsStore::test(cx);
265            cx.set_global(settings_store);
266            Project::init_settings(cx);
267            language::init(cx);
268        });
269    }
270
271    #[gpui::test]
272    async fn test_gemini(cx: &mut TestAppContext) {
273        init_test(cx);
274
275        cx.executor().allow_parking();
276
277        let fs = FakeFs::new(cx.executor());
278        fs.insert_tree(
279            path!("/private/tmp"),
280            json!({"foo": "Lorem ipsum dolor", "bar": "bar", "baz": "baz"}),
281        )
282        .await;
283        let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
284        let server = gemini_acp_server(project.clone(), cx.to_async()).unwrap();
285        let thread = server.create_thread(&mut cx.to_async()).await.unwrap();
286        thread
287            .update(cx, |thread, cx| {
288                thread.send(
289                    "Read the '/private/tmp/foo' file and output all of its contents.",
290                    cx,
291                )
292            })
293            .await
294            .unwrap();
295
296        thread.read_with(cx, |thread, _| {
297            assert!(matches!(
298                thread.entries[0].content,
299                AgentThreadEntryContent::Message(Message {
300                    role: Role::User,
301                    ..
302                })
303            ));
304            assert!(
305                thread.entries().iter().any(|entry| {
306                    entry.content
307                        == AgentThreadEntryContent::ReadFile {
308                            path: "/private/tmp/foo".into(),
309                            content: "Lorem ipsum dolor".into(),
310                        }
311                }),
312                "Thread does not contain entry. Actual: {:?}",
313                thread.entries()
314            );
315        });
316    }
317
318    pub fn gemini_acp_server(project: Entity<Project>, mut cx: AsyncApp) -> Result<Arc<AcpServer>> {
319        let cli_path =
320            Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../gemini-cli/packages/cli");
321        let mut command = util::command::new_smol_command("node");
322        command
323            .arg(cli_path)
324            .arg("--acp")
325            .args(["--model", "gemini-2.5-flash"])
326            .current_dir("/private/tmp")
327            .stdin(Stdio::piped())
328            .stdout(Stdio::piped())
329            .stderr(Stdio::inherit())
330            .kill_on_drop(true);
331
332        if let Ok(gemini_key) = std::env::var("GEMINI_API_KEY") {
333            command.env("GEMINI_API_KEY", gemini_key);
334        }
335
336        let child = command.spawn().unwrap();
337
338        Ok(AcpServer::stdio(child, project, &mut cx))
339    }
340}