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}