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