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