1 //! What the editor shows when the endpoint refuses a turn.
2 //!
3 //! Every endpoint siGit talks to reports failures in the OpenAI error envelope,
4 //! and siGit Code Cloud writes those messages for the person reading them
5 //! ("Monthly siGit Code Cloud allowance reached…"). The agent used to hand the
6 //! raw status line and JSON body to the client, so Zed's error banner showed
7 //! the envelope rather than the sentence inside it.
8 //!
9 //! Two shapes to cover: a failing HTTP status, and an endpoint that only
10 //! discovers the problem after the status line is out and reports it as a
11 //! `data:` frame in the stream.
12
13 use std::collections::VecDeque;
14 use std::io::{BufRead, BufReader, Read, Write};
15 use std::net::TcpListener;
16 use std::process::{Child, ChildStdin, Command, Stdio};
17 use std::sync::Mutex;
18 use std::sync::mpsc::{Receiver, channel};
19 use std::time::{Duration, Instant};
20
21 use serde_json::{Value, json};
22
23 /// This is the first test file to launch the freshly built binary, and on the
24 /// Intel macOS runner that first launch alone has taken 25 seconds.
25 const TIMEOUT: Duration = Duration::from_secs(60);
26
27 const ALLOWANCE_MESSAGE: &str = "Monthly siGit Code Cloud allowance reached. It resets at the start of your next billing period.";
28
29 /// One scripted reply per request: an HTTP status plus a body.
30 struct Reply {
31 status: &'static str,
32 content_type: &'static str,
33 body: String,
34 }
35
36 fn error_status_reply() -> Reply {
37 Reply {
38 status: "429 Too Many Requests",
39 content_type: "application/json",
40 body: json!({"error": {"message": ALLOWANCE_MESSAGE, "type": "server_error"}}).to_string(),
41 }
42 }
43
44 /// A 200 that turns into an error partway through, the way an endpoint has to
45 /// report a failure it only sees once the response is already open.
46 fn error_in_stream_reply() -> Reply {
47 let frame = json!({"error": {"message": ALLOWANCE_MESSAGE, "type": "server_error"}});
48 Reply {
49 status: "200 OK",
50 content_type: "text/event-stream",
51 body: format!("data: {frame}\n\n"),
52 }
53 }
54
55 fn sse_tool_call(id: &str, name: &str, arguments: &str) -> Reply {
56 Reply {
57 status: "200 OK",
58 content_type: "text/event-stream",
59 body: format!(
60 "data: {}\n\ndata: [DONE]\n\n",
61 json!({
62 "choices": [{"delta": {"tool_calls": [{
63 "index": 0,
64 "id": id,
65 "function": {"name": name, "arguments": arguments},
66 }]}}]
67 })
68 ),
69 }
70 }
71
72 fn upstream_500_reply() -> Reply {
73 Reply {
74 status: "500 Internal Server Error",
75 content_type: "application/json",
76 body: json!({
77 "error": {"message": "Onde Cloud upstream error (500)", "type": "server_error"}
78 })
79 .to_string(),
80 }
81 }
82
83 fn start_fake_endpoint(replies: Vec<Reply>) -> u16 {
84 let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
85 let port = listener.local_addr().unwrap().port();
86 let queue = Mutex::new(VecDeque::from(replies));
87
88 std::thread::spawn(move || {
89 for stream in listener.incoming() {
90 let Ok(mut stream) = stream else { continue };
91 let mut reader = BufReader::new(match stream.try_clone() {
92 Ok(clone) => clone,
93 Err(_) => continue,
94 });
95 let mut content_length = 0usize;
96 loop {
97 let mut line = String::new();
98 if reader.read_line(&mut line).unwrap_or(0) == 0 {
99 break;
100 }
101 let line = line.trim();
102 if line.is_empty() {
103 break;
104 }
105 if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
106 content_length = length.trim().parse().unwrap_or(0);
107 }
108 }
109 let mut body = vec![0u8; content_length];
110 if reader.read_exact(&mut body).is_err() {
111 continue;
112 }
113 let reply = queue.lock().unwrap().pop_front().unwrap_or_else(|| Reply {
114 status: "200 OK",
115 content_type: "text/event-stream",
116 body: "data: [DONE]\n\n".to_string(),
117 });
118 let response = format!(
119 "HTTP/1.1 {}\r\ncontent-type: {}\r\ncontent-length: {}\r\n\
120 connection: close\r\n\r\n{}",
121 reply.status,
122 reply.content_type,
123 reply.body.len(),
124 reply.body
125 );
126 let _ = stream.write_all(response.as_bytes());
127 let _ = stream.flush();
128 }
129 });
130
131 port
132 }
133
134 struct AgentUnderTest {
135 child: Child,
136 stdin: ChildStdin,
137 incoming: Receiver<Value>,
138 next_id: u64,
139 }
140
141 fn spawn_agent(port: u16, config_dir: &std::path::Path) -> AgentUnderTest {
142 let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
143 .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
144 .env("OPENAI_API_KEY", "test-key")
145 .env("SIGIT_MODEL", "scripted-model")
146 .env("SIGIT_CONFIG_DIR", config_dir)
147 .env("SIGIT_MCP", "off")
148 .env("SIGIT_PERMISSIONS", "allow")
149 .env_remove("SIGIT_LOCAL_INFERENCE")
150 .stdin(Stdio::piped())
151 .stdout(Stdio::piped())
152 .stderr(Stdio::null())
153 .spawn()
154 .expect("spawn sigit in ACP mode");
155
156 let stdout = child.stdout.take().unwrap();
157 let (message_tx, incoming) = channel();
158 std::thread::spawn(move || {
159 for line in BufReader::new(stdout).lines() {
160 let Ok(line) = line else { break };
161 if let Ok(message) = serde_json::from_str::<Value>(&line)
162 && message_tx.send(message).is_err()
163 {
164 break;
165 }
166 }
167 });
168
169 let stdin = child.stdin.take().unwrap();
170 AgentUnderTest {
171 child,
172 stdin,
173 incoming,
174 next_id: 0,
175 }
176 }
177
178 impl AgentUnderTest {
179 fn request(&mut self, method: &str, params: Value) -> u64 {
180 self.next_id += 1;
181 let id = self.next_id;
182 let mut line =
183 json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
184 line.push('\n');
185 self.stdin.write_all(line.as_bytes()).expect("write stdin");
186 self.stdin.flush().expect("flush stdin");
187 id
188 }
189
190 fn wait_for_message(&mut self, id: u64) -> Value {
191 let deadline = Instant::now() + TIMEOUT;
192 loop {
193 let remaining = deadline.saturating_duration_since(Instant::now());
194 let Ok(message) = self.incoming.recv_timeout(remaining) else {
195 panic!("timed out waiting for the response to request {id}");
196 };
197 if message["id"] == id && message.get("method").is_none() {
198 return message;
199 }
200 }
201 }
202
203 fn wait_for_response(&mut self, id: u64) -> Value {
204 let message = self.wait_for_message(id);
205 assert!(
206 message.get("error").is_none(),
207 "request {id} failed: {message}"
208 );
209 message
210 }
211
212 fn wait_for_prompt_updates(&mut self, id: u64) -> (Value, Vec<Value>) {
213 let mut updates = Vec::new();
214 let deadline = Instant::now() + TIMEOUT;
215 loop {
216 let remaining = deadline.saturating_duration_since(Instant::now());
217 let Ok(message) = self.incoming.recv_timeout(remaining) else {
218 panic!("timed out waiting for the response to request {id}");
219 };
220 if message["method"] == "session/update" {
221 updates.push(message["params"]["update"].clone());
222 }
223 if message["id"] == id && message.get("method").is_none() {
224 return (message, updates);
225 }
226 }
227 }
228
229 fn open_session(&mut self, cwd: &std::path::Path) -> String {
230 let id = self.request(
231 "initialize",
232 json!({"protocolVersion": 1, "clientCapabilities": {}}),
233 );
234 self.wait_for_response(id);
235
236 let id = self.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
237 self.wait_for_response(id)["result"]["sessionId"]
238 .as_str()
239 .expect("session id")
240 .to_string()
241 }
242 }
243
244 impl Drop for AgentUnderTest {
245 fn drop(&mut self) {
246 let _ = self.child.kill();
247 let _ = self.child.wait();
248 }
249 }
250
251 fn scratch(name: &str) -> std::path::PathBuf {
252 let dir = std::env::temp_dir().join(format!("sigit_acp_err_{name}_{}", std::process::id()));
253 std::fs::create_dir_all(dir.join("config")).unwrap();
254 std::fs::create_dir_all(dir.join("work")).unwrap();
255 dir
256 }
257
258 /// The message the endpoint wrote is the message the client gets: no status
259 /// line in front of it, no JSON envelope around it.
260 fn assert_is_the_endpoints_message(error: &Value) {
261 let message = error["error"]["message"].as_str().unwrap_or_default();
262 assert_eq!(message, ALLOWANCE_MESSAGE, "full error: {error}");
263 }
264
265 #[test]
266 fn a_refused_turn_reports_the_endpoints_own_message() {
267 let dir = scratch("status");
268 let port = start_fake_endpoint(vec![error_status_reply()]);
269 let mut agent = spawn_agent(port, &dir.join("config"));
270 let session_id = agent.open_session(&dir.join("work"));
271
272 let id = agent.request(
273 "session/prompt",
274 json!({"sessionId": session_id, "prompt": [{"type": "text", "text": "hello"}]}),
275 );
276 let response = agent.wait_for_message(id);
277
278 assert!(
279 response.get("error").is_some(),
280 "expected a failure: {response}"
281 );
282 assert_is_the_endpoints_message(&response);
283
284 std::fs::remove_dir_all(&dir).ok();
285 }
286
287 /// An error that arrives as a stream frame used to parse as a chunk with no
288 /// choices and get skipped, so the turn ended as if the model had said nothing.
289 #[test]
290 fn an_error_frame_mid_stream_is_not_swallowed() {
291 let dir = scratch("stream");
292 let port = start_fake_endpoint(vec![error_in_stream_reply()]);
293 let mut agent = spawn_agent(port, &dir.join("config"));
294 let session_id = agent.open_session(&dir.join("work"));
295
296 let id = agent.request(
297 "session/prompt",
298 json!({"sessionId": session_id, "prompt": [{"type": "text", "text": "hello"}]}),
299 );
300 let response = agent.wait_for_message(id);
301
302 assert!(
303 response.get("error").is_some(),
304 "expected a failure: {response}"
305 );
306 assert_is_the_endpoints_message(&response);
307
308 std::fs::remove_dir_all(&dir).ok();
309 }
310
311 /// A failing summarization round must not strand the session (issue #125):
312 /// compaction falls back to plain truncation, the pending tool round runs,
313 /// and the turn completes instead of ending with "start a new thread".
314 #[test]
315 fn failed_compaction_falls_back_to_truncation_and_the_turn_continues() {
316 let dir = scratch("compact");
317 let port = start_fake_endpoint(vec![
318 sse_tool_call(
319 "call_1",
320 "run_command",
321 r#"{"command":"echo truncation-fallback-works"}"#,
322 ),
323 upstream_500_reply(),
324 ]);
325 let mut agent = spawn_agent(port, &dir.join("config"));
326 let session_id = agent.open_session(&dir.join("work"));
327
328 let oversized_prompt = "x".repeat(120_000);
329 let id = agent.request(
330 "session/prompt",
331 json!({"sessionId": session_id, "prompt": [{"type": "text", "text": oversized_prompt}]}),
332 );
333 let (response, updates) = agent.wait_for_prompt_updates(id);
334
335 assert!(
336 response.get("error").is_none(),
337 "truncation fallback keeps the turn alive: {response}"
338 );
339 assert_eq!(response["result"]["stopReason"], "end_turn");
340
341 assert!(
342 updates
343 .iter()
344 .any(|update| update["sessionUpdate"] == "tool_call"),
345 "the pending tool call should run after the truncation fallback: {updates:?}"
346 );
347 let rendered = updates
348 .iter()
349 .filter_map(|update| {
350 (update["sessionUpdate"] == "agent_message_chunk")
351 .then(|| update["content"]["text"].as_str())
352 .flatten()
353 })
354 .collect::<String>();
355 assert!(
356 !rendered.contains("could not"),
357 "no compaction-failure banner should be shown when the fallback succeeded: {rendered:?}"
358 );
359
360 std::fs::remove_dir_all(&dir).ok();
361 }