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