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 #[test]
555 fn successful_config_option_changes_are_rendered_as_system_status_cards() {
556 let endpoint = start_fake_endpoint(vec![]);
557
558 let scratch =
559 std::env::temp_dir().join(format!("sigit_acp_config_messages_{}", std::process::id()));
560 let config_dir = scratch.join("config");
561 let cwd = scratch.join("cwd");
562 std::fs::create_dir_all(&config_dir).unwrap();
563 std::fs::create_dir_all(&cwd).unwrap();
564 std::fs::write(
565 config_dir.join("credentials.toml"),
566 "access_token = \"tok_test\"\nemail = \"dev@sigit.si\"\n",
567 )
568 .unwrap();
569
570 let mut agent = spawn_agent(endpoint.port, &config_dir);
571
572 let id = agent.request(
573 "initialize",
574 json!({"protocolVersion": 1, "clientCapabilities": {}}),
575 );
576 agent.wait_for_response(id);
577
578 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
579 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
580 .as_str()
581 .expect("session id")
582 .to_string();
583
584 let id = agent.request(
585 "session/set_config_option",
586 json!({
587 "sessionId": session_id,
588 "configId": "sigit-local-inference",
589 "value": "local-inference-off",
590 }),
591 );
592 let (response, updates) = agent.wait_for_response_with_updates(id);
593 assert!(
594 response["result"]["configOptions"].is_array(),
595 "config response should refresh picker options: {response}"
596 );
597 assert!(
598 !updates
599 .iter()
600 .any(|update| update["sessionUpdate"] == "agent_message_chunk"),
601 "Local Inference picker changes are passive UI state, not chat text: {updates:?}"
602 );
603 let status = updates
604 .iter()
605 .find(|update| {
606 update["sessionUpdate"] == "tool_call"
607 && update["kind"] == "think"
608 && update["status"] == "completed"
609 })
610 .expect("Local Inference change should render as a system-style status card");
611 assert_eq!(
612 status["title"],
613 "Local inference is off. siGit Code Cloud tiers are highlighted; pick one from Model."
614 );
615
616 let id = agent.request(
617 "session/set_config_option",
618 json!({
619 "sessionId": session_id,
620 "configId": "sigit-model",
621 "value": "sigit-cloud:mini",
622 }),
623 );
624 let (response, updates) = agent.wait_for_response_with_updates(id);
625 assert!(
626 response["result"]["configOptions"].is_array(),
627 "config response should refresh picker options: {response}"
628 );
629 assert!(
630 !updates
631 .iter()
632 .any(|update| update["sessionUpdate"] == "agent_message_chunk"),
633 "successful model picker changes must not be rendered as assistant chat text: {updates:?}"
634 );
635 let status = updates
636 .iter()
637 .find(|update| {
638 update["sessionUpdate"] == "tool_call"
639 && update["kind"] == "think"
640 && update["status"] == "completed"
641 })
642 .expect("cloud tier change should render as a system-style status card");
643 assert!(
644 status["title"]
645 .as_str()
646 .unwrap_or_default()
647 .starts_with("Switched to siGit Code Cloud"),
648 "unexpected cloud switch status title: {status}"
649 );
650
651 drop(agent);
652 let _ = std::fs::remove_dir_all(&scratch);
653 }
654
655 #[test]
656 fn auto_permission_mode_runs_mutating_tools_without_asking() {
657 let endpoint = start_fake_endpoint(vec![
658 sse_tool_call("call_1", "run_command", r#"{"command":"echo sigit-auto"}"#),
659 sse_text("done"),
660 ]);
661
662 let scratch = std::env::temp_dir().join(format!("sigit_acp_auto_perm_{}", std::process::id()));
663 let config_dir = scratch.join("config");
664 let cwd = scratch.join("cwd");
665 std::fs::create_dir_all(&config_dir).unwrap();
666 std::fs::create_dir_all(&cwd).unwrap();
667
668 let mut agent = spawn_agent(endpoint.port, &config_dir);
669
670 let id = agent.request(
671 "initialize",
672 json!({"protocolVersion": 1, "clientCapabilities": {}}),
673 );
674 agent.wait_for_response(id);
675
676 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
677 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
678 .as_str()
679 .expect("session id")
680 .to_string();
681
682 let id = agent.request(
683 "session/set_config_option",
684 json!({
685 "sessionId": session_id,
686 "configId": "sigit-permission-mode",
687 "value": "permission-mode-auto",
688 }),
689 );
690 let (response, updates) = agent.wait_for_response_with_updates(id);
691 let permissions = response["result"]["configOptions"]
692 .as_array()
693 .expect("config options")
694 .iter()
695 .find(|option| option["id"] == "sigit-permission-mode")
696 .expect("permissions config option");
697 assert_eq!(permissions["currentValue"], "permission-mode-auto");
698 assert!(
699 !updates
700 .iter()
701 .any(|update| update["sessionUpdate"] == "agent_message_chunk"),
702 "Permissions picker changes are passive UI state, not chat text: {updates:?}"
703 );
704
705 let prompt_id = agent.request(
706 "session/prompt",
707 json!({
708 "sessionId": session_id,
709 "prompt": [{"type": "text", "text": "run the command"}],
710 }),
711 );
712 let (response, _updates) = agent.wait_for_response_with_updates(prompt_id);
713 assert_eq!(response["result"]["stopReason"], "end_turn");
714
715 let requests = endpoint.requests.lock().unwrap();
716 assert_eq!(requests.len(), 2, "expected tool round and final round");
717 let messages = requests[1]["messages"].as_array().expect("messages");
718 let result = messages
719 .iter()
720 .find(|message| message["role"] == "tool" && message["tool_call_id"] == "call_1")
721 .expect("tool result for the automatic call");
722 assert!(
723 result["content"]
724 .as_str()
725 .unwrap_or_default()
726 .contains("sigit-auto"),
727 "automatic command output should reach the endpoint: {result}"
728 );
729
730 drop(agent);
731 let _ = std::fs::remove_dir_all(&scratch);
732 }
733
734 #[test]
735 fn plan_permission_mode_denies_mutating_tools_without_asking() {
736 let endpoint = start_fake_endpoint(vec![
737 sse_tool_call("call_1", "run_command", r#"{"command":"echo sigit-plan"}"#),
738 sse_text("planned"),
739 ]);
740
741 let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_perm_{}", std::process::id()));
742 let config_dir = scratch.join("config");
743 let cwd = scratch.join("cwd");
744 std::fs::create_dir_all(&config_dir).unwrap();
745 std::fs::create_dir_all(&cwd).unwrap();
746
747 let mut agent = spawn_agent(endpoint.port, &config_dir);
748
749 let id = agent.request(
750 "initialize",
751 json!({"protocolVersion": 1, "clientCapabilities": {}}),
752 );
753 agent.wait_for_response(id);
754
755 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
756 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
757 .as_str()
758 .expect("session id")
759 .to_string();
760
761 let id = agent.request(
762 "session/set_config_option",
763 json!({
764 "sessionId": session_id,
765 "configId": "sigit-permission-mode",
766 "value": "permission-mode-plan",
767 }),
768 );
769 let (response, _updates) = agent.wait_for_response_with_updates(id);
770 let permissions = response["result"]["configOptions"]
771 .as_array()
772 .expect("config options")
773 .iter()
774 .find(|option| option["id"] == "sigit-permission-mode")
775 .expect("permissions config option");
776 assert_eq!(permissions["currentValue"], "permission-mode-plan");
777
778 let prompt_id = agent.request(
779 "session/prompt",
780 json!({
781 "sessionId": session_id,
782 "prompt": [{"type": "text", "text": "run the command"}],
783 }),
784 );
785 let (response, _updates) = agent.wait_for_response_with_updates(prompt_id);
786 assert_eq!(response["result"]["stopReason"], "end_turn");
787
788 let requests = endpoint.requests.lock().unwrap();
789 assert_eq!(
790 requests.len(),
791 2,
792 "expected denied tool round and final round"
793 );
794 let messages = requests[1]["messages"].as_array().expect("messages");
795 let result = messages
796 .iter()
797 .find(|message| message["role"] == "tool" && message["tool_call_id"] == "call_1")
798 .expect("tool result for the blocked call");
799 let content = result["content"].as_str().unwrap_or_default();
800 assert!(
801 content.contains("Plan mode is active") && content.contains("was not executed"),
802 "plan mode should return an instructive denial to the endpoint: {result}"
803 );
804
805 drop(agent);
806 let _ = std::fs::remove_dir_all(&scratch);
807 }
808
809 #[test]
810 fn a_slow_commands_progress_reaches_the_client_while_it_runs() {
811 let scratch = std::env::temp_dir().join(format!("sigit_acp_progress_{}", std::process::id()));
812 let config_dir = scratch.join("config");
813 let cwd = scratch.join("cwd");
814 std::fs::create_dir_all(&config_dir).unwrap();
815 std::fs::create_dir_all(&cwd).unwrap();
816
817 // The command has to run for a few seconds through `sh -c` / `cmd /C`.
818 // `sleep` is neither a cmd builtin nor on a Windows runner's PATH (Git's
819 // usr\bin, which carries the MSYS coreutils, is deliberately left off it),
820 // and the Windows job hung on it rather than failing fast. `ping` ships in
821 // System32, waits a second between echoes, and never reads stdin.
822 //
823 // Four seconds against the one-second assertion below is deliberate
824 // padding. A busy runner only ever widens the gap being measured — it
825 // cannot make the command finish sooner — so the one way a correct agent
826 // could fail here is this thread being descheduled for seconds between
827 // reading the two updates off the channel. Three seconds of slack covers
828 // that without weakening the assertion, which is separating four seconds
829 // from the 164µs the blocking version produced.
830 #[cfg(unix)]
831 let command = "sleep 4";
832 #[cfg(windows)]
833 let command = "ping -n 5 127.0.0.1";
834
835 // `SIGIT_PERMISSIONS=allow` skips the gate, so the only thing between the
836 // two updates below is the command itself.
837 let endpoint = start_fake_endpoint(vec![
838 sse_tool_call(
839 "call_1",
840 "run_command",
841 &json!({"command": command, "cwd": cwd}).to_string(),
842 ),
843 sse_text("done"),
844 ]);
845
846 let mut agent = spawn_agent_with_permissions(endpoint.port, &config_dir, Some("allow"));
847
848 let id = agent.request(
849 "initialize",
850 json!({"protocolVersion": 1, "clientCapabilities": {}}),
851 );
852 agent.wait_for_response(id);
853
854 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
855 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
856 .as_str()
857 .expect("session id")
858 .to_string();
859
860 let prompt_id = agent.request(
861 "session/prompt",
862 json!({
863 "sessionId": session_id,
864 "prompt": [{"type": "text", "text": "run the slow command"}],
865 }),
866 );
867
868 let started = agent.wait_for_update("the tool call starting", |update| {
869 update["sessionUpdate"] == "tool_call" && update["toolCallId"] == "call_1"
870 });
871 let in_progress_at = Instant::now();
872 assert_eq!(
873 started["status"], "in_progress",
874 "a running tool call must be announced as in_progress: {started}"
875 );
876
877 let finished = agent.wait_for_update("the tool call finishing", |update| {
878 update["sessionUpdate"] == "tool_call_update"
879 && update["toolCallId"] == "call_1"
880 && update["status"] == "completed"
881 });
882
883 // The command runs between the two updates. If the turn blocked the
884 // connection's actors, both would only reach the client once it had
885 // already finished, and the editor would never draw a spinner.
886 assert!(
887 in_progress_at.elapsed() >= Duration::from_millis(1_000),
888 "in_progress arrived only {:?} before completed — the client had no \
889 window to show progress in. `{command}` said: {}",
890 in_progress_at.elapsed(),
891 finished["rawOutput"]
892 );
893
894 let response = agent.wait_for_response(prompt_id);
895 assert_eq!(response["result"]["stopReason"], "end_turn");
896
897 drop(agent);
898 let _ = std::fs::remove_dir_all(&scratch);
899 }
900
901 #[test]
902 fn text_from_two_tool_rounds_reaches_the_client_as_separate_paragraphs() {
903 let endpoint = start_fake_endpoint(vec![
904 // `list_directory` is read-only, so it runs without a permission gate
905 // and the two rounds follow each other straight away.
906 sse_text_then_tool_call(
907 "I'll search for this pattern.",
908 "call_1",
909 "list_directory",
910 r#"{"path":"."}"#,
911 ),
912 sse_text("Let me broaden the search:"),
913 ]);
914
915 let scratch = std::env::temp_dir().join(format!("sigit_acp_spacing_{}", std::process::id()));
916 let config_dir = scratch.join("config");
917 let cwd = scratch.join("cwd");
918 std::fs::create_dir_all(&config_dir).unwrap();
919 std::fs::create_dir_all(&cwd).unwrap();
920
921 let mut agent = spawn_agent(endpoint.port, &config_dir);
922
923 let id = agent.request(
924 "initialize",
925 json!({"protocolVersion": 1, "clientCapabilities": {}}),
926 );
927 agent.wait_for_response(id);
928
929 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
930 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
931 .as_str()
932 .expect("session id")
933 .to_string();
934
935 let prompt_id = agent.request(
936 "session/prompt",
937 json!({
938 "sessionId": session_id,
939 "prompt": [{"type": "text", "text": "find it"}],
940 }),
941 );
942 let (response, rendered) = agent.wait_for_prompt(prompt_id);
943 assert_eq!(response["result"]["stopReason"], "end_turn");
944
945 assert!(
946 rendered.contains("pattern.\n\nLet me broaden"),
947 "the second round must open a new paragraph, got: {rendered:?}"
948 );
949 assert!(
950 !rendered.contains("pattern.Let me"),
951 "rounds must not run together into one sentence, got: {rendered:?}"
952 );
953
954 drop(agent);
955 let _ = std::fs::remove_dir_all(&scratch);
956 }
957
958 /// A model that writes its tool call out as literal `<tool_call>` text instead
959 /// of using the structured field must still drive the loop. Before recovery
960 /// the tag was streamed to the client as prose and the turn ended with no tool
961 /// calls, so the request silently went unanswered (getsigit/sigit#73).
962 #[test]
963 fn a_tool_call_emitted_as_text_is_executed_rather_than_rendered() {
964 let endpoint = start_fake_endpoint(vec![
965 // Split mid-tag, the way a real stream arrives.
966 sse_body(&[
967 json!({"choices": [{"delta": {"content": "Checking the repo: <tool_c"}}]}),
968 json!({"choices": [{"delta": {"content": "all>command_output<arg_key>task_id</arg_key>"}}]}),
969 json!({"choices": [{"delta": {"content": "<arg_value>2</arg_value></tool_call>"}}]}),
970 ]),
971 sse_text("All done."),
972 ]);
973
974 let scratch = std::env::temp_dir().join(format!("sigit_acp_inline_{}", std::process::id()));
975 let config_dir = scratch.join("config");
976 let cwd = scratch.join("cwd");
977 std::fs::create_dir_all(&config_dir).unwrap();
978 std::fs::create_dir_all(&cwd).unwrap();
979
980 let mut agent = spawn_agent(endpoint.port, &config_dir);
981
982 let id = agent.request(
983 "initialize",
984 json!({"protocolVersion": 1, "clientCapabilities": {}}),
985 );
986 agent.wait_for_response(id);
987
988 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
989 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
990 .as_str()
991 .expect("session id")
992 .to_string();
993
994 let prompt_id = agent.request(
995 "session/prompt",
996 json!({
997 "sessionId": session_id,
998 "prompt": [{"type": "text", "text": "check the repo"}],
999 }),
1000 );
1001 let (_response, rendered) = agent.wait_for_prompt(prompt_id);
1002
1003 // The raw tag must never be shown to the user.
1004 assert!(
1005 !rendered.contains("<tool_call>") && !rendered.contains("<arg_key>"),
1006 "raw tool-call markup reached the client: {rendered:?}"
1007 );
1008 assert!(
1009 rendered.contains("Checking the repo:"),
1010 "surrounding prose should still stream: {rendered:?}"
1011 );
1012
1013 // The loop has to keep going: a second request means the recovered call
1014 // ran and its result was sent back. Before recovery the turn ended here.
1015 let requests = endpoint.requests.lock().unwrap();
1016 assert!(
1017 requests.len() >= 2,
1018 "expected a follow-up request carrying the tool result, got {}",
1019 requests.len()
1020 );
1021 let follow_up = &requests[1];
1022 let messages = follow_up["messages"].as_array().expect("messages");
1023 assert!(
1024 messages.iter().any(|message| message["role"] == "tool"),
1025 "the follow-up should answer the recovered call: {messages:?}"
1026 );
1027 assert!(
1028 messages
1029 .iter()
1030 .any(|message| { message["role"] == "assistant" && message["tool_calls"].is_array() }),
1031 "history must record the recovered call, not the raw tag: {messages:?}"
1032 );
1033 drop(requests);
1034
1035 drop(agent);
1036 let _ = std::fs::remove_dir_all(&scratch);
1037 }
1038
1039 /// A tool-call block that can't be parsed must not reach the editor, and must
1040 /// not stay in history where the model reads it back as a call it made and
1041 /// starts inventing results (getsigit/sigit#97, #105). The model gets one
1042 /// retry with a note that the call didn't run.
1043 #[test]
1044 fn an_unparseable_tool_call_is_hidden_and_retried() {
1045 let endpoint = start_fake_endpoint(vec![
1046 sse_body(&[
1047 json!({"choices": [{"delta": {"content": "Checking. <tool_call>command_output\n\n<invokeID>reply_A"}}]}),
1048 json!({"choices": [{"delta": {"content": "</invokeID>\n<parameter>6</parameter>\n</invoke>\n<function_results>all green"}}]}),
1049 ]),
1050 sse_tool_call(
1051 "call_1",
1052 "command_output",
1053 &json!({"task_id": 6}).to_string(),
1054 ),
1055 sse_text("All done."),
1056 ]);
1057
1058 let scratch = std::env::temp_dir().join(format!("sigit_acp_malformed_{}", std::process::id()));
1059 let config_dir = scratch.join("config");
1060 let cwd = scratch.join("cwd");
1061 std::fs::create_dir_all(&config_dir).unwrap();
1062 std::fs::create_dir_all(&cwd).unwrap();
1063
1064 let mut agent = spawn_agent(endpoint.port, &config_dir);
1065
1066 let id = agent.request(
1067 "initialize",
1068 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1069 );
1070 agent.wait_for_response(id);
1071
1072 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1073 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1074 .as_str()
1075 .expect("session id")
1076 .to_string();
1077
1078 let prompt_id = agent.request(
1079 "session/prompt",
1080 json!({
1081 "sessionId": session_id,
1082 "prompt": [{"type": "text", "text": "is the build done?"}],
1083 }),
1084 );
1085 let (response, rendered) = agent.wait_for_prompt(prompt_id);
1086 assert_eq!(response["result"]["stopReason"], "end_turn");
1087 for markup in [
1088 "<tool_call>",
1089 "<invokeID>",
1090 "<function_results>",
1091 "all green",
1092 ] {
1093 assert!(
1094 !rendered.contains(markup),
1095 "{markup} reached the client: {rendered:?}"
1096 );
1097 }
1098 assert!(rendered.contains("Checking."), "{rendered:?}");
1099 assert!(rendered.contains("All done."), "{rendered:?}");
1100
1101 let requests = endpoint.requests.lock().unwrap();
1102 assert_eq!(
1103 requests.len(),
1104 3,
1105 "expected the retry, then the round answering the retried call"
1106 );
1107 let retry_messages = requests[1]["messages"].as_array().expect("messages");
1108 let last = retry_messages.last().expect("retry note");
1109 assert_eq!(last["role"], "user");
1110 assert!(
1111 last["content"]
1112 .as_str()
1113 .is_some_and(|content| content.starts_with("[siGit Code]")),
1114 "{last:?}"
1115 );
1116 for message in requests[2]["messages"].as_array().expect("messages") {
1117 let content = message["content"].as_str().unwrap_or_default();
1118 assert!(
1119 !content.contains("<tool_call>") && !content.contains("<invokeID>"),
1120 "broken markup was kept in history: {message:?}"
1121 );
1122 }
1123 drop(requests);
1124
1125 drop(agent);
1126 let _ = std::fs::remove_dir_all(&scratch);
1127 }
1128
1129 /// Once the repetition guard removes tools from a request, a model may still
1130 /// emit its learned tool syntax. Known calls must be removed from the visible
1131 /// response without being executed or recorded as orphaned history entries.
1132 #[test]
1133 fn forced_text_suppresses_glm_check_status_tool_markup() {
1134 let repeated_arguments = json!({"path": "missing.txt"}).to_string();
1135 let endpoint = start_fake_endpoint(vec![
1136 sse_tool_call("call_1", "read_file", &repeated_arguments),
1137 sse_tool_call("call_2", "read_file", &repeated_arguments),
1138 sse_tool_call("call_3", "read_file", &repeated_arguments),
1139 sse_body(&[
1140 json!({"choices": [{"delta": {"content": "Build is still running. <tool_call>command_output CheckStatus=true_or_poll_"}}]}),
1141 json!({"choices": [{"delta": {"content": "again_with_different_params</arg_value><arg_key>task_id</arg_key><arg_value>1</arg_value></tool_call>"}}]}),
1142 // Also enforce the forced-text boundary for a structured call.
1143 json!({"choices": [{"delta": {"tool_calls": [{
1144 "index": 0,
1145 "id": "call_4",
1146 "function": {"name": "command_output", "arguments": "{\"task_id\":999}"},
1147 }]}}]}),
1148 ]),
1149 sse_text("Next turn."),
1150 ]);
1151
1152 let scratch =
1153 std::env::temp_dir().join(format!("sigit_acp_forced_text_{}", std::process::id()));
1154 let config_dir = scratch.join("config");
1155 let cwd = scratch.join("cwd");
1156 std::fs::create_dir_all(&config_dir).unwrap();
1157 std::fs::create_dir_all(&cwd).unwrap();
1158
1159 let mut agent = spawn_agent(endpoint.port, &config_dir);
1160
1161 let id = agent.request(
1162 "initialize",
1163 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1164 );
1165 agent.wait_for_response(id);
1166
1167 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1168 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1169 .as_str()
1170 .expect("session id")
1171 .to_string();
1172
1173 let prompt_id = agent.request(
1174 "session/prompt",
1175 json!({
1176 "sessionId": session_id,
1177 "prompt": [{"type": "text", "text": "keep polling"}],
1178 }),
1179 );
1180 let (response, rendered) = agent.wait_for_prompt(prompt_id);
1181 assert_eq!(response["result"]["stopReason"], "end_turn");
1182 assert!(
1183 rendered.contains("Build is still running."),
1184 "surrounding prose should remain visible: {rendered:?}"
1185 );
1186 assert!(
1187 !rendered.contains("<tool_call>")
1188 && !rendered.contains("<arg_key>")
1189 && !rendered.contains("CheckStatus"),
1190 "forced tool markup reached the client: {rendered:?}"
1191 );
1192
1193 {
1194 let requests = endpoint.requests.lock().unwrap();
1195 assert_eq!(
1196 requests.len(),
1197 4,
1198 "a forced-text tool call must not start another inference round"
1199 );
1200 assert!(
1201 requests[3].get("tools").is_none(),
1202 "the repetition guard must not advertise tools: {:?}",
1203 requests[3]
1204 );
1205 }
1206
1207 // Start another turn so the prior assistant message is replayed and its
1208 // sanitized history shape can be inspected in the recorded request.
1209 let next_prompt_id = agent.request(
1210 "session/prompt",
1211 json!({
1212 "sessionId": session_id,
1213 "prompt": [{"type": "text", "text": "what happened?"}],
1214 }),
1215 );
1216 agent.wait_for_prompt(next_prompt_id);
1217
1218 let requests = endpoint.requests.lock().unwrap();
1219 let replayed_messages = requests[4]["messages"].as_array().expect("messages");
1220 let prior_assistant = replayed_messages
1221 .iter()
1222 .rev()
1223 .find(|message| {
1224 message["role"] == "assistant"
1225 && message["content"]
1226 .as_str()
1227 .is_some_and(|content| content.contains("Build is still running."))
1228 })
1229 .expect("sanitized forced-text assistant response");
1230 assert!(prior_assistant.get("tool_calls").is_none());
1231 assert!(
1232 !prior_assistant["content"]
1233 .as_str()
1234 .expect("assistant content")
1235 .contains("<tool_call>")
1236 );
1237 drop(requests);
1238
1239 drop(agent);
1240 let _ = std::fs::remove_dir_all(&scratch);
1241 }
1242
1243 /// Kimi K3 may emit tool calls in XTML content blocks even when the endpoint is
1244 /// OpenAI-compatible. The backend should recover those before ACP sees them,
1245 /// unwrap visible response text, and hide private thinking text.
1246 #[test]
1247 fn a_kimi_k3_tool_call_emitted_as_text_is_executed_rather_than_rendered() {
1248 let endpoint = start_fake_endpoint(vec![
1249 // Split across frames to cover the streaming scanner, not just the
1250 // complete-response extractor.
1251 sse_body(&[
1252 json!({"choices": [{"delta": {"content": "<|open|>think<|sep|>private notes<|close|>think<|sep|>"}}]}),
1253 json!({"choices": [{"delta": {"content": "<|open|>response<|sep|>Checking the repo:<|close|>response<|sep|> <|open|>too"}}]}),
1254 json!({"choices": [{"delta": {"content": "ls<|sep|><|open|>call tool=\"command_output\" index=\"1\"<|sep|>"}}]}),
1255 json!({"choices": [{"delta": {"content": "<|open|>argument key=\"task_id\" type=\"integer\"<|sep|>2<|close|>argument<|sep|><|close|>call<|sep|><|close|>tools<|sep|>"}}]}),
1256 ]),
1257 sse_text("All done."),
1258 ]);
1259
1260 let scratch = std::env::temp_dir().join(format!("sigit_acp_kimi_k3_{}", std::process::id()));
1261 let config_dir = scratch.join("config");
1262 let cwd = scratch.join("cwd");
1263 std::fs::create_dir_all(&config_dir).unwrap();
1264 std::fs::create_dir_all(&cwd).unwrap();
1265
1266 let mut agent = spawn_agent(endpoint.port, &config_dir);
1267
1268 let id = agent.request(
1269 "initialize",
1270 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1271 );
1272 agent.wait_for_response(id);
1273
1274 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1275 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1276 .as_str()
1277 .expect("session id")
1278 .to_string();
1279
1280 let prompt_id = agent.request(
1281 "session/prompt",
1282 json!({
1283 "sessionId": session_id,
1284 "prompt": [{"type": "text", "text": "check the repo"}],
1285 }),
1286 );
1287 let (_response, rendered) = agent.wait_for_prompt(prompt_id);
1288
1289 assert!(
1290 !rendered.contains("<|open|>")
1291 && !rendered.contains("<|sep|>")
1292 && !rendered.contains("private notes"),
1293 "raw Kimi K3 markup reached the client: {rendered:?}"
1294 );
1295 assert!(
1296 rendered.contains("Checking the repo:"),
1297 "response text should still be shown: {rendered:?}"
1298 );
1299
1300 let requests = endpoint.requests.lock().unwrap();
1301 assert!(
1302 requests.len() >= 2,
1303 "expected a follow-up request carrying the tool result, got {}",
1304 requests.len()
1305 );
1306 let messages = requests[1]["messages"].as_array().expect("messages");
1307 assert!(
1308 messages.iter().any(|message| message["role"] == "tool"),
1309 "the follow-up should answer the recovered call: {messages:?}"
1310 );
1311 assert!(
1312 messages
1313 .iter()
1314 .any(|message| { message["role"] == "assistant" && message["tool_calls"].is_array() }),
1315 "history must record the recovered call, not the raw tag: {messages:?}"
1316 );
1317 drop(requests);
1318
1319 drop(agent);
1320 let _ = std::fs::remove_dir_all(&scratch);
1321 }
1322
1323 #[test]
1324 fn a_silent_forced_text_round_still_ends_with_a_message() {
1325 let repeated_arguments = json!({"path": "missing.txt"}).to_string();
1326 let endpoint = start_fake_endpoint(vec![
1327 sse_text_then_tool_call(
1328 "Checking the file.",
1329 "call_1",
1330 "read_file",
1331 &repeated_arguments,
1332 ),
1333 sse_tool_call("call_2", "read_file", &repeated_arguments),
1334 sse_tool_call("call_3", "read_file", &repeated_arguments),
1335 // The forced no-tools round says nothing, as in the stalled thread.
1336 sse_body(&[]),
1337 ]);
1338
1339 let scratch =
1340 std::env::temp_dir().join(format!("sigit_acp_silent_stop_{}", std::process::id()));
1341 let config_dir = scratch.join("config");
1342 let cwd = scratch.join("cwd");
1343 std::fs::create_dir_all(&config_dir).unwrap();
1344 std::fs::create_dir_all(&cwd).unwrap();
1345
1346 let mut agent = spawn_agent(endpoint.port, &config_dir);
1347 let id = agent.request(
1348 "initialize",
1349 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1350 );
1351 agent.wait_for_response(id);
1352 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1353 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1354 .as_str()
1355 .expect("session id")
1356 .to_string();
1357
1358 let prompt_id = agent.request(
1359 "session/prompt",
1360 json!({
1361 "sessionId": session_id,
1362 "prompt": [{"type": "text", "text": "read it"}],
1363 }),
1364 );
1365 let (response, rendered) = agent.wait_for_prompt(prompt_id);
1366 assert_eq!(response["result"]["stopReason"], "end_turn");
1367 assert!(rendered.contains("Checking the file."), "{rendered:?}");
1368 assert!(
1369 rendered.contains("same `read_file` call") && rendered.contains("continue"),
1370 "the turn ended without telling the user why: {rendered:?}"
1371 );
1372
1373 drop(agent);
1374 let _ = std::fs::remove_dir_all(&scratch);
1375 }
1376
1377 #[test]
1378 fn repeated_command_output_calls_remain_available() {
1379 let repeated_arguments = json!({"task_id": 999}).to_string();
1380 let endpoint = start_fake_endpoint(vec![
1381 sse_tool_call("call_1", "command_output", &repeated_arguments),
1382 sse_tool_call("call_2", "command_output", &repeated_arguments),
1383 sse_tool_call("call_3", "command_output", &repeated_arguments),
1384 sse_text("The background task finished."),
1385 ]);
1386
1387 let scratch =
1388 std::env::temp_dir().join(format!("sigit_acp_repeated_poll_{}", std::process::id()));
1389 let config_dir = scratch.join("config");
1390 let cwd = scratch.join("cwd");
1391 std::fs::create_dir_all(&config_dir).unwrap();
1392 std::fs::create_dir_all(&cwd).unwrap();
1393
1394 let mut agent = spawn_agent(endpoint.port, &config_dir);
1395 let id = agent.request(
1396 "initialize",
1397 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1398 );
1399 agent.wait_for_response(id);
1400 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1401 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1402 .as_str()
1403 .expect("session id")
1404 .to_string();
1405
1406 let prompt_id = agent.request(
1407 "session/prompt",
1408 json!({
1409 "sessionId": session_id,
1410 "prompt": [{"type": "text", "text": "wait for the background task"}],
1411 }),
1412 );
1413 let (response, rendered) = agent.wait_for_prompt(prompt_id);
1414 assert_eq!(response["result"]["stopReason"], "end_turn");
1415 assert!(rendered.contains("finished"), "got: {rendered:?}");
1416
1417 let requests = endpoint.requests.lock().unwrap();
1418 assert_eq!(requests.len(), 4);
1419 assert!(
1420 requests[3].get("tools").is_some(),
1421 "command_output repetition must not force tools off: {:?}",
1422 requests[3]
1423 );
1424 drop(requests);
1425
1426 drop(agent);
1427 let _ = std::fs::remove_dir_all(&scratch);
1428 }
1429
1430 #[test]
1431 fn write_todos_reaches_the_client_as_an_acp_plan() {
1432 let endpoint = start_fake_endpoint(vec![
1433 sse_tool_call(
1434 "call_1",
1435 "write_todos",
1436 &json!({
1437 "todos": [
1438 {"content": "Fetch issue screenshot", "status": "completed"},
1439 {"content": "Implement ACP styling", "status": "in_progress"},
1440 {"content": "Build and verify", "status": "pending"}
1441 ]
1442 })
1443 .to_string(),
1444 ),
1445 sse_text("Continuing."),
1446 ]);
1447
1448 let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_{}", std::process::id()));
1449 let config_dir = scratch.join("config");
1450 let cwd = scratch.join("cwd");
1451 std::fs::create_dir_all(&config_dir).unwrap();
1452 std::fs::create_dir_all(&cwd).unwrap();
1453
1454 let mut agent = spawn_agent(endpoint.port, &config_dir);
1455
1456 let id = agent.request(
1457 "initialize",
1458 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1459 );
1460 agent.wait_for_response(id);
1461
1462 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1463 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1464 .as_str()
1465 .expect("session id")
1466 .to_string();
1467
1468 let prompt_id = agent.request(
1469 "session/prompt",
1470 json!({
1471 "sessionId": session_id,
1472 "prompt": [{"type": "text", "text": "fix the ACP display"}],
1473 }),
1474 );
1475 let (response, updates) = agent.wait_for_response_with_updates(prompt_id);
1476 assert_eq!(response["result"]["stopReason"], "end_turn");
1477
1478 let plan = updates
1479 .iter()
1480 .find(|update| update["sessionUpdate"] == "plan")
1481 .expect("write_todos should be rendered as an ACP plan update");
1482 assert_eq!(plan["entries"][0]["content"], "Fetch issue screenshot");
1483 assert_eq!(plan["entries"][0]["status"], "completed");
1484 assert_eq!(plan["entries"][1]["status"], "in_progress");
1485 assert_eq!(plan["entries"][2]["status"], "pending");
1486
1487 assert!(
1488 !updates.iter().any(|update| {
1489 update["sessionUpdate"] == "tool_call" && update["title"] == "write_todos"
1490 }),
1491 "write_todos must not show up as a generic tool-call card: {updates:?}"
1492 );
1493
1494 drop(agent);
1495 let _ = std::fs::remove_dir_all(&scratch);
1496 }
1497
1498 /// An empty `write_todos` is how the model clears a finished checklist. It has
1499 /// to reach the client as a plan with no entries, since ACP plan updates
1500 /// replace the whole plan, rather than as an error card that leaves the old
1501 /// list on screen.
1502 #[test]
1503 fn an_empty_write_todos_clears_the_acp_plan() {
1504 let endpoint = start_fake_endpoint(vec![
1505 sse_tool_call("call_1", "write_todos", &json!({"todos": []}).to_string()),
1506 sse_text("Checklist cleared."),
1507 ]);
1508
1509 let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_clear_{}", std::process::id()));
1510 let config_dir = scratch.join("config");
1511 let cwd = scratch.join("cwd");
1512 std::fs::create_dir_all(&config_dir).unwrap();
1513 std::fs::create_dir_all(&cwd).unwrap();
1514
1515 let mut agent = spawn_agent(endpoint.port, &config_dir);
1516
1517 let id = agent.request(
1518 "initialize",
1519 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1520 );
1521 agent.wait_for_response(id);
1522
1523 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1524 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1525 .as_str()
1526 .expect("session id")
1527 .to_string();
1528
1529 let prompt_id = agent.request(
1530 "session/prompt",
1531 json!({
1532 "sessionId": session_id,
1533 "prompt": [{"type": "text", "text": "clear the checklist"}],
1534 }),
1535 );
1536 let (response, updates) = agent.wait_for_response_with_updates(prompt_id);
1537 assert_eq!(response["result"]["stopReason"], "end_turn");
1538
1539 let plan = updates
1540 .iter()
1541 .find(|update| update["sessionUpdate"] == "plan")
1542 .expect("an empty write_todos should still send a plan update");
1543 assert_eq!(plan["entries"], json!([]));
1544 assert!(
1545 !updates
1546 .iter()
1547 .any(|update| update["sessionUpdate"] == "tool_call"),
1548 "clearing the plan must not show a tool-call card: {updates:?}"
1549 );
1550
1551 drop(agent);
1552 let _ = std::fs::remove_dir_all(&scratch);
1553 }
1554
1555 /// An off-enum status must not make the whole call vanish. `exec_write_todos`
1556 /// renders it as pending and reports success, so the plan has to carry it the
1557 /// same way; dropping the plan here would leave the client showing a stale list.
1558 #[test]
1559 fn write_todos_with_an_unknown_status_still_reaches_the_client() {
1560 let endpoint = start_fake_endpoint(vec![
1561 sse_tool_call(
1562 "call_1",
1563 "write_todos",
1564 &json!({
1565 "todos": [
1566 {"content": "Fetch issue screenshot", "status": "completed"},
1567 {"content": "Ship it", "status": "cancelled"}
1568 ]
1569 })
1570 .to_string(),
1571 ),
1572 sse_text("Continuing."),
1573 ]);
1574
1575 let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_odd_{}", std::process::id()));
1576 let config_dir = scratch.join("config");
1577 let cwd = scratch.join("cwd");
1578 std::fs::create_dir_all(&config_dir).unwrap();
1579 std::fs::create_dir_all(&cwd).unwrap();
1580
1581 let mut agent = spawn_agent(endpoint.port, &config_dir);
1582
1583 let id = agent.request(
1584 "initialize",
1585 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1586 );
1587 agent.wait_for_response(id);
1588
1589 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1590 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1591 .as_str()
1592 .expect("session id")
1593 .to_string();
1594
1595 let prompt_id = agent.request(
1596 "session/prompt",
1597 json!({
1598 "sessionId": session_id,
1599 "prompt": [{"type": "text", "text": "plan the work"}],
1600 }),
1601 );
1602 let (_response, updates) = agent.wait_for_response_with_updates(prompt_id);
1603
1604 let plan = updates
1605 .iter()
1606 .find(|update| update["sessionUpdate"] == "plan")
1607 .expect("an unknown status must not drop the plan");
1608 assert_eq!(plan["entries"][1]["content"], "Ship it");
1609 assert_eq!(plan["entries"][1]["status"], "pending");
1610
1611 drop(agent);
1612 let _ = std::fs::remove_dir_all(&scratch);
1613 }
1614
1615 /// Arguments the converter cannot turn into a plan fall back to the ordinary
1616 /// tool-call card. Without that fallback the call is announced nowhere at all.
1617 #[test]
1618 fn unconvertible_write_todos_falls_back_to_a_tool_call_card() {
1619 let endpoint = start_fake_endpoint(vec![
1620 // `todos` is absent, so there is no plan to build. `exec_write_todos`
1621 // answers with an error string, and the client still has to see the call.
1622 sse_tool_call("call_1", "write_todos", &json!({"items": []}).to_string()),
1623 sse_text("Continuing."),
1624 ]);
1625
1626 let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_bad_{}", std::process::id()));
1627 let config_dir = scratch.join("config");
1628 let cwd = scratch.join("cwd");
1629 std::fs::create_dir_all(&config_dir).unwrap();
1630 std::fs::create_dir_all(&cwd).unwrap();
1631
1632 let mut agent = spawn_agent(endpoint.port, &config_dir);
1633
1634 let id = agent.request(
1635 "initialize",
1636 json!({"protocolVersion": 1, "clientCapabilities": {}}),
1637 );
1638 agent.wait_for_response(id);
1639
1640 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1641 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1642 .as_str()
1643 .expect("session id")
1644 .to_string();
1645
1646 let prompt_id = agent.request(
1647 "session/prompt",
1648 json!({
1649 "sessionId": session_id,
1650 "prompt": [{"type": "text", "text": "plan the work"}],
1651 }),
1652 );
1653 let (_response, updates) = agent.wait_for_response_with_updates(prompt_id);
1654
1655 assert!(
1656 !updates
1657 .iter()
1658 .any(|update| update["sessionUpdate"] == "plan"),
1659 "there is no plan to send: {updates:?}"
1660 );
1661 assert!(
1662 updates.iter().any(|update| {
1663 update["sessionUpdate"] == "tool_call" && update["title"] == "write_todos"
1664 }),
1665 "the call must still be announced as a tool call: {updates:?}"
1666 );
1667
1668 drop(agent);
1669 let _ = std::fs::remove_dir_all(&scratch);
1670 }