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
111impl AcpThread {
112 pub fn new(
113 server: Arc<AcpServer>,
114 thread_id: ThreadId,
115 entries: Vec<AgentThreadEntryContent>,
116 project: Entity<Project>,
117 _: &mut Context<Self>,
118 ) -> Self {
119 let mut next_entry_id = ThreadEntryId(0);
120 Self {
121 title: "A new agent2 thread".into(),
122 entries: entries
123 .into_iter()
124 .map(|entry| ThreadEntry {
125 id: next_entry_id.post_inc(),
126 content: entry,
127 })
128 .collect(),
129 server,
130 id: thread_id,
131 next_entry_id,
132 project,
133 }
134 }
135
136 pub fn title(&self) -> SharedString {
137 self.title.clone()
138 }
139
140 pub fn entries(&self) -> &[ThreadEntry] {
141 &self.entries
142 }
143
144 pub fn push_entry(&mut self, entry: AgentThreadEntryContent, cx: &mut Context<Self>) {
145 self.entries.push(ThreadEntry {
146 id: self.next_entry_id.post_inc(),
147 content: entry,
148 });
149 cx.notify();
150 }
151
152 pub fn push_assistant_chunk(&mut self, chunk: MessageChunk, cx: &mut Context<Self>) {
153 if let Some(last_entry) = self.entries.last_mut() {
154 if let AgentThreadEntryContent::Message(Message {
155 ref mut chunks,
156 role: Role::Assistant,
157 }) = last_entry.content
158 {
159 if let (
160 Some(MessageChunk::Text { chunk: old_chunk }),
161 MessageChunk::Text { chunk: new_chunk },
162 ) = (chunks.last_mut(), &chunk)
163 {
164 old_chunk.push_str(&new_chunk);
165 return cx.notify();
166 }
167
168 chunks.push(chunk);
169 return cx.notify();
170 }
171 }
172
173 self.entries.push(ThreadEntry {
174 id: self.next_entry_id.post_inc(),
175 content: AgentThreadEntryContent::Message(Message {
176 role: Role::Assistant,
177 chunks: vec![chunk],
178 }),
179 });
180 cx.notify();
181 }
182
183 pub fn send(&mut self, message: Message, cx: &mut Context<Self>) -> Task<Result<()>> {
184 let agent = self.server.clone();
185 let id = self.id.clone();
186 self.push_entry(AgentThreadEntryContent::Message(message.clone()), cx);
187 cx.spawn(async move |_, cx| {
188 agent.send_message(id, message, cx).await?;
189 Ok(())
190 })
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use gpui::{AsyncApp, TestAppContext};
198 use project::FakeFs;
199 use serde_json::json;
200 use settings::SettingsStore;
201 use std::{env, path::Path, process::Stdio};
202 use util::path;
203
204 fn init_test(cx: &mut TestAppContext) {
205 env_logger::init();
206 cx.update(|cx| {
207 let settings_store = SettingsStore::test(cx);
208 cx.set_global(settings_store);
209 Project::init_settings(cx);
210 language::init(cx);
211 });
212 }
213
214 #[gpui::test]
215 async fn test_gemini(cx: &mut TestAppContext) {
216 init_test(cx);
217
218 cx.executor().allow_parking();
219
220 let fs = FakeFs::new(cx.executor());
221 fs.insert_tree(
222 path!("/private/tmp"),
223 json!({"foo": "Lorem ipsum dolor", "bar": "bar", "baz": "baz"}),
224 )
225 .await;
226 let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await;
227 let server = gemini_acp_server(project.clone(), cx.to_async()).unwrap();
228 let thread = server.create_thread(&mut cx.to_async()).await.unwrap();
229 thread
230 .update(cx, |thread, cx| {
231 thread.send(
232 Message {
233 role: Role::User,
234 chunks: vec![
235 "Read the '/private/tmp/foo' file and output all of its contents."
236 .into(),
237 ],
238 },
239 cx,
240 )
241 })
242 .await
243 .unwrap();
244
245 thread.read_with(cx, |thread, _| {
246 assert!(matches!(
247 thread.entries[0].content,
248 AgentThreadEntryContent::Message(Message {
249 role: Role::User,
250 ..
251 })
252 ));
253 assert!(
254 thread.entries().iter().any(|entry| {
255 entry.content
256 == AgentThreadEntryContent::ReadFile {
257 path: "/private/tmp/foo".into(),
258 content: "Lorem ipsum dolor".into(),
259 }
260 }),
261 "Thread does not contain entry. Actual: {:?}",
262 thread.entries()
263 );
264 });
265 }
266
267 pub fn gemini_acp_server(project: Entity<Project>, mut cx: AsyncApp) -> Result<Arc<AcpServer>> {
268 let cli_path =
269 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../gemini-cli/packages/cli");
270 let mut command = util::command::new_smol_command("node");
271 command
272 .arg(cli_path)
273 .arg("--acp")
274 .args(["--model", "gemini-2.5-flash"])
275 .current_dir("/private/tmp")
276 .stdin(Stdio::piped())
277 .stdout(Stdio::piped())
278 .stderr(Stdio::inherit())
279 .kill_on_drop(true);
280
281 if let Ok(gemini_key) = std::env::var("GEMINI_API_KEY") {
282 command.env("GEMINI_API_KEY", gemini_key);
283 }
284
285 let child = command.spawn().unwrap();
286
287 Ok(AcpServer::stdio(child, project, &mut cx))
288 }
289}