1 //! End-to-end check of embedded context over ACP (issue #141).
2 //!
3 //! A client only inlines a resource's contents into a prompt when the agent
4 //! advertises `promptCapabilities.embeddedContext`. Otherwise it sends a
5 //! `resource_link` and sigit reads the file from disk, which misses edits the
6 //! user has not saved. This runs the real binary against a scripted
7 //! OpenAI-compatible endpoint and checks that the capability is advertised
8 //! and that an embedded resource reaches the model as sent, not as it is on
9 //! disk.
10
11 use std::collections::VecDeque;
12 use std::io::{BufRead, BufReader, Read, Write};
13 use std::net::TcpListener;
14 use std::process::{Child, ChildStdin, Command, Stdio};
15 use std::sync::mpsc::{Receiver, channel};
16 use std::sync::{Arc, Mutex};
17 use std::time::{Duration, Instant};
18
19 use serde_json::{Value, json};
20
21 const TIMEOUT: Duration = Duration::from_secs(60);
22
23 // ── Scripted OpenAI-compatible endpoint ─────────────────────────────────────
24
25 fn sse_body(events: &[Value]) -> String {
26 let mut body = String::new();
27 for event in events {
28 body.push_str("data: ");
29 body.push_str(&event.to_string());
30 body.push_str("\n\n");
31 }
32 body.push_str("data: [DONE]\n\n");
33 body
34 }
35
36 fn sse_text(text: &str) -> String {
37 sse_body(&[json!({"choices": [{"delta": {"content": text}}]})])
38 }
39
40 /// Serves one scripted SSE response per request and records each request body.
41 struct FakeEndpoint {
42 port: u16,
43 requests: Arc<Mutex<Vec<Value>>>,
44 }
45
46 fn start_fake_endpoint(responses: Vec<String>) -> FakeEndpoint {
47 let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
48 let port = listener.local_addr().unwrap().port();
49 let requests: Arc<Mutex<Vec<Value>>> = Arc::default();
50 let recorded = Arc::clone(&requests);
51 let queue = Mutex::new(VecDeque::from(responses));
52
53 std::thread::spawn(move || {
54 for stream in listener.incoming() {
55 let Ok(mut stream) = stream else { continue };
56 let mut reader = BufReader::new(match stream.try_clone() {
57 Ok(clone) => clone,
58 Err(_) => continue,
59 });
60 let mut content_length = 0usize;
61 loop {
62 let mut line = String::new();
63 if reader.read_line(&mut line).unwrap_or(0) == 0 {
64 break;
65 }
66 let line = line.trim();
67 if line.is_empty() {
68 break;
69 }
70 if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
71 content_length = length.trim().parse().unwrap_or(0);
72 }
73 }
74 let mut body = vec![0u8; content_length];
75 if reader.read_exact(&mut body).is_err() {
76 continue;
77 }
78 if let Ok(request) = serde_json::from_slice::<Value>(&body) {
79 recorded.lock().unwrap().push(request);
80 }
81 let payload = queue
82 .lock()
83 .unwrap()
84 .pop_front()
85 .unwrap_or_else(|| sse_text("out of scripted responses"));
86 let response = format!(
87 "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
88 content-length: {}\r\nconnection: close\r\n\r\n{}",
89 payload.len(),
90 payload
91 );
92 let _ = stream.write_all(response.as_bytes());
93 }
94 });
95
96 FakeEndpoint { port, requests }
97 }
98
99 // ── ACP client over the binary's stdio ──────────────────────────────────────
100
101 struct AgentUnderTest {
102 child: Child,
103 stdin: ChildStdin,
104 incoming: Receiver<Value>,
105 next_id: u64,
106 }
107
108 fn spawn_agent(port: u16, config_dir: &std::path::Path) -> AgentUnderTest {
109 let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
110 .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
111 .env("OPENAI_API_KEY", "test-key")
112 .env("SIGIT_MODEL", "scripted-model")
113 .env("SIGIT_CONFIG_DIR", config_dir)
114 .env("SIGIT_MCP", "off")
115 .env("SIGIT_PERMISSIONS", "allow")
116 .env_remove("SIGIT_LOCAL_INFERENCE")
117 .stdin(Stdio::piped())
118 .stdout(Stdio::piped())
119 .stderr(Stdio::null())
120 .spawn()
121 .expect("spawn sigit in ACP mode");
122
123 let stdout = child.stdout.take().unwrap();
124 let (message_tx, incoming) = channel();
125 std::thread::spawn(move || {
126 for line in BufReader::new(stdout).lines() {
127 let Ok(line) = line else { break };
128 if let Ok(message) = serde_json::from_str::<Value>(&line)
129 && message_tx.send(message).is_err()
130 {
131 break;
132 }
133 }
134 });
135
136 let stdin = child.stdin.take().unwrap();
137 AgentUnderTest {
138 child,
139 stdin,
140 incoming,
141 next_id: 0,
142 }
143 }
144
145 impl AgentUnderTest {
146 fn send(&mut self, message: Value) {
147 let mut line = message.to_string();
148 line.push('\n');
149 self.stdin
150 .write_all(line.as_bytes())
151 .expect("write to agent stdin");
152 self.stdin.flush().expect("flush agent stdin");
153 }
154
155 fn request(&mut self, method: &str, params: Value) -> u64 {
156 self.next_id += 1;
157 let id = self.next_id;
158 self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
159 id
160 }
161
162 /// Wait for the response to our request `id`, collecting the raw JSON of
163 /// every `session/update` notification that arrives before it.
164 fn wait_for_response_collecting_updates(&mut self, id: u64) -> (Value, String) {
165 let deadline = Instant::now() + TIMEOUT;
166 let mut updates = String::new();
167 loop {
168 let remaining = deadline.saturating_duration_since(Instant::now());
169 match self.incoming.recv_timeout(remaining) {
170 Ok(message) if message["id"] == id && message.get("method").is_none() => {
171 assert!(
172 message.get("error").is_none(),
173 "request {id} failed: {message}"
174 );
175 return (message, updates);
176 }
177 Ok(message) => {
178 if message["method"] == "session/update" {
179 updates.push_str(&message["params"].to_string());
180 updates.push('\n');
181 }
182 }
183 Err(_) => panic!("timed out waiting for response to request {id}"),
184 }
185 }
186 }
187
188 fn wait_for_response(&mut self, id: u64) -> Value {
189 self.wait_for_response_collecting_updates(id).0
190 }
191 }
192
193 impl Drop for AgentUnderTest {
194 fn drop(&mut self) {
195 let _ = self.child.kill();
196 let _ = self.child.wait();
197 }
198 }
199
200 #[test]
201 fn an_embedded_resource_reaches_the_model_as_the_client_sent_it() {
202 let endpoint = start_fake_endpoint(vec![sse_text("done")]);
203 let scratch = std::env::temp_dir().join(format!("sigit_acp_embedded_{}", std::process::id()));
204 let config_dir = scratch.join("config");
205 let project = scratch.join("project");
206 std::fs::create_dir_all(&config_dir).unwrap();
207 std::fs::create_dir_all(&project).unwrap();
208 let notes = project.join("notes.txt");
209 std::fs::write(&notes, "saved on disk").unwrap();
210
211 let mut agent = spawn_agent(endpoint.port, &config_dir);
212
213 let id = agent.request(
214 "initialize",
215 json!({"protocolVersion": 1, "clientCapabilities": {}}),
216 );
217 let initialize = agent.wait_for_response(id);
218 assert_eq!(
219 initialize["result"]["agentCapabilities"]["promptCapabilities"]["embeddedContext"], true,
220 "a client only embeds resources for an agent that advertises it: {initialize}"
221 );
222
223 let id = agent.request("session/new", json!({"cwd": project, "mcpServers": []}));
224 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
225 .as_str()
226 .expect("session id")
227 .to_string();
228
229 // The editor's buffer has an edit the file on disk does not.
230 let uri = format!("file://{}", notes.display());
231 let id = agent.request(
232 "session/prompt",
233 json!({
234 "sessionId": session_id,
235 "prompt": [
236 {"type": "text", "text": "summarize this"},
237 {"type": "resource", "resource": {
238 "uri": uri,
239 "mimeType": "text/plain",
240 "text": "unsaved buffer edit",
241 }},
242 ],
243 }),
244 );
245 let response = agent.wait_for_response(id);
246 assert_eq!(response["result"]["stopReason"], "end_turn");
247
248 let request = endpoint
249 .requests
250 .lock()
251 .unwrap()
252 .first()
253 .cloned()
254 .expect("the endpoint received a completion request");
255 let user = request["messages"]
256 .as_array()
257 .expect("messages")
258 .iter()
259 .rev()
260 .find(|message| message["role"] == "user")
261 .expect("a user message")
262 .to_string();
263 assert!(
264 user.contains("unsaved buffer edit"),
265 "the embedded contents must reach the model: {user}"
266 );
267 assert!(
268 !user.contains("saved on disk"),
269 "an embedded resource must not be re-read from disk: {user}"
270 );
271
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 }