1 //! End-to-end ACP permission round-trip against the real binary.
2 //!
3 //! Spawns `sigit` in ACP mode (stdin piped, so not a TTY) wired to a scripted
4 //! OpenAI-compatible SSE endpoint via the `OPENAI_BASE_URL` override, then
5 //! drives newline-delimited JSON-RPC over stdio. The scripted model calls
6 //! `run_command` — a mutating tool — so the agent must send
7 //! `session/request_permission` mid-turn (the exact path the spawned-handler /
8 //! `turn_lock` design exists for). The test answers it twice:
9 //!
10 //! 1. `cancelled` — the prompt must stop with `stopReason: "cancelled"`, and
11 //! the *next* request to the endpoint must show the abandoned round closed
12 //! out with `role: "tool"` results, or a strict OpenAI-compatible endpoint
13 //! would reject the whole session.
14 //! 2. `selected: allow_once` — the tool must actually execute and its output
15 //! travel back to the endpoint as a tool result.
16 //!
17 //! A second test covers live progress: a slow command's `in_progress` tool call
18 //! must reach the client while the command is still running, which is what the
19 //! editor's spinner is driven from. A third test covers what the client
20 //! renders: text from two tool rounds must reach it as separate paragraphs,
21 //! since ACP clients concatenate consecutive agent-message chunks into one
22 //! block.
23
24 use std::collections::VecDeque;
25 use std::io::{BufRead, BufReader, Read, Write};
26 use std::net::TcpListener;
27 use std::process::{Child, ChildStdin, Command, Stdio};
28 use std::sync::mpsc::{Receiver, channel};
29 use std::sync::{Arc, Mutex};
30 use std::time::{Duration, Instant};
31
32 use serde_json::{Value, json};
33
34 const TIMEOUT: Duration = Duration::from_secs(60);
35
36 // ── Scripted OpenAI-compatible endpoint ─────────────────────────────────────
37
38 fn sse_body(events: &[Value]) -> String {
39 let mut body = String::new();
40 for event in events {
41 body.push_str("data: ");
42 body.push_str(&event.to_string());
43 body.push_str("\n\n");
44 }
45 body.push_str("data: [DONE]\n\n");
46 body
47 }
48
49 fn sse_tool_call(id: &str, name: &str, arguments: &str) -> String {
50 sse_body(&[json!({
51 "choices": [{"delta": {"tool_calls": [{
52 "index": 0,
53 "id": id,
54 "function": {"name": name, "arguments": arguments},
55 }]}}]
56 })])
57 }
58
59 fn sse_text(text: &str) -> String {
60 sse_body(&[json!({"choices": [{"delta": {"content": text}}]})])
61 }
62
63 /// One round that says something and then asks for a tool — the shape that used
64 /// to leave the next round's text glued to this sentence.
65 fn sse_text_then_tool_call(text: &str, id: &str, name: &str, arguments: &str) -> String {
66 sse_body(&[
67 json!({"choices": [{"delta": {"content": text}}]}),
68 json!({
69 "choices": [{"delta": {"tool_calls": [{
70 "index": 0,
71 "id": id,
72 "function": {"name": name, "arguments": arguments},
73 }]}}]
74 }),
75 ])
76 }
77
78 /// Serves one scripted SSE response per request and records each request body.
79 struct FakeEndpoint {
80 port: u16,
81 requests: Arc<Mutex<Vec<Value>>>,
82 }
83
84 fn start_fake_endpoint(responses: Vec<String>) -> FakeEndpoint {
85 let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
86 let port = listener.local_addr().unwrap().port();
87 let requests: Arc<Mutex<Vec<Value>>> = Arc::default();
88 let recorded = Arc::clone(&requests);
89 let queue = Mutex::new(VecDeque::from(responses));
90
91 std::thread::spawn(move || {
92 // `connection: close` below means one request per connection, so the
93 // serial accept loop matches the agent's serial completion requests.
94 for stream in listener.incoming() {
95 let Ok(mut stream) = stream else { continue };
96 let mut reader = BufReader::new(match stream.try_clone() {
97 Ok(clone) => clone,
98 Err(_) => continue,
99 });
100 let mut content_length = 0usize;
101 loop {
102 let mut line = String::new();
103 if reader.read_line(&mut line).unwrap_or(0) == 0 {
104 break;
105 }
106 let line = line.trim();
107 if line.is_empty() {
108 break;
109 }
110 if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
111 content_length = length.trim().parse().unwrap_or(0);
112 }
113 }
114 let mut body = vec![0u8; content_length];
115 if reader.read_exact(&mut body).is_err() {
116 continue;
117 }
118 if let Ok(request) = serde_json::from_slice::<Value>(&body) {
119 recorded.lock().unwrap().push(request);
120 }
121 let payload = queue
122 .lock()
123 .unwrap()
124 .pop_front()
125 .unwrap_or_else(|| sse_text("out of scripted responses"));
126 let response = format!(
127 "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
128 content-length: {}\r\nconnection: close\r\n\r\n{}",
129 payload.len(),
130 payload
131 );
132 let _ = stream.write_all(response.as_bytes());
133 }
134 });
135
136 FakeEndpoint { port, requests }
137 }
138
139 // ── ACP client over the binary's stdio ──────────────────────────────────────
140
141 struct AgentUnderTest {
142 child: Child,
143 stdin: ChildStdin,
144 incoming: Receiver<Value>,
145 next_id: u64,
146 }
147
148 fn spawn_agent(port: u16, config_dir: &std::path::Path) -> AgentUnderTest {
149 spawn_agent_with_permissions(port, config_dir, None)
150 }
151
152 /// `permissions` sets `SIGIT_PERMISSIONS`; `None` leaves the default `ask` mode
153 /// in place, which is what the permission round-trip needs.
154 fn spawn_agent_with_permissions(
155 port: u16,
156 config_dir: &std::path::Path,
157 permissions: Option<&str>,
158 ) -> AgentUnderTest {
159 let mut command = Command::new(env!("CARGO_BIN_EXE_sigit"));
160 command
161 .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
162 .env("OPENAI_API_KEY", "test-key")
163 .env("SIGIT_MODEL", "scripted-model")
164 .env("SIGIT_CONFIG_DIR", config_dir)
165 .env("SIGIT_MCP", "off")
166 .env_remove("SIGIT_LOCAL_INFERENCE")
167 .stdin(Stdio::piped())
168 .stdout(Stdio::piped())
169 .stderr(Stdio::null());
170 match permissions {
171 // A fresh config dir means the default permission mode, `ask` — make
172 // sure the environment can't turn the gate off underneath the test.
173 None => command.env_remove("SIGIT_PERMISSIONS"),
174 Some(mode) => command.env("SIGIT_PERMISSIONS", mode),
175 };
176 let mut child = command.spawn().expect("spawn sigit in ACP mode");
177
178 let stdout = child.stdout.take().unwrap();
179 let (message_tx, incoming) = channel();
180 std::thread::spawn(move || {
181 for line in BufReader::new(stdout).lines() {
182 let Ok(line) = line else { break };
183 if let Ok(message) = serde_json::from_str::<Value>(&line)
184 && message_tx.send(message).is_err()
185 {
186 break;
187 }
188 }
189 });
190
191 let stdin = child.stdin.take().unwrap();
192 AgentUnderTest {
193 child,
194 stdin,
195 incoming,
196 next_id: 0,
197 }
198 }
199
200 impl AgentUnderTest {
201 fn send(&mut self, message: Value) {
202 let mut line = message.to_string();
203 line.push('\n');
204 self.stdin
205 .write_all(line.as_bytes())
206 .expect("write to agent stdin");
207 self.stdin.flush().expect("flush agent stdin");
208 }
209
210 fn request(&mut self, method: &str, params: Value) -> u64 {
211 self.next_id += 1;
212 let id = self.next_id;
213 self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
214 id
215 }
216
217 fn respond(&mut self, id: Value, result: Value) {
218 self.send(json!({"jsonrpc": "2.0", "id": id, "result": result}));
219 }
220
221 /// Skip notifications and unrelated traffic until `matches` is satisfied.
222 fn wait_for(&mut self, what: &str, matches: impl Fn(&Value) -> bool) -> Value {
223 let deadline = Instant::now() + TIMEOUT;
224 loop {
225 let remaining = deadline.saturating_duration_since(Instant::now());
226 match self.incoming.recv_timeout(remaining) {
227 Ok(message) if matches(&message) => return message,
228 Ok(_) => continue,
229 Err(_) => panic!("timed out waiting for {what}"),
230 }
231 }
232 }
233
234 /// The response to one of *our* requests (has our id, no `method`).
235 fn wait_for_response(&mut self, id: u64) -> Value {
236 let response = self.wait_for(&format!("response to request {id}"), |message| {
237 message["id"] == id && message.get("method").is_none()
238 });
239 assert!(
240 response.get("error").is_none(),
241 "request {id} failed: {response}"
242 );
243 response
244 }
245
246 /// The first `session/update` whose payload satisfies `matches`.
247 ///
248 /// Only used by tests that run with the permission gate off, so a
249 /// `session/request_permission` means the run is misconfigured — say that
250 /// rather than waiting out the timeout on a request nobody will answer.
251 fn wait_for_update(&mut self, what: &str, matches: impl Fn(&Value) -> bool) -> Value {
252 let message = self.wait_for(what, |message| {
253 assert!(
254 message["method"] != "session/request_permission",
255 "unexpected permission request while waiting for {what}: {message}"
256 );
257 message["method"] == "session/update" && matches(&message["params"]["update"])
258 });
259 message["params"]["update"].clone()
260 }
261
262 /// The response to `session/prompt`, paired with every `agent_message_chunk`
263 /// seen on the way, concatenated exactly as a client renders them.
264 fn wait_for_prompt(&mut self, id: u64) -> (Value, String) {
265 let mut rendered = String::new();
266 let deadline = Instant::now() + TIMEOUT;
267 loop {
268 let remaining = deadline.saturating_duration_since(Instant::now());
269 let Ok(message) = self.incoming.recv_timeout(remaining) else {
270 panic!("timed out waiting for the prompt response");
271 };
272 let update = &message["params"]["update"];
273 if message["method"] == "session/update"
274 && update["sessionUpdate"] == "agent_message_chunk"
275 && let Some(chunk) = update["content"]["text"].as_str()
276 {
277 rendered.push_str(chunk);
278 }
279 if message["id"] == id && message.get("method").is_none() {
280 assert!(message.get("error").is_none(), "prompt failed: {message}");
281 return (message, rendered);
282 }
283 }
284 }
285
286 fn wait_for_response_with_updates(&mut self, id: u64) -> (Value, Vec<Value>) {
287 let mut updates = Vec::new();
288 let deadline = Instant::now() + TIMEOUT;
289 loop {
290 let remaining = deadline.saturating_duration_since(Instant::now());
291 let Ok(message) = self.incoming.recv_timeout(remaining) else {
292 panic!("timed out waiting for response to request {id}");
293 };
294 if message["method"] == "session/update" {
295 updates.push(message["params"]["update"].clone());
296 }
297 assert!(
298 message["method"] != "session/request_permission",
299 "unexpected permission request while waiting for response {id}: {message}"
300 );
301 if message["id"] == id && message.get("method").is_none() {
302 assert!(
303 message.get("error").is_none(),
304 "request {id} failed: {message}"
305 );
306 return (message, updates);
307 }
308 }
309 }
310
311 /// A request *from* the agent (has a `method` and its own id).
312 fn wait_for_agent_request(&mut self, method: &str) -> Value {
313 self.wait_for(&format!("agent request {method}"), |message| {
314 message["method"] == method && message.get("id").is_some()
315 })
316 }
317 }
318
319 impl Drop for AgentUnderTest {
320 fn drop(&mut self) {
321 let _ = self.child.kill();
322 let _ = self.child.wait();
323 }
324 }
325
326 // ── The round-trip ──────────────────────────────────────────────────────────
327
328 #[test]
329 fn permission_round_trip_cancel_then_allow() {
330 let endpoint = start_fake_endpoint(vec![
331 sse_tool_call("call_1", "run_command", r#"{"command":"echo sigit-first"}"#),
332 sse_tool_call(
333 "call_2",
334 "run_command",
335 r#"{"command":"echo sigit-approved"}"#,
336 ),
337 sse_text("done"),
338 ]);
339
340 let scratch = std::env::temp_dir().join(format!("sigit_acp_perm_{}", std::process::id()));
341 let config_dir = scratch.join("config");
342 let cwd = scratch.join("cwd");
343 std::fs::create_dir_all(&config_dir).unwrap();
344 std::fs::create_dir_all(&cwd).unwrap();
345
346 let mut agent = spawn_agent(endpoint.port, &config_dir);
347
348 let id = agent.request(
349 "initialize",
350 json!({"protocolVersion": 1, "clientCapabilities": {}}),
351 );
352 agent.wait_for_response(id);
353
354 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
355 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
356 .as_str()
357 .expect("session id")
358 .to_string();
359
360 // ── Turn 1: cancel at the permission gate ───────────────────────────
361 let prompt_id = agent.request(
362 "session/prompt",
363 json!({
364 "sessionId": session_id,
365 "prompt": [{"type": "text", "text": "run the first command"}],
366 }),
367 );
368
369 let permission = agent.wait_for_agent_request("session/request_permission");
370 let params = &permission["params"];
371 assert_eq!(params["sessionId"], session_id.as_str());
372 let title = params["toolCall"]["title"].as_str().expect("title");
373 assert!(
374 title.contains("run_command") && title.contains("echo sigit-first"),
375 "the dialog must show the tool and its arguments, got: {title}"
376 );
377 assert_eq!(
378 params["toolCall"]["rawInput"]["command"], "echo sigit-first",
379 "full arguments must travel as rawInput"
380 );
381 let option_ids: Vec<&str> = params["options"]
382 .as_array()
383 .expect("options")
384 .iter()
385 .map(|option| option["optionId"].as_str().unwrap_or_default())
386 .collect();
387 assert_eq!(
388 option_ids,
389 [
390 "allow_once",
391 "allow_session",
392 "reject_once",
393 "reject_session"
394 ]
395 );
396 let reject_always = params["options"]
397 .as_array()
398 .unwrap()
399 .iter()
400 .find(|option| option["optionId"] == "reject_session")
401 .unwrap();
402 assert_eq!(reject_always["kind"], "reject_always");
403
404 agent.respond(
405 permission["id"].clone(),
406 json!({"outcome": {"outcome": "cancelled"}}),
407 );
408
409 let response = agent.wait_for_response(prompt_id);
410 assert_eq!(response["result"]["stopReason"], "cancelled");
411
412 // ── Turn 2: history must be repaired; then approve once ─────────────
413 let prompt_id = agent.request(
414 "session/prompt",
415 json!({
416 "sessionId": session_id,
417 "prompt": [{"type": "text", "text": "run the second command"}],
418 }),
419 );
420
421 let permission = agent.wait_for_agent_request("session/request_permission");
422 agent.respond(
423 permission["id"].clone(),
424 json!({"outcome": {"outcome": "selected", "optionId": "allow_once"}}),
425 );
426
427 let response = agent.wait_for_response(prompt_id);
428 assert_eq!(response["result"]["stopReason"], "end_turn");
429
430 // ── What the endpoint saw ────────────────────────────────────────────
431 let requests = endpoint.requests.lock().unwrap();
432 assert_eq!(requests.len(), 3, "expected exactly three completions");
433
434 // The client explicitly requests automatic tool selection. Some
435 // OpenAI-compatible gateways do not honour OpenAI's implicit `auto`
436 // default, which otherwise lets the model describe a tool action in prose
437 // and end the turn without a tool call.
438 assert_eq!(requests[0]["tool_choice"], "auto");
439
440 // Request 2 replays the full history: the cancelled round's tool call
441 // must be answered by a `role: "tool"` message, not left dangling.
442 let messages = requests[1]["messages"].as_array().expect("messages");
443 let call_position = messages
444 .iter()
445 .position(|message| message["tool_calls"][0]["id"] == "call_1")
446 .expect("cancelled turn's assistant tool call in replayed history");
447 let repair = &messages[call_position + 1];
448 assert_eq!(repair["role"], "tool", "dangling tool call not closed out");
449 assert_eq!(repair["tool_call_id"], "call_1");
450 assert!(
451 repair["content"]
452 .as_str()
453 .unwrap_or_default()
454 .contains("cancelled"),
455 "repair message should say the turn was cancelled: {repair}"
456 );
457
458 // Request 3 carries the approved call's real output.
459 let messages = requests[2]["messages"].as_array().expect("messages");
460 let result = messages
461 .iter()
462 .find(|message| message["role"] == "tool" && message["tool_call_id"] == "call_2")
463 .expect("tool result for the approved call");
464 assert!(
465 result["content"]
466 .as_str()
467 .unwrap_or_default()
468 .contains("sigit-approved"),
469 "the approved command's output should reach the endpoint: {result}"
470 );
471
472 drop(agent);
473 let _ = std::fs::remove_dir_all(&scratch);
474 }
475
476 /// A call that needs approval is one tool call from start to finish (issue
477 /// #138). The permission request used to carry an id of its own, so a client
478 /// drew an approval card next to a call that already claimed to be running.
479 #[test]
480 fn a_permission_request_is_about_the_tool_call_it_was_announced_as() {
481 let endpoint = start_fake_endpoint(vec![
482 sse_tool_call(
483 "call_1",
484 "run_command",
485 r#"{"command":"echo sigit-one-card"}"#,
486 ),
487 sse_text("done"),
488 ]);
489
490 let scratch = std::env::temp_dir().join(format!("sigit_acp_perm_id_{}", std::process::id()));
491 let config_dir = scratch.join("config");
492 let cwd = scratch.join("cwd");
493 std::fs::create_dir_all(&config_dir).unwrap();
494 std::fs::create_dir_all(&cwd).unwrap();
495
496 let mut agent = spawn_agent(endpoint.port, &config_dir);
497
498 let id = agent.request(
499 "initialize",
500 json!({"protocolVersion": 1, "clientCapabilities": {}}),
501 );
502 agent.wait_for_response(id);
503
504 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
505 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
506 .as_str()
507 .expect("session id")
508 .to_string();
509
510 let prompt_id = agent.request(
511 "session/prompt",
512 json!({
513 "sessionId": session_id,
514 "prompt": [{"type": "text", "text": "run the command"}],
515 }),
516 );
517
518 // Messages arrive in order and `wait_for` drops what it skips, so each
519 // step below also checks that the one before it came first.
520 let call_update = |message: &Value, kind: &str| {
521 let update = &message["params"]["update"];
522 message["method"] == "session/update"
523 && update["sessionUpdate"] == kind
524 && update["toolCallId"] == "call_1"
525 };
526
527 let announced = agent.wait_for("the tool call announcement", |message| {
528 call_update(message, "tool_call")
529 });
530 // `pending` is the schema's default status, so it is left off the wire.
531 let status = &announced["params"]["update"]["status"];
532 assert!(
533 status.is_null() || status == "pending",
534 "a call waiting for approval has not started: {announced}"
535 );
536
537 let permission = agent.wait_for_agent_request("session/request_permission");
538 assert_eq!(
539 permission["params"]["toolCall"]["toolCallId"], "call_1",
540 "the request must name the announced call: {permission}"
541 );
542 agent.respond(
543 permission["id"].clone(),
544 json!({"outcome": {"outcome": "selected", "optionId": "allow_once"}}),
545 );
546
547 let started = agent.wait_for("the approved call starting", |message| {
548 call_update(message, "tool_call_update")
549 });
550 let started = &started["params"]["update"];
551 assert_eq!(started["status"], "in_progress", "{started}");
552 assert_eq!(
553 started["title"], announced["params"]["update"]["title"],
554 "the card gets its own title back after the approval dialog: {started}"
555 );
556
557 let finished = agent.wait_for("the call finishing", |message| {
558 call_update(message, "tool_call_update")
559 });
560 assert_eq!(finished["params"]["update"]["status"], "completed");
561
562 let response = agent.wait_for_response(prompt_id);
563 assert_eq!(response["result"]["stopReason"], "end_turn");
564
565 drop(agent);
566 let _ = std::fs::remove_dir_all(&scratch);
567 }
568
569 /// A call the user denies at the prompt did not run, so it ends `failed`
570 /// rather than `completed` (issue #139), and gets its own title back from
571 /// the permission request's.
572 #[test]
573 fn a_call_denied_at_the_prompt_ends_failed() {
574 let endpoint = start_fake_endpoint(vec![
575 sse_tool_call(
576 "call_1",
577 "run_command",
578 r#"{"command":"echo sigit-denied"}"#,
579 ),
580 sse_text("ok, skipped"),
581 ]);
582
583 let scratch = std::env::temp_dir().join(format!("sigit_acp_perm_deny_{}", std::process::id()));
584 let config_dir = scratch.join("config");
585 let cwd = scratch.join("cwd");
586 std::fs::create_dir_all(&config_dir).unwrap();
587 std::fs::create_dir_all(&cwd).unwrap();
588
589 let mut agent = spawn_agent(endpoint.port, &config_dir);
590
591 let id = agent.request(
592 "initialize",
593 json!({"protocolVersion": 1, "clientCapabilities": {}}),
594 );
595 agent.wait_for_response(id);
596
597 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
598 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
599 .as_str()
600 .expect("session id")
601 .to_string();
602
603 let prompt_id = agent.request(
604 "session/prompt",
605 json!({
606 "sessionId": session_id,
607 "prompt": [{"type": "text", "text": "run the command"}],
608 }),
609 );
610
611 let call_update = |message: &Value, kind: &str| {
612 let update = &message["params"]["update"];
613 message["method"] == "session/update"
614 && update["sessionUpdate"] == kind
615 && update["toolCallId"] == "call_1"
616 };
617
618 let announced = agent.wait_for("the tool call announcement", |message| {
619 call_update(message, "tool_call")
620 });
621
622 let permission = agent.wait_for_agent_request("session/request_permission");
623 agent.respond(
624 permission["id"].clone(),
625 json!({"outcome": {"outcome": "selected", "optionId": "reject_once"}}),
626 );
627
628 let finished = agent.wait_for("the denied call closing", |message| {
629 call_update(message, "tool_call_update")
630 });
631 let finished = &finished["params"]["update"];
632 assert_eq!(finished["status"], "failed", "{finished}");
633 assert_eq!(
634 finished["title"], announced["params"]["update"]["title"],
635 "the card gets its own title back after the approval dialog: {finished}"
636 );
637
638 let response = agent.wait_for_response(prompt_id);
639 assert_eq!(response["result"]["stopReason"], "end_turn");
640
641 drop(agent);
642 let _ = std::fs::remove_dir_all(&scratch);
643 }
644
645 /// "Deny for this session" answers the call it was picked for and every later
646 /// call in the same family without asking again (issue #199).
647 #[test]
648 fn a_call_denied_for_the_session_is_not_asked_about_again() {
649 let endpoint = start_fake_endpoint(vec![
650 sse_tool_call("call_1", "run_command", r#"{"command":"echo sigit-never"}"#),
651 sse_tool_call(
652 "call_2",
653 "run_command",
654 r#"{"command":"echo sigit-never again"}"#,
655 ),
656 sse_text("ok, I will stop"),
657 ]);
658
659 let scratch =
660 std::env::temp_dir().join(format!("sigit_acp_perm_deny_all_{}", std::process::id()));
661 let config_dir = scratch.join("config");
662 let cwd = scratch.join("cwd");
663 std::fs::create_dir_all(&config_dir).unwrap();
664 std::fs::create_dir_all(&cwd).unwrap();
665
666 let mut agent = spawn_agent(endpoint.port, &config_dir);
667
668 let id = agent.request(
669 "initialize",
670 json!({"protocolVersion": 1, "clientCapabilities": {}}),
671 );
672 agent.wait_for_response(id);
673
674 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
675 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
676 .as_str()
677 .expect("session id")
678 .to_string();
679
680 let prompt_id = agent.request(
681 "session/prompt",
682 json!({
683 "sessionId": session_id,
684 "prompt": [{"type": "text", "text": "run the command"}],
685 }),
686 );
687
688 let permission = agent.wait_for_agent_request("session/request_permission");
689 agent.respond(
690 permission["id"].clone(),
691 json!({"outcome": {"outcome": "selected", "optionId": "reject_session"}}),
692 );
693
694 // The second call is denied by the recorded choice: no second request,
695 // which `wait_for_response_with_updates` would fail on.
696 let (response, updates) = agent.wait_for_response_with_updates(prompt_id);
697 assert_eq!(response["result"]["stopReason"], "end_turn");
698 let second = updates
699 .iter()
700 .rev()
701 .find(|update| {
702 update["sessionUpdate"] == "tool_call_update" && update["toolCallId"] == "call_2"
703 })
704 .expect("the second call closes");
705 assert_eq!(second["status"], "failed", "{second}");
706
707 let requests = endpoint.requests.lock().unwrap();
708 let result = requests[2]["messages"]
709 .as_array()
710 .expect("messages")
711 .iter()
712 .find(|message| message["role"] == "tool" && message["tool_call_id"] == "call_2")
713 .expect("tool result for the second call");
714 assert!(
715 result["content"]
716 .as_str()
717 .unwrap_or_default()
718 .contains("always deny"),
719 "the model is told why: {result}"
720 );
721
722 drop(agent);
723 let _ = std::fs::remove_dir_all(&scratch);
724 }
725
726 /// A client that renders boolean config options gets the Inference switch as
727 /// a toggle, and can flip it with a boolean value; any other client keeps the
728 /// two-value select (issue #198).
729 #[test]
730 fn the_inference_switch_is_a_toggle_for_clients_that_render_one() {
731 let inference_option = |options: &Value| -> Value {
732 options
733 .as_array()
734 .expect("config options")
735 .iter()
736 .find(|option| option["id"] == "sigit-local-inference")
737 .cloned()
738 .expect("inference option")
739 };
740
741 for (capabilities, expected_type) in [
742 (
743 json!({"session": {"configOptions": {"boolean": {}}}}),
744 "boolean",
745 ),
746 (json!({}), "select"),
747 ] {
748 let endpoint = start_fake_endpoint(vec![]);
749 let scratch = std::env::temp_dir().join(format!(
750 "sigit_acp_bool_option_{expected_type}_{}",
751 std::process::id()
752 ));
753 let config_dir = scratch.join("config");
754 let cwd = scratch.join("cwd");
755 std::fs::create_dir_all(&config_dir).unwrap();
756 std::fs::create_dir_all(&cwd).unwrap();
757
758 let mut agent = spawn_agent(endpoint.port, &config_dir);
759 let id = agent.request(
760 "initialize",
761 json!({"protocolVersion": 1, "clientCapabilities": capabilities}),
762 );
763 agent.wait_for_response(id);
764
765 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
766 let created = agent.wait_for_response(id);
767 let session_id = created["result"]["sessionId"]
768 .as_str()
769 .expect("session id")
770 .to_string();
771 let option = inference_option(&created["result"]["configOptions"]);
772 assert_eq!(option["type"], expected_type, "{option}");
773
774 if expected_type == "boolean" {
775 let id = agent.request(
776 "session/set_config_option",
777 json!({
778 "sessionId": session_id,
779 "configId": "sigit-local-inference",
780 "type": "boolean",
781 "value": true,
782 }),
783 );
784 let response = agent.wait_for_response(id);
785 let option = inference_option(&response["result"]["configOptions"]);
786 assert_eq!(option["currentValue"], true, "{option}");
787 }
788
789 drop(agent);
790 let _ = std::fs::remove_dir_all(&scratch);
791 }
792 }
793
794 #[test]
795 fn successful_config_option_changes_are_rendered_as_system_status_cards() {
796 let endpoint = start_fake_endpoint(vec![]);
797
798 let scratch =
799 std::env::temp_dir().join(format!("sigit_acp_config_messages_{}", std::process::id()));
800 let config_dir = scratch.join("config");
801 let cwd = scratch.join("cwd");
802 std::fs::create_dir_all(&config_dir).unwrap();
803 std::fs::create_dir_all(&cwd).unwrap();
804 std::fs::write(
805 config_dir.join("credentials.toml"),
806 "access_token = \"tok_test\"\nemail = \"dev@sigit.si\"\n",
807 )
808 .unwrap();
809
810 let mut agent = spawn_agent(endpoint.port, &config_dir);
811
812 let id = agent.request(
813 "initialize",
814 json!({"protocolVersion": 1, "clientCapabilities": {}}),
815 );
816 agent.wait_for_response(id);
817
818 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
819 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
820 .as_str()
821 .expect("session id")
822 .to_string();
823
824 let id = agent.request(
825 "session/set_config_option",
826 json!({
827 "sessionId": session_id,
828 "configId": "sigit-local-inference",
829 "value": "local-inference-off",
830 }),
831 );
832 let (response, updates) = agent.wait_for_response_with_updates(id);
833 assert!(
834 response["result"]["configOptions"].is_array(),
835 "config response should refresh picker options: {response}"
836 );
837 assert!(
838 !updates
839 .iter()
840 .any(|update| update["sessionUpdate"] == "agent_message_chunk"),
841 "Local Inference picker changes are passive UI state, not chat text: {updates:?}"
842 );
843 let status = updates
844 .iter()
845 .find(|update| {
846 update["sessionUpdate"] == "tool_call"
847 && update["kind"] == "think"
848 && update["status"] == "completed"
849 })
850 .expect("Local Inference change should render as a system-style status card");
851 assert_eq!(
852 status["title"],
853 "Local inference is off. siGit Code Cloud tiers are highlighted; pick one from Model."
854 );
855
856 let id = agent.request(
857 "session/set_config_option",
858 json!({
859 "sessionId": session_id,
860 "configId": "sigit-model",
861 "value": "sigit-cloud:mini",
862 }),
863 );
864 let (response, updates) = agent.wait_for_response_with_updates(id);
865 assert!(
866 response["result"]["configOptions"].is_array(),
867 "config response should refresh picker options: {response}"
868 );
869 assert!(
870 !updates
871 .iter()
872 .any(|update| update["sessionUpdate"] == "agent_message_chunk"),
873 "successful model picker changes must not be rendered as assistant chat text: {updates:?}"
874 );
875 let status = updates
876 .iter()
877 .find(|update| {
878 update["sessionUpdate"] == "tool_call"
879 && update["kind"] == "think"
880 && update["status"] == "completed"
881 })
882 .expect("cloud tier change should render as a system-style status card");
883 assert!(
884 status["title"]
885 .as_str()
886 .unwrap_or_default()
887 .starts_with("Switched to siGit Code Cloud"),
888 "unexpected cloud switch status title: {status}"
889 );
890
891 drop(agent);
892 let _ = std::fs::remove_dir_all(&scratch);
893 }
894
895 #[test]
896 fn auto_permission_mode_runs_mutating_tools_without_asking() {
897 let endpoint = start_fake_endpoint(vec![
898 sse_tool_call("call_1", "run_command", r#"{"command":"echo sigit-auto"}"#),
899 sse_text("done"),
900 ]);
901
902 let scratch = std::env::temp_dir().join(format!("sigit_acp_auto_perm_{}", std::process::id()));
903 let config_dir = scratch.join("config");
904 let cwd = scratch.join("cwd");
905 std::fs::create_dir_all(&config_dir).unwrap();
906 std::fs::create_dir_all(&cwd).unwrap();
907
908 let mut agent = spawn_agent(endpoint.port, &config_dir);
909
910 let id = agent.request(
911 "initialize",
912 json!({"protocolVersion": 1, "clientCapabilities": {}}),
913 );
914 agent.wait_for_response(id);
915
916 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
917 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
918 .as_str()
919 .expect("session id")
920 .to_string();
921
922 let id = agent.request(
923 "session/set_config_option",
924 json!({
925 "sessionId": session_id,
926 "configId": "sigit-permission-mode",
927 "value": "permission-mode-auto",
928 }),
929 );
930 let (response, updates) = agent.wait_for_response_with_updates(id);
931 let permissions = response["result"]["configOptions"]
932 .as_array()
933 .expect("config options")
934 .iter()
935 .find(|option| option["id"] == "sigit-permission-mode")
936 .expect("permissions config option");
937 assert_eq!(permissions["currentValue"], "permission-mode-auto");
938 assert!(
939 !updates
940 .iter()
941 .any(|update| update["sessionUpdate"] == "agent_message_chunk"),
942 "Permissions picker changes are passive UI state, not chat text: {updates:?}"
943 );
944
945 let prompt_id = agent.request(
946 "session/prompt",
947 json!({
948 "sessionId": session_id,
949 "prompt": [{"type": "text", "text": "run the command"}],
950 }),
951 );
952 let (response, _updates) = agent.wait_for_response_with_updates(prompt_id);
953 assert_eq!(response["result"]["stopReason"], "end_turn");
954
955 let requests = endpoint.requests.lock().unwrap();
956 assert_eq!(requests.len(), 2, "expected tool round and final round");
957 let messages = requests[1]["messages"].as_array().expect("messages");
958 let result = messages
959 .iter()
960 .find(|message| message["role"] == "tool" && message["tool_call_id"] == "call_1")
961 .expect("tool result for the automatic call");
962 assert!(
963 result["content"]
964 .as_str()
965 .unwrap_or_default()
966 .contains("sigit-auto"),
967 "automatic command output should reach the endpoint: {result}"
968 );
969
970 drop(agent);
971 let _ = std::fs::remove_dir_all(&scratch);
972 }
973
974 #[test]
975 fn plan_permission_mode_denies_mutating_tools_without_asking() {
976 let endpoint = start_fake_endpoint(vec![
977 sse_tool_call("call_1", "run_command", r#"{"command":"echo sigit-plan"}"#),
978 sse_text("planned"),
979 ]);
980
981 let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_perm_{}", std::process::id()));
982 let config_dir = scratch.join("config");
983 let cwd = scratch.join("cwd");
984 std::fs::create_dir_all(&config_dir).unwrap();
985 std::fs::create_dir_all(&cwd).unwrap();
986
987 let mut agent = spawn_agent(endpoint.port, &config_dir);
988
989 let id = agent.request(
990 "initialize",
991 json!({"protocolVersion": 1, "clientCapabilities": {}}),
992 );
993 agent.wait_for_response(id);
994
995 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
996 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
997 .as_str()
998 .expect("session id")
999 .to_string();
1000
1001 let id = agent.request(
1002 "session/set_config_option",
1003 json!({
1004 "sessionId": session_id,
1005 "configId": "sigit-permission-mode",
1006 "value": "permission-mode-plan",
1007 }),
1008 );
1009 let (response, _updates) = agent.wait_for_response_with_updates(id);
1010 let permissions = response["result"]["configOptions"]
1011 .as_array()
1012 .expect("config options")
1013 .iter()
1014 .find(|option| option["id"] == "sigit-permission-mode")
1015 .expect("permissions config option");
1016 assert_eq!(permissions["currentValue"], "permission-mode-plan");
1017
1018 let prompt_id = agent.request(
1019 "session/prompt",
1020 json!({
1021 "sessionId": session_id,
1022 "prompt": [{"type": "text", "text": "run the command"}],
1023 }),
1024 );
1025 let (response, updates) = agent.wait_for_response_with_updates(prompt_id);
1026 assert_eq!(response["result"]["stopReason"], "end_turn");
1027
1028 // The tool never ran, so its card must not end as a success (issue #139).
1029 let closed = updates
1030 .iter()
1031 .rev()
1032 .find(|update| {
1033 update["sessionUpdate"] == "tool_call_update"
1034 && update["toolCallId"] == "call_1"
1035 && !update["status"].is_null()
1036 })
1037 .expect("the blocked call is closed out");
1038 assert_eq!(closed["status"], "failed", "{closed}");
1039
1040 let requests = endpoint.requests.lock().unwrap();
1041 assert_eq!(
1042 requests.len(),
1043 2,
1044 "expected denied tool round and final round"
1045 );
1046 let messages = requests[1]["messages"].as_array().expect("messages");
1047 let result = messages
1048 .iter()
1049 .find(|message| message["role"] == "tool" && message["tool_call_id"] == "call_1")
1050 .expect("tool result for the blocked call");
1051 let content = result["content"].as_str().unwrap_or_default();
1052 assert!(
1053 content.contains("Plan mode is active") && content.contains("was not executed"),
1054 "plan mode should return an instructive denial to the endpoint: {result}"
1055 );
1056
1057 drop(agent);
1058 let _ = std::fs::remove_dir_all(&scratch);
1059 }
1060
1061 #[test]
1062 fn a_slow_commands_progress_reaches_the_client_while_it_runs() {
1063 let scratch = std::env::temp_dir().join(format!("sigit_acp_progress_{}", std::process::id()));
1064 let config_dir = scratch.join("config");
1065 let cwd = scratch.join("cwd");
1066 std::fs::create_dir_all(&config_dir).unwrap();
1067 std::fs::create_dir_all(&cwd).unwrap();
1068
1069 // The command has to run for a few seconds through `sh -c` / `cmd /C`.
1070 // `sleep` is neither a cmd builtin nor on a Windows runner's PATH (Git's
1071 // usr\bin, which carries the MSYS coreutils, is deliberately left off it),
1072 // and the Windows job hung on it rather than failing fast. `ping` ships in
1073 // System32, waits a second between echoes, and never reads stdin.
1074 //
1075 // Four seconds against the one-second assertion below is deliberate
1076 // padding. A busy runner only ever widens the gap being measured — it
1077 // cannot make the command finish sooner — so the one way a correct agent
1078 // could fail here is this thread being descheduled for seconds between
1079 // reading the two updates off the channel. Three seconds of slack covers
1080 // that without weakening the assertion, which is separating four seconds
1081 // from the 164µs the blocking version produced.
1082 #[cfg(unix)]
1083 let command = "sleep 4";
1084 #[cfg(windows)]
1085 let command = "ping -n 5 127.0.0.1";
1086
1087 // `SIGIT_PERMISSIONS=allow` skips the gate, so the only thing between the
1088 // two updates below is the command itself.
1089 let endpoint = start_fake_endpoint(vec![
1090 sse_tool_call(
1091 "call_1",
1092 "run_command",
1093 &json!({"command": command, "cwd": cwd}).to_string(),
1094 ),
1095 sse_text("done"),
1096 ]);
1097
1098 let mut agent = spawn_agent_with_permissions(endpoint.port, &config_dir, Some("allow"));
1099
1100 let id = agent.request(
1101 "initialize",
1102 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1103 );
1104 agent.wait_for_response(id);
1105
1106 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1107 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1108 .as_str()
1109 .expect("session id")
1110 .to_string();
1111
1112 let prompt_id = agent.request(
1113 "session/prompt",
1114 json!({
1115 "sessionId": session_id,
1116 "prompt": [{"type": "text", "text": "run the slow command"}],
1117 }),
1118 );
1119
1120 let started = agent.wait_for_update("the tool call starting", |update| {
1121 update["sessionUpdate"] == "tool_call" && update["toolCallId"] == "call_1"
1122 });
1123 let in_progress_at = Instant::now();
1124 assert_eq!(
1125 started["status"], "in_progress",
1126 "a running tool call must be announced as in_progress: {started}"
1127 );
1128
1129 let finished = agent.wait_for_update("the tool call finishing", |update| {
1130 update["sessionUpdate"] == "tool_call_update"
1131 && update["toolCallId"] == "call_1"
1132 && update["status"] == "completed"
1133 });
1134
1135 // The command runs between the two updates. If the turn blocked the
1136 // connection's actors, both would only reach the client once it had
1137 // already finished, and the editor would never draw a spinner.
1138 assert!(
1139 in_progress_at.elapsed() >= Duration::from_millis(1_000),
1140 "in_progress arrived only {:?} before completed — the client had no \
1141 window to show progress in. `{command}` said: {}",
1142 in_progress_at.elapsed(),
1143 finished["rawOutput"]
1144 );
1145
1146 let response = agent.wait_for_response(prompt_id);
1147 assert_eq!(response["result"]["stopReason"], "end_turn");
1148
1149 drop(agent);
1150 let _ = std::fs::remove_dir_all(&scratch);
1151 }
1152
1153 #[test]
1154 fn text_from_two_tool_rounds_reaches_the_client_as_separate_paragraphs() {
1155 let endpoint = start_fake_endpoint(vec![
1156 // `list_directory` is read-only, so it runs without a permission gate
1157 // and the two rounds follow each other straight away.
1158 sse_text_then_tool_call(
1159 "I'll search for this pattern.",
1160 "call_1",
1161 "list_directory",
1162 r#"{"path":"."}"#,
1163 ),
1164 sse_text("Let me broaden the search:"),
1165 ]);
1166
1167 let scratch = std::env::temp_dir().join(format!("sigit_acp_spacing_{}", std::process::id()));
1168 let config_dir = scratch.join("config");
1169 let cwd = scratch.join("cwd");
1170 std::fs::create_dir_all(&config_dir).unwrap();
1171 std::fs::create_dir_all(&cwd).unwrap();
1172
1173 let mut agent = spawn_agent(endpoint.port, &config_dir);
1174
1175 let id = agent.request(
1176 "initialize",
1177 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1178 );
1179 agent.wait_for_response(id);
1180
1181 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1182 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1183 .as_str()
1184 .expect("session id")
1185 .to_string();
1186
1187 let prompt_id = agent.request(
1188 "session/prompt",
1189 json!({
1190 "sessionId": session_id,
1191 "prompt": [{"type": "text", "text": "find it"}],
1192 }),
1193 );
1194 let (response, rendered) = agent.wait_for_prompt(prompt_id);
1195 assert_eq!(response["result"]["stopReason"], "end_turn");
1196
1197 assert!(
1198 rendered.contains("pattern.\n\nLet me broaden"),
1199 "the second round must open a new paragraph, got: {rendered:?}"
1200 );
1201 assert!(
1202 !rendered.contains("pattern.Let me"),
1203 "rounds must not run together into one sentence, got: {rendered:?}"
1204 );
1205
1206 drop(agent);
1207 let _ = std::fs::remove_dir_all(&scratch);
1208 }
1209
1210 /// Every chunk of one model message carries the same `messageId`, its
1211 /// reasoning included, and the message after a tool round gets a new one
1212 /// (issue #195).
1213 #[test]
1214 fn chunks_of_one_model_message_share_a_message_id() {
1215 let endpoint = start_fake_endpoint(vec![
1216 sse_body(&[
1217 json!({"choices": [{"delta": {"reasoning_content": "Where is it?"}}]}),
1218 json!({"choices": [{"delta": {"content": "I'll look "}}]}),
1219 json!({"choices": [{"delta": {"content": "around."}}]}),
1220 json!({
1221 "choices": [{"delta": {"tool_calls": [{
1222 "index": 0,
1223 "id": "call_1",
1224 "function": {"name": "list_directory", "arguments": r#"{"path":"."}"#},
1225 }]}}]
1226 }),
1227 ]),
1228 sse_text("Found it."),
1229 ]);
1230
1231 let scratch = std::env::temp_dir().join(format!("sigit_acp_msg_ids_{}", std::process::id()));
1232 let config_dir = scratch.join("config");
1233 let cwd = scratch.join("cwd");
1234 std::fs::create_dir_all(&config_dir).unwrap();
1235 std::fs::create_dir_all(&cwd).unwrap();
1236
1237 let mut agent = spawn_agent(endpoint.port, &config_dir);
1238
1239 let id = agent.request(
1240 "initialize",
1241 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1242 );
1243 agent.wait_for_response(id);
1244
1245 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1246 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1247 .as_str()
1248 .expect("session id")
1249 .to_string();
1250
1251 let prompt_id = agent.request(
1252 "session/prompt",
1253 json!({
1254 "sessionId": session_id,
1255 "prompt": [{"type": "text", "text": "find it"}],
1256 }),
1257 );
1258 let (_, updates) = agent.wait_for_response_with_updates(prompt_id);
1259
1260 let chunks: Vec<(&str, &str)> = updates
1261 .iter()
1262 .filter(|update| {
1263 update["sessionUpdate"] == "agent_message_chunk"
1264 || update["sessionUpdate"] == "agent_thought_chunk"
1265 })
1266 .map(|update| {
1267 (
1268 update["content"]["text"].as_str().unwrap_or_default(),
1269 update["messageId"]
1270 .as_str()
1271 .unwrap_or_else(|| panic!("chunk without a messageId: {update}")),
1272 )
1273 })
1274 .collect();
1275 let id_of = |text: &str| {
1276 chunks
1277 .iter()
1278 .find(|(chunk, _)| chunk.contains(text))
1279 .unwrap_or_else(|| panic!("no chunk with {text:?} in {chunks:?}"))
1280 .1
1281 };
1282
1283 let first = id_of("Where is it?");
1284 assert_eq!(id_of("I'll look"), first, "{chunks:?}");
1285 assert_eq!(id_of("around."), first, "{chunks:?}");
1286 assert_ne!(
1287 id_of("Found it."),
1288 first,
1289 "the round after the tool call is a new message: {chunks:?}"
1290 );
1291
1292 drop(agent);
1293 let _ = std::fs::remove_dir_all(&scratch);
1294 }
1295
1296 /// After each model response the client hears how much of the context window
1297 /// the session uses: the endpoint's own count when it gives one, an estimate
1298 /// when it does not (issue #194).
1299 #[test]
1300 fn each_model_response_reports_context_usage() {
1301 let endpoint = start_fake_endpoint(vec![
1302 // No usage on this one.
1303 sse_tool_call("call_1", "list_directory", r#"{"path":"."}"#),
1304 sse_body(&[
1305 json!({"choices": [{"delta": {"content": "Done."}}]}),
1306 json!({"choices": [], "usage": {"prompt_tokens": 1500, "completion_tokens": 21}}),
1307 ]),
1308 ]);
1309
1310 let scratch = std::env::temp_dir().join(format!("sigit_acp_usage_{}", std::process::id()));
1311 let config_dir = scratch.join("config");
1312 let cwd = scratch.join("cwd");
1313 std::fs::create_dir_all(&config_dir).unwrap();
1314 std::fs::create_dir_all(&cwd).unwrap();
1315
1316 let mut agent = spawn_agent(endpoint.port, &config_dir);
1317
1318 let id = agent.request(
1319 "initialize",
1320 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1321 );
1322 agent.wait_for_response(id);
1323
1324 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1325 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1326 .as_str()
1327 .expect("session id")
1328 .to_string();
1329
1330 let prompt_id = agent.request(
1331 "session/prompt",
1332 json!({
1333 "sessionId": session_id,
1334 "prompt": [{"type": "text", "text": "look around"}],
1335 }),
1336 );
1337 let (_, updates) = agent.wait_for_response_with_updates(prompt_id);
1338
1339 let usage: Vec<&Value> = updates
1340 .iter()
1341 .filter(|update| update["sessionUpdate"] == "usage_update")
1342 .collect();
1343 assert_eq!(usage.len(), 2, "one per model response: {usage:?}");
1344 let estimated = usage[0]["used"].as_u64().expect("used");
1345 assert!(estimated > 0, "an estimate stands in for a missing count");
1346 assert_eq!(usage[1]["used"], 1521, "the endpoint's own count wins");
1347 for update in &usage {
1348 assert!(update["size"].as_u64().unwrap_or_default() > 0, "{update}");
1349 }
1350
1351 drop(agent);
1352 let _ = std::fs::remove_dir_all(&scratch);
1353 }
1354
1355 /// A model that writes its tool call out as literal `<tool_call>` text instead
1356 /// of using the structured field must still drive the loop. Before recovery
1357 /// the tag was streamed to the client as prose and the turn ended with no tool
1358 /// calls, so the request silently went unanswered (getsigit/sigit#73).
1359 #[test]
1360 fn a_tool_call_emitted_as_text_is_executed_rather_than_rendered() {
1361 let endpoint = start_fake_endpoint(vec![
1362 // Split mid-tag, the way a real stream arrives.
1363 sse_body(&[
1364 json!({"choices": [{"delta": {"content": "Checking the repo: <tool_c"}}]}),
1365 json!({"choices": [{"delta": {"content": "all>command_output<arg_key>task_id</arg_key>"}}]}),
1366 json!({"choices": [{"delta": {"content": "<arg_value>2</arg_value></tool_call>"}}]}),
1367 ]),
1368 sse_text("All done."),
1369 ]);
1370
1371 let scratch = std::env::temp_dir().join(format!("sigit_acp_inline_{}", std::process::id()));
1372 let config_dir = scratch.join("config");
1373 let cwd = scratch.join("cwd");
1374 std::fs::create_dir_all(&config_dir).unwrap();
1375 std::fs::create_dir_all(&cwd).unwrap();
1376
1377 let mut agent = spawn_agent(endpoint.port, &config_dir);
1378
1379 let id = agent.request(
1380 "initialize",
1381 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1382 );
1383 agent.wait_for_response(id);
1384
1385 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1386 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1387 .as_str()
1388 .expect("session id")
1389 .to_string();
1390
1391 let prompt_id = agent.request(
1392 "session/prompt",
1393 json!({
1394 "sessionId": session_id,
1395 "prompt": [{"type": "text", "text": "check the repo"}],
1396 }),
1397 );
1398 let (_response, rendered) = agent.wait_for_prompt(prompt_id);
1399
1400 // The raw tag must never be shown to the user.
1401 assert!(
1402 !rendered.contains("<tool_call>") && !rendered.contains("<arg_key>"),
1403 "raw tool-call markup reached the client: {rendered:?}"
1404 );
1405 assert!(
1406 rendered.contains("Checking the repo:"),
1407 "surrounding prose should still stream: {rendered:?}"
1408 );
1409
1410 // The loop has to keep going: a second request means the recovered call
1411 // ran and its result was sent back. Before recovery the turn ended here.
1412 let requests = endpoint.requests.lock().unwrap();
1413 assert!(
1414 requests.len() >= 2,
1415 "expected a follow-up request carrying the tool result, got {}",
1416 requests.len()
1417 );
1418 let follow_up = &requests[1];
1419 let messages = follow_up["messages"].as_array().expect("messages");
1420 assert!(
1421 messages.iter().any(|message| message["role"] == "tool"),
1422 "the follow-up should answer the recovered call: {messages:?}"
1423 );
1424 assert!(
1425 messages
1426 .iter()
1427 .any(|message| { message["role"] == "assistant" && message["tool_calls"].is_array() }),
1428 "history must record the recovered call, not the raw tag: {messages:?}"
1429 );
1430 drop(requests);
1431
1432 drop(agent);
1433 let _ = std::fs::remove_dir_all(&scratch);
1434 }
1435
1436 /// A tool-call block that can't be parsed must not reach the editor, and must
1437 /// not stay in history where the model reads it back as a call it made and
1438 /// starts inventing results (getsigit/sigit#97, #105). The model gets one
1439 /// retry with a note that the call didn't run.
1440 #[test]
1441 fn an_unparseable_tool_call_is_hidden_and_retried() {
1442 let endpoint = start_fake_endpoint(vec![
1443 sse_body(&[
1444 json!({"choices": [{"delta": {"content": "Checking. <tool_call>command_output\n\n<invokeID>reply_A"}}]}),
1445 json!({"choices": [{"delta": {"content": "</invokeID>\n<parameter>6</parameter>\n</invoke>\n<function_results>all green"}}]}),
1446 ]),
1447 sse_tool_call(
1448 "call_1",
1449 "command_output",
1450 &json!({"task_id": 6}).to_string(),
1451 ),
1452 sse_text("All done."),
1453 ]);
1454
1455 let scratch = std::env::temp_dir().join(format!("sigit_acp_malformed_{}", std::process::id()));
1456 let config_dir = scratch.join("config");
1457 let cwd = scratch.join("cwd");
1458 std::fs::create_dir_all(&config_dir).unwrap();
1459 std::fs::create_dir_all(&cwd).unwrap();
1460
1461 let mut agent = spawn_agent(endpoint.port, &config_dir);
1462
1463 let id = agent.request(
1464 "initialize",
1465 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1466 );
1467 agent.wait_for_response(id);
1468
1469 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1470 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1471 .as_str()
1472 .expect("session id")
1473 .to_string();
1474
1475 let prompt_id = agent.request(
1476 "session/prompt",
1477 json!({
1478 "sessionId": session_id,
1479 "prompt": [{"type": "text", "text": "is the build done?"}],
1480 }),
1481 );
1482 let (response, rendered) = agent.wait_for_prompt(prompt_id);
1483 assert_eq!(response["result"]["stopReason"], "end_turn");
1484 for markup in [
1485 "<tool_call>",
1486 "<invokeID>",
1487 "<function_results>",
1488 "all green",
1489 ] {
1490 assert!(
1491 !rendered.contains(markup),
1492 "{markup} reached the client: {rendered:?}"
1493 );
1494 }
1495 assert!(rendered.contains("Checking."), "{rendered:?}");
1496 assert!(rendered.contains("All done."), "{rendered:?}");
1497
1498 let requests = endpoint.requests.lock().unwrap();
1499 assert_eq!(
1500 requests.len(),
1501 3,
1502 "expected the retry, then the round answering the retried call"
1503 );
1504 let retry_messages = requests[1]["messages"].as_array().expect("messages");
1505 let last = retry_messages.last().expect("retry note");
1506 assert_eq!(last["role"], "user");
1507 assert!(
1508 last["content"]
1509 .as_str()
1510 .is_some_and(|content| content.starts_with("[siGit Code]")),
1511 "{last:?}"
1512 );
1513 for message in requests[2]["messages"].as_array().expect("messages") {
1514 let content = message["content"].as_str().unwrap_or_default();
1515 assert!(
1516 !content.contains("<tool_call>") && !content.contains("<invokeID>"),
1517 "broken markup was kept in history: {message:?}"
1518 );
1519 }
1520 drop(requests);
1521
1522 drop(agent);
1523 let _ = std::fs::remove_dir_all(&scratch);
1524 }
1525
1526 /// The third identical call is skipped by the repetition guard. It never ran,
1527 /// so its card ends `failed` while the two that did run end `completed`
1528 /// (issue #139).
1529 #[test]
1530 fn a_call_skipped_by_the_repeat_guard_ends_failed() {
1531 let repeated_arguments = json!({"path": "notes.txt"}).to_string();
1532 let endpoint = start_fake_endpoint(vec![
1533 sse_tool_call("call_1", "read_file", &repeated_arguments),
1534 sse_tool_call("call_2", "read_file", &repeated_arguments),
1535 sse_tool_call("call_3", "read_file", &repeated_arguments),
1536 sse_text("Stopping here."),
1537 ]);
1538
1539 let scratch =
1540 std::env::temp_dir().join(format!("sigit_acp_repeat_failed_{}", std::process::id()));
1541 let config_dir = scratch.join("config");
1542 let cwd = scratch.join("cwd");
1543 std::fs::create_dir_all(&config_dir).unwrap();
1544 std::fs::create_dir_all(&cwd).unwrap();
1545 std::fs::write(cwd.join("notes.txt"), "hello").unwrap();
1546
1547 let mut agent = spawn_agent(endpoint.port, &config_dir);
1548
1549 let id = agent.request(
1550 "initialize",
1551 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1552 );
1553 agent.wait_for_response(id);
1554
1555 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1556 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1557 .as_str()
1558 .expect("session id")
1559 .to_string();
1560
1561 let prompt_id = agent.request(
1562 "session/prompt",
1563 json!({
1564 "sessionId": session_id,
1565 "prompt": [{"type": "text", "text": "read the notes"}],
1566 }),
1567 );
1568 let (response, updates) = agent.wait_for_response_with_updates(prompt_id);
1569 assert_eq!(response["result"]["stopReason"], "end_turn");
1570
1571 let final_status = |call_id: &str| {
1572 updates
1573 .iter()
1574 .rev()
1575 .find(|update| {
1576 update["sessionUpdate"] == "tool_call_update"
1577 && update["toolCallId"] == call_id
1578 && !update["status"].is_null()
1579 })
1580 .unwrap_or_else(|| panic!("{call_id} is closed out: {updates:#?}"))["status"]
1581 .clone()
1582 };
1583 assert_eq!(final_status("call_1"), "completed");
1584 assert_eq!(final_status("call_2"), "completed");
1585 assert_eq!(final_status("call_3"), "failed");
1586
1587 drop(agent);
1588 let _ = std::fs::remove_dir_all(&scratch);
1589 }
1590
1591 /// Once the repetition guard removes tools from a request, a model may still
1592 /// emit its learned tool syntax. Known calls must be removed from the visible
1593 /// response without being executed or recorded as orphaned history entries.
1594 #[test]
1595 fn forced_text_suppresses_glm_check_status_tool_markup() {
1596 let repeated_arguments = json!({"path": "missing.txt"}).to_string();
1597 let endpoint = start_fake_endpoint(vec![
1598 sse_tool_call("call_1", "read_file", &repeated_arguments),
1599 sse_tool_call("call_2", "read_file", &repeated_arguments),
1600 sse_tool_call("call_3", "read_file", &repeated_arguments),
1601 sse_body(&[
1602 json!({"choices": [{"delta": {"content": "Build is still running. <tool_call>command_output CheckStatus=true_or_poll_"}}]}),
1603 json!({"choices": [{"delta": {"content": "again_with_different_params</arg_value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_call>"}}]}),
1604 // Also enforce the forced-text boundary for a structured call.
1605 json!({"choices": [{"delta": {"tool_calls": [{
1606 "index": 0,
1607 "id": "call_4",
1608 "function": {"name": "command_output", "arguments": "{\"task_id\":999}"},
1609 }]}}]}),
1610 ]),
1611 sse_text("Next turn."),
1612 ]);
1613
1614 let scratch =
1615 std::env::temp_dir().join(format!("sigit_acp_forced_text_{}", std::process::id()));
1616 let config_dir = scratch.join("config");
1617 let cwd = scratch.join("cwd");
1618 std::fs::create_dir_all(&config_dir).unwrap();
1619 std::fs::create_dir_all(&cwd).unwrap();
1620
1621 let mut agent = spawn_agent(endpoint.port, &config_dir);
1622
1623 let id = agent.request(
1624 "initialize",
1625 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1626 );
1627 agent.wait_for_response(id);
1628
1629 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1630 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1631 .as_str()
1632 .expect("session id")
1633 .to_string();
1634
1635 let prompt_id = agent.request(
1636 "session/prompt",
1637 json!({
1638 "sessionId": session_id,
1639 "prompt": [{"type": "text", "text": "keep polling"}],
1640 }),
1641 );
1642 let (response, rendered) = agent.wait_for_prompt(prompt_id);
1643 assert_eq!(response["result"]["stopReason"], "end_turn");
1644 assert!(
1645 rendered.contains("Build is still running."),
1646 "surrounding prose should remain visible: {rendered:?}"
1647 );
1648 assert!(
1649 !rendered.contains("<tool_call>")
1650 && !rendered.contains("<arg_key>")
1651 && !rendered.contains("CheckStatus"),
1652 "forced tool markup reached the client: {rendered:?}"
1653 );
1654
1655 {
1656 let requests = endpoint.requests.lock().unwrap();
1657 assert_eq!(
1658 requests.len(),
1659 4,
1660 "a forced-text tool call must not start another inference round"
1661 );
1662 assert!(
1663 requests[3].get("tools").is_none(),
1664 "the repetition guard must not advertise tools: {:?}",
1665 requests[3]
1666 );
1667 }
1668
1669 // Start another turn so the prior assistant message is replayed and its
1670 // sanitized history shape can be inspected in the recorded request.
1671 let next_prompt_id = agent.request(
1672 "session/prompt",
1673 json!({
1674 "sessionId": session_id,
1675 "prompt": [{"type": "text", "text": "what happened?"}],
1676 }),
1677 );
1678 agent.wait_for_prompt(next_prompt_id);
1679
1680 let requests = endpoint.requests.lock().unwrap();
1681 let replayed_messages = requests[4]["messages"].as_array().expect("messages");
1682 let prior_assistant = replayed_messages
1683 .iter()
1684 .rev()
1685 .find(|message| {
1686 message["role"] == "assistant"
1687 && message["content"]
1688 .as_str()
1689 .is_some_and(|content| content.contains("Build is still running."))
1690 })
1691 .expect("sanitized forced-text assistant response");
1692 assert!(prior_assistant.get("tool_calls").is_none());
1693 assert!(
1694 !prior_assistant["content"]
1695 .as_str()
1696 .expect("assistant content")
1697 .contains("<tool_call>")
1698 );
1699 drop(requests);
1700
1701 drop(agent);
1702 let _ = std::fs::remove_dir_all(&scratch);
1703 }
1704
1705 /// Kimi K3 may emit tool calls in XTML content blocks even when the endpoint is
1706 /// OpenAI-compatible. The backend should recover those before ACP sees them,
1707 /// unwrap visible response text, and hide private thinking text.
1708 #[test]
1709 fn a_kimi_k3_tool_call_emitted_as_text_is_executed_rather_than_rendered() {
1710 let endpoint = start_fake_endpoint(vec![
1711 // Split across frames to cover the streaming scanner, not just the
1712 // complete-response extractor.
1713 sse_body(&[
1714 json!({"choices": [{"delta": {"content": "<|open|>think<|sep|>private notes<|close|>think<|sep|>"}}]}),
1715 json!({"choices": [{"delta": {"content": "<|open|>response<|sep|>Checking the repo:<|close|>response<|sep|> <|open|>too"}}]}),
1716 json!({"choices": [{"delta": {"content": "ls<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|>"}}]}),
1717 json!({"choices": [{"delta": {"content": "<|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>"}}]}),
1718 ]),
1719 sse_text("All done."),
1720 ]);
1721
1722 let scratch = std::env::temp_dir().join(format!("sigit_acp_kimi_k3_{}", std::process::id()));
1723 let config_dir = scratch.join("config");
1724 let cwd = scratch.join("cwd");
1725 std::fs::create_dir_all(&config_dir).unwrap();
1726 std::fs::create_dir_all(&cwd).unwrap();
1727
1728 let mut agent = spawn_agent(endpoint.port, &config_dir);
1729
1730 let id = agent.request(
1731 "initialize",
1732 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1733 );
1734 agent.wait_for_response(id);
1735
1736 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1737 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1738 .as_str()
1739 .expect("session id")
1740 .to_string();
1741
1742 let prompt_id = agent.request(
1743 "session/prompt",
1744 json!({
1745 "sessionId": session_id,
1746 "prompt": [{"type": "text", "text": "check the repo"}],
1747 }),
1748 );
1749 let (_response, rendered) = agent.wait_for_prompt(prompt_id);
1750
1751 assert!(
1752 !rendered.contains("<|open|>")
1753 && !rendered.contains("<|sep|>")
1754 && !rendered.contains("private notes"),
1755 "raw Kimi K3 markup reached the client: {rendered:?}"
1756 );
1757 assert!(
1758 rendered.contains("Checking the repo:"),
1759 "response text should still be shown: {rendered:?}"
1760 );
1761
1762 let requests = endpoint.requests.lock().unwrap();
1763 assert!(
1764 requests.len() >= 2,
1765 "expected a follow-up request carrying the tool result, got {}",
1766 requests.len()
1767 );
1768 let messages = requests[1]["messages"].as_array().expect("messages");
1769 assert!(
1770 messages.iter().any(|message| message["role"] == "tool"),
1771 "the follow-up should answer the recovered call: {messages:?}"
1772 );
1773 assert!(
1774 messages
1775 .iter()
1776 .any(|message| { message["role"] == "assistant" && message["tool_calls"].is_array() }),
1777 "history must record the recovered call, not the raw tag: {messages:?}"
1778 );
1779 drop(requests);
1780
1781 drop(agent);
1782 let _ = std::fs::remove_dir_all(&scratch);
1783 }
1784
1785 #[test]
1786 fn a_silent_forced_text_round_still_ends_with_a_message() {
1787 let repeated_arguments = json!({"path": "missing.txt"}).to_string();
1788 let endpoint = start_fake_endpoint(vec![
1789 sse_text_then_tool_call(
1790 "Checking the file.",
1791 "call_1",
1792 "read_file",
1793 &repeated_arguments,
1794 ),
1795 sse_tool_call("call_2", "read_file", &repeated_arguments),
1796 sse_tool_call("call_3", "read_file", &repeated_arguments),
1797 // The forced no-tools round says nothing, as in the stalled thread.
1798 sse_body(&[]),
1799 ]);
1800
1801 let scratch =
1802 std::env::temp_dir().join(format!("sigit_acp_silent_stop_{}", std::process::id()));
1803 let config_dir = scratch.join("config");
1804 let cwd = scratch.join("cwd");
1805 std::fs::create_dir_all(&config_dir).unwrap();
1806 std::fs::create_dir_all(&cwd).unwrap();
1807
1808 let mut agent = spawn_agent(endpoint.port, &config_dir);
1809 let id = agent.request(
1810 "initialize",
1811 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1812 );
1813 agent.wait_for_response(id);
1814 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1815 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1816 .as_str()
1817 .expect("session id")
1818 .to_string();
1819
1820 let prompt_id = agent.request(
1821 "session/prompt",
1822 json!({
1823 "sessionId": session_id,
1824 "prompt": [{"type": "text", "text": "read it"}],
1825 }),
1826 );
1827 let (response, rendered) = agent.wait_for_prompt(prompt_id);
1828 assert_eq!(response["result"]["stopReason"], "end_turn");
1829 assert!(rendered.contains("Checking the file."), "{rendered:?}");
1830 assert!(
1831 rendered.contains("same `read_file` call") && rendered.contains("continue"),
1832 "the turn ended without telling the user why: {rendered:?}"
1833 );
1834
1835 drop(agent);
1836 let _ = std::fs::remove_dir_all(&scratch);
1837 }
1838
1839 #[test]
1840 fn repeated_command_output_calls_remain_available() {
1841 let repeated_arguments = json!({"task_id": 999}).to_string();
1842 let endpoint = start_fake_endpoint(vec![
1843 sse_tool_call("call_1", "command_output", &repeated_arguments),
1844 sse_tool_call("call_2", "command_output", &repeated_arguments),
1845 sse_tool_call("call_3", "command_output", &repeated_arguments),
1846 sse_text("The background task finished."),
1847 ]);
1848
1849 let scratch =
1850 std::env::temp_dir().join(format!("sigit_acp_repeated_poll_{}", std::process::id()));
1851 let config_dir = scratch.join("config");
1852 let cwd = scratch.join("cwd");
1853 std::fs::create_dir_all(&config_dir).unwrap();
1854 std::fs::create_dir_all(&cwd).unwrap();
1855
1856 let mut agent = spawn_agent(endpoint.port, &config_dir);
1857 let id = agent.request(
1858 "initialize",
1859 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1860 );
1861 agent.wait_for_response(id);
1862 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1863 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1864 .as_str()
1865 .expect("session id")
1866 .to_string();
1867
1868 let prompt_id = agent.request(
1869 "session/prompt",
1870 json!({
1871 "sessionId": session_id,
1872 "prompt": [{"type": "text", "text": "wait for the background task"}],
1873 }),
1874 );
1875 let (response, rendered) = agent.wait_for_prompt(prompt_id);
1876 assert_eq!(response["result"]["stopReason"], "end_turn");
1877 assert!(rendered.contains("finished"), "got: {rendered:?}");
1878
1879 let requests = endpoint.requests.lock().unwrap();
1880 assert_eq!(requests.len(), 4);
1881 assert!(
1882 requests[3].get("tools").is_some(),
1883 "command_output repetition must not force tools off: {:?}",
1884 requests[3]
1885 );
1886 drop(requests);
1887
1888 drop(agent);
1889 let _ = std::fs::remove_dir_all(&scratch);
1890 }
1891
1892 #[test]
1893 fn write_todos_reaches_the_client_as_an_acp_plan() {
1894 let endpoint = start_fake_endpoint(vec![
1895 sse_tool_call(
1896 "call_1",
1897 "write_todos",
1898 &json!({
1899 "todos": [
1900 {"content": "Fetch issue screenshot", "status": "completed"},
1901 {"content": "Implement ACP styling", "status": "in_progress"},
1902 {"content": "Build and verify", "status": "pending"}
1903 ]
1904 })
1905 .to_string(),
1906 ),
1907 sse_text("Continuing."),
1908 ]);
1909
1910 let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_{}", std::process::id()));
1911 let config_dir = scratch.join("config");
1912 let cwd = scratch.join("cwd");
1913 std::fs::create_dir_all(&config_dir).unwrap();
1914 std::fs::create_dir_all(&cwd).unwrap();
1915
1916 let mut agent = spawn_agent(endpoint.port, &config_dir);
1917
1918 let id = agent.request(
1919 "initialize",
1920 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1921 );
1922 agent.wait_for_response(id);
1923
1924 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1925 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1926 .as_str()
1927 .expect("session id")
1928 .to_string();
1929
1930 let prompt_id = agent.request(
1931 "session/prompt",
1932 json!({
1933 "sessionId": session_id,
1934 "prompt": [{"type": "text", "text": "fix the ACP display"}],
1935 }),
1936 );
1937 let (response, updates) = agent.wait_for_response_with_updates(prompt_id);
1938 assert_eq!(response["result"]["stopReason"], "end_turn");
1939
1940 let plan = updates
1941 .iter()
1942 .find(|update| update["sessionUpdate"] == "plan")
1943 .expect("write_todos should be rendered as an ACP plan update");
1944 assert_eq!(plan["entries"][0]["content"], "Fetch issue screenshot");
1945 assert_eq!(plan["entries"][0]["status"], "completed");
1946 assert_eq!(plan["entries"][1]["status"], "in_progress");
1947 assert_eq!(plan["entries"][2]["status"], "pending");
1948
1949 assert!(
1950 !updates.iter().any(|update| {
1951 update["sessionUpdate"] == "tool_call" && update["title"] == "write_todos"
1952 }),
1953 "write_todos must not show up as a generic tool-call card: {updates:?}"
1954 );
1955
1956 drop(agent);
1957 let _ = std::fs::remove_dir_all(&scratch);
1958 }
1959
1960 /// An empty `write_todos` is how the model clears a finished checklist. It has
1961 /// to reach the client as a plan with no entries, since ACP plan updates
1962 /// replace the whole plan, rather than as an error card that leaves the old
1963 /// list on screen.
1964 #[test]
1965 fn an_empty_write_todos_clears_the_acp_plan() {
1966 let endpoint = start_fake_endpoint(vec![
1967 sse_tool_call("call_1", "write_todos", &json!({"todos": []}).to_string()),
1968 sse_text("Checklist cleared."),
1969 ]);
1970
1971 let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_clear_{}", std::process::id()));
1972 let config_dir = scratch.join("config");
1973 let cwd = scratch.join("cwd");
1974 std::fs::create_dir_all(&config_dir).unwrap();
1975 std::fs::create_dir_all(&cwd).unwrap();
1976
1977 let mut agent = spawn_agent(endpoint.port, &config_dir);
1978
1979 let id = agent.request(
1980 "initialize",
1981 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1982 );
1983 agent.wait_for_response(id);
1984
1985 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1986 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1987 .as_str()
1988 .expect("session id")
1989 .to_string();
1990
1991 let prompt_id = agent.request(
1992 "session/prompt",
1993 json!({
1994 "sessionId": session_id,
1995 "prompt": [{"type": "text", "text": "clear the checklist"}],
1996 }),
1997 );
1998 let (response, updates) = agent.wait_for_response_with_updates(prompt_id);
1999 assert_eq!(response["result"]["stopReason"], "end_turn");
2000
2001 let plan = updates
2002 .iter()
2003 .find(|update| update["sessionUpdate"] == "plan")
2004 .expect("an empty write_todos should still send a plan update");
2005 assert_eq!(plan["entries"], json!([]));
2006 assert!(
2007 !updates
2008 .iter()
2009 .any(|update| update["sessionUpdate"] == "tool_call"),
2010 "clearing the plan must not show a tool-call card: {updates:?}"
2011 );
2012
2013 drop(agent);
2014 let _ = std::fs::remove_dir_all(&scratch);
2015 }
2016
2017 /// An off-enum status must not make the whole call vanish. `exec_write_todos`
2018 /// renders it as pending and reports success, so the plan has to carry it the
2019 /// same way; dropping the plan here would leave the client showing a stale list.
2020 #[test]
2021 fn write_todos_with_an_unknown_status_still_reaches_the_client() {
2022 let endpoint = start_fake_endpoint(vec![
2023 sse_tool_call(
2024 "call_1",
2025 "write_todos",
2026 &json!({
2027 "todos": [
2028 {"content": "Fetch issue screenshot", "status": "completed"},
2029 {"content": "Ship it", "status": "cancelled"}
2030 ]
2031 })
2032 .to_string(),
2033 ),
2034 sse_text("Continuing."),
2035 ]);
2036
2037 let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_odd_{}", std::process::id()));
2038 let config_dir = scratch.join("config");
2039 let cwd = scratch.join("cwd");
2040 std::fs::create_dir_all(&config_dir).unwrap();
2041 std::fs::create_dir_all(&cwd).unwrap();
2042
2043 let mut agent = spawn_agent(endpoint.port, &config_dir);
2044
2045 let id = agent.request(
2046 "initialize",
2047 json!({"protocolVersion": 1, "clientCapabilities": {}}),
2048 );
2049 agent.wait_for_response(id);
2050
2051 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
2052 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
2053 .as_str()
2054 .expect("session id")
2055 .to_string();
2056
2057 let prompt_id = agent.request(
2058 "session/prompt",
2059 json!({
2060 "sessionId": session_id,
2061 "prompt": [{"type": "text", "text": "plan the work"}],
2062 }),
2063 );
2064 let (_response, updates) = agent.wait_for_response_with_updates(prompt_id);
2065
2066 let plan = updates
2067 .iter()
2068 .find(|update| update["sessionUpdate"] == "plan")
2069 .expect("an unknown status must not drop the plan");
2070 assert_eq!(plan["entries"][1]["content"], "Ship it");
2071 assert_eq!(plan["entries"][1]["status"], "pending");
2072
2073 drop(agent);
2074 let _ = std::fs::remove_dir_all(&scratch);
2075 }
2076
2077 /// Arguments the converter cannot turn into a plan fall back to the ordinary
2078 /// tool-call card. Without that fallback the call is announced nowhere at all.
2079 #[test]
2080 fn unconvertible_write_todos_falls_back_to_a_tool_call_card() {
2081 let endpoint = start_fake_endpoint(vec![
2082 // `todos` is absent, so there is no plan to build. `exec_write_todos`
2083 // answers with an error string, and the client still has to see the call.
2084 sse_tool_call("call_1", "write_todos", &json!({"items": []}).to_string()),
2085 sse_text("Continuing."),
2086 ]);
2087
2088 let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_bad_{}", std::process::id()));
2089 let config_dir = scratch.join("config");
2090 let cwd = scratch.join("cwd");
2091 std::fs::create_dir_all(&config_dir).unwrap();
2092 std::fs::create_dir_all(&cwd).unwrap();
2093
2094 let mut agent = spawn_agent(endpoint.port, &config_dir);
2095
2096 let id = agent.request(
2097 "initialize",
2098 json!({"protocolVersion": 1, "clientCapabilities": {}}),
2099 );
2100 agent.wait_for_response(id);
2101
2102 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
2103 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
2104 .as_str()
2105 .expect("session id")
2106 .to_string();
2107
2108 let prompt_id = agent.request(
2109 "session/prompt",
2110 json!({
2111 "sessionId": session_id,
2112 "prompt": [{"type": "text", "text": "plan the work"}],
2113 }),
2114 );
2115 let (_response, updates) = agent.wait_for_response_with_updates(prompt_id);
2116
2117 assert!(
2118 !updates
2119 .iter()
2120 .any(|update| update["sessionUpdate"] == "plan"),
2121 "there is no plan to send: {updates:?}"
2122 );
2123 assert!(
2124 updates.iter().any(|update| {
2125 update["sessionUpdate"] == "tool_call" && update["title"] == "write_todos"
2126 }),
2127 "the call must still be announced as a tool call: {updates:?}"
2128 );
2129
2130 drop(agent);
2131 let _ = std::fs::remove_dir_all(&scratch);
2132 }
2133
2134 // ── Session modes (issue #148) ──────────────────────────────────────────────
2135
2136 /// The Permissions dropdown is also offered as ACP session modes, for a client
2137 /// that draws the mode selector and not config options. Both are the same
2138 /// state, so a change made through either has to show up in the other.
2139 #[test]
2140 fn session_modes_mirror_the_permissions_config_option() {
2141 let endpoint = start_fake_endpoint(vec![
2142 sse_tool_call("call_1", "run_command", "{\"command\":\"echo sigit-mode\"}"),
2143 sse_text("planned"),
2144 ]);
2145
2146 let scratch = std::env::temp_dir().join(format!("sigit_acp_modes_{}", std::process::id()));
2147 let config_dir = scratch.join("config");
2148 let cwd = scratch.join("cwd");
2149 std::fs::create_dir_all(&config_dir).unwrap();
2150 std::fs::create_dir_all(&cwd).unwrap();
2151
2152 let mut agent = spawn_agent(endpoint.port, &config_dir);
2153
2154 let id = agent.request(
2155 "initialize",
2156 json!({"protocolVersion": 1, "clientCapabilities": {}}),
2157 );
2158 agent.wait_for_response(id);
2159
2160 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
2161 let response = agent.wait_for_response(id);
2162 let session_id = response["result"]["sessionId"]
2163 .as_str()
2164 .expect("session id")
2165 .to_string();
2166 let modes = &response["result"]["modes"];
2167 assert_eq!(modes["currentModeId"], "permission-mode-manual");
2168 let mode_ids: Vec<&str> = modes["availableModes"]
2169 .as_array()
2170 .expect("available modes")
2171 .iter()
2172 .filter_map(|mode| mode["id"].as_str())
2173 .collect();
2174 assert_eq!(
2175 mode_ids,
2176 [
2177 "permission-mode-manual",
2178 "permission-mode-auto",
2179 "permission-mode-plan"
2180 ]
2181 );
2182
2183 // set_mode changes the session and refreshes the config option.
2184 let id = agent.request(
2185 "session/set_mode",
2186 json!({"sessionId": session_id, "modeId": "permission-mode-plan"}),
2187 );
2188 let (_response, updates) = agent.wait_for_response_with_updates(id);
2189 let refreshed = updates
2190 .iter()
2191 .find(|update| update["sessionUpdate"] == "config_option_update")
2192 .expect("set_mode refreshes the config options");
2193 let permissions = refreshed["configOptions"]
2194 .as_array()
2195 .expect("config options")
2196 .iter()
2197 .find(|option| option["id"] == "sigit-permission-mode")
2198 .expect("permissions config option");
2199 assert_eq!(permissions["currentValue"], "permission-mode-plan");
2200
2201 // Plan mode is really on: the mutating call is denied, not asked about.
2202 let prompt_id = agent.request(
2203 "session/prompt",
2204 json!({
2205 "sessionId": session_id,
2206 "prompt": [{"type": "text", "text": "run the command"}],
2207 }),
2208 );
2209 let (response, _updates) = agent.wait_for_response_with_updates(prompt_id);
2210 assert_eq!(response["result"]["stopReason"], "end_turn");
2211 {
2212 let requests = endpoint.requests.lock().unwrap();
2213 let messages = requests[1]["messages"].as_array().expect("messages");
2214 let result = messages
2215 .iter()
2216 .find(|message| message["role"] == "tool" && message["tool_call_id"] == "call_1")
2217 .expect("tool result for the blocked call");
2218 assert!(
2219 !result["content"]
2220 .as_str()
2221 .unwrap_or_default()
2222 .contains("sigit-mode"),
2223 "plan mode must not run the command: {result}"
2224 );
2225 }
2226
2227 // The config option changes the mode, and says so to the mode selector.
2228 let id = agent.request(
2229 "session/set_config_option",
2230 json!({
2231 "sessionId": session_id,
2232 "configId": "sigit-permission-mode",
2233 "value": "permission-mode-auto",
2234 }),
2235 );
2236 let (_response, updates) = agent.wait_for_response_with_updates(id);
2237 let current = updates
2238 .iter()
2239 .find(|update| update["sessionUpdate"] == "current_mode_update")
2240 .expect("a config option change announces the new mode");
2241 assert_eq!(current["currentModeId"], "permission-mode-auto");
2242
2243 // So does /plan.
2244 let prompt_id = agent.request(
2245 "session/prompt",
2246 json!({
2247 "sessionId": session_id,
2248 "prompt": [{"type": "text", "text": "/plan on"}],
2249 }),
2250 );
2251 let (_response, updates) = agent.wait_for_response_with_updates(prompt_id);
2252 let current = updates
2253 .iter()
2254 .find(|update| update["sessionUpdate"] == "current_mode_update")
2255 .expect("/plan announces the new mode");
2256 assert_eq!(current["currentModeId"], "permission-mode-plan");
2257
2258 // An id that is not a mode is an invalid-params error, and changes nothing.
2259 let id = agent.request(
2260 "session/set_mode",
2261 json!({"sessionId": session_id, "modeId": "permission-mode-yolo"}),
2262 );
2263 let response = agent.wait_for("the set_mode error", |message| {
2264 message["id"] == id && message.get("method").is_none()
2265 });
2266 assert_eq!(response["error"]["code"], -32602, "{response}");
2267
2268 drop(agent);
2269 let _ = std::fs::remove_dir_all(&scratch);
2270 }
2271
2272 /// A mode change is heard while a turn waits on a permission prompt, and the
2273 /// call that follows obeys it. The turn holds its session's lock for its whole
2274 /// length, so a change that queued behind it would sit unanswered until the
2275 /// prompt had been dealt with.
2276 #[test]
2277 fn a_mode_switch_during_a_permission_prompt_is_answered_at_once() {
2278 let endpoint = start_fake_endpoint(vec![
2279 sse_tool_call("call_1", "run_command", r#"{"command":"echo sigit-first"}"#),
2280 sse_tool_call(
2281 "call_2",
2282 "run_command",
2283 r#"{"command":"echo sigit-second"}"#,
2284 ),
2285 sse_text("done"),
2286 ]);
2287
2288 let scratch = std::env::temp_dir().join(format!("sigit_acp_mode_mid_{}", std::process::id()));
2289 let config_dir = scratch.join("config");
2290 let cwd = scratch.join("cwd");
2291 std::fs::create_dir_all(&config_dir).unwrap();
2292 std::fs::create_dir_all(&cwd).unwrap();
2293
2294 let mut agent = spawn_agent(endpoint.port, &config_dir);
2295
2296 let id = agent.request(
2297 "initialize",
2298 json!({"protocolVersion": 1, "clientCapabilities": {}}),
2299 );
2300 agent.wait_for_response(id);
2301 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
2302 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
2303 .as_str()
2304 .expect("session id")
2305 .to_string();
2306
2307 let prompt_id = agent.request(
2308 "session/prompt",
2309 json!({
2310 "sessionId": session_id,
2311 "prompt": [{"type": "text", "text": "run both commands"}],
2312 }),
2313 );
2314 let permission = agent.wait_for_agent_request("session/request_permission");
2315
2316 // The turn is parked on the question. Switch to Auto, and expect the
2317 // answer now, not after the turn.
2318 let mode_id = agent.request(
2319 "session/set_mode",
2320 json!({"sessionId": session_id, "modeId": "permission-mode-auto"}),
2321 );
2322 let response = agent.wait_for(
2323 "the set_mode response while the permission prompt is open",
2324 |message| {
2325 message["id"] == mode_id && message.get("method").is_none()
2326 || message["id"] == prompt_id && message.get("method").is_none()
2327 },
2328 );
2329 assert_eq!(
2330 response["id"], mode_id,
2331 "the turn ended before set_mode was answered: {response}"
2332 );
2333 assert!(response.get("error").is_none(), "{response}");
2334
2335 // Same for the config option, which is the path most editors use.
2336 let option_id = agent.request(
2337 "session/set_config_option",
2338 json!({
2339 "sessionId": session_id,
2340 "configId": "sigit-permission-mode",
2341 "value": "permission-mode-auto",
2342 }),
2343 );
2344 let response = agent.wait_for(
2345 "the set_config_option response while the permission prompt is open",
2346 |message| {
2347 message["id"] == option_id && message.get("method").is_none()
2348 || message["id"] == prompt_id && message.get("method").is_none()
2349 },
2350 );
2351 assert_eq!(response["id"], option_id, "{response}");
2352 assert!(response.get("error").is_none(), "{response}");
2353
2354 // Allow the pending call. The next one is decided under Auto: no prompt.
2355 agent.respond(
2356 permission["id"].clone(),
2357 json!({"outcome": {"outcome": "selected", "optionId": "allow_once"}}),
2358 );
2359 let (response, _updates) = agent.wait_for_response_with_updates(prompt_id);
2360 assert_eq!(response["result"]["stopReason"], "end_turn");
2361
2362 let requests = endpoint.requests.lock().unwrap();
2363 let second_result = requests[2]["messages"]
2364 .as_array()
2365 .expect("messages")
2366 .iter()
2367 .find(|message| message["role"] == "tool" && message["tool_call_id"] == "call_2")
2368 .expect("tool result for the second call");
2369 assert!(
2370 second_result["content"]
2371 .as_str()
2372 .unwrap_or_default()
2373 .contains("sigit-second"),
2374 "the second call should have run under Auto: {second_result}"
2375 );
2376
2377 drop(requests);
2378 drop(agent);
2379 let _ = std::fs::remove_dir_all(&scratch);
2380 }
2381
2382 #[test]
2383 fn a_mode_change_for_an_unknown_session_is_resource_not_found() {
2384 let endpoint = start_fake_endpoint(vec![]);
2385 let scratch =
2386 std::env::temp_dir().join(format!("sigit_acp_mode_unknown_{}", std::process::id()));
2387 let config_dir = scratch.join("config");
2388 std::fs::create_dir_all(&config_dir).unwrap();
2389
2390 let mut agent = spawn_agent(endpoint.port, &config_dir);
2391 let id = agent.request(
2392 "initialize",
2393 json!({"protocolVersion": 1, "clientCapabilities": {}}),
2394 );
2395 agent.wait_for_response(id);
2396
2397 let id = agent.request(
2398 "session/set_mode",
2399 json!({"sessionId": "nope", "modeId": "permission-mode-auto"}),
2400 );
2401 let response = agent.wait_for("set_mode answer", |m| m["id"] == id);
2402 assert_eq!(response["error"]["code"], -32002, "{response}");
2403
2404 drop(agent);
2405 let _ = std::fs::remove_dir_all(&scratch);
2406 }