1use std::sync::Arc;
17
18use aion_core::{
19 ActivityEvent, AgentOutcome, InterventionCapabilities, InterventionCommand, Payload, StopKind,
20};
21use aion_integrations::contract::AgentSession;
22use aion_integrations::error::HarnessError;
23use async_trait::async_trait;
24use futures::stream::BoxStream;
25use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader, Lines};
26use tokio::sync::{Mutex, mpsc, oneshot};
27
28use crate::demux::{self, EventIdentity};
29
30enum ChildEnd {
37 NoChild,
40 Exited(i32),
42 Signalled,
44 StatusUnreadable {
48 detail: String,
50 },
51}
52
53struct TerminalOutcome {
56 final_line: Option<String>,
58 end: ChildEnd,
60 announced_stop: Option<StopKind>,
64}
65
66pub struct CliSession {
71 capabilities: InterventionCapabilities,
72 events: Option<mpsc::UnboundedReceiver<ActivityEvent>>,
73 result: Option<oneshot::Receiver<TerminalOutcome>>,
74 pump: Option<tokio::task::JoinHandle<()>>,
75 child: Arc<Mutex<Option<tokio::process::Child>>>,
77}
78
79impl CliSession {
80 pub(crate) fn start<R>(
86 stdout: R,
87 identity: EventIdentity,
88 child: Option<tokio::process::Child>,
89 ) -> Self
90 where
91 R: AsyncRead + Unpin + Send + 'static,
92 {
93 let (event_tx, event_rx) = mpsc::unbounded_channel();
94 let (result_tx, result_rx) = oneshot::channel();
95 let child = Arc::new(Mutex::new(child));
96
97 let pump = tokio::spawn(pump_loop(
98 BufReader::new(stdout).lines(),
99 identity,
100 event_tx,
101 result_tx,
102 Arc::clone(&child),
103 ));
104
105 Self {
106 capabilities: InterventionCapabilities::none(),
107 events: Some(event_rx),
108 result: Some(result_rx),
109 pump: Some(pump),
110 child,
111 }
112 }
113}
114
115async fn pump_loop<R>(
121 mut lines: Lines<BufReader<R>>,
122 identity: EventIdentity,
123 event_tx: mpsc::UnboundedSender<ActivityEvent>,
124 result_tx: oneshot::Sender<TerminalOutcome>,
125 child: Arc<Mutex<Option<tokio::process::Child>>>,
126) where
127 R: AsyncRead + Unpin + Send,
128{
129 let mut worker_seq: u64 = 0;
130 let mut final_line: Option<String> = None;
131 let mut announced_stop: Option<StopKind> = None;
132 loop {
133 match lines.next_line().await {
134 Ok(Some(line)) => {
135 if !line.trim().is_empty() {
136 final_line = Some(line.clone());
137 }
138 let event = demux::line_to_event(&identity, worker_seq, &line);
139 worker_seq = worker_seq.saturating_add(1);
140 if let aion_core::ActivityEventKind::Stop { reason } = &event.kind {
145 announced_stop = Some(reason.clone());
146 }
147 let _ = event_tx.send(event);
148 }
149 Ok(None) => break,
151 Err(error) => {
152 tracing::debug!(%error, "cli adapter: reader pump ended on a stdout read error");
153 break;
154 }
155 }
156 }
157
158 let end = wait_child_end(&child).await;
159 if matches!(end, ChildEnd::Signalled) {
165 let stop = demux::synthesized_stop_event(
166 &identity,
167 worker_seq,
168 StopKind::Other {
169 reason: "terminated by signal".to_owned(),
170 },
171 );
172 let _ = event_tx.send(stop);
173 }
174 let _ = result_tx.send(TerminalOutcome {
176 final_line,
177 end,
178 announced_stop,
179 });
180}
181
182async fn wait_child_end(child: &Arc<Mutex<Option<tokio::process::Child>>>) -> ChildEnd {
185 let mut guard = child.lock().await;
186 let Some(child) = guard.as_mut() else {
187 return ChildEnd::NoChild;
188 };
189 match child.wait().await {
190 Ok(status) => match status.code() {
191 Some(code) => ChildEnd::Exited(code),
192 None => ChildEnd::Signalled,
195 },
196 Err(error) => ChildEnd::StatusUnreadable {
197 detail: error.to_string(),
198 },
199 }
200}
201
202#[async_trait]
203impl AgentSession for CliSession {
204 fn capabilities(&self) -> &InterventionCapabilities {
205 &self.capabilities
207 }
208
209 fn events(&mut self) -> BoxStream<'static, ActivityEvent> {
210 match self.events.take() {
212 Some(receiver) => Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new(
213 receiver,
214 )),
215 None => Box::pin(futures::stream::empty()),
216 }
217 }
218
219 async fn intervene(&self, cmd: InterventionCommand) -> Result<(), HarnessError> {
220 Err(HarnessError::capability_not_supported(format!(
223 "{:?}",
224 cmd.kind.primitive()
225 )))
226 }
227
228 async fn wait_result(mut self) -> Result<Payload, HarnessError> {
229 let receiver = self
230 .result
231 .take()
232 .ok_or_else(|| HarnessError::protocol("run result already awaited"))?;
233 let outcome = receiver.await.map_err(|_recv| {
234 HarnessError::transport("cli agent result channel closed before the run finished")
235 })?;
236 terminal_outcome_into_payload(outcome)
237 }
238}
239
240fn terminal_outcome_into_payload(outcome: TerminalOutcome) -> Result<Payload, HarnessError> {
258 let TerminalOutcome {
259 final_line,
260 end,
261 announced_stop,
262 } = outcome;
263 let stop = match end {
264 ChildEnd::Exited(code) if code != 0 => {
265 return Err(HarnessError::harness(format!(
266 "cli agent exited with a non-zero status: {code}"
267 )));
268 }
269 ChildEnd::StatusUnreadable { detail } => {
270 return Err(HarnessError::transport(format!(
271 "the cli agent's exit status could not be read, so the run has no honest \
272 terminal classification: {detail}"
273 )));
274 }
275 ChildEnd::Signalled => StopKind::Other {
276 reason: "terminated by signal".to_owned(),
277 },
278 ChildEnd::Exited(_) | ChildEnd::NoChild => announced_stop.unwrap_or(StopKind::EndTurn),
279 };
280 AgentOutcome::new(final_line.unwrap_or_default(), &stop)
281 .into_payload()
282 .map_err(|source| {
283 HarnessError::protocol(format!("cli agent result is not encodable: {source}"))
284 })
285}
286
287impl Drop for CliSession {
288 fn drop(&mut self) {
289 if let Some(pump) = self.pump.take() {
292 pump.abort();
293 }
294 if let Ok(mut guard) = self.child.try_lock()
295 && let Some(child) = guard.as_mut()
296 {
297 let _ = child.start_kill();
298 }
299 }
300}
301
302#[cfg(test)]
303#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
304mod tests {
305 use super::*;
310 use aion_core::{
311 ActivityEventKind, ActivityId, ContentType, InterventionKind, MessageRole, RunId,
312 WorkflowId,
313 };
314 use chrono::Utc;
315 use futures::StreamExt;
316 use serde_json::json;
317 use uuid::Uuid;
318
319 fn identity() -> EventIdentity {
320 EventIdentity {
321 workflow_id: WorkflowId::new(Uuid::nil()),
322 run_id: RunId::new(Uuid::from_u128(0x11)),
323 activity_id: ActivityId::from_sequence_position(2),
324 attempt: 1,
325 }
326 }
327
328 fn session_over(text: &str) -> CliSession {
331 let stdout = std::io::Cursor::new(text.as_bytes().to_vec());
332 CliSession::start(stdout, identity(), None)
333 }
334
335 #[tokio::test]
336 async fn capabilities_are_always_empty() {
337 let session = session_over("");
338 assert!(
339 session.capabilities().is_empty(),
340 "the observability-only session advertises no interventions"
341 );
342 }
343
344 #[tokio::test]
345 async fn stdout_lines_demux_into_a_live_event_stream() {
346 let stdout = "\
347[info] starting\n\
348{\"type\":\"message\",\"text\":\"hello\"}\n\
349{\"type\":\"stop\",\"reason\":\"end_turn\"}\n";
350 let mut session = session_over(stdout);
351 let events: Vec<ActivityEvent> = session.events().collect().await;
352
353 assert_eq!(events.len(), 3, "every stdout line becomes an event");
354 assert!(matches!(events[0].kind, ActivityEventKind::Raw { .. }));
356 match &events[1].kind {
357 ActivityEventKind::Message { role, text } => {
358 assert_eq!(*role, MessageRole::Assistant);
359 assert_eq!(text, "hello");
360 }
361 other => panic!("expected Message, got {other:?}"),
362 }
363 assert!(matches!(events[2].kind, ActivityEventKind::Stop { .. }));
364 assert_eq!(events[0].worker_seq, 0);
366 assert_eq!(events[2].worker_seq, 2);
367 }
368
369 #[tokio::test]
370 async fn intervene_rejects_every_command_as_capability_not_supported() {
371 let session = session_over("{\"type\":\"stop\",\"reason\":\"end_turn\"}\n");
372 for kind in [
373 InterventionKind::InjectMessage {
374 text: "steer".to_owned(),
375 priority: aion_core::InjectPriority::Interrupt,
376 },
377 InterventionKind::Cancel {
378 reason: "stop".to_owned(),
379 },
380 ] {
381 let cmd = InterventionCommand {
382 workflow_id: WorkflowId::new(Uuid::nil()),
383 run_id: RunId::new_v4(),
384 activity_id: ActivityId::from_sequence_position(2),
385 attempt: 1,
386 issued_by: None,
387 issued_at: Utc::now(),
388 kind,
389 };
390 let error = session.intervene(cmd).await.unwrap_err();
391 assert!(
392 matches!(error, HarnessError::CapabilityNotSupported { .. }),
393 "an observability-only session rejects every command, got {error:?}"
394 );
395 }
396 }
397
398 #[tokio::test]
399 async fn a_run_that_announced_no_stop_falls_back_to_end_turn() {
400 let stdout = "\
403{\"type\":\"message\",\"text\":\"thinking\"}\n\
404the final answer is 42\n";
405 let mut session = session_over(stdout);
406 let _events: Vec<ActivityEvent> = session.events().collect().await;
408 let payload = session.wait_result().await.unwrap();
409 assert_eq!(payload.content_type(), &ContentType::Json);
410 let decoded = payload.to_json().unwrap();
411 assert_eq!(
412 decoded,
413 json!({ "text": "the final answer is 42", "stop_reason": "end_turn" }),
414 "with no announced stop, a cleanly-ended run reports the end_turn fallback"
415 );
416 }
417
418 #[tokio::test]
419 async fn the_announced_stop_reason_reaches_the_outcome_record() {
420 let stdout = "\
423partial work\n\
424{\"type\":\"stop\",\"reason\":\"limit_reached\"}\n";
425 let mut session = session_over(stdout);
426 let _events: Vec<ActivityEvent> = session.events().collect().await;
427 let decoded = session.wait_result().await.unwrap().to_json().unwrap();
428 assert_eq!(
429 decoded["stop_reason"],
430 json!("limit_reached"),
431 "the demuxed stop reason is the record's stop_reason, never re-derived: {decoded}"
432 );
433 }
434
435 #[tokio::test]
436 async fn the_last_announced_stop_wins() {
437 let stdout = "\
439{\"type\":\"stop\",\"reason\":\"tool_use\"}\n\
440{\"type\":\"message\",\"text\":\"done now\"}\n\
441{\"type\":\"stop\",\"reason\":\"cancelled\"}\n";
442 let mut session = session_over(stdout);
443 let _events: Vec<ActivityEvent> = session.events().collect().await;
444 let decoded = session.wait_result().await.unwrap().to_json().unwrap();
445 assert_eq!(decoded["stop_reason"], json!("cancelled"));
446 }
447
448 #[tokio::test]
449 async fn an_event_is_never_the_result_the_two_are_separate_channels() {
450 let mut session = session_over("{\"type\":\"message\",\"text\":\"not the result\"}\n");
453 let events: Vec<ActivityEvent> = session.events().collect().await;
454 assert_eq!(events.len(), 1);
455 let payload = session.wait_result().await.unwrap();
456 let decoded = payload.to_json().unwrap();
458 assert_eq!(decoded["stop_reason"], json!("end_turn"));
459 assert_eq!(
460 decoded["text"],
461 json!("{\"type\":\"message\",\"text\":\"not the result\"}")
462 );
463 }
464}