1
+//! The JSON-RPC error codes the agent answers with.
2
+//!
3
+//! ACP uses the standard codes: `-32602` for a request whose parameters are
4
+//! wrong (an auth method that was never offered, a relative `cwd`), `-32002`
5
+//! for a session id the agent does not know. `-32000` is reserved for
6
+//! "authentication required", so a sign-in that fails is `-32603`.
7
+
8
+use std::collections::VecDeque;
9
+use std::io::{BufRead, BufReader, Read, Write};
10
+use std::net::TcpListener;
11
+use std::process::{Child, ChildStdin, Command, Stdio};
12
+use std::sync::mpsc::{Receiver, channel};
13
+use std::sync::{Arc, Mutex};
14
+use std::time::{Duration, Instant};
15
+
16
+use serde_json::{Value, json};
17
+
18
+const TIMEOUT: Duration = Duration::from_secs(60);
19
+
20
+fn sse_body(events: &[Value]) -> String {
21
+ let mut body = String::new();
22
+ for event in events {
23
+ body.push_str("data: ");
24
+ body.push_str(&event.to_string());
25
+ body.push_str("\n\n");
26
+ }
27
+ body.push_str("data: [DONE]\n\n");
28
+ body
29
+}
30
+
31
+fn sse_text(text: &str) -> String {
32
+ sse_body(&[json!({"choices": [{"delta": {"content": text}}]})])
33
+}
34
+
35
+/// Serves one scripted SSE response per request and records each request body.
36
+// Shared harness; this file never reads the recorded requests.
37
+#[allow(dead_code)]
38
+struct FakeEndpoint {
39
+ port: u16,
40
+ requests: Arc<Mutex<Vec<Value>>>,
41
+}
42
+
43
+fn start_fake_endpoint(responses: Vec<String>) -> FakeEndpoint {
44
+ let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
45
+ let port = listener.local_addr().unwrap().port();
46
+ let requests: Arc<Mutex<Vec<Value>>> = Arc::default();
47
+ let recorded = Arc::clone(&requests);
48
+ let queue = Mutex::new(VecDeque::from(responses));
49
+
50
+ std::thread::spawn(move || {
51
+ for stream in listener.incoming() {
52
+ let Ok(mut stream) = stream else { continue };
53
+ let mut reader = BufReader::new(match stream.try_clone() {
54
+ Ok(clone) => clone,
55
+ Err(_) => continue,
56
+ });
57
+ let mut content_length = 0usize;
58
+ loop {
59
+ let mut line = String::new();
60
+ if reader.read_line(&mut line).unwrap_or(0) == 0 {
61
+ break;
62
+ }
63
+ let line = line.trim();
64
+ if line.is_empty() {
65
+ break;
66
+ }
67
+ if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
68
+ content_length = length.trim().parse().unwrap_or(0);
69
+ }
70
+ }
71
+ let mut body = vec![0u8; content_length];
72
+ if reader.read_exact(&mut body).is_err() {
73
+ continue;
74
+ }
75
+ if let Ok(request) = serde_json::from_slice::<Value>(&body) {
76
+ recorded.lock().unwrap().push(request);
77
+ }
78
+ let payload = queue
79
+ .lock()
80
+ .unwrap()
81
+ .pop_front()
82
+ .unwrap_or_else(|| sse_text("out of scripted responses"));
83
+ let response = format!(
84
+ "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
85
+ content-length: {}\r\nconnection: close\r\n\r\n{}",
86
+ payload.len(),
87
+ payload
88
+ );
89
+ let _ = stream.write_all(response.as_bytes());
90
+ }
91
+ });
92
+
93
+ FakeEndpoint { port, requests }
94
+}
95
+
96
+// ── ACP client over the binary's stdio ──────────────────────────────────────
97
+
98
+struct AgentUnderTest {
99
+ child: Child,
100
+ stdin: ChildStdin,
101
+ incoming: Receiver<Value>,
102
+ next_id: u64,
103
+}
104
+
105
+fn spawn_agent(port: u16, config_dir: &std::path::Path) -> AgentUnderTest {
106
+ let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
107
+ .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
108
+ .env("OPENAI_API_KEY", "test-key")
109
+ .env("SIGIT_MODEL", "scripted-model")
110
+ .env("SIGIT_CONFIG_DIR", config_dir)
111
+ .env("SIGIT_MCP", "off")
112
+ .env("SIGIT_PERMISSIONS", "allow")
113
+ .env_remove("SIGIT_LOCAL_INFERENCE")
114
+ .stdin(Stdio::piped())
115
+ .stdout(Stdio::piped())
116
+ .stderr(Stdio::null())
117
+ .spawn()
118
+ .expect("spawn sigit in ACP mode");
119
+
120
+ let stdout = child.stdout.take().unwrap();
121
+ let (message_tx, incoming) = channel();
122
+ std::thread::spawn(move || {
123
+ for line in BufReader::new(stdout).lines() {
124
+ let Ok(line) = line else { break };
125
+ if let Ok(message) = serde_json::from_str::<Value>(&line)
126
+ && message_tx.send(message).is_err()
127
+ {
128
+ break;
129
+ }
130
+ }
131
+ });
132
+
133
+ let stdin = child.stdin.take().unwrap();
134
+ AgentUnderTest {
135
+ child,
136
+ stdin,
137
+ incoming,
138
+ next_id: 0,
139
+ }
140
+}
141
+
142
+impl AgentUnderTest {
143
+ fn send(&mut self, message: Value) {
144
+ let mut line = message.to_string();
145
+ line.push('\n');
146
+ self.stdin
147
+ .write_all(line.as_bytes())
148
+ .expect("write to agent stdin");
149
+ self.stdin.flush().expect("flush agent stdin");
150
+ }
151
+
152
+ fn request(&mut self, method: &str, params: Value) -> u64 {
153
+ self.next_id += 1;
154
+ let id = self.next_id;
155
+ self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
156
+ id
157
+ }
158
+
159
+ /// Wait for the response to our request `id`, collecting the raw JSON of
160
+ /// every `session/update` notification that arrives before it.
161
+ fn wait_for_response_collecting_updates(&mut self, id: u64) -> (Value, String) {
162
+ let deadline = Instant::now() + TIMEOUT;
163
+ let mut updates = String::new();
164
+ loop {
165
+ let remaining = deadline.saturating_duration_since(Instant::now());
166
+ match self.incoming.recv_timeout(remaining) {
167
+ Ok(message) if message["id"] == id && message.get("method").is_none() => {
168
+ assert!(
169
+ message.get("error").is_none(),
170
+ "request {id} failed: {message}"
171
+ );
172
+ return (message, updates);
173
+ }
174
+ Ok(message) => {
175
+ if message["method"] == "session/update" {
176
+ updates.push_str(&message["params"].to_string());
177
+ updates.push('\n');
178
+ }
179
+ }
180
+ Err(_) => panic!("timed out waiting for response to request {id}"),
181
+ }
182
+ }
183
+ }
184
+
185
+ fn wait_for_response(&mut self, id: u64) -> Value {
186
+ self.wait_for_response_collecting_updates(id).0
187
+ }
188
+}
189
+
190
+impl Drop for AgentUnderTest {
191
+ fn drop(&mut self) {
192
+ let _ = self.child.kill();
193
+ let _ = self.child.wait();
194
+ }
195
+}
196
+
197
+impl AgentUnderTest {
198
+ /// The raw answer to request `id`, error or not.
199
+ fn answer(&mut self, id: u64) -> Value {
200
+ let deadline = Instant::now() + TIMEOUT;
201
+ loop {
202
+ let remaining = deadline.saturating_duration_since(Instant::now());
203
+ match self.incoming.recv_timeout(remaining) {
204
+ Ok(message) if message["id"] == id && message.get("method").is_none() => {
205
+ return message;
206
+ }
207
+ Ok(_) => {}
208
+ Err(_) => panic!("timed out waiting for the answer to request {id}"),
209
+ }
210
+ }
211
+ }
212
+}
213
+
214
+fn open() -> (AgentUnderTest, std::path::PathBuf, std::path::PathBuf) {
215
+ let endpoint = start_fake_endpoint(vec![]);
216
+ let scratch = std::env::temp_dir().join(format!(
217
+ "sigit_acp_codes_{}_{}",
218
+ std::process::id(),
219
+ endpoint.port
220
+ ));
221
+ let config_dir = scratch.join("config");
222
+ std::fs::create_dir_all(&config_dir).unwrap();
223
+ let mut agent = spawn_agent(endpoint.port, &config_dir);
224
+ let id = agent.request(
225
+ "initialize",
226
+ json!({"protocolVersion": 1, "clientCapabilities": {}}),
227
+ );
228
+ agent.wait_for_response(id);
229
+ (agent, scratch, config_dir)
230
+}
231
+
232
+#[test]
233
+fn an_auth_method_that_was_never_offered_is_invalid_params() {
234
+ let (mut agent, scratch, _) = open();
235
+ let id = agent.request("authenticate", json!({"methodId": "not-a-method"}));
236
+ let answer = agent.answer(id);
237
+ assert_eq!(answer["error"]["code"], -32602, "{answer}");
238
+ drop(agent);
239
+ let _ = std::fs::remove_dir_all(scratch);
240
+}
241
+
242
+#[test]
243
+fn a_session_the_agent_does_not_know_is_resource_not_found() {
244
+ let (mut agent, scratch, _) = open();
245
+ for (method, params) in [
246
+ (
247
+ "session/prompt",
248
+ json!({"sessionId": "nope", "prompt": [{"type": "text", "text": "hi"}]}),
249
+ ),
250
+ (
251
+ "session/set_mode",
252
+ json!({"sessionId": "nope", "modeId": "permission-mode-auto"}),
253
+ ),
254
+ (
255
+ "session/set_config_option",
256
+ json!({"sessionId": "nope", "configId": "sigit-permission-mode",
257
+ "value": "permission-mode-auto"}),
258
+ ),
259
+ ] {
260
+ let id = agent.request(method, params);
261
+ let answer = agent.answer(id);
262
+ assert_eq!(answer["error"]["code"], -32002, "{method}: {answer}");
263
+ }
264
+ drop(agent);
265
+ let _ = std::fs::remove_dir_all(scratch);
266
+}
267
+
268
+#[test]
269
+fn a_relative_cwd_filter_on_session_list_is_invalid_params() {
270
+ let (mut agent, scratch, _) = open();
271
+ let id = agent.request("session/list", json!({"cwd": "relative/dir"}));
272
+ let answer = agent.answer(id);
273
+ assert_eq!(answer["error"]["code"], -32602, "{answer}");
274
+
275
+ let id = agent.request("session/list", json!({}));
276
+ let answer = agent.answer(id);
277
+ assert!(answer.get("error").is_none(), "{answer}");
278
+ drop(agent);
279
+ let _ = std::fs::remove_dir_all(scratch);
280
+}