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!(option_ids, ["allow_once", "allow_session", "reject_once"]);
388
389 agent.respond(
390 permission["id"].clone(),
391 json!({"outcome": {"outcome": "cancelled"}}),
392 );
393
394 let response = agent.wait_for_response(prompt_id);
395 assert_eq!(response["result"]["stopReason"], "cancelled");
396
397 // ── Turn 2: history must be repaired; then approve once ─────────────
398 let prompt_id = agent.request(
399 "session/prompt",
400 json!({
401 "sessionId": session_id,
402 "prompt": [{"type": "text", "text": "run the second command"}],
403 }),
404 );
405
406 let permission = agent.wait_for_agent_request("session/request_permission");
407 agent.respond(
408 permission["id"].clone(),
409 json!({"outcome": {"outcome": "selected", "optionId": "allow_once"}}),
410 );
411
412 let response = agent.wait_for_response(prompt_id);
413 assert_eq!(response["result"]["stopReason"], "end_turn");
414
415 // ── What the endpoint saw ────────────────────────────────────────────
416 let requests = endpoint.requests.lock().unwrap();
417 assert_eq!(requests.len(), 3, "expected exactly three completions");
418
419 // The client explicitly requests automatic tool selection. Some
420 // OpenAI-compatible gateways do not honour OpenAI's implicit `auto`
421 // default, which otherwise lets the model describe a tool action in prose
422 // and end the turn without a tool call.
423 assert_eq!(requests[0]["tool_choice"], "auto");
424
425 // Request 2 replays the full history: the cancelled round's tool call
426 // must be answered by a `role: "tool"` message, not left dangling.
427 let messages = requests[1]["messages"].as_array().expect("messages");
428 let call_position = messages
429 .iter()
430 .position(|message| message["tool_calls"][0]["id"] == "call_1")
431 .expect("cancelled turn's assistant tool call in replayed history");
432 let repair = &messages[call_position + 1];
433 assert_eq!(repair["role"], "tool", "dangling tool call not closed out");
434 assert_eq!(repair["tool_call_id"], "call_1");
435 assert!(
436 repair["content"]
437 .as_str()
438 .unwrap_or_default()
439 .contains("cancelled"),
440 "repair message should say the turn was cancelled: {repair}"
441 );
442
443 // Request 3 carries the approved call's real output.
444 let messages = requests[2]["messages"].as_array().expect("messages");
445 let result = messages
446 .iter()
447 .find(|message| message["role"] == "tool" && message["tool_call_id"] == "call_2")
448 .expect("tool result for the approved call");
449 assert!(
450 result["content"]
451 .as_str()
452 .unwrap_or_default()
453 .contains("sigit-approved"),
454 "the approved command's output should reach the endpoint: {result}"
455 );
456
457 drop(agent);
458 let _ = std::fs::remove_dir_all(&scratch);
459 }
460
461 /// A call that needs approval is one tool call from start to finish (issue
462 /// #138). The permission request used to carry an id of its own, so a client
463 /// drew an approval card next to a call that already claimed to be running.
464 #[test]
465 fn a_permission_request_is_about_the_tool_call_it_was_announced_as() {
466 let endpoint = start_fake_endpoint(vec![
467 sse_tool_call(
468 "call_1",
469 "run_command",
470 r#"{"command":"echo sigit-one-card"}"#,
471 ),
472 sse_text("done"),
473 ]);
474
475 let scratch = std::env::temp_dir().join(format!("sigit_acp_perm_id_{}", std::process::id()));
476 let config_dir = scratch.join("config");
477 let cwd = scratch.join("cwd");
478 std::fs::create_dir_all(&config_dir).unwrap();
479 std::fs::create_dir_all(&cwd).unwrap();
480
481 let mut agent = spawn_agent(endpoint.port, &config_dir);
482
483 let id = agent.request(
484 "initialize",
485 json!({"protocolVersion": 1, "clientCapabilities": {}}),
486 );
487 agent.wait_for_response(id);
488
489 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
490 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
491 .as_str()
492 .expect("session id")
493 .to_string();
494
495 let prompt_id = agent.request(
496 "session/prompt",
497 json!({
498 "sessionId": session_id,
499 "prompt": [{"type": "text", "text": "run the command"}],
500 }),
501 );
502
503 // Messages arrive in order and `wait_for` drops what it skips, so each
504 // step below also checks that the one before it came first.
505 let call_update = |message: &Value, kind: &str| {
506 let update = &message["params"]["update"];
507 message["method"] == "session/update"
508 && update["sessionUpdate"] == kind
509 && update["toolCallId"] == "call_1"
510 };
511
512 let announced = agent.wait_for("the tool call announcement", |message| {
513 call_update(message, "tool_call")
514 });
515 // `pending` is the schema's default status, so it is left off the wire.
516 let status = &announced["params"]["update"]["status"];
517 assert!(
518 status.is_null() || status == "pending",
519 "a call waiting for approval has not started: {announced}"
520 );
521
522 let permission = agent.wait_for_agent_request("session/request_permission");
523 assert_eq!(
524 permission["params"]["toolCall"]["toolCallId"], "call_1",
525 "the request must name the announced call: {permission}"
526 );
527 agent.respond(
528 permission["id"].clone(),
529 json!({"outcome": {"outcome": "selected", "optionId": "allow_once"}}),
530 );
531
532 let started = agent.wait_for("the approved call starting", |message| {
533 call_update(message, "tool_call_update")
534 });
535 let started = &started["params"]["update"];
536 assert_eq!(started["status"], "in_progress", "{started}");
537 assert_eq!(
538 started["title"], announced["params"]["update"]["title"],
539 "the card gets its own title back after the approval dialog: {started}"
540 );
541
542 let finished = agent.wait_for("the call finishing", |message| {
543 call_update(message, "tool_call_update")
544 });
545 assert_eq!(finished["params"]["update"]["status"], "completed");
546
547 let response = agent.wait_for_response(prompt_id);
548 assert_eq!(response["result"]["stopReason"], "end_turn");
549
550 drop(agent);
551 let _ = std::fs::remove_dir_all(&scratch);
552 }
553
554 /// A call the user denies at the prompt did not run, so it ends `failed`
555 /// rather than `completed` (issue #139), and gets its own title back from
556 /// the permission request's.
557 #[test]
558 fn a_call_denied_at_the_prompt_ends_failed() {
559 let endpoint = start_fake_endpoint(vec![
560 sse_tool_call(
561 "call_1",
562 "run_command",
563 r#"{"command":"echo sigit-denied"}"#,
564 ),
565 sse_text("ok, skipped"),
566 ]);
567
568 let scratch = std::env::temp_dir().join(format!("sigit_acp_perm_deny_{}", std::process::id()));
569 let config_dir = scratch.join("config");
570 let cwd = scratch.join("cwd");
571 std::fs::create_dir_all(&config_dir).unwrap();
572 std::fs::create_dir_all(&cwd).unwrap();
573
574 let mut agent = spawn_agent(endpoint.port, &config_dir);
575
576 let id = agent.request(
577 "initialize",
578 json!({"protocolVersion": 1, "clientCapabilities": {}}),
579 );
580 agent.wait_for_response(id);
581
582 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
583 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
584 .as_str()
585 .expect("session id")
586 .to_string();
587
588 let prompt_id = agent.request(
589 "session/prompt",
590 json!({
591 "sessionId": session_id,
592 "prompt": [{"type": "text", "text": "run the command"}],
593 }),
594 );
595
596 let call_update = |message: &Value, kind: &str| {
597 let update = &message["params"]["update"];
598 message["method"] == "session/update"
599 && update["sessionUpdate"] == kind
600 && update["toolCallId"] == "call_1"
601 };
602
603 let announced = agent.wait_for("the tool call announcement", |message| {
604 call_update(message, "tool_call")
605 });
606
607 let permission = agent.wait_for_agent_request("session/request_permission");
608 agent.respond(
609 permission["id"].clone(),
610 json!({"outcome": {"outcome": "selected", "optionId": "reject_once"}}),
611 );
612
613 let finished = agent.wait_for("the denied call closing", |message| {
614 call_update(message, "tool_call_update")
615 });
616 let finished = &finished["params"]["update"];
617 assert_eq!(finished["status"], "failed", "{finished}");
618 assert_eq!(
619 finished["title"], announced["params"]["update"]["title"],
620 "the card gets its own title back after the approval dialog: {finished}"
621 );
622
623 let response = agent.wait_for_response(prompt_id);
624 assert_eq!(response["result"]["stopReason"], "end_turn");
625
626 drop(agent);
627 let _ = std::fs::remove_dir_all(&scratch);
628 }
629
630 #[test]
631 fn successful_config_option_changes_are_rendered_as_system_status_cards() {
632 let endpoint = start_fake_endpoint(vec![]);
633
634 let scratch =
635 std::env::temp_dir().join(format!("sigit_acp_config_messages_{}", std::process::id()));
636 let config_dir = scratch.join("config");
637 let cwd = scratch.join("cwd");
638 std::fs::create_dir_all(&config_dir).unwrap();
639 std::fs::create_dir_all(&cwd).unwrap();
640 std::fs::write(
641 config_dir.join("credentials.toml"),
642 "access_token = \"tok_test\"\nemail = \"dev@sigit.si\"\n",
643 )
644 .unwrap();
645
646 let mut agent = spawn_agent(endpoint.port, &config_dir);
647
648 let id = agent.request(
649 "initialize",
650 json!({"protocolVersion": 1, "clientCapabilities": {}}),
651 );
652 agent.wait_for_response(id);
653
654 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
655 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
656 .as_str()
657 .expect("session id")
658 .to_string();
659
660 let id = agent.request(
661 "session/set_config_option",
662 json!({
663 "sessionId": session_id,
664 "configId": "sigit-local-inference",
665 "value": "local-inference-off",
666 }),
667 );
668 let (response, updates) = agent.wait_for_response_with_updates(id);
669 assert!(
670 response["result"]["configOptions"].is_array(),
671 "config response should refresh picker options: {response}"
672 );
673 assert!(
674 !updates
675 .iter()
676 .any(|update| update["sessionUpdate"] == "agent_message_chunk"),
677 "Local Inference picker changes are passive UI state, not chat text: {updates:?}"
678 );
679 let status = updates
680 .iter()
681 .find(|update| {
682 update["sessionUpdate"] == "tool_call"
683 && update["kind"] == "think"
684 && update["status"] == "completed"
685 })
686 .expect("Local Inference change should render as a system-style status card");
687 assert_eq!(
688 status["title"],
689 "Local inference is off. siGit Code Cloud tiers are highlighted; pick one from Model."
690 );
691
692 let id = agent.request(
693 "session/set_config_option",
694 json!({
695 "sessionId": session_id,
696 "configId": "sigit-model",
697 "value": "sigit-cloud:mini",
698 }),
699 );
700 let (response, updates) = agent.wait_for_response_with_updates(id);
701 assert!(
702 response["result"]["configOptions"].is_array(),
703 "config response should refresh picker options: {response}"
704 );
705 assert!(
706 !updates
707 .iter()
708 .any(|update| update["sessionUpdate"] == "agent_message_chunk"),
709 "successful model picker changes must not be rendered as assistant chat text: {updates:?}"
710 );
711 let status = updates
712 .iter()
713 .find(|update| {
714 update["sessionUpdate"] == "tool_call"
715 && update["kind"] == "think"
716 && update["status"] == "completed"
717 })
718 .expect("cloud tier change should render as a system-style status card");
719 assert!(
720 status["title"]
721 .as_str()
722 .unwrap_or_default()
723 .starts_with("Switched to siGit Code Cloud"),
724 "unexpected cloud switch status title: {status}"
725 );
726
727 drop(agent);
728 let _ = std::fs::remove_dir_all(&scratch);
729 }
730
731 #[test]
732 fn auto_permission_mode_runs_mutating_tools_without_asking() {
733 let endpoint = start_fake_endpoint(vec![
734 sse_tool_call("call_1", "run_command", r#"{"command":"echo sigit-auto"}"#),
735 sse_text("done"),
736 ]);
737
738 let scratch = std::env::temp_dir().join(format!("sigit_acp_auto_perm_{}", std::process::id()));
739 let config_dir = scratch.join("config");
740 let cwd = scratch.join("cwd");
741 std::fs::create_dir_all(&config_dir).unwrap();
742 std::fs::create_dir_all(&cwd).unwrap();
743
744 let mut agent = spawn_agent(endpoint.port, &config_dir);
745
746 let id = agent.request(
747 "initialize",
748 json!({"protocolVersion": 1, "clientCapabilities": {}}),
749 );
750 agent.wait_for_response(id);
751
752 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
753 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
754 .as_str()
755 .expect("session id")
756 .to_string();
757
758 let id = agent.request(
759 "session/set_config_option",
760 json!({
761 "sessionId": session_id,
762 "configId": "sigit-permission-mode",
763 "value": "permission-mode-auto",
764 }),
765 );
766 let (response, updates) = agent.wait_for_response_with_updates(id);
767 let permissions = response["result"]["configOptions"]
768 .as_array()
769 .expect("config options")
770 .iter()
771 .find(|option| option["id"] == "sigit-permission-mode")
772 .expect("permissions config option");
773 assert_eq!(permissions["currentValue"], "permission-mode-auto");
774 assert!(
775 !updates
776 .iter()
777 .any(|update| update["sessionUpdate"] == "agent_message_chunk"),
778 "Permissions picker changes are passive UI state, not chat text: {updates:?}"
779 );
780
781 let prompt_id = agent.request(
782 "session/prompt",
783 json!({
784 "sessionId": session_id,
785 "prompt": [{"type": "text", "text": "run the command"}],
786 }),
787 );
788 let (response, _updates) = agent.wait_for_response_with_updates(prompt_id);
789 assert_eq!(response["result"]["stopReason"], "end_turn");
790
791 let requests = endpoint.requests.lock().unwrap();
792 assert_eq!(requests.len(), 2, "expected tool round and final round");
793 let messages = requests[1]["messages"].as_array().expect("messages");
794 let result = messages
795 .iter()
796 .find(|message| message["role"] == "tool" && message["tool_call_id"] == "call_1")
797 .expect("tool result for the automatic call");
798 assert!(
799 result["content"]
800 .as_str()
801 .unwrap_or_default()
802 .contains("sigit-auto"),
803 "automatic command output should reach the endpoint: {result}"
804 );
805
806 drop(agent);
807 let _ = std::fs::remove_dir_all(&scratch);
808 }
809
810 #[test]
811 fn plan_permission_mode_denies_mutating_tools_without_asking() {
812 let endpoint = start_fake_endpoint(vec![
813 sse_tool_call("call_1", "run_command", r#"{"command":"echo sigit-plan"}"#),
814 sse_text("planned"),
815 ]);
816
817 let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_perm_{}", std::process::id()));
818 let config_dir = scratch.join("config");
819 let cwd = scratch.join("cwd");
820 std::fs::create_dir_all(&config_dir).unwrap();
821 std::fs::create_dir_all(&cwd).unwrap();
822
823 let mut agent = spawn_agent(endpoint.port, &config_dir);
824
825 let id = agent.request(
826 "initialize",
827 json!({"protocolVersion": 1, "clientCapabilities": {}}),
828 );
829 agent.wait_for_response(id);
830
831 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
832 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
833 .as_str()
834 .expect("session id")
835 .to_string();
836
837 let id = agent.request(
838 "session/set_config_option",
839 json!({
840 "sessionId": session_id,
841 "configId": "sigit-permission-mode",
842 "value": "permission-mode-plan",
843 }),
844 );
845 let (response, _updates) = agent.wait_for_response_with_updates(id);
846 let permissions = response["result"]["configOptions"]
847 .as_array()
848 .expect("config options")
849 .iter()
850 .find(|option| option["id"] == "sigit-permission-mode")
851 .expect("permissions config option");
852 assert_eq!(permissions["currentValue"], "permission-mode-plan");
853
854 let prompt_id = agent.request(
855 "session/prompt",
856 json!({
857 "sessionId": session_id,
858 "prompt": [{"type": "text", "text": "run the command"}],
859 }),
860 );
861 let (response, updates) = agent.wait_for_response_with_updates(prompt_id);
862 assert_eq!(response["result"]["stopReason"], "end_turn");
863
864 // The tool never ran, so its card must not end as a success (issue #139).
865 let closed = updates
866 .iter()
867 .rev()
868 .find(|update| {
869 update["sessionUpdate"] == "tool_call_update"
870 && update["toolCallId"] == "call_1"
871 && !update["status"].is_null()
872 })
873 .expect("the blocked call is closed out");
874 assert_eq!(closed["status"], "failed", "{closed}");
875
876 let requests = endpoint.requests.lock().unwrap();
877 assert_eq!(
878 requests.len(),
879 2,
880 "expected denied tool round and final round"
881 );
882 let messages = requests[1]["messages"].as_array().expect("messages");
883 let result = messages
884 .iter()
885 .find(|message| message["role"] == "tool" && message["tool_call_id"] == "call_1")
886 .expect("tool result for the blocked call");
887 let content = result["content"].as_str().unwrap_or_default();
888 assert!(
889 content.contains("Plan mode is active") && content.contains("was not executed"),
890 "plan mode should return an instructive denial to the endpoint: {result}"
891 );
892
893 drop(agent);
894 let _ = std::fs::remove_dir_all(&scratch);
895 }
896
897 #[test]
898 fn a_slow_commands_progress_reaches_the_client_while_it_runs() {
899 let scratch = std::env::temp_dir().join(format!("sigit_acp_progress_{}", std::process::id()));
900 let config_dir = scratch.join("config");
901 let cwd = scratch.join("cwd");
902 std::fs::create_dir_all(&config_dir).unwrap();
903 std::fs::create_dir_all(&cwd).unwrap();
904
905 // The command has to run for a few seconds through `sh -c` / `cmd /C`.
906 // `sleep` is neither a cmd builtin nor on a Windows runner's PATH (Git's
907 // usr\bin, which carries the MSYS coreutils, is deliberately left off it),
908 // and the Windows job hung on it rather than failing fast. `ping` ships in
909 // System32, waits a second between echoes, and never reads stdin.
910 //
911 // Four seconds against the one-second assertion below is deliberate
912 // padding. A busy runner only ever widens the gap being measured — it
913 // cannot make the command finish sooner — so the one way a correct agent
914 // could fail here is this thread being descheduled for seconds between
915 // reading the two updates off the channel. Three seconds of slack covers
916 // that without weakening the assertion, which is separating four seconds
917 // from the 164µs the blocking version produced.
918 #[cfg(unix)]
919 let command = "sleep 4";
920 #[cfg(windows)]
921 let command = "ping -n 5 127.0.0.1";
922
923 // `SIGIT_PERMISSIONS=allow` skips the gate, so the only thing between the
924 // two updates below is the command itself.
925 let endpoint = start_fake_endpoint(vec![
926 sse_tool_call(
927 "call_1",
928 "run_command",
929 &json!({"command": command, "cwd": cwd}).to_string(),
930 ),
931 sse_text("done"),
932 ]);
933
934 let mut agent = spawn_agent_with_permissions(endpoint.port, &config_dir, Some("allow"));
935
936 let id = agent.request(
937 "initialize",
938 json!({"protocolVersion": 1, "clientCapabilities": {}}),
939 );
940 agent.wait_for_response(id);
941
942 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
943 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
944 .as_str()
945 .expect("session id")
946 .to_string();
947
948 let prompt_id = agent.request(
949 "session/prompt",
950 json!({
951 "sessionId": session_id,
952 "prompt": [{"type": "text", "text": "run the slow command"}],
953 }),
954 );
955
956 let started = agent.wait_for_update("the tool call starting", |update| {
957 update["sessionUpdate"] == "tool_call" && update["toolCallId"] == "call_1"
958 });
959 let in_progress_at = Instant::now();
960 assert_eq!(
961 started["status"], "in_progress",
962 "a running tool call must be announced as in_progress: {started}"
963 );
964
965 let finished = agent.wait_for_update("the tool call finishing", |update| {
966 update["sessionUpdate"] == "tool_call_update"
967 && update["toolCallId"] == "call_1"
968 && update["status"] == "completed"
969 });
970
971 // The command runs between the two updates. If the turn blocked the
972 // connection's actors, both would only reach the client once it had
973 // already finished, and the editor would never draw a spinner.
974 assert!(
975 in_progress_at.elapsed() >= Duration::from_millis(1_000),
976 "in_progress arrived only {:?} before completed — the client had no \
977 window to show progress in. `{command}` said: {}",
978 in_progress_at.elapsed(),
979 finished["rawOutput"]
980 );
981
982 let response = agent.wait_for_response(prompt_id);
983 assert_eq!(response["result"]["stopReason"], "end_turn");
984
985 drop(agent);
986 let _ = std::fs::remove_dir_all(&scratch);
987 }
988
989 #[test]
990 fn text_from_two_tool_rounds_reaches_the_client_as_separate_paragraphs() {
991 let endpoint = start_fake_endpoint(vec![
992 // `list_directory` is read-only, so it runs without a permission gate
993 // and the two rounds follow each other straight away.
994 sse_text_then_tool_call(
995 "I'll search for this pattern.",
996 "call_1",
997 "list_directory",
998 r#"{"path":"."}"#,
999 ),
1000 sse_text("Let me broaden the search:"),
1001 ]);
1002
1003 let scratch = std::env::temp_dir().join(format!("sigit_acp_spacing_{}", std::process::id()));
1004 let config_dir = scratch.join("config");
1005 let cwd = scratch.join("cwd");
1006 std::fs::create_dir_all(&config_dir).unwrap();
1007 std::fs::create_dir_all(&cwd).unwrap();
1008
1009 let mut agent = spawn_agent(endpoint.port, &config_dir);
1010
1011 let id = agent.request(
1012 "initialize",
1013 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1014 );
1015 agent.wait_for_response(id);
1016
1017 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1018 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1019 .as_str()
1020 .expect("session id")
1021 .to_string();
1022
1023 let prompt_id = agent.request(
1024 "session/prompt",
1025 json!({
1026 "sessionId": session_id,
1027 "prompt": [{"type": "text", "text": "find it"}],
1028 }),
1029 );
1030 let (response, rendered) = agent.wait_for_prompt(prompt_id);
1031 assert_eq!(response["result"]["stopReason"], "end_turn");
1032
1033 assert!(
1034 rendered.contains("pattern.\n\nLet me broaden"),
1035 "the second round must open a new paragraph, got: {rendered:?}"
1036 );
1037 assert!(
1038 !rendered.contains("pattern.Let me"),
1039 "rounds must not run together into one sentence, got: {rendered:?}"
1040 );
1041
1042 drop(agent);
1043 let _ = std::fs::remove_dir_all(&scratch);
1044 }
1045
1046 /// A model that writes its tool call out as literal `<tool_call>` text instead
1047 /// of using the structured field must still drive the loop. Before recovery
1048 /// the tag was streamed to the client as prose and the turn ended with no tool
1049 /// calls, so the request silently went unanswered (getsigit/sigit#73).
1050 #[test]
1051 fn a_tool_call_emitted_as_text_is_executed_rather_than_rendered() {
1052 let endpoint = start_fake_endpoint(vec![
1053 // Split mid-tag, the way a real stream arrives.
1054 sse_body(&[
1055 json!({"choices": [{"delta": {"content": "Checking the repo: <tool_c"}}]}),
1056 json!({"choices": [{"delta": {"content": "all>command_output<arg_key>task_id</arg_key>"}}]}),
1057 json!({"choices": [{"delta": {"content": "<arg_value>2</arg_value></tool_call>"}}]}),
1058 ]),
1059 sse_text("All done."),
1060 ]);
1061
1062 let scratch = std::env::temp_dir().join(format!("sigit_acp_inline_{}", std::process::id()));
1063 let config_dir = scratch.join("config");
1064 let cwd = scratch.join("cwd");
1065 std::fs::create_dir_all(&config_dir).unwrap();
1066 std::fs::create_dir_all(&cwd).unwrap();
1067
1068 let mut agent = spawn_agent(endpoint.port, &config_dir);
1069
1070 let id = agent.request(
1071 "initialize",
1072 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1073 );
1074 agent.wait_for_response(id);
1075
1076 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1077 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1078 .as_str()
1079 .expect("session id")
1080 .to_string();
1081
1082 let prompt_id = agent.request(
1083 "session/prompt",
1084 json!({
1085 "sessionId": session_id,
1086 "prompt": [{"type": "text", "text": "check the repo"}],
1087 }),
1088 );
1089 let (_response, rendered) = agent.wait_for_prompt(prompt_id);
1090
1091 // The raw tag must never be shown to the user.
1092 assert!(
1093 !rendered.contains("<tool_call>") && !rendered.contains("<arg_key>"),
1094 "raw tool-call markup reached the client: {rendered:?}"
1095 );
1096 assert!(
1097 rendered.contains("Checking the repo:"),
1098 "surrounding prose should still stream: {rendered:?}"
1099 );
1100
1101 // The loop has to keep going: a second request means the recovered call
1102 // ran and its result was sent back. Before recovery the turn ended here.
1103 let requests = endpoint.requests.lock().unwrap();
1104 assert!(
1105 requests.len() >= 2,
1106 "expected a follow-up request carrying the tool result, got {}",
1107 requests.len()
1108 );
1109 let follow_up = &requests[1];
1110 let messages = follow_up["messages"].as_array().expect("messages");
1111 assert!(
1112 messages.iter().any(|message| message["role"] == "tool"),
1113 "the follow-up should answer the recovered call: {messages:?}"
1114 );
1115 assert!(
1116 messages
1117 .iter()
1118 .any(|message| { message["role"] == "assistant" && message["tool_calls"].is_array() }),
1119 "history must record the recovered call, not the raw tag: {messages:?}"
1120 );
1121 drop(requests);
1122
1123 drop(agent);
1124 let _ = std::fs::remove_dir_all(&scratch);
1125 }
1126
1127 /// A tool-call block that can't be parsed must not reach the editor, and must
1128 /// not stay in history where the model reads it back as a call it made and
1129 /// starts inventing results (getsigit/sigit#97, #105). The model gets one
1130 /// retry with a note that the call didn't run.
1131 #[test]
1132 fn an_unparseable_tool_call_is_hidden_and_retried() {
1133 let endpoint = start_fake_endpoint(vec![
1134 sse_body(&[
1135 json!({"choices": [{"delta": {"content": "Checking. <tool_call>command_output\n\n<invokeID>reply_A"}}]}),
1136 json!({"choices": [{"delta": {"content": "</invokeID>\n<parameter>6</parameter>\n</invoke>\n<function_results>all green"}}]}),
1137 ]),
1138 sse_tool_call(
1139 "call_1",
1140 "command_output",
1141 &json!({"task_id": 6}).to_string(),
1142 ),
1143 sse_text("All done."),
1144 ]);
1145
1146 let scratch = std::env::temp_dir().join(format!("sigit_acp_malformed_{}", std::process::id()));
1147 let config_dir = scratch.join("config");
1148 let cwd = scratch.join("cwd");
1149 std::fs::create_dir_all(&config_dir).unwrap();
1150 std::fs::create_dir_all(&cwd).unwrap();
1151
1152 let mut agent = spawn_agent(endpoint.port, &config_dir);
1153
1154 let id = agent.request(
1155 "initialize",
1156 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1157 );
1158 agent.wait_for_response(id);
1159
1160 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1161 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1162 .as_str()
1163 .expect("session id")
1164 .to_string();
1165
1166 let prompt_id = agent.request(
1167 "session/prompt",
1168 json!({
1169 "sessionId": session_id,
1170 "prompt": [{"type": "text", "text": "is the build done?"}],
1171 }),
1172 );
1173 let (response, rendered) = agent.wait_for_prompt(prompt_id);
1174 assert_eq!(response["result"]["stopReason"], "end_turn");
1175 for markup in [
1176 "<tool_call>",
1177 "<invokeID>",
1178 "<function_results>",
1179 "all green",
1180 ] {
1181 assert!(
1182 !rendered.contains(markup),
1183 "{markup} reached the client: {rendered:?}"
1184 );
1185 }
1186 assert!(rendered.contains("Checking."), "{rendered:?}");
1187 assert!(rendered.contains("All done."), "{rendered:?}");
1188
1189 let requests = endpoint.requests.lock().unwrap();
1190 assert_eq!(
1191 requests.len(),
1192 3,
1193 "expected the retry, then the round answering the retried call"
1194 );
1195 let retry_messages = requests[1]["messages"].as_array().expect("messages");
1196 let last = retry_messages.last().expect("retry note");
1197 assert_eq!(last["role"], "user");
1198 assert!(
1199 last["content"]
1200 .as_str()
1201 .is_some_and(|content| content.starts_with("[siGit Code]")),
1202 "{last:?}"
1203 );
1204 for message in requests[2]["messages"].as_array().expect("messages") {
1205 let content = message["content"].as_str().unwrap_or_default();
1206 assert!(
1207 !content.contains("<tool_call>") && !content.contains("<invokeID>"),
1208 "broken markup was kept in history: {message:?}"
1209 );
1210 }
1211 drop(requests);
1212
1213 drop(agent);
1214 let _ = std::fs::remove_dir_all(&scratch);
1215 }
1216
1217 /// The third identical call is skipped by the repetition guard. It never ran,
1218 /// so its card ends `failed` while the two that did run end `completed`
1219 /// (issue #139).
1220 #[test]
1221 fn a_call_skipped_by_the_repeat_guard_ends_failed() {
1222 let repeated_arguments = json!({"path": "notes.txt"}).to_string();
1223 let endpoint = start_fake_endpoint(vec![
1224 sse_tool_call("call_1", "read_file", &repeated_arguments),
1225 sse_tool_call("call_2", "read_file", &repeated_arguments),
1226 sse_tool_call("call_3", "read_file", &repeated_arguments),
1227 sse_text("Stopping here."),
1228 ]);
1229
1230 let scratch =
1231 std::env::temp_dir().join(format!("sigit_acp_repeat_failed_{}", 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 std::fs::write(cwd.join("notes.txt"), "hello").unwrap();
1237
1238 let mut agent = spawn_agent(endpoint.port, &config_dir);
1239
1240 let id = agent.request(
1241 "initialize",
1242 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1243 );
1244 agent.wait_for_response(id);
1245
1246 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1247 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1248 .as_str()
1249 .expect("session id")
1250 .to_string();
1251
1252 let prompt_id = agent.request(
1253 "session/prompt",
1254 json!({
1255 "sessionId": session_id,
1256 "prompt": [{"type": "text", "text": "read the notes"}],
1257 }),
1258 );
1259 let (response, updates) = agent.wait_for_response_with_updates(prompt_id);
1260 assert_eq!(response["result"]["stopReason"], "end_turn");
1261
1262 let final_status = |call_id: &str| {
1263 updates
1264 .iter()
1265 .rev()
1266 .find(|update| {
1267 update["sessionUpdate"] == "tool_call_update"
1268 && update["toolCallId"] == call_id
1269 && !update["status"].is_null()
1270 })
1271 .unwrap_or_else(|| panic!("{call_id} is closed out: {updates:#?}"))["status"]
1272 .clone()
1273 };
1274 assert_eq!(final_status("call_1"), "completed");
1275 assert_eq!(final_status("call_2"), "completed");
1276 assert_eq!(final_status("call_3"), "failed");
1277
1278 drop(agent);
1279 let _ = std::fs::remove_dir_all(&scratch);
1280 }
1281
1282 /// Once the repetition guard removes tools from a request, a model may still
1283 /// emit its learned tool syntax. Known calls must be removed from the visible
1284 /// response without being executed or recorded as orphaned history entries.
1285 #[test]
1286 fn forced_text_suppresses_glm_check_status_tool_markup() {
1287 let repeated_arguments = json!({"path": "missing.txt"}).to_string();
1288 let endpoint = start_fake_endpoint(vec![
1289 sse_tool_call("call_1", "read_file", &repeated_arguments),
1290 sse_tool_call("call_2", "read_file", &repeated_arguments),
1291 sse_tool_call("call_3", "read_file", &repeated_arguments),
1292 sse_body(&[
1293 json!({"choices": [{"delta": {"content": "Build is still running. <tool_call>command_output CheckStatus=true_or_poll_"}}]}),
1294 json!({"choices": [{"delta": {"content": "again_with_different_params</arg_value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_call>"}}]}),
1295 // Also enforce the forced-text boundary for a structured call.
1296 json!({"choices": [{"delta": {"tool_calls": [{
1297 "index": 0,
1298 "id": "call_4",
1299 "function": {"name": "command_output", "arguments": "{\"task_id\":999}"},
1300 }]}}]}),
1301 ]),
1302 sse_text("Next turn."),
1303 ]);
1304
1305 let scratch =
1306 std::env::temp_dir().join(format!("sigit_acp_forced_text_{}", std::process::id()));
1307 let config_dir = scratch.join("config");
1308 let cwd = scratch.join("cwd");
1309 std::fs::create_dir_all(&config_dir).unwrap();
1310 std::fs::create_dir_all(&cwd).unwrap();
1311
1312 let mut agent = spawn_agent(endpoint.port, &config_dir);
1313
1314 let id = agent.request(
1315 "initialize",
1316 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1317 );
1318 agent.wait_for_response(id);
1319
1320 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1321 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1322 .as_str()
1323 .expect("session id")
1324 .to_string();
1325
1326 let prompt_id = agent.request(
1327 "session/prompt",
1328 json!({
1329 "sessionId": session_id,
1330 "prompt": [{"type": "text", "text": "keep polling"}],
1331 }),
1332 );
1333 let (response, rendered) = agent.wait_for_prompt(prompt_id);
1334 assert_eq!(response["result"]["stopReason"], "end_turn");
1335 assert!(
1336 rendered.contains("Build is still running."),
1337 "surrounding prose should remain visible: {rendered:?}"
1338 );
1339 assert!(
1340 !rendered.contains("<tool_call>")
1341 && !rendered.contains("<arg_key>")
1342 && !rendered.contains("CheckStatus"),
1343 "forced tool markup reached the client: {rendered:?}"
1344 );
1345
1346 {
1347 let requests = endpoint.requests.lock().unwrap();
1348 assert_eq!(
1349 requests.len(),
1350 4,
1351 "a forced-text tool call must not start another inference round"
1352 );
1353 assert!(
1354 requests[3].get("tools").is_none(),
1355 "the repetition guard must not advertise tools: {:?}",
1356 requests[3]
1357 );
1358 }
1359
1360 // Start another turn so the prior assistant message is replayed and its
1361 // sanitized history shape can be inspected in the recorded request.
1362 let next_prompt_id = agent.request(
1363 "session/prompt",
1364 json!({
1365 "sessionId": session_id,
1366 "prompt": [{"type": "text", "text": "what happened?"}],
1367 }),
1368 );
1369 agent.wait_for_prompt(next_prompt_id);
1370
1371 let requests = endpoint.requests.lock().unwrap();
1372 let replayed_messages = requests[4]["messages"].as_array().expect("messages");
1373 let prior_assistant = replayed_messages
1374 .iter()
1375 .rev()
1376 .find(|message| {
1377 message["role"] == "assistant"
1378 && message["content"]
1379 .as_str()
1380 .is_some_and(|content| content.contains("Build is still running."))
1381 })
1382 .expect("sanitized forced-text assistant response");
1383 assert!(prior_assistant.get("tool_calls").is_none());
1384 assert!(
1385 !prior_assistant["content"]
1386 .as_str()
1387 .expect("assistant content")
1388 .contains("<tool_call>")
1389 );
1390 drop(requests);
1391
1392 drop(agent);
1393 let _ = std::fs::remove_dir_all(&scratch);
1394 }
1395
1396 /// Kimi K3 may emit tool calls in XTML content blocks even when the endpoint is
1397 /// OpenAI-compatible. The backend should recover those before ACP sees them,
1398 /// unwrap visible response text, and hide private thinking text.
1399 #[test]
1400 fn a_kimi_k3_tool_call_emitted_as_text_is_executed_rather_than_rendered() {
1401 let endpoint = start_fake_endpoint(vec![
1402 // Split across frames to cover the streaming scanner, not just the
1403 // complete-response extractor.
1404 sse_body(&[
1405 json!({"choices": [{"delta": {"content": "<|open|>think<|sep|>private notes<|close|>think<|sep|>"}}]}),
1406 json!({"choices": [{"delta": {"content": "<|open|>response<|sep|>Checking the repo:<|close|>response<|sep|> <|open|>too"}}]}),
1407 json!({"choices": [{"delta": {"content": "ls<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|>"}}]}),
1408 json!({"choices": [{"delta": {"content": "<|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>"}}]}),
1409 ]),
1410 sse_text("All done."),
1411 ]);
1412
1413 let scratch = std::env::temp_dir().join(format!("sigit_acp_kimi_k3_{}", std::process::id()));
1414 let config_dir = scratch.join("config");
1415 let cwd = scratch.join("cwd");
1416 std::fs::create_dir_all(&config_dir).unwrap();
1417 std::fs::create_dir_all(&cwd).unwrap();
1418
1419 let mut agent = spawn_agent(endpoint.port, &config_dir);
1420
1421 let id = agent.request(
1422 "initialize",
1423 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1424 );
1425 agent.wait_for_response(id);
1426
1427 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1428 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1429 .as_str()
1430 .expect("session id")
1431 .to_string();
1432
1433 let prompt_id = agent.request(
1434 "session/prompt",
1435 json!({
1436 "sessionId": session_id,
1437 "prompt": [{"type": "text", "text": "check the repo"}],
1438 }),
1439 );
1440 let (_response, rendered) = agent.wait_for_prompt(prompt_id);
1441
1442 assert!(
1443 !rendered.contains("<|open|>")
1444 && !rendered.contains("<|sep|>")
1445 && !rendered.contains("private notes"),
1446 "raw Kimi K3 markup reached the client: {rendered:?}"
1447 );
1448 assert!(
1449 rendered.contains("Checking the repo:"),
1450 "response text should still be shown: {rendered:?}"
1451 );
1452
1453 let requests = endpoint.requests.lock().unwrap();
1454 assert!(
1455 requests.len() >= 2,
1456 "expected a follow-up request carrying the tool result, got {}",
1457 requests.len()
1458 );
1459 let messages = requests[1]["messages"].as_array().expect("messages");
1460 assert!(
1461 messages.iter().any(|message| message["role"] == "tool"),
1462 "the follow-up should answer the recovered call: {messages:?}"
1463 );
1464 assert!(
1465 messages
1466 .iter()
1467 .any(|message| { message["role"] == "assistant" && message["tool_calls"].is_array() }),
1468 "history must record the recovered call, not the raw tag: {messages:?}"
1469 );
1470 drop(requests);
1471
1472 drop(agent);
1473 let _ = std::fs::remove_dir_all(&scratch);
1474 }
1475
1476 #[test]
1477 fn a_silent_forced_text_round_still_ends_with_a_message() {
1478 let repeated_arguments = json!({"path": "missing.txt"}).to_string();
1479 let endpoint = start_fake_endpoint(vec![
1480 sse_text_then_tool_call(
1481 "Checking the file.",
1482 "call_1",
1483 "read_file",
1484 &repeated_arguments,
1485 ),
1486 sse_tool_call("call_2", "read_file", &repeated_arguments),
1487 sse_tool_call("call_3", "read_file", &repeated_arguments),
1488 // The forced no-tools round says nothing, as in the stalled thread.
1489 sse_body(&[]),
1490 ]);
1491
1492 let scratch =
1493 std::env::temp_dir().join(format!("sigit_acp_silent_stop_{}", std::process::id()));
1494 let config_dir = scratch.join("config");
1495 let cwd = scratch.join("cwd");
1496 std::fs::create_dir_all(&config_dir).unwrap();
1497 std::fs::create_dir_all(&cwd).unwrap();
1498
1499 let mut agent = spawn_agent(endpoint.port, &config_dir);
1500 let id = agent.request(
1501 "initialize",
1502 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1503 );
1504 agent.wait_for_response(id);
1505 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1506 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1507 .as_str()
1508 .expect("session id")
1509 .to_string();
1510
1511 let prompt_id = agent.request(
1512 "session/prompt",
1513 json!({
1514 "sessionId": session_id,
1515 "prompt": [{"type": "text", "text": "read it"}],
1516 }),
1517 );
1518 let (response, rendered) = agent.wait_for_prompt(prompt_id);
1519 assert_eq!(response["result"]["stopReason"], "end_turn");
1520 assert!(rendered.contains("Checking the file."), "{rendered:?}");
1521 assert!(
1522 rendered.contains("same `read_file` call") && rendered.contains("continue"),
1523 "the turn ended without telling the user why: {rendered:?}"
1524 );
1525
1526 drop(agent);
1527 let _ = std::fs::remove_dir_all(&scratch);
1528 }
1529
1530 #[test]
1531 fn repeated_command_output_calls_remain_available() {
1532 let repeated_arguments = json!({"task_id": 999}).to_string();
1533 let endpoint = start_fake_endpoint(vec![
1534 sse_tool_call("call_1", "command_output", &repeated_arguments),
1535 sse_tool_call("call_2", "command_output", &repeated_arguments),
1536 sse_tool_call("call_3", "command_output", &repeated_arguments),
1537 sse_text("The background task finished."),
1538 ]);
1539
1540 let scratch =
1541 std::env::temp_dir().join(format!("sigit_acp_repeated_poll_{}", std::process::id()));
1542 let config_dir = scratch.join("config");
1543 let cwd = scratch.join("cwd");
1544 std::fs::create_dir_all(&config_dir).unwrap();
1545 std::fs::create_dir_all(&cwd).unwrap();
1546
1547 let mut agent = spawn_agent(endpoint.port, &config_dir);
1548 let id = agent.request(
1549 "initialize",
1550 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1551 );
1552 agent.wait_for_response(id);
1553 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1554 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1555 .as_str()
1556 .expect("session id")
1557 .to_string();
1558
1559 let prompt_id = agent.request(
1560 "session/prompt",
1561 json!({
1562 "sessionId": session_id,
1563 "prompt": [{"type": "text", "text": "wait for the background task"}],
1564 }),
1565 );
1566 let (response, rendered) = agent.wait_for_prompt(prompt_id);
1567 assert_eq!(response["result"]["stopReason"], "end_turn");
1568 assert!(rendered.contains("finished"), "got: {rendered:?}");
1569
1570 let requests = endpoint.requests.lock().unwrap();
1571 assert_eq!(requests.len(), 4);
1572 assert!(
1573 requests[3].get("tools").is_some(),
1574 "command_output repetition must not force tools off: {:?}",
1575 requests[3]
1576 );
1577 drop(requests);
1578
1579 drop(agent);
1580 let _ = std::fs::remove_dir_all(&scratch);
1581 }
1582
1583 #[test]
1584 fn write_todos_reaches_the_client_as_an_acp_plan() {
1585 let endpoint = start_fake_endpoint(vec![
1586 sse_tool_call(
1587 "call_1",
1588 "write_todos",
1589 &json!({
1590 "todos": [
1591 {"content": "Fetch issue screenshot", "status": "completed"},
1592 {"content": "Implement ACP styling", "status": "in_progress"},
1593 {"content": "Build and verify", "status": "pending"}
1594 ]
1595 })
1596 .to_string(),
1597 ),
1598 sse_text("Continuing."),
1599 ]);
1600
1601 let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_{}", std::process::id()));
1602 let config_dir = scratch.join("config");
1603 let cwd = scratch.join("cwd");
1604 std::fs::create_dir_all(&config_dir).unwrap();
1605 std::fs::create_dir_all(&cwd).unwrap();
1606
1607 let mut agent = spawn_agent(endpoint.port, &config_dir);
1608
1609 let id = agent.request(
1610 "initialize",
1611 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1612 );
1613 agent.wait_for_response(id);
1614
1615 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1616 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1617 .as_str()
1618 .expect("session id")
1619 .to_string();
1620
1621 let prompt_id = agent.request(
1622 "session/prompt",
1623 json!({
1624 "sessionId": session_id,
1625 "prompt": [{"type": "text", "text": "fix the ACP display"}],
1626 }),
1627 );
1628 let (response, updates) = agent.wait_for_response_with_updates(prompt_id);
1629 assert_eq!(response["result"]["stopReason"], "end_turn");
1630
1631 let plan = updates
1632 .iter()
1633 .find(|update| update["sessionUpdate"] == "plan")
1634 .expect("write_todos should be rendered as an ACP plan update");
1635 assert_eq!(plan["entries"][0]["content"], "Fetch issue screenshot");
1636 assert_eq!(plan["entries"][0]["status"], "completed");
1637 assert_eq!(plan["entries"][1]["status"], "in_progress");
1638 assert_eq!(plan["entries"][2]["status"], "pending");
1639
1640 assert!(
1641 !updates.iter().any(|update| {
1642 update["sessionUpdate"] == "tool_call" && update["title"] == "write_todos"
1643 }),
1644 "write_todos must not show up as a generic tool-call card: {updates:?}"
1645 );
1646
1647 drop(agent);
1648 let _ = std::fs::remove_dir_all(&scratch);
1649 }
1650
1651 /// An empty `write_todos` is how the model clears a finished checklist. It has
1652 /// to reach the client as a plan with no entries, since ACP plan updates
1653 /// replace the whole plan, rather than as an error card that leaves the old
1654 /// list on screen.
1655 #[test]
1656 fn an_empty_write_todos_clears_the_acp_plan() {
1657 let endpoint = start_fake_endpoint(vec![
1658 sse_tool_call("call_1", "write_todos", &json!({"todos": []}).to_string()),
1659 sse_text("Checklist cleared."),
1660 ]);
1661
1662 let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_clear_{}", std::process::id()));
1663 let config_dir = scratch.join("config");
1664 let cwd = scratch.join("cwd");
1665 std::fs::create_dir_all(&config_dir).unwrap();
1666 std::fs::create_dir_all(&cwd).unwrap();
1667
1668 let mut agent = spawn_agent(endpoint.port, &config_dir);
1669
1670 let id = agent.request(
1671 "initialize",
1672 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1673 );
1674 agent.wait_for_response(id);
1675
1676 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1677 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1678 .as_str()
1679 .expect("session id")
1680 .to_string();
1681
1682 let prompt_id = agent.request(
1683 "session/prompt",
1684 json!({
1685 "sessionId": session_id,
1686 "prompt": [{"type": "text", "text": "clear the checklist"}],
1687 }),
1688 );
1689 let (response, updates) = agent.wait_for_response_with_updates(prompt_id);
1690 assert_eq!(response["result"]["stopReason"], "end_turn");
1691
1692 let plan = updates
1693 .iter()
1694 .find(|update| update["sessionUpdate"] == "plan")
1695 .expect("an empty write_todos should still send a plan update");
1696 assert_eq!(plan["entries"], json!([]));
1697 assert!(
1698 !updates
1699 .iter()
1700 .any(|update| update["sessionUpdate"] == "tool_call"),
1701 "clearing the plan must not show a tool-call card: {updates:?}"
1702 );
1703
1704 drop(agent);
1705 let _ = std::fs::remove_dir_all(&scratch);
1706 }
1707
1708 /// An off-enum status must not make the whole call vanish. `exec_write_todos`
1709 /// renders it as pending and reports success, so the plan has to carry it the
1710 /// same way; dropping the plan here would leave the client showing a stale list.
1711 #[test]
1712 fn write_todos_with_an_unknown_status_still_reaches_the_client() {
1713 let endpoint = start_fake_endpoint(vec![
1714 sse_tool_call(
1715 "call_1",
1716 "write_todos",
1717 &json!({
1718 "todos": [
1719 {"content": "Fetch issue screenshot", "status": "completed"},
1720 {"content": "Ship it", "status": "cancelled"}
1721 ]
1722 })
1723 .to_string(),
1724 ),
1725 sse_text("Continuing."),
1726 ]);
1727
1728 let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_odd_{}", std::process::id()));
1729 let config_dir = scratch.join("config");
1730 let cwd = scratch.join("cwd");
1731 std::fs::create_dir_all(&config_dir).unwrap();
1732 std::fs::create_dir_all(&cwd).unwrap();
1733
1734 let mut agent = spawn_agent(endpoint.port, &config_dir);
1735
1736 let id = agent.request(
1737 "initialize",
1738 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1739 );
1740 agent.wait_for_response(id);
1741
1742 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1743 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1744 .as_str()
1745 .expect("session id")
1746 .to_string();
1747
1748 let prompt_id = agent.request(
1749 "session/prompt",
1750 json!({
1751 "sessionId": session_id,
1752 "prompt": [{"type": "text", "text": "plan the work"}],
1753 }),
1754 );
1755 let (_response, updates) = agent.wait_for_response_with_updates(prompt_id);
1756
1757 let plan = updates
1758 .iter()
1759 .find(|update| update["sessionUpdate"] == "plan")
1760 .expect("an unknown status must not drop the plan");
1761 assert_eq!(plan["entries"][1]["content"], "Ship it");
1762 assert_eq!(plan["entries"][1]["status"], "pending");
1763
1764 drop(agent);
1765 let _ = std::fs::remove_dir_all(&scratch);
1766 }
1767
1768 /// Arguments the converter cannot turn into a plan fall back to the ordinary
1769 /// tool-call card. Without that fallback the call is announced nowhere at all.
1770 #[test]
1771 fn unconvertible_write_todos_falls_back_to_a_tool_call_card() {
1772 let endpoint = start_fake_endpoint(vec![
1773 // `todos` is absent, so there is no plan to build. `exec_write_todos`
1774 // answers with an error string, and the client still has to see the call.
1775 sse_tool_call("call_1", "write_todos", &json!({"items": []}).to_string()),
1776 sse_text("Continuing."),
1777 ]);
1778
1779 let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_bad_{}", std::process::id()));
1780 let config_dir = scratch.join("config");
1781 let cwd = scratch.join("cwd");
1782 std::fs::create_dir_all(&config_dir).unwrap();
1783 std::fs::create_dir_all(&cwd).unwrap();
1784
1785 let mut agent = spawn_agent(endpoint.port, &config_dir);
1786
1787 let id = agent.request(
1788 "initialize",
1789 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1790 );
1791 agent.wait_for_response(id);
1792
1793 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1794 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1795 .as_str()
1796 .expect("session id")
1797 .to_string();
1798
1799 let prompt_id = agent.request(
1800 "session/prompt",
1801 json!({
1802 "sessionId": session_id,
1803 "prompt": [{"type": "text", "text": "plan the work"}],
1804 }),
1805 );
1806 let (_response, updates) = agent.wait_for_response_with_updates(prompt_id);
1807
1808 assert!(
1809 !updates
1810 .iter()
1811 .any(|update| update["sessionUpdate"] == "plan"),
1812 "there is no plan to send: {updates:?}"
1813 );
1814 assert!(
1815 updates.iter().any(|update| {
1816 update["sessionUpdate"] == "tool_call" && update["title"] == "write_todos"
1817 }),
1818 "the call must still be announced as a tool call: {updates:?}"
1819 );
1820
1821 drop(agent);
1822 let _ = std::fs::remove_dir_all(&scratch);
1823 }
1824
1825 // ── Session modes (issue #148) ──────────────────────────────────────────────
1826
1827 /// The Permissions dropdown is also offered as ACP session modes, for a client
1828 /// that draws the mode selector and not config options. Both are the same
1829 /// state, so a change made through either has to show up in the other.
1830 #[test]
1831 fn session_modes_mirror_the_permissions_config_option() {
1832 let endpoint = start_fake_endpoint(vec![
1833 sse_tool_call("call_1", "run_command", "{\"command\":\"echo sigit-mode\"}"),
1834 sse_text("planned"),
1835 ]);
1836
1837 let scratch = std::env::temp_dir().join(format!("sigit_acp_modes_{}", std::process::id()));
1838 let config_dir = scratch.join("config");
1839 let cwd = scratch.join("cwd");
1840 std::fs::create_dir_all(&config_dir).unwrap();
1841 std::fs::create_dir_all(&cwd).unwrap();
1842
1843 let mut agent = spawn_agent(endpoint.port, &config_dir);
1844
1845 let id = agent.request(
1846 "initialize",
1847 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1848 );
1849 agent.wait_for_response(id);
1850
1851 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1852 let response = agent.wait_for_response(id);
1853 let session_id = response["result"]["sessionId"]
1854 .as_str()
1855 .expect("session id")
1856 .to_string();
1857 let modes = &response["result"]["modes"];
1858 assert_eq!(modes["currentModeId"], "permission-mode-manual");
1859 let mode_ids: Vec<&str> = modes["availableModes"]
1860 .as_array()
1861 .expect("available modes")
1862 .iter()
1863 .filter_map(|mode| mode["id"].as_str())
1864 .collect();
1865 assert_eq!(
1866 mode_ids,
1867 [
1868 "permission-mode-manual",
1869 "permission-mode-auto",
1870 "permission-mode-plan"
1871 ]
1872 );
1873
1874 // set_mode changes the session and refreshes the config option.
1875 let id = agent.request(
1876 "session/set_mode",
1877 json!({"sessionId": session_id, "modeId": "permission-mode-plan"}),
1878 );
1879 let (_response, updates) = agent.wait_for_response_with_updates(id);
1880 let refreshed = updates
1881 .iter()
1882 .find(|update| update["sessionUpdate"] == "config_option_update")
1883 .expect("set_mode refreshes the config options");
1884 let permissions = refreshed["configOptions"]
1885 .as_array()
1886 .expect("config options")
1887 .iter()
1888 .find(|option| option["id"] == "sigit-permission-mode")
1889 .expect("permissions config option");
1890 assert_eq!(permissions["currentValue"], "permission-mode-plan");
1891
1892 // Plan mode is really on: the mutating call is denied, not asked about.
1893 let prompt_id = agent.request(
1894 "session/prompt",
1895 json!({
1896 "sessionId": session_id,
1897 "prompt": [{"type": "text", "text": "run the command"}],
1898 }),
1899 );
1900 let (response, _updates) = agent.wait_for_response_with_updates(prompt_id);
1901 assert_eq!(response["result"]["stopReason"], "end_turn");
1902 {
1903 let requests = endpoint.requests.lock().unwrap();
1904 let messages = requests[1]["messages"].as_array().expect("messages");
1905 let result = messages
1906 .iter()
1907 .find(|message| message["role"] == "tool" && message["tool_call_id"] == "call_1")
1908 .expect("tool result for the blocked call");
1909 assert!(
1910 !result["content"]
1911 .as_str()
1912 .unwrap_or_default()
1913 .contains("sigit-mode"),
1914 "plan mode must not run the command: {result}"
1915 );
1916 }
1917
1918 // The config option changes the mode, and says so to the mode selector.
1919 let id = agent.request(
1920 "session/set_config_option",
1921 json!({
1922 "sessionId": session_id,
1923 "configId": "sigit-permission-mode",
1924 "value": "permission-mode-auto",
1925 }),
1926 );
1927 let (_response, updates) = agent.wait_for_response_with_updates(id);
1928 let current = updates
1929 .iter()
1930 .find(|update| update["sessionUpdate"] == "current_mode_update")
1931 .expect("a config option change announces the new mode");
1932 assert_eq!(current["currentModeId"], "permission-mode-auto");
1933
1934 // So does /plan.
1935 let prompt_id = agent.request(
1936 "session/prompt",
1937 json!({
1938 "sessionId": session_id,
1939 "prompt": [{"type": "text", "text": "/plan on"}],
1940 }),
1941 );
1942 let (_response, updates) = agent.wait_for_response_with_updates(prompt_id);
1943 let current = updates
1944 .iter()
1945 .find(|update| update["sessionUpdate"] == "current_mode_update")
1946 .expect("/plan announces the new mode");
1947 assert_eq!(current["currentModeId"], "permission-mode-plan");
1948
1949 // An id that is not a mode is an invalid-params error, and changes nothing.
1950 let id = agent.request(
1951 "session/set_mode",
1952 json!({"sessionId": session_id, "modeId": "permission-mode-yolo"}),
1953 );
1954 let response = agent.wait_for("the set_mode error", |message| {
1955 message["id"] == id && message.get("method").is_none()
1956 });
1957 assert_eq!(response["error"]["code"], -32602, "{response}");
1958
1959 drop(agent);
1960 let _ = std::fs::remove_dir_all(&scratch);
1961 }