1
+//! `run_command` runs in the ACP client's terminal when it offers one.
2
+//!
3
+//! An editor that advertises `terminal` can run a command itself and show it
4
+//! live in the tool call. These tests spawn `sigit` in ACP mode against a
5
+//! scripted endpoint and play that editor: it answers `terminal/*` with
6
+//! output the command could not have printed locally, so each assertion can
7
+//! tell where the command ran.
8
+
9
+use std::collections::VecDeque;
10
+use std::io::{BufRead, BufReader, Read, Write};
11
+use std::net::TcpListener;
12
+use std::path::{Path, PathBuf};
13
+use std::process::{Child, ChildStdin, Command, Stdio};
14
+use std::sync::mpsc::{Receiver, channel};
15
+use std::sync::{Arc, Mutex};
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_tool_call(id: &str, name: &str, arguments: Value) -> String {
36
+ sse_body(&[json!({
37
+ "choices": [{"delta": {"tool_calls": [{
38
+ "index": 0,
39
+ "id": id,
40
+ "function": {"name": name, "arguments": arguments.to_string()},
41
+ }]}}]
42
+ })])
43
+}
44
+
45
+fn sse_text(text: &str) -> String {
46
+ sse_body(&[json!({"choices": [{"delta": {"content": text}}]})])
47
+}
48
+
49
+/// Serves one scripted SSE response per request and records each request body.
50
+struct FakeEndpoint {
51
+ port: u16,
52
+ requests: Arc<Mutex<Vec<Value>>>,
53
+}
54
+
55
+impl FakeEndpoint {
56
+ /// The result the model was given for the tool call `id`.
57
+ fn tool_result(&self, id: &str) -> String {
58
+ let requests = self.requests.lock().unwrap();
59
+ requests
60
+ .iter()
61
+ .flat_map(|request| request["messages"].as_array().cloned().unwrap_or_default())
62
+ .find(|message| message["role"] == "tool" && message["tool_call_id"] == id)
63
+ .and_then(|message| message["content"].as_str().map(str::to_string))
64
+ .unwrap_or_else(|| panic!("no tool result for {id} reached the endpoint"))
65
+ }
66
+}
67
+
68
+fn start_fake_endpoint(responses: Vec<String>) -> FakeEndpoint {
69
+ let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
70
+ let port = listener.local_addr().unwrap().port();
71
+ let requests: Arc<Mutex<Vec<Value>>> = Arc::default();
72
+ let recorded = Arc::clone(&requests);
73
+ let queue = Mutex::new(VecDeque::from(responses));
74
+
75
+ std::thread::spawn(move || {
76
+ for stream in listener.incoming() {
77
+ let Ok(mut stream) = stream else { continue };
78
+ let mut reader = BufReader::new(match stream.try_clone() {
79
+ Ok(clone) => clone,
80
+ Err(_) => continue,
81
+ });
82
+ let mut content_length = 0usize;
83
+ loop {
84
+ let mut line = String::new();
85
+ if reader.read_line(&mut line).unwrap_or(0) == 0 {
86
+ break;
87
+ }
88
+ let line = line.trim();
89
+ if line.is_empty() {
90
+ break;
91
+ }
92
+ if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
93
+ content_length = length.trim().parse().unwrap_or(0);
94
+ }
95
+ }
96
+ let mut body = vec![0u8; content_length];
97
+ if reader.read_exact(&mut body).is_err() {
98
+ continue;
99
+ }
100
+ if let Ok(request) = serde_json::from_slice::<Value>(&body) {
101
+ recorded.lock().unwrap().push(request);
102
+ }
103
+ let payload = queue
104
+ .lock()
105
+ .unwrap()
106
+ .pop_front()
107
+ .unwrap_or_else(|| sse_text("out of scripted responses"));
108
+ let response = format!(
109
+ "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
110
+ content-length: {}\r\nconnection: close\r\n\r\n{}",
111
+ payload.len(),
112
+ payload
113
+ );
114
+ let _ = stream.write_all(response.as_bytes());
115
+ let _ = stream.flush();
116
+ }
117
+ });
118
+
119
+ FakeEndpoint { port, requests }
120
+}
121
+
122
+// ── ACP client over the binary's stdio ──────────────────────────────────────
123
+
124
+/// What the editor's terminal does, and what it was asked.
125
+#[derive(Default)]
126
+struct Editor {
127
+ /// Answer `terminal/create` with an error, the way a client with
128
+ /// terminals turned off would.
129
+ refuse_create: bool,
130
+ /// Every `terminal/*` request the agent sent, in order.
131
+ requests: Vec<Value>,
132
+ /// Every `session/update` the agent sent, in order.
133
+ updates: Vec<Value>,
134
+}
135
+
136
+impl Editor {
137
+ fn methods(&self) -> Vec<&str> {
138
+ self.requests
139
+ .iter()
140
+ .filter_map(|request| request["method"].as_str())
141
+ .collect()
142
+ }
143
+
144
+ fn request(&self, method: &str) -> &Value {
145
+ self.requests
146
+ .iter()
147
+ .find(|request| request["method"] == method)
148
+ .unwrap_or_else(|| panic!("no {method} request in {:?}", self.requests))
149
+ }
150
+
151
+ /// The `tool_call_update`s for one tool call, in order.
152
+ fn call_updates(&self, tool_call_id: &str) -> Vec<&Value> {
153
+ self.updates
154
+ .iter()
155
+ .map(|update| &update["params"]["update"])
156
+ .filter(|update| {
157
+ update["sessionUpdate"] == "tool_call_update"
158
+ && update["toolCallId"] == tool_call_id
159
+ })
160
+ .collect()
161
+ }
162
+}
163
+
164
+/// What the editor's terminal printed, which no local run could.
165
+const EDITOR_OUTPUT: &str = "printed in the editor\n";
166
+
167
+struct AgentUnderTest {
168
+ child: Child,
169
+ stdin: ChildStdin,
170
+ incoming: Receiver<Value>,
171
+ next_id: u64,
172
+}
173
+
174
+fn spawn_agent(port: u16, config_dir: &Path, client_terminal: Option<&str>) -> AgentUnderTest {
175
+ let mut command = Command::new(env!("CARGO_BIN_EXE_sigit"));
176
+ command
177
+ .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
178
+ .env("OPENAI_API_KEY", "test-key")
179
+ .env("SIGIT_MODEL", "scripted-model")
180
+ .env("SIGIT_CONFIG_DIR", config_dir)
181
+ .env("SIGIT_MCP", "off")
182
+ .env("SIGIT_PERMISSIONS", "allow")
183
+ .env_remove("SIGIT_LOCAL_INFERENCE")
184
+ .stdin(Stdio::piped())
185
+ .stdout(Stdio::piped())
186
+ .stderr(Stdio::null());
187
+ match client_terminal {
188
+ None => command.env_remove("SIGIT_CLIENT_TERMINAL"),
189
+ Some(value) => command.env("SIGIT_CLIENT_TERMINAL", value),
190
+ };
191
+ let mut child = command.spawn().expect("spawn sigit in ACP mode");
192
+
193
+ let stdout = child.stdout.take().unwrap();
194
+ let (message_tx, incoming) = channel();
195
+ std::thread::spawn(move || {
196
+ for line in BufReader::new(stdout).lines() {
197
+ let Ok(line) = line else { break };
198
+ if let Ok(message) = serde_json::from_str::<Value>(&line)
199
+ && message_tx.send(message).is_err()
200
+ {
201
+ break;
202
+ }
203
+ }
204
+ });
205
+
206
+ let stdin = child.stdin.take().unwrap();
207
+ AgentUnderTest {
208
+ child,
209
+ stdin,
210
+ incoming,
211
+ next_id: 0,
212
+ }
213
+}
214
+
215
+impl AgentUnderTest {
216
+ fn send(&mut self, message: Value) {
217
+ let mut line = message.to_string();
218
+ line.push('\n');
219
+ self.stdin.write_all(line.as_bytes()).expect("write stdin");
220
+ self.stdin.flush().expect("flush stdin");
221
+ }
222
+
223
+ fn request(&mut self, method: &str, params: Value) -> u64 {
224
+ self.next_id += 1;
225
+ let id = self.next_id;
226
+ self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
227
+ id
228
+ }
229
+
230
+ /// Wait for the response to request `id`, serving the agent's
231
+ /// `terminal/*` requests from `editor` on the way.
232
+ fn wait_for_response(&mut self, id: u64, editor: &mut Editor) -> Value {
233
+ let deadline = Instant::now() + TIMEOUT;
234
+ loop {
235
+ let remaining = deadline.saturating_duration_since(Instant::now());
236
+ let Ok(message) = self.incoming.recv_timeout(remaining) else {
237
+ panic!("timed out waiting for the response to request {id}");
238
+ };
239
+ if message["id"] == id && message.get("method").is_none() {
240
+ assert!(
241
+ message.get("error").is_none(),
242
+ "request {id} failed: {message}"
243
+ );
244
+ return message;
245
+ }
246
+ let Some(method) = message["method"].as_str() else {
247
+ continue;
248
+ };
249
+ if method == "session/update" {
250
+ editor.updates.push(message.clone());
251
+ continue;
252
+ }
253
+ if !method.starts_with("terminal/") {
254
+ continue;
255
+ }
256
+ editor.requests.push(message.clone());
257
+ let request_id = message["id"].clone();
258
+ let result = match method {
259
+ "terminal/create" if editor.refuse_create => {
260
+ self.send(json!({
261
+ "jsonrpc": "2.0",
262
+ "id": request_id,
263
+ "error": {"code": -32603, "message": "terminals are off"},
264
+ }));
265
+ continue;
266
+ }
267
+ "terminal/create" => json!({"terminalId": "term-1"}),
268
+ "terminal/wait_for_exit" => json!({"exitCode": 0}),
269
+ "terminal/output" => json!({
270
+ "output": EDITOR_OUTPUT,
271
+ "truncated": false,
272
+ "exitStatus": {"exitCode": 0},
273
+ }),
274
+ _ => json!({}),
275
+ };
276
+ self.send(json!({"jsonrpc": "2.0", "id": request_id, "result": result}));
277
+ }
278
+ }
279
+
280
+ /// `initialize` with or without the `terminal` capability, then a session
281
+ /// in `cwd`.
282
+ fn open_session(&mut self, terminal: bool, cwd: &Path) -> String {
283
+ let mut editor = Editor::default();
284
+ let id = self.request(
285
+ "initialize",
286
+ json!({"protocolVersion": 1, "clientCapabilities": {"terminal": terminal}}),
287
+ );
288
+ self.wait_for_response(id, &mut editor);
289
+
290
+ let id = self.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
291
+ self.wait_for_response(id, &mut editor)["result"]["sessionId"]
292
+ .as_str()
293
+ .expect("session id")
294
+ .to_string()
295
+ }
296
+
297
+ fn prompt(&mut self, session_id: &str, editor: &mut Editor) {
298
+ let id = self.request(
299
+ "session/prompt",
300
+ json!({"sessionId": session_id, "prompt": [{"type": "text", "text": "go"}]}),
301
+ );
302
+ self.wait_for_response(id, editor);
303
+ }
304
+}
305
+
306
+impl Drop for AgentUnderTest {
307
+ fn drop(&mut self) {
308
+ let _ = self.child.kill();
309
+ let _ = self.child.wait();
310
+ }
311
+}
312
+
313
+/// A scratch project: `config/` for the agent and `work/` as the session root.
314
+struct Scratch {
315
+ root: PathBuf,
316
+ config: PathBuf,
317
+ work: PathBuf,
318
+}
319
+
320
+impl Scratch {
321
+ fn new(name: &str) -> Self {
322
+ let root =
323
+ std::env::temp_dir().join(format!("sigit_acp_terminal_{name}_{}", std::process::id()));
324
+ let _ = std::fs::remove_dir_all(&root);
325
+ let config = root.join("config");
326
+ let work = root.join("work");
327
+ std::fs::create_dir_all(&config).unwrap();
328
+ std::fs::create_dir_all(&work).unwrap();
329
+ Self { root, config, work }
330
+ }
331
+}
332
+
333
+impl Drop for Scratch {
334
+ fn drop(&mut self) {
335
+ std::fs::remove_dir_all(&self.root).ok();
336
+ }
337
+}
338
+
339
+/// A `run_command` call printing a marker, in `cwd`. Quote-free so the same
340
+/// command line works under `sh -c` and `cmd /C`.
341
+fn echo_call(id: &str, cwd: &Path, background: bool) -> String {
342
+ sse_tool_call(
343
+ id,
344
+ "run_command",
345
+ json!({"command": "echo ran-locally", "cwd": cwd, "run_in_background": background}),
346
+ )
347
+}
348
+
349
+// ── The tests ───────────────────────────────────────────────────────────────
350
+
351
+#[test]
352
+fn run_command_runs_in_the_client_terminal_and_shows_it() {
353
+ let scratch = Scratch::new("terminal");
354
+ let endpoint = start_fake_endpoint(vec![
355
+ echo_call("call_1", &scratch.work, false),
356
+ sse_text("done"),
357
+ ]);
358
+ let mut agent = spawn_agent(endpoint.port, &scratch.config, None);
359
+ let session_id = agent.open_session(true, &scratch.work);
360
+
361
+ let mut editor = Editor::default();
362
+ agent.prompt(&session_id, &mut editor);
363
+
364
+ assert_eq!(
365
+ endpoint.tool_result("call_1"),
366
+ format!("Exit code 0:\n{EDITOR_OUTPUT}")
367
+ );
368
+ assert_eq!(
369
+ editor.methods(),
370
+ [
371
+ "terminal/create",
372
+ "terminal/wait_for_exit",
373
+ "terminal/output",
374
+ "terminal/release",
375
+ ]
376
+ );
377
+
378
+ let create = &editor.request("terminal/create")["params"];
379
+ assert_eq!(create["sessionId"], session_id.as_str());
380
+ #[cfg(unix)]
381
+ assert_eq!(create["command"], "sh");
382
+ #[cfg(windows)]
383
+ assert_eq!(create["command"], "cmd");
384
+ assert_eq!(create["args"][1], "echo ran-locally");
385
+ let cwd = PathBuf::from(create["cwd"].as_str().unwrap());
386
+ assert_eq!(
387
+ cwd.canonicalize().unwrap(),
388
+ scratch.work.canonicalize().unwrap()
389
+ );
390
+ for method in [
391
+ "terminal/wait_for_exit",
392
+ "terminal/output",
393
+ "terminal/release",
394
+ ] {
395
+ assert_eq!(editor.request(method)["params"]["terminalId"], "term-1");
396
+ }
397
+
398
+ // The terminal is in the card while the command runs and after it ends.
399
+ let terminal = json!([{"type": "terminal", "terminalId": "term-1"}]);
400
+ let updates = editor.call_updates("call_1");
401
+ let shown = updates
402
+ .iter()
403
+ .position(|update| update["content"] == terminal && update["status"].is_null())
404
+ .unwrap_or_else(|| panic!("terminal never embedded: {updates:?}"));
405
+ let finished = updates
406
+ .iter()
407
+ .position(|update| update["status"] == "completed")
408
+ .expect("call_1 never completed");
409
+ assert!(shown < finished, "embedded after completion: {updates:?}");
410
+ assert_eq!(updates[finished]["content"], terminal);
411
+}
412
+
413
+#[test]
414
+fn a_client_without_a_terminal_gets_the_command_run_locally() {
415
+ let scratch = Scratch::new("noterminal");
416
+ let endpoint = start_fake_endpoint(vec![
417
+ echo_call("call_1", &scratch.work, false),
418
+ sse_text("done"),
419
+ ]);
420
+ let mut agent = spawn_agent(endpoint.port, &scratch.config, None);
421
+ let session_id = agent.open_session(false, &scratch.work);
422
+
423
+ let mut editor = Editor::default();
424
+ agent.prompt(&session_id, &mut editor);
425
+
426
+ let result = endpoint.tool_result("call_1");
427
+ assert!(result.contains("ran-locally"), "run_command said: {result}");
428
+ assert!(editor.requests.is_empty(), "{:?}", editor.requests);
429
+}
430
+
431
+#[test]
432
+fn a_background_command_stays_local() {
433
+ let scratch = Scratch::new("background");
434
+ let endpoint = start_fake_endpoint(vec![
435
+ echo_call("call_1", &scratch.work, true),
436
+ sse_text("done"),
437
+ ]);
438
+ let mut agent = spawn_agent(endpoint.port, &scratch.config, None);
439
+ let session_id = agent.open_session(true, &scratch.work);
440
+
441
+ let mut editor = Editor::default();
442
+ agent.prompt(&session_id, &mut editor);
443
+
444
+ let result = endpoint.tool_result("call_1");
445
+ assert!(result.contains("task"), "run_command said: {result}");
446
+ assert!(editor.requests.is_empty(), "{:?}", editor.requests);
447
+}
448
+
449
+#[test]
450
+fn a_directory_outside_the_session_roots_stays_local() {
451
+ let scratch = Scratch::new("outside");
452
+ let outside = scratch.root.join("elsewhere");
453
+ std::fs::create_dir_all(&outside).unwrap();
454
+ let endpoint =
455
+ start_fake_endpoint(vec![echo_call("call_1", &outside, false), sse_text("done")]);
456
+ let mut agent = spawn_agent(endpoint.port, &scratch.config, None);
457
+ let session_id = agent.open_session(true, &scratch.work);
458
+
459
+ let mut editor = Editor::default();
460
+ agent.prompt(&session_id, &mut editor);
461
+
462
+ let result = endpoint.tool_result("call_1");
463
+ assert!(result.contains("ran-locally"), "run_command said: {result}");
464
+ assert!(editor.requests.is_empty(), "{:?}", editor.requests);
465
+}
466
+
467
+#[test]
468
+fn a_terminal_the_client_refuses_falls_back_to_running_locally() {
469
+ let scratch = Scratch::new("refused");
470
+ let endpoint = start_fake_endpoint(vec![
471
+ echo_call("call_1", &scratch.work, false),
472
+ sse_text("done"),
473
+ ]);
474
+ let mut agent = spawn_agent(endpoint.port, &scratch.config, None);
475
+ let session_id = agent.open_session(true, &scratch.work);
476
+
477
+ let mut editor = Editor {
478
+ refuse_create: true,
479
+ ..Editor::default()
480
+ };
481
+ agent.prompt(&session_id, &mut editor);
482
+
483
+ let result = endpoint.tool_result("call_1");
484
+ assert!(result.contains("ran-locally"), "run_command said: {result}");
485
+ assert_eq!(editor.methods(), ["terminal/create"]);
486
+}
487
+
488
+#[test]
489
+fn sigit_client_terminal_off_keeps_commands_local() {
490
+ let scratch = Scratch::new("off");
491
+ let endpoint = start_fake_endpoint(vec![
492
+ echo_call("call_1", &scratch.work, false),
493
+ sse_text("done"),
494
+ ]);
495
+ let mut agent = spawn_agent(endpoint.port, &scratch.config, Some("off"));
496
+ let session_id = agent.open_session(true, &scratch.work);
497
+
498
+ let mut editor = Editor::default();
499
+ agent.prompt(&session_id, &mut editor);
500
+
501
+ let result = endpoint.tool_result("call_1");
502
+ assert!(result.contains("ran-locally"), "run_command said: {result}");
503
+ assert!(editor.requests.is_empty(), "{:?}", editor.requests);
504
+}