1 //! The file tools go through the ACP client when it offers to serve files.
2 //!
3 //! An editor that advertises `fs.readTextFile` / `fs.writeTextFile` holds the
4 //! truth about an open file: the buffer, which may have changes the disk does
5 //! not. These tests spawn `sigit` in ACP mode against a scripted endpoint and
6 //! play that editor: they answer `fs/read_text_file` with text that differs
7 //! from the file on disk and record `fs/write_text_file` without touching the
8 //! disk, so each assertion can tell which of the two the tool used.
9
10 use std::collections::VecDeque;
11 use std::io::{BufRead, BufReader, Read, Write};
12 use std::net::TcpListener;
13 use std::path::{Path, PathBuf};
14 use std::process::{Child, ChildStdin, Command, Stdio};
15 use std::sync::mpsc::{Receiver, channel};
16 use std::sync::{Arc, Mutex};
17 use std::time::{Duration, Instant};
18
19 use serde_json::{Value, json};
20
21 const TIMEOUT: Duration = Duration::from_secs(60);
22
23 // ── Scripted OpenAI-compatible endpoint ─────────────────────────────────────
24
25 fn sse_body(events: &[Value]) -> String {
26 let mut body = String::new();
27 for event in events {
28 body.push_str("data: ");
29 body.push_str(&event.to_string());
30 body.push_str("\n\n");
31 }
32 body.push_str("data: [DONE]\n\n");
33 body
34 }
35
36 fn sse_tool_call(id: &str, name: &str, arguments: Value) -> String {
37 sse_body(&[json!({
38 "choices": [{"delta": {"tool_calls": [{
39 "index": 0,
40 "id": id,
41 "function": {"name": name, "arguments": arguments.to_string()},
42 }]}}]
43 })])
44 }
45
46 fn sse_text(text: &str) -> String {
47 sse_body(&[json!({"choices": [{"delta": {"content": text}}]})])
48 }
49
50 /// Serves one scripted SSE response per request and records each request body.
51 struct FakeEndpoint {
52 port: u16,
53 requests: Arc<Mutex<Vec<Value>>>,
54 }
55
56 impl FakeEndpoint {
57 /// The result the model was given for the tool call `id`.
58 fn tool_result(&self, id: &str) -> String {
59 let requests = self.requests.lock().unwrap();
60 requests
61 .iter()
62 .flat_map(|request| request["messages"].as_array().cloned().unwrap_or_default())
63 .find(|message| message["role"] == "tool" && message["tool_call_id"] == id)
64 .and_then(|message| message["content"].as_str().map(str::to_string))
65 .unwrap_or_else(|| panic!("no tool result for {id} reached the endpoint"))
66 }
67 }
68
69 fn start_fake_endpoint(responses: Vec<String>) -> FakeEndpoint {
70 let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
71 let port = listener.local_addr().unwrap().port();
72 let requests: Arc<Mutex<Vec<Value>>> = Arc::default();
73 let recorded = Arc::clone(&requests);
74 let queue = Mutex::new(VecDeque::from(responses));
75
76 std::thread::spawn(move || {
77 for stream in listener.incoming() {
78 let Ok(mut stream) = stream else { continue };
79 let mut reader = BufReader::new(match stream.try_clone() {
80 Ok(clone) => clone,
81 Err(_) => continue,
82 });
83 let mut content_length = 0usize;
84 loop {
85 let mut line = String::new();
86 if reader.read_line(&mut line).unwrap_or(0) == 0 {
87 break;
88 }
89 let line = line.trim();
90 if line.is_empty() {
91 break;
92 }
93 if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
94 content_length = length.trim().parse().unwrap_or(0);
95 }
96 }
97 let mut body = vec![0u8; content_length];
98 if reader.read_exact(&mut body).is_err() {
99 continue;
100 }
101 if let Ok(request) = serde_json::from_slice::<Value>(&body) {
102 recorded.lock().unwrap().push(request);
103 }
104 let payload = queue
105 .lock()
106 .unwrap()
107 .pop_front()
108 .unwrap_or_else(|| sse_text("out of scripted responses"));
109 let response = format!(
110 "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
111 content-length: {}\r\nconnection: close\r\n\r\n{}",
112 payload.len(),
113 payload
114 );
115 let _ = stream.write_all(response.as_bytes());
116 let _ = stream.flush();
117 }
118 });
119
120 FakeEndpoint { port, requests }
121 }
122
123 // ── ACP client over the binary's stdio ──────────────────────────────────────
124
125 /// What the editor's buffers hold, and what it does with a file request.
126 #[derive(Default)]
127 struct Editor {
128 /// Buffer text by file name. A read of anything else is an error, the way
129 /// an editor answers for a file it cannot open.
130 buffers: Vec<(&'static str, &'static str)>,
131 /// Every `fs/*` request the agent sent, in order.
132 requests: Vec<Value>,
133 }
134
135 impl Editor {
136 fn requests_for(&self, method: &str) -> Vec<&Value> {
137 self.requests
138 .iter()
139 .filter(|request| request["method"] == method)
140 .collect()
141 }
142 }
143
144 struct AgentUnderTest {
145 child: Child,
146 stdin: ChildStdin,
147 incoming: Receiver<Value>,
148 next_id: u64,
149 }
150
151 fn spawn_agent(port: u16, config_dir: &Path, client_fs: Option<&str>) -> AgentUnderTest {
152 let mut command = Command::new(env!("CARGO_BIN_EXE_sigit"));
153 command
154 .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
155 .env("OPENAI_API_KEY", "test-key")
156 .env("SIGIT_MODEL", "scripted-model")
157 .env("SIGIT_CONFIG_DIR", config_dir)
158 .env("SIGIT_MCP", "off")
159 .env("SIGIT_PERMISSIONS", "allow")
160 .env_remove("SIGIT_LOCAL_INFERENCE")
161 .stdin(Stdio::piped())
162 .stdout(Stdio::piped())
163 .stderr(Stdio::null());
164 match client_fs {
165 None => command.env_remove("SIGIT_CLIENT_FS"),
166 Some(value) => command.env("SIGIT_CLIENT_FS", value),
167 };
168 let mut child = command.spawn().expect("spawn sigit in ACP mode");
169
170 let stdout = child.stdout.take().unwrap();
171 let (message_tx, incoming) = channel();
172 std::thread::spawn(move || {
173 for line in BufReader::new(stdout).lines() {
174 let Ok(line) = line else { break };
175 if let Ok(message) = serde_json::from_str::<Value>(&line)
176 && message_tx.send(message).is_err()
177 {
178 break;
179 }
180 }
181 });
182
183 let stdin = child.stdin.take().unwrap();
184 AgentUnderTest {
185 child,
186 stdin,
187 incoming,
188 next_id: 0,
189 }
190 }
191
192 impl AgentUnderTest {
193 fn send(&mut self, message: Value) {
194 let mut line = message.to_string();
195 line.push('\n');
196 self.stdin.write_all(line.as_bytes()).expect("write stdin");
197 self.stdin.flush().expect("flush stdin");
198 }
199
200 fn request(&mut self, method: &str, params: Value) -> u64 {
201 self.next_id += 1;
202 let id = self.next_id;
203 self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
204 id
205 }
206
207 /// Wait for the response to request `id`, serving the agent's `fs/*`
208 /// requests from `editor` on the way.
209 fn wait_for_response(&mut self, id: u64, editor: &mut Editor) -> Value {
210 let deadline = Instant::now() + TIMEOUT;
211 loop {
212 let remaining = deadline.saturating_duration_since(Instant::now());
213 let Ok(message) = self.incoming.recv_timeout(remaining) else {
214 panic!("timed out waiting for the response to request {id}");
215 };
216 if message["id"] == id && message.get("method").is_none() {
217 assert!(
218 message.get("error").is_none(),
219 "request {id} failed: {message}"
220 );
221 return message;
222 }
223 let Some(method) = message["method"].as_str() else {
224 continue;
225 };
226 if !method.starts_with("fs/") {
227 continue;
228 }
229 editor.requests.push(message.clone());
230 let request_id = message["id"].clone();
231 let path = message["params"]["path"].as_str().unwrap_or_default();
232 let name = Path::new(path)
233 .file_name()
234 .and_then(|name| name.to_str())
235 .unwrap_or_default();
236 let buffer = editor.buffers.iter().find(|(file, _)| *file == name);
237 let reply = match (method, buffer) {
238 ("fs/read_text_file", Some((_, text))) => {
239 json!({"jsonrpc": "2.0", "id": request_id, "result": {"content": text}})
240 }
241 ("fs/read_text_file", None) => json!({
242 "jsonrpc": "2.0",
243 "id": request_id,
244 "error": {"code": -32002, "message": "no such buffer"},
245 }),
246 // A write is recorded and deliberately not applied to the disk.
247 _ => json!({"jsonrpc": "2.0", "id": request_id, "result": {}}),
248 };
249 self.send(reply);
250 }
251 }
252
253 /// `initialize` with the given `fs` capabilities, then a session in `cwd`.
254 fn open_session(&mut self, fs: Value, cwd: &Path) -> String {
255 let mut editor = Editor::default();
256 let id = self.request(
257 "initialize",
258 json!({"protocolVersion": 1, "clientCapabilities": {"fs": fs}}),
259 );
260 self.wait_for_response(id, &mut editor);
261
262 let id = self.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
263 self.wait_for_response(id, &mut editor)["result"]["sessionId"]
264 .as_str()
265 .expect("session id")
266 .to_string()
267 }
268
269 fn prompt(&mut self, session_id: &str, editor: &mut Editor) {
270 let id = self.request(
271 "session/prompt",
272 json!({"sessionId": session_id, "prompt": [{"type": "text", "text": "go"}]}),
273 );
274 self.wait_for_response(id, editor);
275 }
276 }
277
278 impl Drop for AgentUnderTest {
279 fn drop(&mut self) {
280 let _ = self.child.kill();
281 let _ = self.child.wait();
282 }
283 }
284
285 /// A scratch project: `config/` for the agent and `work/` as the session root.
286 struct Scratch {
287 root: PathBuf,
288 config: PathBuf,
289 work: PathBuf,
290 }
291
292 impl Scratch {
293 fn new(name: &str) -> Self {
294 let root = std::env::temp_dir().join(format!("sigit_acp_fs_{name}_{}", std::process::id()));
295 let _ = std::fs::remove_dir_all(&root);
296 let config = root.join("config");
297 let work = root.join("work");
298 std::fs::create_dir_all(&config).unwrap();
299 std::fs::create_dir_all(&work).unwrap();
300 Self { root, config, work }
301 }
302 }
303
304 impl Drop for Scratch {
305 fn drop(&mut self) {
306 std::fs::remove_dir_all(&self.root).ok();
307 }
308 }
309
310 const BOTH: fn() -> Value = || json!({"readTextFile": true, "writeTextFile": true});
311
312 // ── The tests ───────────────────────────────────────────────────────────────
313
314 #[test]
315 fn read_file_sees_the_unsaved_buffer() {
316 let scratch = Scratch::new("read");
317 let file = scratch.work.join("notes.txt");
318 std::fs::write(&file, "saved text").unwrap();
319
320 let endpoint = start_fake_endpoint(vec![
321 // A relative path: the request to the client still has to be absolute.
322 sse_tool_call("call_1", "read_file", json!({"path": "notes.txt"})),
323 sse_text("done"),
324 ]);
325 let mut agent = spawn_agent(endpoint.port, &scratch.config, None);
326 let session_id = agent.open_session(BOTH(), &scratch.work);
327
328 let mut editor = Editor {
329 buffers: vec![("notes.txt", "unsaved text")],
330 ..Editor::default()
331 };
332 agent.prompt(&session_id, &mut editor);
333
334 assert_eq!(endpoint.tool_result("call_1"), "unsaved text");
335
336 let reads = editor.requests_for("fs/read_text_file");
337 assert_eq!(reads.len(), 1, "requests: {:?}", editor.requests);
338 assert_eq!(reads[0]["params"]["sessionId"], session_id.as_str());
339 let sent = Path::new(reads[0]["params"]["path"].as_str().unwrap());
340 assert!(sent.is_absolute(), "path sent to the client: {sent:?}");
341 assert!(sent.ends_with("notes.txt"));
342 }
343
344 #[test]
345 fn edit_file_edits_the_buffer_and_writes_through_the_client() {
346 let scratch = Scratch::new("edit");
347 let file = scratch.work.join("main.rs");
348 std::fs::write(&file, "fn saved() {}\n").unwrap();
349
350 let endpoint = start_fake_endpoint(vec![
351 // `unsaved` exists only in the buffer, so the edit can only match
352 // if the tool read the client's copy.
353 sse_tool_call(
354 "call_1",
355 "edit_file",
356 json!({"path": file, "old_text": "unsaved", "new_text": "edited"}),
357 ),
358 sse_tool_call(
359 "call_2",
360 "multi_edit",
361 json!({"path": file, "edits": [
362 {"old_text": "fn ", "new_text": "pub fn "},
363 {"old_text": "{}", "new_text": "{ }"},
364 ]}),
365 ),
366 sse_text("done"),
367 ]);
368 let mut agent = spawn_agent(endpoint.port, &scratch.config, None);
369 let session_id = agent.open_session(BOTH(), &scratch.work);
370
371 let mut editor = Editor {
372 buffers: vec![("main.rs", "fn unsaved() {}\n")],
373 ..Editor::default()
374 };
375 agent.prompt(&session_id, &mut editor);
376
377 let first = endpoint.tool_result("call_1");
378 assert!(first.starts_with("Edited file:"), "edit_file said: {first}");
379 let second = endpoint.tool_result("call_2");
380 assert!(
381 second.starts_with("Applied 2 edits"),
382 "multi_edit said: {second}"
383 );
384
385 let writes = editor.requests_for("fs/write_text_file");
386 assert_eq!(writes.len(), 2, "requests: {:?}", editor.requests);
387 assert_eq!(writes[0]["params"]["sessionId"], session_id.as_str());
388 assert_eq!(writes[0]["params"]["content"], "fn edited() {}\n");
389 assert_eq!(writes[1]["params"]["content"], "pub fn unsaved() { }\n");
390
391 // The editor owns the write; sigit must not also have written the disk.
392 assert_eq!(std::fs::read_to_string(&file).unwrap(), "fn saved() {}\n");
393 }
394
395 #[test]
396 fn create_file_writes_through_the_client() {
397 let scratch = Scratch::new("create");
398 let file = scratch.work.join("src").join("new.txt");
399
400 let endpoint = start_fake_endpoint(vec![
401 sse_tool_call(
402 "call_1",
403 "create_file",
404 json!({"path": file, "content": "hello"}),
405 ),
406 sse_text("done"),
407 ]);
408 let mut agent = spawn_agent(endpoint.port, &scratch.config, None);
409 let session_id = agent.open_session(BOTH(), &scratch.work);
410
411 let mut editor = Editor::default();
412 agent.prompt(&session_id, &mut editor);
413
414 let result = endpoint.tool_result("call_1");
415 assert!(
416 result.starts_with("Created file:"),
417 "create_file said: {result}"
418 );
419 let writes = editor.requests_for("fs/write_text_file");
420 assert_eq!(writes.len(), 1, "requests: {:?}", editor.requests);
421 assert_eq!(writes[0]["params"]["content"], "hello");
422 assert!(!file.exists(), "sigit wrote the file itself");
423 }
424
425 #[test]
426 fn a_client_without_fs_capabilities_gets_no_fs_requests() {
427 let scratch = Scratch::new("nocaps");
428 let file = scratch.work.join("notes.txt");
429 std::fs::write(&file, "saved text").unwrap();
430
431 let endpoint = start_fake_endpoint(vec![
432 sse_tool_call("call_1", "read_file", json!({"path": file})),
433 sse_tool_call(
434 "call_2",
435 "edit_file",
436 json!({"path": file, "old_text": "saved", "new_text": "edited"}),
437 ),
438 sse_text("done"),
439 ]);
440 let mut agent = spawn_agent(endpoint.port, &scratch.config, None);
441 let session_id = agent.open_session(json!({}), &scratch.work);
442
443 let mut editor = Editor {
444 buffers: vec![("notes.txt", "unsaved text")],
445 ..Editor::default()
446 };
447 agent.prompt(&session_id, &mut editor);
448
449 assert_eq!(endpoint.tool_result("call_1"), "saved text");
450 assert!(editor.requests.is_empty(), "{:?}", editor.requests);
451 assert_eq!(std::fs::read_to_string(&file).unwrap(), "edited text");
452 }
453
454 #[test]
455 fn only_the_advertised_half_goes_through_the_client() {
456 let scratch = Scratch::new("readonly");
457 let file = scratch.work.join("notes.txt");
458 std::fs::write(&file, "saved text").unwrap();
459
460 let endpoint = start_fake_endpoint(vec![
461 sse_tool_call(
462 "call_1",
463 "edit_file",
464 json!({"path": file, "old_text": "unsaved", "new_text": "edited"}),
465 ),
466 sse_text("done"),
467 ]);
468 let mut agent = spawn_agent(endpoint.port, &scratch.config, None);
469 let session_id = agent.open_session(json!({"readTextFile": true}), &scratch.work);
470
471 let mut editor = Editor {
472 buffers: vec![("notes.txt", "unsaved text")],
473 ..Editor::default()
474 };
475 agent.prompt(&session_id, &mut editor);
476
477 assert_eq!(editor.requests_for("fs/read_text_file").len(), 1);
478 assert!(editor.requests_for("fs/write_text_file").is_empty());
479 assert_eq!(std::fs::read_to_string(&file).unwrap(), "edited text");
480 }
481
482 #[test]
483 fn a_path_outside_the_session_roots_stays_on_disk() {
484 let scratch = Scratch::new("outside");
485 let outside = scratch.root.join("elsewhere");
486 std::fs::create_dir_all(&outside).unwrap();
487 let file = outside.join("notes.txt");
488 std::fs::write(&file, "saved text").unwrap();
489
490 let endpoint = start_fake_endpoint(vec![
491 sse_tool_call("call_1", "read_file", json!({"path": file})),
492 sse_text("done"),
493 ]);
494 let mut agent = spawn_agent(endpoint.port, &scratch.config, None);
495 let session_id = agent.open_session(BOTH(), &scratch.work);
496
497 let mut editor = Editor {
498 buffers: vec![("notes.txt", "unsaved text")],
499 ..Editor::default()
500 };
501 agent.prompt(&session_id, &mut editor);
502
503 assert_eq!(endpoint.tool_result("call_1"), "saved text");
504 assert!(editor.requests.is_empty(), "{:?}", editor.requests);
505 }
506
507 #[test]
508 fn a_read_the_client_refuses_falls_back_to_disk() {
509 let scratch = Scratch::new("fallback");
510 let file = scratch.work.join("notes.txt");
511 std::fs::write(&file, "saved text").unwrap();
512
513 let endpoint = start_fake_endpoint(vec![
514 sse_tool_call("call_1", "read_file", json!({"path": file})),
515 sse_text("done"),
516 ]);
517 let mut agent = spawn_agent(endpoint.port, &scratch.config, None);
518 let session_id = agent.open_session(BOTH(), &scratch.work);
519
520 // No buffers: the editor answers the read with an error.
521 let mut editor = Editor::default();
522 agent.prompt(&session_id, &mut editor);
523
524 assert_eq!(editor.requests_for("fs/read_text_file").len(), 1);
525 assert_eq!(endpoint.tool_result("call_1"), "saved text");
526 }
527
528 #[test]
529 fn sigit_client_fs_off_keeps_the_tools_on_disk() {
530 let scratch = Scratch::new("off");
531 let file = scratch.work.join("notes.txt");
532 std::fs::write(&file, "saved text").unwrap();
533
534 let endpoint = start_fake_endpoint(vec![
535 sse_tool_call("call_1", "read_file", json!({"path": file})),
536 sse_text("done"),
537 ]);
538 let mut agent = spawn_agent(endpoint.port, &scratch.config, Some("off"));
539 let session_id = agent.open_session(BOTH(), &scratch.work);
540
541 let mut editor = Editor {
542 buffers: vec![("notes.txt", "unsaved text")],
543 ..Editor::default()
544 };
545 agent.prompt(&session_id, &mut editor);
546
547 assert_eq!(endpoint.tool_result("call_1"), "saved text");
548 assert!(editor.requests.is_empty(), "{:?}", editor.requests);
549 }