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 if message["id"] == id && message.get("method").is_none() {
298 assert!(
299 message.get("error").is_none(),
300 "request {id} failed: {message}"
301 );
302 return (message, updates);
303 }
304 }
305 }
306
307 /// A request *from* the agent (has a `method` and its own id).
308 fn wait_for_agent_request(&mut self, method: &str) -> Value {
309 self.wait_for(&format!("agent request {method}"), |message| {
310 message["method"] == method && message.get("id").is_some()
311 })
312 }
313 }
314
315 impl Drop for AgentUnderTest {
316 fn drop(&mut self) {
317 let _ = self.child.kill();
318 let _ = self.child.wait();
319 }
320 }
321
322 // ── The round-trip ──────────────────────────────────────────────────────────
323
324 #[test]
325 fn permission_round_trip_cancel_then_allow() {
326 let endpoint = start_fake_endpoint(vec![
327 sse_tool_call("call_1", "run_command", r#"{"command":"echo sigit-first"}"#),
328 sse_tool_call(
329 "call_2",
330 "run_command",
331 r#"{"command":"echo sigit-approved"}"#,
332 ),
333 sse_text("done"),
334 ]);
335
336 let scratch = std::env::temp_dir().join(format!("sigit_acp_perm_{}", std::process::id()));
337 let config_dir = scratch.join("config");
338 let cwd = scratch.join("cwd");
339 std::fs::create_dir_all(&config_dir).unwrap();
340 std::fs::create_dir_all(&cwd).unwrap();
341
342 let mut agent = spawn_agent(endpoint.port, &config_dir);
343
344 let id = agent.request(
345 "initialize",
346 json!({"protocolVersion": 1, "clientCapabilities": {}}),
347 );
348 agent.wait_for_response(id);
349
350 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
351 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
352 .as_str()
353 .expect("session id")
354 .to_string();
355
356 // ── Turn 1: cancel at the permission gate ───────────────────────────
357 let prompt_id = agent.request(
358 "session/prompt",
359 json!({
360 "sessionId": session_id,
361 "prompt": [{"type": "text", "text": "run the first command"}],
362 }),
363 );
364
365 let permission = agent.wait_for_agent_request("session/request_permission");
366 let params = &permission["params"];
367 assert_eq!(params["sessionId"], session_id.as_str());
368 let title = params["toolCall"]["title"].as_str().expect("title");
369 assert!(
370 title.contains("run_command") && title.contains("echo sigit-first"),
371 "the dialog must show the tool and its arguments, got: {title}"
372 );
373 assert_eq!(
374 params["toolCall"]["rawInput"]["command"], "echo sigit-first",
375 "full arguments must travel as rawInput"
376 );
377 let option_ids: Vec<&str> = params["options"]
378 .as_array()
379 .expect("options")
380 .iter()
381 .map(|option| option["optionId"].as_str().unwrap_or_default())
382 .collect();
383 assert_eq!(option_ids, ["allow_once", "allow_session", "reject_once"]);
384
385 agent.respond(
386 permission["id"].clone(),
387 json!({"outcome": {"outcome": "cancelled"}}),
388 );
389
390 let response = agent.wait_for_response(prompt_id);
391 assert_eq!(response["result"]["stopReason"], "cancelled");
392
393 // ── Turn 2: history must be repaired; then approve once ─────────────
394 let prompt_id = agent.request(
395 "session/prompt",
396 json!({
397 "sessionId": session_id,
398 "prompt": [{"type": "text", "text": "run the second command"}],
399 }),
400 );
401
402 let permission = agent.wait_for_agent_request("session/request_permission");
403 agent.respond(
404 permission["id"].clone(),
405 json!({"outcome": {"outcome": "selected", "optionId": "allow_once"}}),
406 );
407
408 let response = agent.wait_for_response(prompt_id);
409 assert_eq!(response["result"]["stopReason"], "end_turn");
410
411 // ── What the endpoint saw ────────────────────────────────────────────
412 let requests = endpoint.requests.lock().unwrap();
413 assert_eq!(requests.len(), 3, "expected exactly three completions");
414
415 // The client explicitly requests automatic tool selection. Some
416 // OpenAI-compatible gateways do not honour OpenAI's implicit `auto`
417 // default, which otherwise lets the model describe a tool action in prose
418 // and end the turn without a tool call.
419 assert_eq!(requests[0]["tool_choice"], "auto");
420
421 // Request 2 replays the full history: the cancelled round's tool call
422 // must be answered by a `role: "tool"` message, not left dangling.
423 let messages = requests[1]["messages"].as_array().expect("messages");
424 let call_position = messages
425 .iter()
426 .position(|message| message["tool_calls"][0]["id"] == "call_1")
427 .expect("cancelled turn's assistant tool call in replayed history");
428 let repair = &messages[call_position + 1];
429 assert_eq!(repair["role"], "tool", "dangling tool call not closed out");
430 assert_eq!(repair["tool_call_id"], "call_1");
431 assert!(
432 repair["content"]
433 .as_str()
434 .unwrap_or_default()
435 .contains("cancelled"),
436 "repair message should say the turn was cancelled: {repair}"
437 );
438
439 // Request 3 carries the approved call's real output.
440 let messages = requests[2]["messages"].as_array().expect("messages");
441 let result = messages
442 .iter()
443 .find(|message| message["role"] == "tool" && message["tool_call_id"] == "call_2")
444 .expect("tool result for the approved call");
445 assert!(
446 result["content"]
447 .as_str()
448 .unwrap_or_default()
449 .contains("sigit-approved"),
450 "the approved command's output should reach the endpoint: {result}"
451 );
452
453 drop(agent);
454 let _ = std::fs::remove_dir_all(&scratch);
455 }
456
457 #[test]
458 fn successful_config_option_changes_are_rendered_as_system_status_cards() {
459 let endpoint = start_fake_endpoint(vec![]);
460
461 let scratch =
462 std::env::temp_dir().join(format!("sigit_acp_config_messages_{}", std::process::id()));
463 let config_dir = scratch.join("config");
464 let cwd = scratch.join("cwd");
465 std::fs::create_dir_all(&config_dir).unwrap();
466 std::fs::create_dir_all(&cwd).unwrap();
467 std::fs::write(
468 config_dir.join("credentials.toml"),
469 "access_token = \"tok_test\"\nemail = \"dev@sigit.si\"\n",
470 )
471 .unwrap();
472
473 let mut agent = spawn_agent(endpoint.port, &config_dir);
474
475 let id = agent.request(
476 "initialize",
477 json!({"protocolVersion": 1, "clientCapabilities": {}}),
478 );
479 agent.wait_for_response(id);
480
481 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
482 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
483 .as_str()
484 .expect("session id")
485 .to_string();
486
487 let id = agent.request(
488 "session/set_config_option",
489 json!({
490 "sessionId": session_id,
491 "configId": "sigit-local-inference",
492 "value": "local-inference-off",
493 }),
494 );
495 let (response, updates) = agent.wait_for_response_with_updates(id);
496 assert!(
497 response["result"]["configOptions"].is_array(),
498 "config response should refresh picker options: {response}"
499 );
500 assert!(
501 !updates
502 .iter()
503 .any(|update| update["sessionUpdate"] == "agent_message_chunk"),
504 "Local Inference picker changes are passive UI state, not chat text: {updates:?}"
505 );
506 let status = updates
507 .iter()
508 .find(|update| {
509 update["sessionUpdate"] == "tool_call"
510 && update["kind"] == "think"
511 && update["status"] == "completed"
512 })
513 .expect("Local Inference change should render as a system-style status card");
514 assert_eq!(
515 status["title"],
516 "Local inference is off. siGit Code Cloud tiers are highlighted; pick one from Model."
517 );
518
519 let id = agent.request(
520 "session/set_config_option",
521 json!({
522 "sessionId": session_id,
523 "configId": "sigit-model",
524 "value": "sigit-cloud:mini",
525 }),
526 );
527 let (response, updates) = agent.wait_for_response_with_updates(id);
528 assert!(
529 response["result"]["configOptions"].is_array(),
530 "config response should refresh picker options: {response}"
531 );
532 assert!(
533 !updates
534 .iter()
535 .any(|update| update["sessionUpdate"] == "agent_message_chunk"),
536 "successful model picker changes must not be rendered as assistant chat text: {updates:?}"
537 );
538 let status = updates
539 .iter()
540 .find(|update| {
541 update["sessionUpdate"] == "tool_call"
542 && update["kind"] == "think"
543 && update["status"] == "completed"
544 })
545 .expect("cloud tier change should render as a system-style status card");
546 assert!(
547 status["title"]
548 .as_str()
549 .unwrap_or_default()
550 .starts_with("Switched to siGit Code Cloud"),
551 "unexpected cloud switch status title: {status}"
552 );
553
554 drop(agent);
555 let _ = std::fs::remove_dir_all(&scratch);
556 }
557
558 #[test]
559 fn a_slow_commands_progress_reaches_the_client_while_it_runs() {
560 let scratch = std::env::temp_dir().join(format!("sigit_acp_progress_{}", std::process::id()));
561 let config_dir = scratch.join("config");
562 let cwd = scratch.join("cwd");
563 std::fs::create_dir_all(&config_dir).unwrap();
564 std::fs::create_dir_all(&cwd).unwrap();
565
566 // The command has to run for a few seconds through `sh -c` / `cmd /C`.
567 // `sleep` is neither a cmd builtin nor on a Windows runner's PATH (Git's
568 // usr\bin, which carries the MSYS coreutils, is deliberately left off it),
569 // and the Windows job hung on it rather than failing fast. `ping` ships in
570 // System32, waits a second between echoes, and never reads stdin.
571 //
572 // Four seconds against the one-second assertion below is deliberate
573 // padding. A busy runner only ever widens the gap being measured — it
574 // cannot make the command finish sooner — so the one way a correct agent
575 // could fail here is this thread being descheduled for seconds between
576 // reading the two updates off the channel. Three seconds of slack covers
577 // that without weakening the assertion, which is separating four seconds
578 // from the 164µs the blocking version produced.
579 #[cfg(unix)]
580 let command = "sleep 4";
581 #[cfg(windows)]
582 let command = "ping -n 5 127.0.0.1";
583
584 // `SIGIT_PERMISSIONS=allow` skips the gate, so the only thing between the
585 // two updates below is the command itself.
586 let endpoint = start_fake_endpoint(vec![
587 sse_tool_call(
588 "call_1",
589 "run_command",
590 &json!({"command": command, "cwd": cwd}).to_string(),
591 ),
592 sse_text("done"),
593 ]);
594
595 let mut agent = spawn_agent_with_permissions(endpoint.port, &config_dir, Some("allow"));
596
597 let id = agent.request(
598 "initialize",
599 json!({"protocolVersion": 1, "clientCapabilities": {}}),
600 );
601 agent.wait_for_response(id);
602
603 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
604 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
605 .as_str()
606 .expect("session id")
607 .to_string();
608
609 let prompt_id = agent.request(
610 "session/prompt",
611 json!({
612 "sessionId": session_id,
613 "prompt": [{"type": "text", "text": "run the slow command"}],
614 }),
615 );
616
617 let started = agent.wait_for_update("the tool call starting", |update| {
618 update["sessionUpdate"] == "tool_call" && update["toolCallId"] == "call_1"
619 });
620 let in_progress_at = Instant::now();
621 assert_eq!(
622 started["status"], "in_progress",
623 "a running tool call must be announced as in_progress: {started}"
624 );
625
626 let finished = agent.wait_for_update("the tool call finishing", |update| {
627 update["sessionUpdate"] == "tool_call_update"
628 && update["toolCallId"] == "call_1"
629 && update["status"] == "completed"
630 });
631
632 // The command runs between the two updates. If the turn blocked the
633 // connection's actors, both would only reach the client once it had
634 // already finished, and the editor would never draw a spinner.
635 assert!(
636 in_progress_at.elapsed() >= Duration::from_millis(1_000),
637 "in_progress arrived only {:?} before completed — the client had no \
638 window to show progress in. `{command}` said: {}",
639 in_progress_at.elapsed(),
640 finished["rawOutput"]
641 );
642
643 let response = agent.wait_for_response(prompt_id);
644 assert_eq!(response["result"]["stopReason"], "end_turn");
645
646 drop(agent);
647 let _ = std::fs::remove_dir_all(&scratch);
648 }
649
650 #[test]
651 fn text_from_two_tool_rounds_reaches_the_client_as_separate_paragraphs() {
652 let endpoint = start_fake_endpoint(vec![
653 // `list_directory` is read-only, so it runs without a permission gate
654 // and the two rounds follow each other straight away.
655 sse_text_then_tool_call(
656 "I'll search for this pattern.",
657 "call_1",
658 "list_directory",
659 r#"{"path":"."}"#,
660 ),
661 sse_text("Let me broaden the search:"),
662 ]);
663
664 let scratch = std::env::temp_dir().join(format!("sigit_acp_spacing_{}", std::process::id()));
665 let config_dir = scratch.join("config");
666 let cwd = scratch.join("cwd");
667 std::fs::create_dir_all(&config_dir).unwrap();
668 std::fs::create_dir_all(&cwd).unwrap();
669
670 let mut agent = spawn_agent(endpoint.port, &config_dir);
671
672 let id = agent.request(
673 "initialize",
674 json!({"protocolVersion": 1, "clientCapabilities": {}}),
675 );
676 agent.wait_for_response(id);
677
678 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
679 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
680 .as_str()
681 .expect("session id")
682 .to_string();
683
684 let prompt_id = agent.request(
685 "session/prompt",
686 json!({
687 "sessionId": session_id,
688 "prompt": [{"type": "text", "text": "find it"}],
689 }),
690 );
691 let (response, rendered) = agent.wait_for_prompt(prompt_id);
692 assert_eq!(response["result"]["stopReason"], "end_turn");
693
694 assert!(
695 rendered.contains("pattern.\n\nLet me broaden"),
696 "the second round must open a new paragraph, got: {rendered:?}"
697 );
698 assert!(
699 !rendered.contains("pattern.Let me"),
700 "rounds must not run together into one sentence, got: {rendered:?}"
701 );
702
703 drop(agent);
704 let _ = std::fs::remove_dir_all(&scratch);
705 }
706
707 /// A model that writes its tool call out as literal `<tool_call>` text instead
708 /// of using the structured field must still drive the loop. Before recovery
709 /// the tag was streamed to the client as prose and the turn ended with no tool
710 /// calls, so the request silently went unanswered (getsigit/sigit#73).
711 #[test]
712 fn a_tool_call_emitted_as_text_is_executed_rather_than_rendered() {
713 let endpoint = start_fake_endpoint(vec![
714 // Split mid-tag, the way a real stream arrives.
715 sse_body(&[
716 json!({"choices": [{"delta": {"content": "Checking the repo: <tool_c"}}]}),
717 json!({"choices": [{"delta": {"content": "all>command_output<arg_key>task_id</arg_key>"}}]}),
718 json!({"choices": [{"delta": {"content": "<arg_value>2</arg_value></tool_call>"}}]}),
719 ]),
720 sse_text("All done."),
721 ]);
722
723 let scratch = std::env::temp_dir().join(format!("sigit_acp_inline_{}", std::process::id()));
724 let config_dir = scratch.join("config");
725 let cwd = scratch.join("cwd");
726 std::fs::create_dir_all(&config_dir).unwrap();
727 std::fs::create_dir_all(&cwd).unwrap();
728
729 let mut agent = spawn_agent(endpoint.port, &config_dir);
730
731 let id = agent.request(
732 "initialize",
733 json!({"protocolVersion": 1, "clientCapabilities": {}}),
734 );
735 agent.wait_for_response(id);
736
737 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
738 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
739 .as_str()
740 .expect("session id")
741 .to_string();
742
743 let prompt_id = agent.request(
744 "session/prompt",
745 json!({
746 "sessionId": session_id,
747 "prompt": [{"type": "text", "text": "check the repo"}],
748 }),
749 );
750 let (_response, rendered) = agent.wait_for_prompt(prompt_id);
751
752 // The raw tag must never be shown to the user.
753 assert!(
754 !rendered.contains("<tool_call>") && !rendered.contains("<arg_key>"),
755 "raw tool-call markup reached the client: {rendered:?}"
756 );
757 assert!(
758 rendered.contains("Checking the repo:"),
759 "surrounding prose should still stream: {rendered:?}"
760 );
761
762 // The loop has to keep going: a second request means the recovered call
763 // ran and its result was sent back. Before recovery the turn ended here.
764 let requests = endpoint.requests.lock().unwrap();
765 assert!(
766 requests.len() >= 2,
767 "expected a follow-up request carrying the tool result, got {}",
768 requests.len()
769 );
770 let follow_up = &requests[1];
771 let messages = follow_up["messages"].as_array().expect("messages");
772 assert!(
773 messages.iter().any(|message| message["role"] == "tool"),
774 "the follow-up should answer the recovered call: {messages:?}"
775 );
776 assert!(
777 messages
778 .iter()
779 .any(|message| { message["role"] == "assistant" && message["tool_calls"].is_array() }),
780 "history must record the recovered call, not the raw tag: {messages:?}"
781 );
782 drop(requests);
783
784 drop(agent);
785 let _ = std::fs::remove_dir_all(&scratch);
786 }
787
788 #[test]
789 fn write_todos_reaches_the_client_as_an_acp_plan() {
790 let endpoint = start_fake_endpoint(vec![
791 sse_tool_call(
792 "call_1",
793 "write_todos",
794 &json!({
795 "todos": [
796 {"content": "Fetch issue screenshot", "status": "completed"},
797 {"content": "Implement ACP styling", "status": "in_progress"},
798 {"content": "Build and verify", "status": "pending"}
799 ]
800 })
801 .to_string(),
802 ),
803 sse_text("Continuing."),
804 ]);
805
806 let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_{}", std::process::id()));
807 let config_dir = scratch.join("config");
808 let cwd = scratch.join("cwd");
809 std::fs::create_dir_all(&config_dir).unwrap();
810 std::fs::create_dir_all(&cwd).unwrap();
811
812 let mut agent = spawn_agent(endpoint.port, &config_dir);
813
814 let id = agent.request(
815 "initialize",
816 json!({"protocolVersion": 1, "clientCapabilities": {}}),
817 );
818 agent.wait_for_response(id);
819
820 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
821 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
822 .as_str()
823 .expect("session id")
824 .to_string();
825
826 let prompt_id = agent.request(
827 "session/prompt",
828 json!({
829 "sessionId": session_id,
830 "prompt": [{"type": "text", "text": "fix the ACP display"}],
831 }),
832 );
833 let (response, updates) = agent.wait_for_response_with_updates(prompt_id);
834 assert_eq!(response["result"]["stopReason"], "end_turn");
835
836 let plan = updates
837 .iter()
838 .find(|update| update["sessionUpdate"] == "plan")
839 .expect("write_todos should be rendered as an ACP plan update");
840 assert_eq!(plan["entries"][0]["content"], "Fetch issue screenshot");
841 assert_eq!(plan["entries"][0]["status"], "completed");
842 assert_eq!(plan["entries"][1]["status"], "in_progress");
843 assert_eq!(plan["entries"][2]["status"], "pending");
844
845 assert!(
846 !updates.iter().any(|update| {
847 update["sessionUpdate"] == "tool_call" && update["title"] == "write_todos"
848 }),
849 "write_todos must not show up as a generic tool-call card: {updates:?}"
850 );
851
852 drop(agent);
853 let _ = std::fs::remove_dir_all(&scratch);
854 }
855
856 /// An off-enum status must not make the whole call vanish. `exec_write_todos`
857 /// renders it as pending and reports success, so the plan has to carry it the
858 /// same way; dropping the plan here would leave the client showing a stale list.
859 #[test]
860 fn write_todos_with_an_unknown_status_still_reaches_the_client() {
861 let endpoint = start_fake_endpoint(vec![
862 sse_tool_call(
863 "call_1",
864 "write_todos",
865 &json!({
866 "todos": [
867 {"content": "Fetch issue screenshot", "status": "completed"},
868 {"content": "Ship it", "status": "cancelled"}
869 ]
870 })
871 .to_string(),
872 ),
873 sse_text("Continuing."),
874 ]);
875
876 let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_odd_{}", std::process::id()));
877 let config_dir = scratch.join("config");
878 let cwd = scratch.join("cwd");
879 std::fs::create_dir_all(&config_dir).unwrap();
880 std::fs::create_dir_all(&cwd).unwrap();
881
882 let mut agent = spawn_agent(endpoint.port, &config_dir);
883
884 let id = agent.request(
885 "initialize",
886 json!({"protocolVersion": 1, "clientCapabilities": {}}),
887 );
888 agent.wait_for_response(id);
889
890 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
891 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
892 .as_str()
893 .expect("session id")
894 .to_string();
895
896 let prompt_id = agent.request(
897 "session/prompt",
898 json!({
899 "sessionId": session_id,
900 "prompt": [{"type": "text", "text": "plan the work"}],
901 }),
902 );
903 let (_response, updates) = agent.wait_for_response_with_updates(prompt_id);
904
905 let plan = updates
906 .iter()
907 .find(|update| update["sessionUpdate"] == "plan")
908 .expect("an unknown status must not drop the plan");
909 assert_eq!(plan["entries"][1]["content"], "Ship it");
910 assert_eq!(plan["entries"][1]["status"], "pending");
911
912 drop(agent);
913 let _ = std::fs::remove_dir_all(&scratch);
914 }
915
916 /// Arguments the converter cannot turn into a plan fall back to the ordinary
917 /// tool-call card. Without that fallback the call is announced nowhere at all.
918 #[test]
919 fn unconvertible_write_todos_falls_back_to_a_tool_call_card() {
920 let endpoint = start_fake_endpoint(vec![
921 // `todos` is absent, so there is no plan to build. `exec_write_todos`
922 // answers with an error string, and the client still has to see the call.
923 sse_tool_call("call_1", "write_todos", &json!({"items": []}).to_string()),
924 sse_text("Continuing."),
925 ]);
926
927 let scratch = std::env::temp_dir().join(format!("sigit_acp_plan_bad_{}", std::process::id()));
928 let config_dir = scratch.join("config");
929 let cwd = scratch.join("cwd");
930 std::fs::create_dir_all(&config_dir).unwrap();
931 std::fs::create_dir_all(&cwd).unwrap();
932
933 let mut agent = spawn_agent(endpoint.port, &config_dir);
934
935 let id = agent.request(
936 "initialize",
937 json!({"protocolVersion": 1, "clientCapabilities": {}}),
938 );
939 agent.wait_for_response(id);
940
941 let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
942 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
943 .as_str()
944 .expect("session id")
945 .to_string();
946
947 let prompt_id = agent.request(
948 "session/prompt",
949 json!({
950 "sessionId": session_id,
951 "prompt": [{"type": "text", "text": "plan the work"}],
952 }),
953 );
954 let (_response, updates) = agent.wait_for_response_with_updates(prompt_id);
955
956 assert!(
957 !updates
958 .iter()
959 .any(|update| update["sessionUpdate"] == "plan"),
960 "there is no plan to send: {updates:?}"
961 );
962 assert!(
963 updates.iter().any(|update| {
964 update["sessionUpdate"] == "tool_call" && update["title"] == "write_todos"
965 }),
966 "the call must still be announced as a tool call: {updates:?}"
967 );
968
969 drop(agent);
970 let _ = std::fs::remove_dir_all(&scratch);
971 }