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 /// Send a prompt, then `session/cancel` as soon as the agent starts
298 /// waiting on the editor's terminal, which never answers. Returns the
299 /// prompt's response.
300 fn prompt_cancelled_mid_command(&mut self, session_id: &str, editor: &mut Editor) -> Value {
301 let id = self.request(
302 "session/prompt",
303 json!({"sessionId": session_id, "prompt": [{"type": "text", "text": "go"}]}),
304 );
305 let deadline = Instant::now() + TIMEOUT;
306 loop {
307 let remaining = deadline.saturating_duration_since(Instant::now());
308 let Ok(message) = self.incoming.recv_timeout(remaining) else {
309 panic!("timed out: the cancel did not end the prompt");
310 };
311 if message["id"] == id && message.get("method").is_none() {
312 return message;
313 }
314 let Some(method) = message["method"].as_str() else {
315 continue;
316 };
317 if method == "session/update" {
318 editor.updates.push(message.clone());
319 continue;
320 }
321 if !method.starts_with("terminal/") {
322 continue;
323 }
324 editor.requests.push(message.clone());
325 let request_id = message["id"].clone();
326 let result = match method {
327 "terminal/create" => json!({"terminalId": "term-1"}),
328 "terminal/wait_for_exit" => {
329 // The command is still running; the user presses stop.
330 self.send(json!({
331 "jsonrpc": "2.0",
332 "method": "session/cancel",
333 "params": {"sessionId": session_id},
334 }));
335 continue;
336 }
337 "terminal/output" => json!({
338 "output": "partial\n",
339 "truncated": false,
340 }),
341 _ => json!({}),
342 };
343 self.send(json!({"jsonrpc": "2.0", "id": request_id, "result": result}));
344 }
345 }
346
347 fn prompt(&mut self, session_id: &str, editor: &mut Editor) {
348 let id = self.request(
349 "session/prompt",
350 json!({"sessionId": session_id, "prompt": [{"type": "text", "text": "go"}]}),
351 );
352 self.wait_for_response(id, editor);
353 }
354 }
355
356 impl Drop for AgentUnderTest {
357 fn drop(&mut self) {
358 let _ = self.child.kill();
359 let _ = self.child.wait();
360 }
361 }
362
363 /// A scratch project: `config/` for the agent and `work/` as the session root.
364 struct Scratch {
365 root: PathBuf,
366 config: PathBuf,
367 work: PathBuf,
368 }
369
370 impl Scratch {
371 fn new(name: &str) -> Self {
372 let root =
373 std::env::temp_dir().join(format!("sigit_acp_terminal_{name}_{}", std::process::id()));
374 let _ = std::fs::remove_dir_all(&root);
375 let config = root.join("config");
376 let work = root.join("work");
377 std::fs::create_dir_all(&config).unwrap();
378 std::fs::create_dir_all(&work).unwrap();
379 Self { root, config, work }
380 }
381 }
382
383 impl Drop for Scratch {
384 fn drop(&mut self) {
385 std::fs::remove_dir_all(&self.root).ok();
386 }
387 }
388
389 /// A `run_command` call printing a marker, in `cwd`. Quote-free so the same
390 /// command line works under `sh -c` and `cmd /C`.
391 fn echo_call(id: &str, cwd: &Path, background: bool) -> String {
392 sse_tool_call(
393 id,
394 "run_command",
395 json!({"command": "echo ran-locally", "cwd": cwd, "run_in_background": background}),
396 )
397 }
398
399 // ── The tests ───────────────────────────────────────────────────────────────
400
401 #[test]
402 fn run_command_runs_in_the_client_terminal_and_shows_it() {
403 let scratch = Scratch::new("terminal");
404 let endpoint = start_fake_endpoint(vec![
405 echo_call("call_1", &scratch.work, false),
406 sse_text("done"),
407 ]);
408 let mut agent = spawn_agent(endpoint.port, &scratch.config, None);
409 let session_id = agent.open_session(true, &scratch.work);
410
411 let mut editor = Editor::default();
412 agent.prompt(&session_id, &mut editor);
413
414 assert_eq!(
415 endpoint.tool_result("call_1"),
416 format!("Exit code 0:\n{EDITOR_OUTPUT}")
417 );
418 assert_eq!(
419 editor.methods(),
420 [
421 "terminal/create",
422 "terminal/wait_for_exit",
423 "terminal/output",
424 "terminal/release",
425 ]
426 );
427
428 let create = &editor.request("terminal/create")["params"];
429 assert_eq!(create["sessionId"], session_id.as_str());
430 #[cfg(unix)]
431 assert_eq!(create["command"], "sh");
432 #[cfg(windows)]
433 assert_eq!(create["command"], "cmd");
434 assert_eq!(create["args"][1], "echo ran-locally");
435 let cwd = PathBuf::from(create["cwd"].as_str().unwrap());
436 assert_eq!(
437 cwd.canonicalize().unwrap(),
438 scratch.work.canonicalize().unwrap()
439 );
440 for method in [
441 "terminal/wait_for_exit",
442 "terminal/output",
443 "terminal/release",
444 ] {
445 assert_eq!(editor.request(method)["params"]["terminalId"], "term-1");
446 }
447
448 // The terminal is in the card while the command runs and after it ends.
449 let terminal = json!([{"type": "terminal", "terminalId": "term-1"}]);
450 let updates = editor.call_updates("call_1");
451 let shown = updates
452 .iter()
453 .position(|update| update["content"] == terminal && update["status"].is_null())
454 .unwrap_or_else(|| panic!("terminal never embedded: {updates:?}"));
455 let finished = updates
456 .iter()
457 .position(|update| update["status"] == "completed")
458 .expect("call_1 never completed");
459 assert!(shown < finished, "embedded after completion: {updates:?}");
460 // The terminal was released by then, so the closing update does not name
461 // it again; the card keeps the one already embedded.
462 assert!(
463 updates[finished]["content"].is_null(),
464 "the final update must not re-send the released terminal: {}",
465 updates[finished]
466 );
467 assert_eq!(
468 updates[finished]["rawOutput"],
469 format!("Exit code 0:\n{EDITOR_OUTPUT}")
470 );
471 }
472
473 #[test]
474 fn a_client_without_a_terminal_gets_the_command_run_locally() {
475 let scratch = Scratch::new("noterminal");
476 let endpoint = start_fake_endpoint(vec![
477 echo_call("call_1", &scratch.work, false),
478 sse_text("done"),
479 ]);
480 let mut agent = spawn_agent(endpoint.port, &scratch.config, None);
481 let session_id = agent.open_session(false, &scratch.work);
482
483 let mut editor = Editor::default();
484 agent.prompt(&session_id, &mut editor);
485
486 let result = endpoint.tool_result("call_1");
487 assert!(result.contains("ran-locally"), "run_command said: {result}");
488 assert!(editor.requests.is_empty(), "{:?}", editor.requests);
489 }
490
491 #[test]
492 fn a_background_command_stays_local() {
493 let scratch = Scratch::new("background");
494 let endpoint = start_fake_endpoint(vec![
495 echo_call("call_1", &scratch.work, true),
496 sse_text("done"),
497 ]);
498 let mut agent = spawn_agent(endpoint.port, &scratch.config, None);
499 let session_id = agent.open_session(true, &scratch.work);
500
501 let mut editor = Editor::default();
502 agent.prompt(&session_id, &mut editor);
503
504 let result = endpoint.tool_result("call_1");
505 assert!(result.contains("task"), "run_command said: {result}");
506 assert!(editor.requests.is_empty(), "{:?}", editor.requests);
507 }
508
509 #[test]
510 fn a_directory_outside_the_session_roots_stays_local() {
511 let scratch = Scratch::new("outside");
512 let outside = scratch.root.join("elsewhere");
513 std::fs::create_dir_all(&outside).unwrap();
514 let endpoint =
515 start_fake_endpoint(vec![echo_call("call_1", &outside, false), sse_text("done")]);
516 let mut agent = spawn_agent(endpoint.port, &scratch.config, None);
517 let session_id = agent.open_session(true, &scratch.work);
518
519 let mut editor = Editor::default();
520 agent.prompt(&session_id, &mut editor);
521
522 let result = endpoint.tool_result("call_1");
523 assert!(result.contains("ran-locally"), "run_command said: {result}");
524 assert!(editor.requests.is_empty(), "{:?}", editor.requests);
525 }
526
527 #[test]
528 fn a_terminal_the_client_refuses_falls_back_to_running_locally() {
529 let scratch = Scratch::new("refused");
530 let endpoint = start_fake_endpoint(vec![
531 echo_call("call_1", &scratch.work, false),
532 sse_text("done"),
533 ]);
534 let mut agent = spawn_agent(endpoint.port, &scratch.config, None);
535 let session_id = agent.open_session(true, &scratch.work);
536
537 let mut editor = Editor {
538 refuse_create: true,
539 ..Editor::default()
540 };
541 agent.prompt(&session_id, &mut editor);
542
543 let result = endpoint.tool_result("call_1");
544 assert!(result.contains("ran-locally"), "run_command said: {result}");
545 assert_eq!(editor.methods(), ["terminal/create"]);
546 }
547
548 #[test]
549 fn sigit_client_terminal_off_keeps_commands_local() {
550 let scratch = Scratch::new("off");
551 let endpoint = start_fake_endpoint(vec![
552 echo_call("call_1", &scratch.work, false),
553 sse_text("done"),
554 ]);
555 let mut agent = spawn_agent(endpoint.port, &scratch.config, Some("off"));
556 let session_id = agent.open_session(true, &scratch.work);
557
558 let mut editor = Editor::default();
559 agent.prompt(&session_id, &mut editor);
560
561 let result = endpoint.tool_result("call_1");
562 assert!(result.contains("ran-locally"), "run_command said: {result}");
563 assert!(editor.requests.is_empty(), "{:?}", editor.requests);
564 }
565
566 #[test]
567 fn a_cancel_mid_command_kills_the_client_terminal_and_ends_the_turn_cancelled() {
568 let scratch = Scratch::new("cancel");
569 let endpoint = start_fake_endpoint(vec![
570 echo_call("call_1", &scratch.work, false),
571 sse_text("never asked for"),
572 ]);
573 let mut agent = spawn_agent(endpoint.port, &scratch.config, None);
574 let session_id = agent.open_session(true, &scratch.work);
575
576 let mut editor = Editor::default();
577 let response = agent.prompt_cancelled_mid_command(&session_id, &mut editor);
578
579 assert_eq!(response["result"]["stopReason"], "cancelled", "{response}");
580 assert_eq!(
581 editor.methods(),
582 [
583 "terminal/create",
584 "terminal/wait_for_exit",
585 "terminal/kill",
586 "terminal/output",
587 "terminal/release",
588 ]
589 );
590 assert_eq!(
591 editor.request("terminal/kill")["params"]["terminalId"],
592 "term-1"
593 );
594 assert_eq!(
595 endpoint.requests.lock().unwrap().len(),
596 1,
597 "a cancelled turn must not ask the model for another round"
598 );
599 }