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
+}