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
+}