1 //! End-to-end check of image attachments over ACP (issue #134).
2 //!
3 //! `promptCapabilities.image` is fixed for the connection, so the editor will
4 //! attach an image whatever model is selected. Two things have to hold:
5 //!
6 //! - a model that reads images receives the image, as an OpenAI `image_url`
7 //! content part carrying a base64 `data:` URL;
8 //! - a model that does not is sent text only, with a note in place of the
9 //! image, and the user is told the image was left out.
10 //!
11 //! Both run the real binary against a scripted OpenAI-compatible endpoint that
12 //! records what it was sent. The model id decides which case applies: an
13 //! `onde-*` id is looked up in the tier table, anything else is the user's own
14 //! endpoint and is passed the image.
15
16 use std::collections::VecDeque;
17 use std::io::{BufRead, BufReader, Read, Write};
18 use std::net::TcpListener;
19 use std::process::{Child, ChildStdin, Command, Stdio};
20 use std::sync::mpsc::{Receiver, channel};
21 use std::sync::{Arc, Mutex};
22 use std::time::{Duration, Instant};
23
24 use serde_json::{Value, json};
25
26 const TIMEOUT: Duration = Duration::from_secs(60);
27
28 // ── Scripted OpenAI-compatible endpoint ─────────────────────────────────────
29
30 fn sse_body(events: &[Value]) -> String {
31 let mut body = String::new();
32 for event in events {
33 body.push_str("data: ");
34 body.push_str(&event.to_string());
35 body.push_str("\n\n");
36 }
37 body.push_str("data: [DONE]\n\n");
38 body
39 }
40
41 fn sse_text(text: &str) -> String {
42 sse_body(&[json!({"choices": [{"delta": {"content": text}}]})])
43 }
44
45 /// Serves one scripted SSE response per request and records each request body.
46 struct FakeEndpoint {
47 port: u16,
48 requests: Arc<Mutex<Vec<Value>>>,
49 }
50
51 fn start_fake_endpoint(responses: Vec<String>) -> FakeEndpoint {
52 let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
53 let port = listener.local_addr().unwrap().port();
54 let requests: Arc<Mutex<Vec<Value>>> = Arc::default();
55 let recorded = Arc::clone(&requests);
56 let queue = Mutex::new(VecDeque::from(responses));
57
58 std::thread::spawn(move || {
59 for stream in listener.incoming() {
60 let Ok(mut stream) = stream else { continue };
61 let mut reader = BufReader::new(match stream.try_clone() {
62 Ok(clone) => clone,
63 Err(_) => continue,
64 });
65 let mut content_length = 0usize;
66 loop {
67 let mut line = String::new();
68 if reader.read_line(&mut line).unwrap_or(0) == 0 {
69 break;
70 }
71 let line = line.trim();
72 if line.is_empty() {
73 break;
74 }
75 if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
76 content_length = length.trim().parse().unwrap_or(0);
77 }
78 }
79 let mut body = vec![0u8; content_length];
80 if reader.read_exact(&mut body).is_err() {
81 continue;
82 }
83 if let Ok(request) = serde_json::from_slice::<Value>(&body) {
84 recorded.lock().unwrap().push(request);
85 }
86 let payload = queue
87 .lock()
88 .unwrap()
89 .pop_front()
90 .unwrap_or_else(|| sse_text("out of scripted responses"));
91 let response = format!(
92 "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
93 content-length: {}\r\nconnection: close\r\n\r\n{}",
94 payload.len(),
95 payload
96 );
97 let _ = stream.write_all(response.as_bytes());
98 }
99 });
100
101 FakeEndpoint { port, requests }
102 }
103
104 // ── ACP client over the binary's stdio ──────────────────────────────────────
105
106 struct AgentUnderTest {
107 child: Child,
108 stdin: ChildStdin,
109 incoming: Receiver<Value>,
110 next_id: u64,
111 }
112
113 fn spawn_agent(port: u16, config_dir: &std::path::Path, model: &str) -> AgentUnderTest {
114 let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
115 .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
116 .env("OPENAI_API_KEY", "test-key")
117 .env("SIGIT_MODEL", model)
118 .env("SIGIT_CONFIG_DIR", config_dir)
119 .env("SIGIT_MCP", "off")
120 .env("SIGIT_PERMISSIONS", "allow")
121 .env_remove("SIGIT_LOCAL_INFERENCE")
122 .stdin(Stdio::piped())
123 .stdout(Stdio::piped())
124 .stderr(Stdio::null())
125 .spawn()
126 .expect("spawn sigit in ACP mode");
127
128 let stdout = child.stdout.take().unwrap();
129 let (message_tx, incoming) = channel();
130 std::thread::spawn(move || {
131 for line in BufReader::new(stdout).lines() {
132 let Ok(line) = line else { break };
133 if let Ok(message) = serde_json::from_str::<Value>(&line)
134 && message_tx.send(message).is_err()
135 {
136 break;
137 }
138 }
139 });
140
141 let stdin = child.stdin.take().unwrap();
142 AgentUnderTest {
143 child,
144 stdin,
145 incoming,
146 next_id: 0,
147 }
148 }
149
150 impl AgentUnderTest {
151 fn send(&mut self, message: Value) {
152 let mut line = message.to_string();
153 line.push('\n');
154 self.stdin
155 .write_all(line.as_bytes())
156 .expect("write to agent stdin");
157 self.stdin.flush().expect("flush agent stdin");
158 }
159
160 fn request(&mut self, method: &str, params: Value) -> u64 {
161 self.next_id += 1;
162 let id = self.next_id;
163 self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
164 id
165 }
166
167 /// Wait for the response to our request `id`, collecting the raw JSON of
168 /// every `session/update` notification that arrives before it.
169 fn wait_for_response_collecting_updates(&mut self, id: u64) -> (Value, String) {
170 let deadline = Instant::now() + TIMEOUT;
171 let mut updates = String::new();
172 loop {
173 let remaining = deadline.saturating_duration_since(Instant::now());
174 match self.incoming.recv_timeout(remaining) {
175 Ok(message) if message["id"] == id && message.get("method").is_none() => {
176 assert!(
177 message.get("error").is_none(),
178 "request {id} failed: {message}"
179 );
180 return (message, updates);
181 }
182 Ok(message) => {
183 if message["method"] == "session/update" {
184 updates.push_str(&message["params"].to_string());
185 updates.push('\n');
186 }
187 }
188 Err(_) => panic!("timed out waiting for response to request {id}"),
189 }
190 }
191 }
192
193 fn wait_for_response(&mut self, id: u64) -> Value {
194 self.wait_for_response_collecting_updates(id).0
195 }
196 }
197
198 impl Drop for AgentUnderTest {
199 fn drop(&mut self) {
200 let _ = self.child.kill();
201 let _ = self.child.wait();
202 }
203 }
204
205 // ── The two cases ────────────────────────────────────────────────────────────
206
207 /// A 1x1 PNG, base64-encoded.
208 const PIXEL: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
209
210 /// Run one prompt that carries text and an image against `model`. Returns the
211 /// `initialize` response, the raw `session/update` stream of the prompt, and
212 /// the first completion request the endpoint received.
213 fn prompt_with_image(model: &str, tag: &str) -> (Value, String, Value) {
214 let endpoint = start_fake_endpoint(vec![sse_text("done")]);
215 let scratch =
216 std::env::temp_dir().join(format!("sigit_acp_images_{tag}_{}", std::process::id()));
217 let config_dir = scratch.join("config");
218 let project = scratch.join("project");
219 std::fs::create_dir_all(&config_dir).unwrap();
220 std::fs::create_dir_all(&project).unwrap();
221
222 let mut agent = spawn_agent(endpoint.port, &config_dir, model);
223
224 let id = agent.request(
225 "initialize",
226 json!({"protocolVersion": 1, "clientCapabilities": {}}),
227 );
228 let initialize = agent.wait_for_response(id);
229
230 let id = agent.request("session/new", json!({"cwd": project, "mcpServers": []}));
231 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
232 .as_str()
233 .expect("session id")
234 .to_string();
235
236 let id = agent.request(
237 "session/prompt",
238 json!({
239 "sessionId": session_id,
240 "prompt": [
241 {"type": "text", "text": "what is in this picture?"},
242 {"type": "image", "mimeType": "image/png", "data": PIXEL},
243 ],
244 }),
245 );
246 let (response, updates) = agent.wait_for_response_collecting_updates(id);
247 assert_eq!(response["result"]["stopReason"], "end_turn");
248
249 let request = endpoint
250 .requests
251 .lock()
252 .unwrap()
253 .first()
254 .cloned()
255 .expect("the endpoint received a completion request");
256
257 drop(agent);
258 let _ = std::fs::remove_dir_all(&scratch);
259 (initialize, updates, request)
260 }
261
262 fn last_user_message(request: &Value) -> &Value {
263 request["messages"]
264 .as_array()
265 .expect("messages")
266 .iter()
267 .rev()
268 .find(|message| message["role"] == "user")
269 .expect("a user message")
270 }
271
272 #[test]
273 fn an_image_reaches_a_model_that_reads_images() {
274 let (initialize, updates, request) = prompt_with_image("scripted-vision-model", "vision");
275
276 assert_eq!(
277 initialize["result"]["agentCapabilities"]["promptCapabilities"]["image"], true,
278 "a client only attaches images for an agent that advertises them: {initialize}"
279 );
280
281 let message = last_user_message(&request);
282 assert_eq!(
283 message["content"],
284 json!([
285 {"type": "text", "text": "what is in this picture?"},
286 {"type": "image_url", "image_url": {"url": format!("data:image/png;base64,{PIXEL}")}},
287 ]),
288 "the image must travel as an image_url content part"
289 );
290 assert!(
291 !updates.contains("Image not sent"),
292 "nothing was left out, so nothing should be flagged: {updates}"
293 );
294 }
295
296 #[test]
297 fn a_text_only_model_gets_the_text_and_the_user_is_told() {
298 // `onde-nova` is the default cloud tier and is text-only at the source.
299 let (_, updates, request) = prompt_with_image("onde-nova", "text_only");
300
301 let message = last_user_message(&request);
302 let content = message["content"]
303 .as_str()
304 .unwrap_or_else(|| panic!("a text-only model must get string content: {message}"));
305 assert!(content.starts_with("what is in this picture?"), "{content}");
306 assert!(
307 content.contains("This model cannot read images"),
308 "the model must know an image was withheld: {content}"
309 );
310 assert!(
311 !request.to_string().contains(PIXEL),
312 "no image data may be sent to a model that cannot read it"
313 );
314 assert!(
315 updates.contains("Image not sent"),
316 "the user must be told the image was left out: {updates}"
317 );
318 }