1 //! End-to-end check that `session/list` and `session/delete` work over ACP.
2 //!
3 //! Zed's "Import Threads" picker calls `session/list` for every configured
4 //! agent; siGit Code advertised no capability for it, so the entry showed a
5 //! warning triangle even though the transcripts were on disk all along
6 //! (issue #14). This drives the real binary: list before anything ran, run
7 //! one prompt, list again filtered by cwd, then delete and confirm both the
8 //! transcript and its metadata sidecar are gone.
9
10 use std::collections::VecDeque;
11 use std::io::{BufRead, BufReader, Read, Write};
12 use std::net::TcpListener;
13 use std::process::{Child, ChildStdin, Command, Stdio};
14 use std::sync::Mutex;
15 use std::sync::mpsc::{Receiver, channel};
16 use std::time::{Duration, Instant};
17
18 use serde_json::{Value, json};
19
20 const TIMEOUT: Duration = Duration::from_secs(60);
21
22 // ── Scripted OpenAI-compatible endpoint ─────────────────────────────────────
23
24 fn sse_body(events: &[Value]) -> String {
25 let mut body = String::new();
26 for event in events {
27 body.push_str("data: ");
28 body.push_str(&event.to_string());
29 body.push_str("\n\n");
30 }
31 body.push_str("data: [DONE]\n\n");
32 body
33 }
34
35 fn sse_text(text: &str) -> String {
36 sse_body(&[json!({"choices": [{"delta": {"content": text}}]})])
37 }
38
39 /// Serves one scripted SSE response per request.
40 fn start_fake_endpoint(responses: Vec<String>) -> u16 {
41 let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
42 let port = listener.local_addr().unwrap().port();
43 let queue = Mutex::new(VecDeque::from(responses));
44
45 std::thread::spawn(move || {
46 for stream in listener.incoming() {
47 let Ok(mut stream) = stream else { continue };
48 let mut reader = BufReader::new(match stream.try_clone() {
49 Ok(clone) => clone,
50 Err(_) => continue,
51 });
52 let mut content_length = 0usize;
53 loop {
54 let mut line = String::new();
55 if reader.read_line(&mut line).unwrap_or(0) == 0 {
56 break;
57 }
58 let line = line.trim();
59 if line.is_empty() {
60 break;
61 }
62 if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
63 content_length = length.trim().parse().unwrap_or(0);
64 }
65 }
66 let mut body = vec![0u8; content_length];
67 if reader.read_exact(&mut body).is_err() {
68 continue;
69 }
70 let payload = queue
71 .lock()
72 .unwrap()
73 .pop_front()
74 .unwrap_or_else(|| "data: [DONE]\n\n".to_string());
75 let response = format!(
76 "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
77 content-length: {}\r\nconnection: close\r\n\r\n{}",
78 payload.len(),
79 payload
80 );
81 let _ = stream.write_all(response.as_bytes());
82 }
83 });
84
85 port
86 }
87
88 // ── The agent under test ────────────────────────────────────────────────────
89
90 struct AgentUnderTest {
91 child: Child,
92 stdin: ChildStdin,
93 incoming: Receiver<Value>,
94 next_id: u64,
95 }
96
97 fn spawn_agent(port: u16, config_dir: &std::path::Path) -> AgentUnderTest {
98 let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
99 .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
100 .env("OPENAI_API_KEY", "test-key")
101 .env("SIGIT_MODEL", "scripted-model")
102 .env("SIGIT_CONFIG_DIR", config_dir)
103 .env("SIGIT_MCP", "off")
104 .env("SIGIT_PERMISSIONS", "allow")
105 .env_remove("SIGIT_LOCAL_INFERENCE")
106 .stdin(Stdio::piped())
107 .stdout(Stdio::piped())
108 .stderr(Stdio::null())
109 .spawn()
110 .expect("spawn sigit in ACP mode");
111
112 let stdout = child.stdout.take().unwrap();
113 let (message_tx, incoming) = channel();
114 std::thread::spawn(move || {
115 for line in BufReader::new(stdout).lines() {
116 let Ok(line) = line else { break };
117 if let Ok(message) = serde_json::from_str::<Value>(&line)
118 && message_tx.send(message).is_err()
119 {
120 break;
121 }
122 }
123 });
124
125 let stdin = child.stdin.take().unwrap();
126 AgentUnderTest {
127 child,
128 stdin,
129 incoming,
130 next_id: 0,
131 }
132 }
133
134 impl AgentUnderTest {
135 fn request(&mut self, method: &str, params: Value) -> u64 {
136 self.next_id += 1;
137 let id = self.next_id;
138 let mut line =
139 json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
140 line.push('\n');
141 self.stdin
142 .write_all(line.as_bytes())
143 .expect("write to agent stdin");
144 self.stdin.flush().expect("flush agent stdin");
145 id
146 }
147
148 /// The response to one of our requests. `session/update` notifications
149 /// that arrive along the way are dropped — this test only cares about
150 /// list/delete responses, not turn replay.
151 fn wait_for_response(&mut self, id: u64) -> Value {
152 let deadline = Instant::now() + TIMEOUT;
153 loop {
154 let remaining = deadline.saturating_duration_since(Instant::now());
155 let Ok(message) = self.incoming.recv_timeout(remaining) else {
156 panic!("timed out waiting for the response to request {id}");
157 };
158 if message["id"] == id && message.get("method").is_none() {
159 assert!(
160 message.get("error").is_none(),
161 "request {id} failed: {message}"
162 );
163 return message;
164 }
165 }
166 }
167 }
168
169 impl Drop for AgentUnderTest {
170 fn drop(&mut self) {
171 let _ = self.child.kill();
172 let _ = self.child.wait();
173 }
174 }
175
176 #[test]
177 fn listing_and_deleting_sessions_over_acp() {
178 let scratch = std::env::temp_dir().join(format!("sigit_acp_list_{}", std::process::id()));
179 let config_dir = scratch.join("config");
180 let project = scratch.join("project");
181 let elsewhere = scratch.join("elsewhere");
182 std::fs::create_dir_all(&config_dir).unwrap();
183 std::fs::create_dir_all(&project).unwrap();
184 std::fs::create_dir_all(&elsewhere).unwrap();
185
186 let port = start_fake_endpoint(vec![sse_text("The notes say hello.")]);
187 let mut agent = spawn_agent(port, &config_dir);
188
189 let id = agent.request(
190 "initialize",
191 json!({"protocolVersion": 1, "clientCapabilities": {}}),
192 );
193 let initialize = agent.wait_for_response(id);
194 let session_caps = &initialize["result"]["agentCapabilities"]["sessionCapabilities"];
195 assert!(
196 session_caps.get("list").is_some(),
197 "session/list capability must be advertised: {initialize}"
198 );
199 assert!(
200 session_caps.get("delete").is_some(),
201 "session/delete capability must be advertised: {initialize}"
202 );
203
204 // Before anything ran, the list is empty.
205 let id = agent.request("session/list", json!({}));
206 let listed = agent.wait_for_response(id);
207 assert_eq!(
208 listed["result"]["sessions"].as_array().unwrap().len(),
209 0,
210 "no sessions yet: {listed}"
211 );
212
213 let id = agent.request("session/new", json!({"cwd": project, "mcpServers": []}));
214 let new_session = agent.wait_for_response(id);
215 let session_id = new_session["result"]["sessionId"]
216 .as_str()
217 .expect("session id")
218 .to_string();
219
220 let id = agent.request(
221 "session/prompt",
222 json!({
223 "sessionId": session_id,
224 "prompt": [{"type": "text", "text": "what do the notes say?"}],
225 }),
226 );
227 agent.wait_for_response(id);
228
229 // Listing filtered by the project's cwd finds exactly this session.
230 let id = agent.request("session/list", json!({"cwd": project}));
231 let listed = agent.wait_for_response(id);
232 let sessions = listed["result"]["sessions"].as_array().unwrap();
233 assert_eq!(sessions.len(), 1, "expected one session: {listed}");
234 let entry = &sessions[0];
235 assert_eq!(entry["sessionId"], session_id);
236 assert_eq!(entry["cwd"], project.to_string_lossy().to_string());
237 assert_eq!(entry["title"], "what do the notes say?");
238 let updated_at = entry["updatedAt"].as_str().expect("updatedAt present");
239 assert!(
240 chrono::DateTime::parse_from_rfc3339(updated_at).is_ok(),
241 "updatedAt must be RFC 3339: {updated_at}"
242 );
243
244 // Filtered by a different cwd, the list is empty.
245 let id = agent.request("session/list", json!({"cwd": elsewhere}));
246 let listed = agent.wait_for_response(id);
247 assert_eq!(
248 listed["result"]["sessions"].as_array().unwrap().len(),
249 0,
250 "wrong cwd must not match: {listed}"
251 );
252
253 // Delete it, then confirm it's gone from the list and off disk.
254 let id = agent.request("session/delete", json!({"sessionId": session_id}));
255 let deleted = agent.wait_for_response(id);
256 assert!(deleted.get("error").is_none(), "delete failed: {deleted}");
257
258 let id = agent.request("session/list", json!({"cwd": project}));
259 let listed = agent.wait_for_response(id);
260 assert_eq!(
261 listed["result"]["sessions"].as_array().unwrap().len(),
262 0,
263 "deleted session must not be listed: {listed}"
264 );
265
266 let sessions_dir = config_dir.join("sessions");
267 assert!(
268 std::fs::read_dir(&sessions_dir)
269 .into_iter()
270 .flatten()
271 .flatten()
272 .all(|entry| {
273 let name = entry.file_name();
274 let name = name.to_string_lossy();
275 !name.contains(&session_id)
276 }),
277 "both the transcript and its meta sidecar must be removed"
278 );
279
280 drop(agent);
281 let _ = std::fs::remove_dir_all(&scratch);
282 }