ag_agent/
app_server_transport.rs1use std::time::Duration;
9
10use serde_json::Value;
11use tokio::io::{AsyncWriteExt, BufReader, Lines};
12
13use crate::app_server::AppServerError;
14
15#[derive(Debug, thiserror::Error)]
21pub enum AppServerTransportError {
22 #[error("{context}: {source}")]
24 Io {
25 context: String,
27 #[source]
29 source: std::io::Error,
30 },
31
32 #[error("App-server terminated before sending expected response")]
34 ProcessTerminated,
35
36 #[error(
38 "Timed out waiting for app-server response `{response_id}` after {timeout_seconds} seconds"
39 )]
40 Timeout {
41 response_id: String,
43 timeout_seconds: u64,
45 },
46}
47
48pub const STARTUP_TIMEOUT: Duration = Duration::from_mins(5);
55
56pub const TURN_TIMEOUT: Duration = Duration::from_hours(4);
63
64pub async fn write_json_line(
70 stdin: &mut tokio::process::ChildStdin,
71 payload: &Value,
72) -> Result<(), AppServerTransportError> {
73 let serialized_payload = payload.to_string();
74
75 stdin
76 .write_all(serialized_payload.as_bytes())
77 .await
78 .map_err(|source| AppServerTransportError::Io {
79 context: "Failed writing to app-server stdin".to_string(),
80 source,
81 })?;
82 stdin
83 .write_all(b"\n")
84 .await
85 .map_err(|source| AppServerTransportError::Io {
86 context: "Failed writing newline to app-server stdin".to_string(),
87 source,
88 })?;
89 stdin
90 .flush()
91 .await
92 .map_err(|source| AppServerTransportError::Io {
93 context: "Failed flushing app-server stdin".to_string(),
94 source,
95 })
96}
97
98pub async fn wait_for_response_line<R>(
108 stdout_lines: &mut Lines<BufReader<R>>,
109 response_id: &str,
110) -> Result<String, AppServerTransportError>
111where
112 R: tokio::io::AsyncRead + Unpin,
113{
114 tokio::time::timeout(STARTUP_TIMEOUT, async {
115 loop {
116 let stdout_line = stdout_lines
117 .next_line()
118 .await
119 .map_err(|source| AppServerTransportError::Io {
120 context: "Failed reading app-server stdout".to_string(),
121 source,
122 })?
123 .ok_or(AppServerTransportError::ProcessTerminated)?;
124
125 let Ok(response_value) = serde_json::from_str::<Value>(&stdout_line) else {
126 continue;
127 };
128 if response_id_matches(&response_value, response_id) {
129 return Ok(stdout_line);
130 }
131 }
132 })
133 .await
134 .map_err(|_| AppServerTransportError::Timeout {
135 response_id: response_id.to_string(),
136 timeout_seconds: STARTUP_TIMEOUT.as_secs(),
137 })?
138}
139
140pub fn response_id_matches(response_value: &Value, response_id: &str) -> bool {
142 response_value
143 .get("id")
144 .and_then(Value::as_str)
145 .is_some_and(|line_id| line_id == response_id)
146}
147
148pub fn extract_json_error_message(response_value: &Value) -> Option<String> {
150 response_value
151 .get("error")
152 .and_then(|error| error.get("message"))
153 .and_then(Value::as_str)
154 .map(ToString::to_string)
155}
156
157pub async fn shutdown_child(child: &mut tokio::process::Child) {
160 drop(child.stdin.take());
162
163 if tokio::time::timeout(Duration::from_secs(1), child.wait())
164 .await
165 .is_err()
166 {
167 let _ = child.kill().await;
169 let _ = child.wait().await;
171 }
172}
173
174pub fn spawn_runtime_command(
186 command: std::process::Command,
187 runtime_name: &str,
188) -> Result<
189 (
190 tokio::process::Child,
191 tokio::process::ChildStdin,
192 tokio::process::ChildStdout,
193 ),
194 AppServerError,
195> {
196 let mut command = tokio::process::Command::from(command);
197 command
198 .stdin(std::process::Stdio::piped())
199 .stdout(std::process::Stdio::piped())
200 .stderr(std::process::Stdio::null())
201 .kill_on_drop(true);
202
203 let mut child = command.spawn().map_err(|error| {
204 AppServerError::Provider(format!("Failed to spawn `{runtime_name}`: {error}"))
205 })?;
206 let stdin = child
207 .stdin
208 .take()
209 .ok_or_else(|| AppServerError::Provider(format!("{runtime_name} stdin is unavailable")))?;
210 let stdout = child
211 .stdout
212 .take()
213 .ok_or_else(|| AppServerError::Provider(format!("{runtime_name} stdout is unavailable")))?;
214
215 Ok((child, stdin, stdout))
216}
217
218#[cfg(test)]
219mod tests {
220 use std::process::Stdio;
221
222 use tokio::io::AsyncBufReadExt;
223
224 use super::*;
225
226 fn spawn_cat_process() -> tokio::process::Child {
229 let mut command = tokio::process::Command::new("cat");
230 command
231 .stdin(Stdio::piped())
232 .stdout(Stdio::piped())
233 .stderr(Stdio::null())
234 .kill_on_drop(true);
235
236 command.spawn().expect("failed to spawn `cat`")
237 }
238
239 #[test]
240 fn response_id_matches_returns_true_for_matching_string_id() {
241 let response_value = serde_json::json!({"id": "init-123", "result": {}});
243
244 assert!(response_id_matches(&response_value, "init-123"));
246 }
247
248 #[test]
249 fn response_id_matches_returns_false_for_different_id() {
250 let response_value = serde_json::json!({"id": "init-123", "result": {}});
252
253 assert!(!response_id_matches(&response_value, "init-456"));
255 }
256
257 #[test]
258 fn response_id_matches_returns_false_when_id_is_missing() {
259 let response_value = serde_json::json!({"method": "session/update", "params": {}});
261
262 assert!(!response_id_matches(&response_value, "init-123"));
264 }
265
266 #[test]
267 fn response_id_matches_returns_false_for_integer_id() {
268 let response_value = serde_json::json!({"id": 1, "result": {}});
270
271 assert!(!response_id_matches(&response_value, "1"));
273 }
274
275 #[test]
276 fn extract_json_error_message_returns_message_string() {
277 let response_value = serde_json::json!({
279 "id": "req-1",
280 "error": {"code": -32600, "message": "Invalid request"}
281 });
282
283 let message = extract_json_error_message(&response_value);
285
286 assert_eq!(message, Some("Invalid request".to_string()));
288 }
289
290 #[test]
291 fn extract_json_error_message_returns_none_without_error() {
292 let response_value = serde_json::json!({"id": "req-1", "result": {}});
294
295 let message = extract_json_error_message(&response_value);
297
298 assert_eq!(message, None);
300 }
301
302 #[test]
303 fn extract_json_error_message_returns_none_without_message_field() {
304 let response_value = serde_json::json!({
306 "id": "req-1",
307 "error": {"code": -32600}
308 });
309
310 let message = extract_json_error_message(&response_value);
312
313 assert_eq!(message, None);
315 }
316
317 #[tokio::test]
320 async fn write_json_line_writes_serialized_payload_with_newline() {
321 let mut child = spawn_cat_process();
323 let mut stdin = child.stdin.take().expect("`cat` stdin should be piped");
324 let stdout = child.stdout.take().expect("`cat` stdout should be piped");
325 let payload = serde_json::json!({
326 "id": "req-1",
327 "method": "initialize",
328 "params": {"value": 1}
329 });
330
331 write_json_line(&mut stdin, &payload)
333 .await
334 .expect("write should succeed");
335 drop(stdin);
336 let echoed_line = BufReader::new(stdout)
337 .lines()
338 .next_line()
339 .await
340 .expect("stdout read should succeed")
341 .expect("echoed payload line should exist");
342
343 assert_eq!(echoed_line, payload.to_string());
345 shutdown_child(&mut child).await;
346 }
347
348 #[tokio::test]
351 async fn wait_for_response_line_skips_invalid_and_non_matching_lines() {
352 let (reader, mut writer) = tokio::io::duplex(512);
354 let writer_task = tokio::spawn(async move {
355 writer
356 .write_all(
357 b"not-json\n{\"id\":\"other\",\"result\":{}}\n{\"id\":\"req-1\",\"result\":{\"ok\":true}}\n",
358 )
359 .await
360 .expect("test writer should succeed");
361 });
362 let mut stdout_lines = BufReader::new(reader).lines();
363
364 let response_line = wait_for_response_line(&mut stdout_lines, "req-1")
366 .await
367 .expect("matching response should be returned");
368
369 assert_eq!(response_line, "{\"id\":\"req-1\",\"result\":{\"ok\":true}}");
371 writer_task.await.expect("writer task should finish");
372 }
373
374 #[tokio::test]
377 async fn wait_for_response_line_returns_error_when_stream_ends() {
378 let (reader, mut writer) = tokio::io::duplex(256);
380 let writer_task = tokio::spawn(async move {
381 writer
382 .write_all(b"{\"id\":\"other\",\"result\":{}}\n")
383 .await
384 .expect("test writer should succeed");
385 drop(writer);
386 });
387 let mut stdout_lines = BufReader::new(reader).lines();
388
389 let response_result = wait_for_response_line(&mut stdout_lines, "req-1").await;
391
392 assert!(
394 matches!(
395 response_result,
396 Err(AppServerTransportError::ProcessTerminated)
397 ),
398 "expected ProcessTerminated, got: {response_result:?}"
399 );
400 writer_task.await.expect("writer task should finish");
401 }
402
403 #[test]
404 fn io_error_display_includes_context_and_source() {
405 let error = AppServerTransportError::Io {
407 context: "Failed writing to app-server stdin".to_string(),
408 source: std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe closed"),
409 };
410
411 let display = error.to_string();
413
414 assert_eq!(display, "Failed writing to app-server stdin: pipe closed");
416 }
417
418 #[test]
419 fn process_terminated_display_message() {
420 let error = AppServerTransportError::ProcessTerminated;
422
423 assert_eq!(
425 error.to_string(),
426 "App-server terminated before sending expected response"
427 );
428 }
429
430 #[test]
431 fn timeout_display_includes_response_id_and_seconds() {
432 let error = AppServerTransportError::Timeout {
434 response_id: "init-123".to_string(),
435 timeout_seconds: 300,
436 };
437
438 assert_eq!(
440 error.to_string(),
441 "Timed out waiting for app-server response `init-123` after 300 seconds"
442 );
443 }
444
445 #[tokio::test]
448 async fn shutdown_child_exits_cleanly_after_closing_stdin() {
449 let mut child = spawn_cat_process();
451
452 shutdown_child(&mut child).await;
454 let exit_status = child.wait().await.expect("child wait should succeed");
455
456 assert!(exit_status.success());
458 }
459}