fix(acp): report why a prompt turn stopped

ACP defines five stop reasons and handle_prompt only returned end_turn and cancelled. A client could not tell a finished answer from one cut off at the token limit, withheld by the endpoint, or stopped at the tool-round cap. TurnResult now carries the endpoint's finish_reason, read on the JSON, streaming and on-device paths. handle_prompt maps it at the end of the turn: hitting the 24-round cap is max_turn_requests, length is max_tokens, content_filter is refusal. The cap is checked first, since the last round is a forced reply once it is hit. The spec says a refused prompt and what followed it are not part of the next prompt, and the client drops them from its view. So on a refusal the history is put back to what it was before the turn, instead of only changing the label on the response. Fixes #140 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 5, 2026 at 09:58 UTC 5ab3d24cd27bf91c3a0b51c873c87e437cf8b7e9
5 files changed +516 -8
.agents/AGENTS.md
+5
@@ -127,6 +127,11 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
127 endpoint can also fail *after* the response is open, reporting it as a `data:` frame holding
128 the same envelope; that frame has no `choices`, so `consume_stream` has to check for it
129 explicitly or it parses as an empty chunk and the turn ends looking like an empty answer.
130 + `TurnResult::finish` carries the endpoint's `finish_reason` up to the loop, and
131 + `stop_reason_for` in `main.rs` turns it into the ACP stop reason: the tool-round cap is
132 + `max_turn_requests`, `length` is `max_tokens`, `content_filter` is `refusal`. ACP defines a
133 + refusal as a turn the next prompt will not include, so `handle_prompt` restores the history
134 + it snapshotted before the turn instead of only relabelling the response.
135 Some models write tool calls into content as text; `src/inline_tool_calls.rs` recovers the
136 well-formed ones. A block that doesn't parse (or never closes) is dropped from both the reply
137 and history, and `OpenAiBackend::complete` retries once with a note telling the model the call
CHANGELOG.md
+7
@@ -10,6 +10,13 @@
10
11 ### Fixed
12
13 +- **A prompt turn now reports why it stopped.** ACP defines five stop reasons
14 + and siGit Code only ever sent `end_turn` and `cancelled`. A turn that runs
15 + into the tool-round cap now ends with `max_turn_requests`, a reply cut off
16 + at the token limit with `max_tokens`, and a reply the endpoint withheld
17 + (`content_filter`) with `refusal`. A refused turn is also taken out of the
18 + conversation, which is what the protocol tells the client to expect (#140).
19 +
20 - **A headless run no longer ends silently with no final message.** When the
21 last round came back empty, which is what happens when a run is cut off at
22 the tool-round cap mid-task, stdout was empty and the exit status was 0, so
src/backend.rs
+112 -6
@@ -58,6 +58,36 @@ pub struct ToolResult {
58 pub struct TurnResult {
59 pub text: String,
60 pub tool_calls: Vec<ToolCall>,
61 + /// Why the model stopped. Only the last round of a turn decides how the
62 + /// turn is reported; a round that goes on to run tools is not the end.
63 + pub finish: FinishReason,
64 +}
65 +
66 +/// Why the model stopped generating, reduced to what the agent loop acts on.
67 +/// Endpoints report it as `finish_reason`; without it a reply cut off at the
68 +/// token limit is indistinguishable from one the model finished.
69 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
70 +pub enum FinishReason {
71 + /// The model finished on its own: `stop`, `tool_calls`, or no reason given.
72 + #[default]
73 + Complete,
74 + /// The output ran into the token limit, so the reply is cut short.
75 + Length,
76 + /// The endpoint withheld the reply instead of answering.
77 + ContentFilter,
78 +}
79 +
80 +impl FinishReason {
81 + /// Map a wire `finish_reason`. OpenAI's spellings are `length` and
82 + /// `content_filter`; gateways in front of other providers sometimes pass
83 + /// the upstream's own word through, so those are accepted too.
84 + fn from_wire(reason: Option<&str>) -> Self {
85 + match reason {
86 + Some("length" | "max_tokens") => Self::Length,
87 + Some("content_filter" | "refusal") => Self::ContentFilter,
88 + _ => Self::Complete,
89 + }
90 + }
91 }
92
93 /// Backend errors are plain strings. Callers map them to ACP errors.
@@ -603,6 +633,7 @@ async fn drain_onde_stream(
633 sink: &TokenSink,
634 ) -> Result<TurnResult, BackendError> {
635 let mut text = String::new();
636 + let mut finish = FinishReason::default();
637 while let Some(chunk) = rx.recv().await {
638 if !chunk.delta.is_empty() {
639 text.push_str(&chunk.delta);
@@ -613,23 +644,26 @@ async fn drain_onde_stream(
644 }
645 }
646 if chunk.done {
616 - if let Some(reason) = chunk.finish_reason
647 + if let Some(reason) = chunk.finish_reason.as_deref()
648 && let Some(message) = reason.strip_prefix("error: ")
649 {
650 return Err(message.to_string());
651 }
652 + finish = FinishReason::from_wire(chunk.finish_reason.as_deref());
653 break;
654 }
655 }
656 Ok(TurnResult {
657 text,
658 tool_calls: Vec::new(),
659 + finish,
660 })
661 }
662
663 /// Convert an `onde` tool-aware result into the neutral [`TurnResult`].
664 fn onde_result_to_turn(result: onde::inference::ToolAwareResult) -> TurnResult {
665 TurnResult {
666 + finish: FinishReason::from_wire(Some(&result.finish_reason)),
667 text: result.text,
668 tool_calls: result
669 .tool_calls
@@ -747,6 +781,7 @@ impl OpenAiBackend {
781 Ok(TurnResult {
782 text: join_reply_text(&result.text, &retry.text),
783 tool_calls: retry.tool_calls,
784 + finish: retry.finish,
785 })
786 }
787
@@ -865,12 +900,13 @@ impl OpenAiBackend {
900 .await
901 .map_err(|error| format!("response parse error: {error}"))?;
902
868 - let message = parsed
903 + let choice = parsed
904 .choices
905 .into_iter()
906 .next()
872 - .map(|choice| choice.message)
907 .ok_or_else(|| "endpoint returned no choices".to_string())?;
908 + let finish = FinishReason::from_wire(choice.finish_reason.as_deref());
909 + let message = choice.message;
910
911 let mut text = message.content.clone().unwrap_or_default();
912 let mut tool_calls: Vec<ToolCall> = message
@@ -913,6 +949,7 @@ impl OpenAiBackend {
949 TurnResult {
950 text,
951 tool_calls: Vec::new(),
952 + finish,
953 },
954 malformed,
955 ));
@@ -951,6 +988,7 @@ impl OpenAiBackend {
988 TurnResult {
989 text: cleaned,
990 tool_calls,
991 + finish,
992 },
993 malformed,
994 ));
@@ -963,6 +1001,7 @@ impl OpenAiBackend {
1001 TurnResult {
1002 text: cleaned,
1003 tool_calls,
1004 + finish,
1005 },
1006 malformed,
1007 ));
@@ -992,6 +1031,7 @@ impl OpenAiBackend {
1031 TurnResult {
1032 text: extracted.text,
1033 tool_calls,
1034 + finish,
1035 },
1036 malformed,
1037 ));
@@ -1000,7 +1040,14 @@ impl OpenAiBackend {
1040 // Record the assistant turn so later tool results have context.
1041 self.history.lock().await.push(message.into_history_value());
1042
1003 - Ok((TurnResult { text, tool_calls }, 0))
1043 + Ok((
1044 + TurnResult {
1045 + text,
1046 + tool_calls,
1047 + finish,
1048 + },
1049 + 0,
1050 + ))
1051 }
1052
1053 /// Record a text-only assistant reply. An empty one is skipped: strict
@@ -1034,6 +1081,8 @@ impl OpenAiBackend {
1081 let mut text = String::new();
1082 let mut tool_accum: Vec<StreamingToolCall> = Vec::new();
1083 let mut done = false;
1084 + // Arrives on a late chunk, usually one with an empty delta.
1085 + let mut finish = FinishReason::default();
1086 // Recovers a tool call the model wrote as literal `<tool_call>` text
1087 // instead of a structured delta. Scanning here (rather than after the
1088 // stream) keeps the tag off the UI: content goes straight to `sink` as
@@ -1084,6 +1133,9 @@ impl OpenAiBackend {
1133 let Some(choice) = chunk.choices.into_iter().next() else {
1134 continue;
1135 };
1136 + if let Some(reason) = choice.finish_reason.as_deref() {
1137 + finish = FinishReason::from_wire(Some(reason));
1138 + }
1139 if let Some(content) = choice.delta.content
1140 && !content.is_empty()
1141 {
@@ -1201,7 +1253,14 @@ impl OpenAiBackend {
1253 .push(streamed_assistant_history(&text, &tool_calls));
1254 }
1255
1204 - Ok((TurnResult { text, tool_calls }, malformed))
1256 + Ok((
1257 + TurnResult {
1258 + text,
1259 + tool_calls,
1260 + finish,
1261 + },
1262 + malformed,
1263 + ))
1264 }
1265 }
1266
@@ -1566,6 +1625,8 @@ struct ChatCompletion {
1625 #[derive(Debug, Deserialize)]
1626 struct CompletionChoice {
1627 message: ResponseMessage,
1628 + #[serde(default)]
1629 + finish_reason: Option<String>,
1630 }
1631
1632 #[derive(Debug, Deserialize)]
@@ -1629,6 +1690,8 @@ struct StreamCompletion {
1690 struct StreamChoice {
1691 #[serde(default)]
1692 delta: StreamDelta,
1693 + #[serde(default)]
1694 + finish_reason: Option<String>,
1695 }
1696
1697 #[derive(Debug, Default, Deserialize)]
@@ -2149,13 +2212,21 @@ mod tests {
2212 /// Like [`spawn_completion_stub`], answering with an arbitrary message.
2213 fn spawn_message_stub(
2214 message: serde_json::Value,
2215 + ) -> (std::net::SocketAddr, std::sync::mpsc::Receiver<String>) {
2216 + spawn_choice_stub(serde_json::json!({ "message": message }))
2217 + }
2218 +
2219 + /// Like [`spawn_message_stub`], answering with a whole choice, so a test
2220 + /// can set what sits beside the message.
2221 + fn spawn_choice_stub(
2222 + choice: serde_json::Value,
2223 ) -> (std::net::SocketAddr, std::sync::mpsc::Receiver<String>) {
2224 use std::io::{Read, Write};
2225
2226 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2227 let addr = listener.local_addr().unwrap();
2228 let (sender, receiver) = std::sync::mpsc::channel();
2158 - let body = serde_json::json!({ "choices": [{ "message": message }] }).to_string();
2229 + let body = serde_json::json!({ "choices": [choice] }).to_string();
2230 std::thread::spawn(move || {
2231 let (mut stream, _) = listener.accept().unwrap();
2232 // Read until the full request (headers + content-length body) is in.
@@ -2198,6 +2269,41 @@ mod tests {
2269 (addr, receiver)
2270 }
2271
2272 + #[test]
2273 + fn finish_reasons_map_from_the_wire() {
2274 + for reason in [None, Some("stop"), Some("tool_calls"), Some("unheard-of")] {
2275 + assert_eq!(FinishReason::from_wire(reason), FinishReason::Complete);
2276 + }
2277 + assert_eq!(
2278 + FinishReason::from_wire(Some("length")),
2279 + FinishReason::Length
2280 + );
2281 + assert_eq!(
2282 + FinishReason::from_wire(Some("content_filter")),
2283 + FinishReason::ContentFilter
2284 + );
2285 + }
2286 +
2287 + /// The non-streaming path (headless runs, subagents) reads the reason off
2288 + /// the choice; a reply cut off at the token limit must not look finished.
2289 + #[tokio::test]
2290 + async fn a_truncated_json_reply_reports_length() {
2291 + let (addr, _requests) = spawn_choice_stub(serde_json::json!({
2292 + "message": { "role": "assistant", "content": "The answer is" },
2293 + "finish_reason": "length",
2294 + }));
2295 + let backend =
2296 + OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
2297 +
2298 + let result = backend
2299 + .send_message_with_tools("explain", &[], None)
2300 + .await
2301 + .unwrap();
2302 +
2303 + assert_eq!(result.text, "The answer is");
2304 + assert_eq!(result.finish, FinishReason::Length);
2305 + }
2306 +
2307 /// A structured call can come back with a broken inline block in the same
2308 /// message. The call runs; the block reaches neither the reply nor the
2309 /// history, where the model would read back a call that never ran.
src/main.rs
+73 -2
@@ -282,6 +282,21 @@ pub(crate) fn system_prompt_for_model(tool_calling: bool) -> String {
282 /// context window, so the cap can afford to be generous
283 const MAX_TOOL_ROUNDS: usize = 24;
284
285 +/// The ACP stop reason for a turn that ran to its end (cancellation returns
286 +/// earlier). The round cap comes first: once it is hit the last round is a
287 +/// forced text reply, and whatever that round reports, the cap is why the
288 +/// turn stopped where it did.
289 +fn stop_reason_for(round_cap_reached: bool, finish: backend::FinishReason) -> StopReason {
290 + if round_cap_reached {
291 + return StopReason::MaxTurnRequests;
292 + }
293 + match finish {
294 + backend::FinishReason::Complete => StopReason::EndTurn,
295 + backend::FinishReason::Length => StopReason::MaxTokens,
296 + backend::FinishReason::ContentFilter => StopReason::Refusal,
297 + }
298 +}
299 +
300 /// What the user sees when a turn that ran tools ends with no closing text.
301 fn silent_stop_message(repeated_tool: Option<&str>, rounds: usize) -> String {
302 match repeated_tool {
@@ -2371,6 +2386,15 @@ impl SiGitAgent {
2386 let mut reply = StreamedReply::default();
2387 let mut repeated_tool_calls = std::collections::HashMap::<String, usize>::new();
2388
2389 + // Kept so a refused turn can be taken back out of history (see the end
2390 + // of this function). Only a remote endpoint reports a refusal, and an
2391 + // on-device snapshot would not survive the round trip intact anyway.
2392 + let history_before_turn = if backend.is_remote() {
2393 + Some(backend.history_snapshot().await)
2394 + } else {
2395 + None
2396 + };
2397 +
2398 let mut result = match self
2399 .drain_turn(
2400 cx,
@@ -2743,14 +2767,35 @@ impl SiGitAgent {
2767 .ok();
2768 }
2769
2770 + let stop_reason = stop_reason_for(round >= MAX_TOOL_ROUNDS, result.finish);
2771 +
2772 + // ACP defines a refusal as a turn the next prompt will not include:
2773 + // the client drops the user message and everything after it from its
2774 + // own view. Take the turn out of history too, or the model keeps
2775 + // answering a conversation the user can no longer see.
2776 + if stop_reason == StopReason::Refusal
2777 + && let Some(history) = history_before_turn
2778 + {
2779 + log::warn!(
2780 + "prompt({}) refused by the endpoint — dropping the turn from history",
2781 + session_id
2782 + );
2783 + backend.restore_history(history).await;
2784 + }
2785 +
2786 // Persist the completed turn so a restart (or session/load) can pick
2787 // the conversation back up.
2788 let snapshot = backend.history_snapshot().await;
2789 self.persist_session(&session_id, &snapshot).await;
2790 self.finish_prompt(&session_id, &cancellation);
2791
2752 - log::info!("prompt({}) complete — {} tool round(s)", session_id, round);
2753 - Ok(PromptResponse::new(StopReason::EndTurn))
2792 + log::info!(
2793 + "prompt({}) complete — {} tool round(s), stop reason {:?}",
2794 + session_id,
2795 + round,
2796 + stop_reason
2797 + );
2798 + Ok(PromptResponse::new(stop_reason))
2799 }
2800
2801 /// Ask the ACP client for permission to run one tool call. Presents
@@ -4869,6 +4914,32 @@ async fn main() -> anyhow::Result<()> {
4914 mod tests {
4915 use super::*;
4916
4917 + #[test]
4918 + fn stop_reasons_follow_the_round_cap_then_the_finish_reason() {
4919 + use backend::FinishReason;
4920 +
4921 + assert_eq!(
4922 + stop_reason_for(false, FinishReason::Complete),
4923 + StopReason::EndTurn
4924 + );
4925 + assert_eq!(
4926 + stop_reason_for(false, FinishReason::Length),
4927 + StopReason::MaxTokens
4928 + );
4929 + assert_eq!(
4930 + stop_reason_for(false, FinishReason::ContentFilter),
4931 + StopReason::Refusal
4932 + );
4933 + // The cap wins: the last round was a forced reply either way.
4934 + for finish in [
4935 + FinishReason::Complete,
4936 + FinishReason::Length,
4937 + FinishReason::ContentFilter,
4938 + ] {
4939 + assert_eq!(stop_reason_for(true, finish), StopReason::MaxTurnRequests);
4940 + }
4941 + }
4942 +
4943 #[test]
4944 fn same_dir_matches_through_symlinks_and_survives_a_missing_directory() {
4945 let root = std::env::temp_dir().join(format!("sigit_same_dir_{}", std::process::id()));
tests/acp_stop_reasons.rs new
+319
@@ -0,0 +1,319 @@
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 + let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
117 + .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
118 + .env("OPENAI_API_KEY", "test-key")
119 + .env("SIGIT_MODEL", "scripted-model")
120 + .env("SIGIT_CONFIG_DIR", config_dir)
121 + .env("SIGIT_MCP", "off")
122 + .env("SIGIT_PERMISSIONS", "allow")
123 + .env_remove("SIGIT_LOCAL_INFERENCE")
124 + .stdin(Stdio::piped())
125 + .stdout(Stdio::piped())
126 + .stderr(Stdio::null())
127 + .spawn()
128 + .expect("spawn sigit in ACP mode");
129 +
130 + let stdout = child.stdout.take().unwrap();
131 + let (message_tx, incoming) = channel();
132 + std::thread::spawn(move || {
133 + for line in BufReader::new(stdout).lines() {
134 + let Ok(line) = line else { break };
135 + if let Ok(message) = serde_json::from_str::<Value>(&line)
136 + && message_tx.send(message).is_err()
137 + {
138 + break;
139 + }
140 + }
141 + });
142 +
143 + let stdin = child.stdin.take().unwrap();
144 + AgentUnderTest {
145 + child,
146 + stdin,
147 + incoming,
148 + next_id: 0,
149 + }
150 +}
151 +
152 +impl AgentUnderTest {
153 + fn request(&mut self, method: &str, params: Value) -> u64 {
154 + self.next_id += 1;
155 + let id = self.next_id;
156 + let mut line =
157 + json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
158 + line.push('\n');
159 + self.stdin.write_all(line.as_bytes()).expect("write stdin");
160 + self.stdin.flush().expect("flush stdin");
161 + id
162 + }
163 +
164 + fn wait_for_response(&mut self, id: u64) -> Value {
165 + let deadline = Instant::now() + TIMEOUT;
166 + loop {
167 + let remaining = deadline.saturating_duration_since(Instant::now());
168 + let Ok(message) = self.incoming.recv_timeout(remaining) else {
169 + panic!("timed out waiting for the response to request {id}");
170 + };
171 + if message["id"] == id && message.get("method").is_none() {
172 + assert!(
173 + message.get("error").is_none(),
174 + "request {id} failed: {message}"
175 + );
176 + return message;
177 + }
178 + }
179 + }
180 +
181 + fn open_session(&mut self, cwd: &std::path::Path) -> String {
182 + let id = self.request(
183 + "initialize",
184 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
185 + );
186 + self.wait_for_response(id);
187 +
188 + let id = self.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
189 + self.wait_for_response(id)["result"]["sessionId"]
190 + .as_str()
191 + .expect("session id")
192 + .to_string()
193 + }
194 +
195 + /// Send one text prompt and return the stop reason the turn ended with.
196 + fn prompt(&mut self, session_id: &str, text: &str) -> String {
197 + let id = self.request(
198 + "session/prompt",
199 + json!({"sessionId": session_id, "prompt": [{"type": "text", "text": text}]}),
200 + );
201 + self.wait_for_response(id)["result"]["stopReason"]
202 + .as_str()
203 + .expect("stop reason")
204 + .to_string()
205 + }
206 +}
207 +
208 +impl Drop for AgentUnderTest {
209 + fn drop(&mut self) {
210 + let _ = self.child.kill();
211 + let _ = self.child.wait();
212 + }
213 +}
214 +
215 +fn scratch(name: &str) -> std::path::PathBuf {
216 + let dir = std::env::temp_dir().join(format!("sigit_acp_stop_{name}_{}", std::process::id()));
217 + std::fs::create_dir_all(dir.join("config")).unwrap();
218 + std::fs::create_dir_all(dir.join("work")).unwrap();
219 + dir
220 +}
221 +
222 +/// Whether any message of a chat-completion request mentions `needle`.
223 +fn request_mentions(request: &Value, needle: &str) -> bool {
224 + request["messages"]
225 + .as_array()
226 + .is_some_and(|messages| messages.iter().any(|m| m.to_string().contains(needle)))
227 +}
228 +
229 +#[test]
230 +fn a_reply_cut_off_at_the_token_limit_ends_with_max_tokens() {
231 + let dir = scratch("length");
232 + let endpoint = start_fake_endpoint(vec![sse_text("The answer is", "length")]);
233 + let mut agent = spawn_agent(endpoint.port, &dir.join("config"));
234 + let session_id = agent.open_session(&dir.join("work"));
235 +
236 + assert_eq!(agent.prompt(&session_id, "explain"), "max_tokens");
237 +
238 + std::fs::remove_dir_all(&dir).ok();
239 +}
240 +
241 +#[test]
242 +fn a_finished_reply_still_ends_with_end_turn() {
243 + let dir = scratch("stop");
244 + let endpoint = start_fake_endpoint(vec![sse_text("Done.", "stop")]);
245 + let mut agent = spawn_agent(endpoint.port, &dir.join("config"));
246 + let session_id = agent.open_session(&dir.join("work"));
247 +
248 + assert_eq!(agent.prompt(&session_id, "explain"), "end_turn");
249 +
250 + std::fs::remove_dir_all(&dir).ok();
251 +}
252 +
253 +/// A refusal is more than a label: ACP says the refused prompt and what
254 +/// followed it are not part of the next prompt, and the client drops them from
255 +/// its view on that promise. The agent has to drop them from history too.
256 +#[test]
257 +fn a_withheld_reply_ends_with_refusal_and_leaves_history() {
258 + let dir = scratch("refusal");
259 + let endpoint = start_fake_endpoint(vec![
260 + sse_text("Sure.", "stop"),
261 + sse_text("", "content_filter"),
262 + sse_text("Here you go.", "stop"),
263 + ]);
264 + let mut agent = spawn_agent(endpoint.port, &dir.join("config"));
265 + let session_id = agent.open_session(&dir.join("work"));
266 +
267 + assert_eq!(agent.prompt(&session_id, "first-kept-prompt"), "end_turn");
268 + assert_eq!(
269 + agent.prompt(&session_id, "second-refused-prompt"),
270 + "refusal"
271 + );
272 + assert_eq!(agent.prompt(&session_id, "third-prompt"), "end_turn");
273 +
274 + let requests = endpoint.requests.lock().unwrap();
275 + let last = requests
276 + .last()
277 + .expect("the third prompt reached the endpoint");
278 + assert!(
279 + request_mentions(last, "first-kept-prompt"),
280 + "the turn before the refusal stays in history: {last}"
281 + );
282 + assert!(
283 + !request_mentions(last, "second-refused-prompt"),
284 + "the refused prompt must not be replayed: {last}"
285 + );
286 +
287 + std::fs::remove_dir_all(&dir).ok();
288 +}
289 +
290 +/// The case the reason exists for: the model keeps asking for tools until the
291 +/// agent stops offering them.
292 +#[test]
293 +fn a_turn_stopped_at_the_tool_round_cap_ends_with_max_turn_requests() {
294 + let dir = scratch("cap");
295 + // Each call differs, or the repeated-call guard ends the turn first. The
296 + // commands stay quote-free: `cmd /C` mangles quotes on Windows.
297 + let replies = (1..=TOOL_ROUND_CAP)
298 + .map(|round| {
299 + sse_tool_call(
300 + &format!("call_{round}"),
301 + "run_command",
302 + &json!({"command": format!("echo round-{round}")}).to_string(),
303 + )
304 + })
305 + .chain([sse_text("Out of rounds.", "stop")])
306 + .collect();
307 + let endpoint = start_fake_endpoint(replies);
308 + let mut agent = spawn_agent(endpoint.port, &dir.join("config"));
309 + let session_id = agent.open_session(&dir.join("work"));
310 +
311 + assert_eq!(agent.prompt(&session_id, "keep going"), "max_turn_requests");
312 + assert_eq!(
313 + endpoint.requests.lock().unwrap().len(),
314 + TOOL_ROUND_CAP + 1,
315 + "one request per tool round, plus the forced closing reply"
316 + );
317 +
318 + std::fs::remove_dir_all(&dir).ok();
319 +}