1 //! The stop reason a prompt turn ends with (issue #140).
2 //!
3 //! ACP defines five and the agent only ever sent `end_turn` and `cancelled`,
4 //! so a client could not tell a finished answer from one cut off at the token
5 //! limit, withheld by the endpoint, or stopped at the tool-round cap. The
6 //! `cancelled` case is covered in `acp_permissions.rs`; this file drives the
7 //! other three through a scripted endpoint.
8
9 use std::collections::VecDeque;
10 use std::io::{BufRead, BufReader, Read, Write};
11 use std::net::TcpListener;
12 use std::process::{Child, ChildStdin, Command, Stdio};
13 use std::sync::mpsc::{Receiver, channel};
14 use std::sync::{Arc, Mutex};
15 use std::time::{Duration, Instant};
16
17 use serde_json::{Value, json};
18
19 const TIMEOUT: Duration = Duration::from_secs(60);
20
21 /// The built-in tool-round cap (`MAX_TOOL_ROUNDS` in `main.rs`).
22 const TOOL_ROUND_CAP: usize = 24;
23
24 /// A streamed text reply that ends with `finish_reason`.
25 fn sse_text(text: &str, finish_reason: &str) -> String {
26 format!(
27 "data: {}\n\ndata: {}\n\ndata: [DONE]\n\n",
28 json!({"choices": [{"delta": {"content": text}}]}),
29 json!({"choices": [{"delta": {}, "finish_reason": finish_reason}]}),
30 )
31 }
32
33 fn sse_tool_call(id: &str, name: &str, arguments: &str) -> String {
34 format!(
35 "data: {}\n\ndata: {}\n\ndata: [DONE]\n\n",
36 json!({
37 "choices": [{"delta": {"tool_calls": [{
38 "index": 0,
39 "id": id,
40 "function": {"name": name, "arguments": arguments},
41 }]}}]
42 }),
43 json!({"choices": [{"delta": {}, "finish_reason": "tool_calls"}]}),
44 )
45 }
46
47 /// A scripted endpoint: one SSE body per request, in order, then empty
48 /// replies. Every request body it receives is kept for the test to read.
49 struct FakeEndpoint {
50 port: u16,
51 requests: Arc<Mutex<Vec<Value>>>,
52 }
53
54 fn start_fake_endpoint(replies: Vec<String>) -> FakeEndpoint {
55 let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
56 let port = listener.local_addr().unwrap().port();
57 let queue = Mutex::new(VecDeque::from(replies));
58 let requests = Arc::new(Mutex::new(Vec::new()));
59 let seen = Arc::clone(&requests);
60
61 std::thread::spawn(move || {
62 for stream in listener.incoming() {
63 let Ok(mut stream) = stream else { continue };
64 let mut reader = BufReader::new(match stream.try_clone() {
65 Ok(clone) => clone,
66 Err(_) => continue,
67 });
68 let mut content_length = 0usize;
69 loop {
70 let mut line = String::new();
71 if reader.read_line(&mut line).unwrap_or(0) == 0 {
72 break;
73 }
74 let line = line.trim();
75 if line.is_empty() {
76 break;
77 }
78 if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
79 content_length = length.trim().parse().unwrap_or(0);
80 }
81 }
82 let mut body = vec![0u8; content_length];
83 if reader.read_exact(&mut body).is_err() {
84 continue;
85 }
86 if let Ok(request) = serde_json::from_slice::<Value>(&body) {
87 seen.lock().unwrap().push(request);
88 }
89 let reply = queue
90 .lock()
91 .unwrap()
92 .pop_front()
93 .unwrap_or_else(|| "data: [DONE]\n\n".to_string());
94 let response = format!(
95 "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\n\
96 connection: close\r\n\r\n{}",
97 reply.len(),
98 reply
99 );
100 let _ = stream.write_all(response.as_bytes());
101 let _ = stream.flush();
102 }
103 });
104
105 FakeEndpoint { port, requests }
106 }
107
108 struct AgentUnderTest {
109 child: Child,
110 stdin: ChildStdin,
111 incoming: Receiver<Value>,
112 next_id: u64,
113 }
114
115 fn spawn_agent(port: u16, config_dir: &std::path::Path) -> AgentUnderTest {
116 spawn_agent_with_env(port, config_dir, &[])
117 }
118
119 fn spawn_agent_with_env(
120 port: u16,
121 config_dir: &std::path::Path,
122 env: &[(&str, &str)],
123 ) -> AgentUnderTest {
124 let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
125 .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
126 .env("OPENAI_API_KEY", "test-key")
127 .env("SIGIT_MODEL", "scripted-model")
128 .env("SIGIT_CONFIG_DIR", config_dir)
129 .env("SIGIT_MCP", "off")
130 .env("SIGIT_PERMISSIONS", "allow")
131 .env_remove("SIGIT_LOCAL_INFERENCE")
132 .env_remove("SIGIT_MAX_TOOL_ROUNDS")
133 .envs(env.iter().copied())
134 .stdin(Stdio::piped())
135 .stdout(Stdio::piped())
136 .stderr(Stdio::null())
137 .spawn()
138 .expect("spawn sigit in ACP mode");
139
140 let stdout = child.stdout.take().unwrap();
141 let (message_tx, incoming) = channel();
142 std::thread::spawn(move || {
143 for line in BufReader::new(stdout).lines() {
144 let Ok(line) = line else { break };
145 if let Ok(message) = serde_json::from_str::<Value>(&line)
146 && message_tx.send(message).is_err()
147 {
148 break;
149 }
150 }
151 });
152
153 let stdin = child.stdin.take().unwrap();
154 AgentUnderTest {
155 child,
156 stdin,
157 incoming,
158 next_id: 0,
159 }
160 }
161
162 impl AgentUnderTest {
163 fn request(&mut self, method: &str, params: Value) -> u64 {
164 self.next_id += 1;
165 let id = self.next_id;
166 let mut line =
167 json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
168 line.push('\n');
169 self.stdin.write_all(line.as_bytes()).expect("write stdin");
170 self.stdin.flush().expect("flush stdin");
171 id
172 }
173
174 fn wait_for_response(&mut self, id: u64) -> Value {
175 let deadline = Instant::now() + TIMEOUT;
176 loop {
177 let remaining = deadline.saturating_duration_since(Instant::now());
178 let Ok(message) = self.incoming.recv_timeout(remaining) else {
179 panic!("timed out waiting for the response to request {id}");
180 };
181 if message["id"] == id && message.get("method").is_none() {
182 assert!(
183 message.get("error").is_none(),
184 "request {id} failed: {message}"
185 );
186 return message;
187 }
188 }
189 }
190
191 fn open_session(&mut self, cwd: &std::path::Path) -> String {
192 let id = self.request(
193 "initialize",
194 json!({"protocolVersion": 1, "clientCapabilities": {}}),
195 );
196 self.wait_for_response(id);
197
198 let id = self.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
199 self.wait_for_response(id)["result"]["sessionId"]
200 .as_str()
201 .expect("session id")
202 .to_string()
203 }
204
205 /// Send one text prompt and return the stop reason the turn ended with.
206 fn prompt(&mut self, session_id: &str, text: &str) -> String {
207 self.prompt_with_reply(session_id, text).0
208 }
209
210 /// Like [`Self::prompt`], also returning the assistant text the client
211 /// was sent on the way, joined the way a client renders it.
212 fn prompt_with_reply(&mut self, session_id: &str, text: &str) -> (String, String) {
213 let id = self.request(
214 "session/prompt",
215 json!({"sessionId": session_id, "prompt": [{"type": "text", "text": text}]}),
216 );
217 let mut rendered = String::new();
218 let deadline = Instant::now() + TIMEOUT;
219 loop {
220 let remaining = deadline.saturating_duration_since(Instant::now());
221 let Ok(message) = self.incoming.recv_timeout(remaining) else {
222 panic!("timed out waiting for the response to request {id}");
223 };
224 let update = &message["params"]["update"];
225 if message["method"] == "session/update"
226 && update["sessionUpdate"] == "agent_message_chunk"
227 && let Some(chunk) = update["content"]["text"].as_str()
228 {
229 rendered.push_str(chunk);
230 }
231 if message["id"] == id && message.get("method").is_none() {
232 assert!(message.get("error").is_none(), "prompt failed: {message}");
233 let stop_reason = message["result"]["stopReason"]
234 .as_str()
235 .expect("stop reason")
236 .to_string();
237 return (stop_reason, rendered);
238 }
239 }
240 }
241 }
242
243 impl Drop for AgentUnderTest {
244 fn drop(&mut self) {
245 let _ = self.child.kill();
246 let _ = self.child.wait();
247 }
248 }
249
250 fn scratch(name: &str) -> std::path::PathBuf {
251 let dir = std::env::temp_dir().join(format!("sigit_acp_stop_{name}_{}", std::process::id()));
252 std::fs::create_dir_all(dir.join("config")).unwrap();
253 std::fs::create_dir_all(dir.join("work")).unwrap();
254 dir
255 }
256
257 /// Whether any message of a chat-completion request mentions `needle`.
258 fn request_mentions(request: &Value, needle: &str) -> bool {
259 request["messages"]
260 .as_array()
261 .is_some_and(|messages| messages.iter().any(|m| m.to_string().contains(needle)))
262 }
263
264 #[test]
265 fn a_reply_cut_off_at_the_token_limit_ends_with_max_tokens() {
266 let dir = scratch("length");
267 let endpoint = start_fake_endpoint(vec![sse_text("The answer is", "length")]);
268 let mut agent = spawn_agent(endpoint.port, &dir.join("config"));
269 let session_id = agent.open_session(&dir.join("work"));
270
271 assert_eq!(agent.prompt(&session_id, "explain"), "max_tokens");
272
273 std::fs::remove_dir_all(&dir).ok();
274 }
275
276 #[test]
277 fn a_finished_reply_still_ends_with_end_turn() {
278 let dir = scratch("stop");
279 let endpoint = start_fake_endpoint(vec![sse_text("Done.", "stop")]);
280 let mut agent = spawn_agent(endpoint.port, &dir.join("config"));
281 let session_id = agent.open_session(&dir.join("work"));
282
283 assert_eq!(agent.prompt(&session_id, "explain"), "end_turn");
284
285 std::fs::remove_dir_all(&dir).ok();
286 }
287
288 /// A refusal is more than a label: ACP says the refused prompt and what
289 /// followed it are not part of the next prompt, and the client drops them from
290 /// its view on that promise. The agent has to drop them from history too.
291 #[test]
292 fn a_withheld_reply_ends_with_refusal_and_leaves_history() {
293 let dir = scratch("refusal");
294 let endpoint = start_fake_endpoint(vec![
295 sse_text("Sure.", "stop"),
296 sse_text("", "content_filter"),
297 sse_text("Here you go.", "stop"),
298 ]);
299 let mut agent = spawn_agent(endpoint.port, &dir.join("config"));
300 let session_id = agent.open_session(&dir.join("work"));
301
302 assert_eq!(agent.prompt(&session_id, "first-kept-prompt"), "end_turn");
303 assert_eq!(
304 agent.prompt(&session_id, "second-refused-prompt"),
305 "refusal"
306 );
307 assert_eq!(agent.prompt(&session_id, "third-prompt"), "end_turn");
308
309 let requests = endpoint.requests.lock().unwrap();
310 let last = requests
311 .last()
312 .expect("the third prompt reached the endpoint");
313 assert!(
314 request_mentions(last, "first-kept-prompt"),
315 "the turn before the refusal stays in history: {last}"
316 );
317 assert!(
318 !request_mentions(last, "second-refused-prompt"),
319 "the refused prompt must not be replayed: {last}"
320 );
321
322 std::fs::remove_dir_all(&dir).ok();
323 }
324
325 /// The case the reason exists for: the model keeps asking for tools until the
326 /// agent stops offering them.
327 #[test]
328 fn a_turn_stopped_at_the_tool_round_cap_ends_with_max_turn_requests() {
329 let dir = scratch("cap");
330 // Each call differs, or the repeated-call guard ends the turn first. The
331 // commands stay quote-free: `cmd /C` mangles quotes on Windows.
332 let replies = (1..=TOOL_ROUND_CAP)
333 .map(|round| {
334 sse_tool_call(
335 &format!("call_{round}"),
336 "run_command",
337 &json!({"command": format!("echo round-{round}")}).to_string(),
338 )
339 })
340 .chain([sse_text("Out of rounds.", "stop")])
341 .collect();
342 let endpoint = start_fake_endpoint(replies);
343 let mut agent = spawn_agent(endpoint.port, &dir.join("config"));
344 let session_id = agent.open_session(&dir.join("work"));
345
346 assert_eq!(agent.prompt(&session_id, "keep going"), "max_turn_requests");
347 assert_eq!(
348 endpoint.requests.lock().unwrap().len(),
349 TOOL_ROUND_CAP + 1,
350 "one request per tool round, plus the forced closing reply"
351 );
352
353 std::fs::remove_dir_all(&dir).ok();
354 }
355
356 /// What issue #120 looked like from the editor: the turn reached the cap, the
357 /// model used its last reply to announce the next step, the call behind it was
358 /// dropped, and the thread stopped on "Now commit:" with nothing after it.
359 #[test]
360 fn a_turn_stopped_at_the_cap_says_so_to_the_user_and_the_model() {
361 let dir = scratch("cap_notice");
362 let endpoint = start_fake_endpoint(vec![
363 sse_tool_call("call_1", "run_command", r#"{"command":"echo round-1"}"#),
364 sse_tool_call("call_2", "run_command", r#"{"command":"echo round-2"}"#),
365 sse_text("Now commit:", "stop"),
366 ]);
367 let mut agent = spawn_agent_with_env(
368 endpoint.port,
369 &dir.join("config"),
370 &[("SIGIT_MAX_TOOL_ROUNDS", "2")],
371 );
372 let session_id = agent.open_session(&dir.join("work"));
373
374 let (stop_reason, reply) = agent.prompt_with_reply(&session_id, "ship it");
375
376 assert_eq!(stop_reason, "max_turn_requests");
377 assert!(
378 reply.contains("Now commit:") && reply.contains("all 2 of its tool rounds"),
379 "the user must be told why the turn stopped: {reply:?}"
380 );
381
382 // The model is told before its last reply, on the result of the last
383 // call, and only there.
384 let requests = endpoint.requests.lock().unwrap();
385 assert_eq!(requests.len(), 3);
386 let tool_results = |request: &Value| -> Vec<String> {
387 request["messages"]
388 .as_array()
389 .expect("messages")
390 .iter()
391 .filter(|message| message["role"] == "tool")
392 .map(|message| message["content"].as_str().unwrap_or_default().to_string())
393 .collect()
394 };
395 let before_cap = tool_results(&requests[1]);
396 assert!(
397 before_cap
398 .iter()
399 .all(|content| !content.contains("[siGit Code]")),
400 "no note while rounds remain: {before_cap:?}"
401 );
402 let at_cap = tool_results(&requests[2]);
403 assert!(
404 at_cap.last().is_some_and(|content| {
405 content.contains("round-2") && content.contains("no more tools can run")
406 }),
407 "the last tool result carries the note: {at_cap:?}"
408 );
409 assert!(
410 requests[2].get("tools").is_none(),
411 "the closing round offers no tools: {}",
412 requests[2]
413 );
414
415 std::fs::remove_dir_all(&dir).ok();
416 }
417
418 /// A turn that finishes inside its rounds gets no notice.
419 #[test]
420 fn a_turn_that_finishes_inside_the_cap_gets_no_notice() {
421 let dir = scratch("under_cap");
422 let endpoint = start_fake_endpoint(vec![
423 sse_tool_call("call_1", "run_command", r#"{"command":"echo round-1"}"#),
424 sse_text("Committed.", "stop"),
425 ]);
426 let mut agent = spawn_agent_with_env(
427 endpoint.port,
428 &dir.join("config"),
429 &[("SIGIT_MAX_TOOL_ROUNDS", "2")],
430 );
431 let session_id = agent.open_session(&dir.join("work"));
432
433 let (stop_reason, reply) = agent.prompt_with_reply(&session_id, "ship it");
434
435 assert_eq!(stop_reason, "end_turn");
436 assert_eq!(reply.trim(), "Committed.");
437
438 std::fs::remove_dir_all(&dir).ok();
439 }