acp.rs

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