Validate the auth method id, use the standard error codes, tidy small ACP gaps

authenticate ignored methodId and opened a browser for anything. It now rejects an id the agent never offered with -32602, and reports a failed sign-in as -32603, since -32000 means authentication is required. An unknown session in prompt, set_mode and set_config_option is now -32002, matching load and resume. The final update of a command that ran in the client's terminal no longer re-sends the terminal after terminal/release. session/list rejects a relative cwd filter. An embedded image or audio blob is read as an image or audio input, and other blobs name their MIME type in the placeholder. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Co-Authored-By: siGit Code v1.6.6-acp <noreply@sigit.si>

Seto Elkahfi committed Oct 10, 2026 at 17:08 UTC 657101c6ae052a5f18b57583da3d2f0f98e903b8
6 files changed +441 -38
.agents/AGENTS.md
+2 -1
@@ -345,7 +345,8 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
345 the exception to the lock order: the dispatch loop runs them with neither lock, because
346 they touch only state keyed by session (`permissions`) and have to land mid-turn. They
347 check the id against `sessions` (`-32002` when unknown) and build the options from
348 - that session's own model (`config_options_for_session`), never `activate_session`. Tool
348 + that session's own model (`config_options_for_session`), never `activate_session`. Unknown session ids are `-32002` (resource not found) everywhere,
349 + and `authenticate` accepts only `AUTH_METHOD_ID`. Tool
350 execution, subagents included, still runs one session at a time.
351 - **`src/instructions.rs`** — project instruction files, the always-on counterpart to skills.
352 Reads `AGENTS.md` (the cross-tool [agents.md](https://agents.md) standard) and `CLAUDE.md`,
CHANGELOG.md
+18
@@ -20,6 +20,24 @@
20 ended. They now take no lock, are answered at once, and the pending call's
21 successors follow the new mode. Model and Inference changes still wait for
22 the turn.
23 +- **`authenticate` checks the method id.** An id that `initialize` never offered
24 + is now rejected with `invalid_params` (`-32602`) instead of starting a
25 + browser sign-in. A sign-in that fails reports `-32603`, since `-32000` means
26 + authentication is required before a call.
27 +- **A session the agent does not know is `-32002`.** `session/prompt`,
28 + `session/set_mode` and `session/set_config_option` answered `-32602` for an
29 + unknown id; they now say resource not found, as `session/load` and
30 + `session/resume` already did.
31 +- **A finished terminal command no longer names a released terminal.** The
32 + closing `tool_call_update` of a command that ran in the editor's terminal
33 + re-sent the terminal id after `terminal/release`. It now leaves the content
34 + alone, so the embedded terminal stays as it was, and the result still goes out
35 + as `rawOutput`.
36 +- **`session/list` rejects a relative `cwd` filter** with `-32602`, as
37 + `session/new` does.
38 +- **An embedded binary resource is read when the model can read it.** An image
39 + blob becomes an image input and an audio blob an audio input. Any other blob
40 + keeps its placeholder, now with its MIME type.
41 - **A prompt that is only audio is answered.** A clip with no text or image used
42 to end the turn at once with nothing sent to the model, even though the agent
43 advertises audio.
src/main.rs
+53 -36
@@ -594,20 +594,13 @@ fn file_change_content(change: &tools::FileChange) -> ToolCallContent {
594 .into()
595 }
596
597 -/// The content of a finished tool call's card: the client terminal the
598 -/// command ran in, when it ran in one, which already shows its output (the
599 -/// full result text still travels as `raw_output`); otherwise the diff of the
600 -/// file it wrote, if it wrote one, then its result text.
601 -fn tool_result_content(
602 - output: &str,
603 - change: Option<&tools::FileChange>,
604 - terminal: Option<&str>,
605 -) -> Vec<ToolCallContent> {
606 - if let Some(terminal_id) = terminal {
607 - return vec![ToolCallContent::Terminal(Terminal::new(
608 - terminal_id.to_string(),
609 - ))];
610 - }
597 +/// The content of a finished tool call's card: the diff of the file it wrote,
598 +/// if it wrote one, then its result text. A command that ran in the client's
599 +/// terminal gets no content at all in its final update: the terminal is
600 +/// already embedded in the card and was released by then, so naming it again
601 +/// would point at an id the client no longer has. The result text still
602 +/// travels as `raw_output`.
603 +fn tool_result_content(output: &str, change: Option<&tools::FileChange>) -> Vec<ToolCallContent> {
604 change
605 .map(file_change_content)
606 .into_iter()
@@ -2196,6 +2189,9 @@ fn validate_session_roots(
2189 Ok(())
2190 }
2191
2192 +/// The id of the one auth method `initialize` advertises.
2193 +const AUTH_METHOD_ID: &str = "sigit";
2194 +
2195 // ── ACP handler implementations ───────────────────────────────────────────────
2196
2197 impl SiGitAgent {
@@ -2266,7 +2262,7 @@ impl SiGitAgent {
2262 // With an Agent method, clicking calls `authenticate`, which opens the
2263 // browser and finishes the sign-in itself.
2264 let auth_methods = vec![AuthMethod::Agent(
2269 - AuthMethodAgent::new("sigit", "Sign in to siGit Code")
2265 + AuthMethodAgent::new(AUTH_METHOD_ID, "Sign in to siGit Code")
2266 .description("Opens sigit.si in your browser to authorize this device."),
2267 )];
2268
@@ -2345,6 +2341,16 @@ impl SiGitAgent {
2341 ) -> agent_client_protocol::Result<AuthenticateResponse> {
2342 log::info!("authenticate: method={}", req.method_id.0);
2343
2344 + if req.method_id.0.as_ref() != AUTH_METHOD_ID {
2345 + return Err(agent_client_protocol::Error::new(
2346 + -32602,
2347 + format!(
2348 + "unknown auth method {}; the only one offered is {AUTH_METHOD_ID}",
2349 + req.method_id.0
2350 + ),
2351 + ));
2352 + }
2353 +
2354 // An existing session clears the gate without sending anyone to a
2355 // browser they didn't ask for.
2356 if let Ok(email) = account::verify_session().await {
@@ -2360,7 +2366,7 @@ impl SiGitAgent {
2366 let flow = browser_auth::begin();
2367 if !flow.is_loopback() {
2368 return Err(agent_client_protocol::Error::new(
2363 - -32000,
2369 + -32603,
2370 "Could not open a local port to receive the sign-in. \
2371 Run `sigit login` in a terminal instead."
2372 .to_string(),
@@ -2383,7 +2389,7 @@ impl SiGitAgent {
2389 Ok(AuthenticateResponse::default())
2390 }
2391 Err(reason) => Err(agent_client_protocol::Error::new(
2386 - -32000,
2392 + -32603,
2393 format!(
2394 "Sign-in did not complete ({reason}). \
2395 Try again, or create an account at https://sigit.si."
@@ -2567,7 +2573,7 @@ impl SiGitAgent {
2573
2574 let Some(state) = self.sessions.lock().unwrap().get(&key).cloned() else {
2575 return Err(agent_client_protocol::Error::new(
2570 - -32602,
2576 + -32002,
2577 format!(
2578 "unknown session {key}; create it with session/new or restore it with session/load"
2579 ),
@@ -2827,6 +2833,14 @@ impl SiGitAgent {
2833 &self,
2834 args: ListSessionsRequest,
2835 ) -> agent_client_protocol::Result<ListSessionsResponse> {
2836 + if let Some(cwd) = args.cwd.as_ref()
2837 + && !cwd.is_absolute()
2838 + {
2839 + return Err(agent_client_protocol::Error::new(
2840 + -32602,
2841 + format!("cwd must be an absolute path, got {}", cwd.display()),
2842 + ));
2843 + }
2844 let filter = args.cwd.clone();
2845 let sessions: Vec<SessionInfo> = session_store::list()
2846 .into_iter()
@@ -3334,7 +3348,22 @@ impl SiGitAgent {
3348 ));
3349 }
3350 EmbeddedResourceResource::BlobResourceContents(blob) => {
3337 - parts.push(format!("[binary resource: {}]", blob.uri));
3351 + let mime = blob.mime_type.clone().unwrap_or_default();
3352 + if mime.starts_with("image/") {
3353 + images.push(ImageInput {
3354 + mime_type: mime,
3355 + data: blob.blob.clone(),
3356 + });
3357 + } else if mime.starts_with("audio/") {
3358 + audio.push(AudioInput {
3359 + mime_type: mime,
3360 + data: blob.blob.clone(),
3361 + });
3362 + } else if mime.is_empty() {
3363 + parts.push(format!("[binary resource: {}]", blob.uri));
3364 + } else {
3365 + parts.push(format!("[binary resource: {} ({mime})]", blob.uri));
3366 + }
3367 }
3368 _ => {
3369 log::debug!("ignoring unsupported embedded resource variant");
@@ -3897,12 +3926,10 @@ impl SiGitAgent {
3926 } else {
3927 ToolCallStatus::Failed
3928 })
3900 - .content(tool_result_content(
3901 - &output,
3902 - change.as_ref(),
3903 - terminal.as_deref(),
3904 - ))
3929 .raw_output(serde_json::Value::String(output.clone()));
3930 + if terminal.is_none() {
3931 + fields = fields.content(tool_result_content(&output, change.as_ref()));
3932 + }
3933 // A denial at the prompt leaves the card titled with the
3934 // permission request's full arguments; put its own back.
3935 if !executed && announced_status == ToolCallStatus::Pending {
@@ -6836,7 +6863,7 @@ mod tests {
6863 .activate_session(&SessionId::new("not-opened"))
6864 .await
6865 .unwrap_err();
6839 - assert_eq!(error.code, agent_client_protocol::ErrorCode::InvalidParams);
6866 + assert_eq!(i32::from(error.code), -32002);
6867 assert!(error.message.contains("session/new"));
6868 assert!(agent.active_session.lock().unwrap().is_none());
6869 }
@@ -8015,7 +8042,6 @@ mod tests {
8042 let content = serde_json::to_value(tool_result_content(
8043 "Edited file: /repo/src/lib.rs (4 bytes written)",
8044 Some(&change),
8018 - None,
8045 ))
8046 .unwrap();
8047 assert_eq!(
@@ -8039,16 +8065,7 @@ mod tests {
8065 assert!(diff.get("oldText").is_none_or(serde_json::Value::is_null));
8066
8067 // Every other tool keeps the result text alone.
8042 - // A command that ran in the client's terminal keeps the terminal,
8043 - // which already shows its output.
8044 - let ran = serde_json::to_value(tool_result_content("Exit code 0:\nhi", None, Some("t-1")))
8045 - .unwrap();
8046 - assert_eq!(
8047 - ran,
8048 - serde_json::json!([{ "type": "terminal", "terminalId": "t-1" }])
8049 - );
8050 -
8051 - let plain = serde_json::to_value(tool_result_content("ok", None, None)).unwrap();
8068 + let plain = serde_json::to_value(tool_result_content("ok", None)).unwrap();
8069 assert_eq!(plain.as_array().unwrap().len(), 1);
8070 assert_eq!(plain[0]["type"], "content");
8071 }
tests/acp_client_terminal.rs
+11 -1
@@ -457,7 +457,17 @@ fn run_command_runs_in_the_client_terminal_and_shows_it() {
457 .position(|update| update["status"] == "completed")
458 .expect("call_1 never completed");
459 assert!(shown < finished, "embedded after completion: {updates:?}");
460 - assert_eq!(updates[finished]["content"], terminal);
460 + // The terminal was released by then, so the closing update does not name
461 + // it again; the card keeps the one already embedded.
462 + assert!(
463 + updates[finished]["content"].is_null(),
464 + "the final update must not re-send the released terminal: {}",
465 + updates[finished]
466 + );
467 + assert_eq!(
468 + updates[finished]["rawOutput"],
469 + format!("Exit code 0:\n{EDITOR_OUTPUT}")
470 + );
471 }
472
473 #[test]
tests/acp_embedded_context.rs
+77
@@ -272,3 +272,80 @@ fn an_embedded_resource_reaches_the_model_as_the_client_sent_it() {
272 drop(agent);
273 let _ = std::fs::remove_dir_all(&scratch);
274 }
275 +
276 +/// A binary resource is not a text placeholder when the model can read it:
277 +/// an image becomes an image input, anything unreadable says what it was.
278 +#[test]
279 +fn an_embedded_blob_is_an_image_input_or_names_its_type() {
280 + let endpoint = start_fake_endpoint(vec![sse_text("done")]);
281 + let scratch = std::env::temp_dir().join(format!("sigit_acp_blob_{}", std::process::id()));
282 + let config_dir = scratch.join("config");
283 + let project = scratch.join("project");
284 + std::fs::create_dir_all(&config_dir).unwrap();
285 + std::fs::create_dir_all(&project).unwrap();
286 +
287 + let mut agent = spawn_agent(endpoint.port, &config_dir);
288 + let id = agent.request(
289 + "initialize",
290 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
291 + );
292 + agent.wait_for_response(id);
293 + let id = agent.request("session/new", json!({"cwd": project, "mcpServers": []}));
294 + let session_id = agent.wait_for_response(id)["result"]["sessionId"]
295 + .as_str()
296 + .expect("session id")
297 + .to_string();
298 +
299 + let id = agent.request(
300 + "session/prompt",
301 + json!({
302 + "sessionId": session_id,
303 + "prompt": [
304 + {"type": "text", "text": "what are these?"},
305 + {"type": "resource", "resource": {
306 + "uri": "file:///shot.png",
307 + "mimeType": "image/png",
308 + "blob": "QUJDRA==",
309 + }},
310 + {"type": "resource", "resource": {
311 + "uri": "file:///paper.pdf",
312 + "mimeType": "application/pdf",
313 + "blob": "JVBERi0=",
314 + }},
315 + ],
316 + }),
317 + );
318 + let response = agent.wait_for_response(id);
319 + assert_eq!(response["result"]["stopReason"], "end_turn");
320 +
321 + let request = endpoint
322 + .requests
323 + .lock()
324 + .unwrap()
325 + .first()
326 + .cloned()
327 + .expect("the endpoint received a completion request");
328 + let user = request["messages"]
329 + .as_array()
330 + .expect("messages")
331 + .iter()
332 + .rev()
333 + .find(|message| message["role"] == "user")
334 + .expect("a user message")
335 + .to_string();
336 + assert!(
337 + user.contains("data:image/png;base64,QUJDRA=="),
338 + "the image blob must reach the model as an image: {user}"
339 + );
340 + assert!(
341 + user.contains("[binary resource: file:///paper.pdf (application/pdf)]"),
342 + "an unreadable blob must say what it was: {user}"
343 + );
344 + assert!(
345 + !user.contains("JVBERi0="),
346 + "the pdf bytes must not be forwarded: {user}"
347 + );
348 +
349 + drop(agent);
350 + let _ = std::fs::remove_dir_all(&scratch);
351 +}
tests/acp_error_codes.rs new
+280
@@ -0,0 +1,280 @@
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 +}