1use std::sync::Arc;
16
17use aion_core::{
18 ActivityEvent, ContentType, InterventionCapabilities, InterventionCommand, Payload,
19};
20use aion_integrations::contract::AgentSession;
21use aion_integrations::error::HarnessError;
22use async_trait::async_trait;
23use futures::stream::BoxStream;
24use serde_json::json;
25use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader, Lines};
26use tokio::sync::{Mutex, mpsc, oneshot};
27
28use crate::demux::{self, EventIdentity};
29
30struct TerminalOutcome {
32 final_line: Option<String>,
34 exit_code: Option<i32>,
36}
37
38pub struct CliSession {
43 capabilities: InterventionCapabilities,
44 events: Option<mpsc::UnboundedReceiver<ActivityEvent>>,
45 result: Option<oneshot::Receiver<TerminalOutcome>>,
46 pump: Option<tokio::task::JoinHandle<()>>,
47 child: Arc<Mutex<Option<tokio::process::Child>>>,
49}
50
51impl CliSession {
52 pub(crate) fn start<R>(
58 stdout: R,
59 identity: EventIdentity,
60 child: Option<tokio::process::Child>,
61 ) -> Self
62 where
63 R: AsyncRead + Unpin + Send + 'static,
64 {
65 let (event_tx, event_rx) = mpsc::unbounded_channel();
66 let (result_tx, result_rx) = oneshot::channel();
67 let child = Arc::new(Mutex::new(child));
68
69 let pump = tokio::spawn(pump_loop(
70 BufReader::new(stdout).lines(),
71 identity,
72 event_tx,
73 result_tx,
74 Arc::clone(&child),
75 ));
76
77 Self {
78 capabilities: InterventionCapabilities::none(),
79 events: Some(event_rx),
80 result: Some(result_rx),
81 pump: Some(pump),
82 child,
83 }
84 }
85}
86
87async fn pump_loop<R>(
93 mut lines: Lines<BufReader<R>>,
94 identity: EventIdentity,
95 event_tx: mpsc::UnboundedSender<ActivityEvent>,
96 result_tx: oneshot::Sender<TerminalOutcome>,
97 child: Arc<Mutex<Option<tokio::process::Child>>>,
98) where
99 R: AsyncRead + Unpin + Send,
100{
101 let mut worker_seq: u64 = 0;
102 let mut final_line: Option<String> = None;
103 loop {
104 match lines.next_line().await {
105 Ok(Some(line)) => {
106 if !line.trim().is_empty() {
107 final_line = Some(line.clone());
108 }
109 let event = demux::line_to_event(&identity, worker_seq, &line);
110 worker_seq = worker_seq.saturating_add(1);
111 let _ = event_tx.send(event);
112 }
113 Ok(None) => break,
115 Err(error) => {
116 tracing::debug!(%error, "cli adapter: reader pump ended on a stdout read error");
117 break;
118 }
119 }
120 }
121
122 let exit_code = wait_exit_code(&child).await;
123 let _ = result_tx.send(TerminalOutcome {
125 final_line,
126 exit_code,
127 });
128}
129
130async fn wait_exit_code(child: &Arc<Mutex<Option<tokio::process::Child>>>) -> Option<i32> {
132 let mut guard = child.lock().await;
133 match guard.as_mut() {
134 Some(child) => child.wait().await.ok().and_then(|status| status.code()),
135 None => None,
136 }
137}
138
139#[async_trait]
140impl AgentSession for CliSession {
141 fn capabilities(&self) -> &InterventionCapabilities {
142 &self.capabilities
144 }
145
146 fn events(&mut self) -> BoxStream<'static, ActivityEvent> {
147 match self.events.take() {
149 Some(receiver) => Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new(
150 receiver,
151 )),
152 None => Box::pin(futures::stream::empty()),
153 }
154 }
155
156 async fn intervene(&self, cmd: InterventionCommand) -> Result<(), HarnessError> {
157 Err(HarnessError::capability_not_supported(format!(
160 "{:?}",
161 cmd.kind.primitive()
162 )))
163 }
164
165 async fn wait_result(mut self) -> Result<Payload, HarnessError> {
166 let receiver = self
167 .result
168 .take()
169 .ok_or_else(|| HarnessError::protocol("run result already awaited"))?;
170 let outcome = receiver.await.map_err(|_recv| {
171 HarnessError::transport("cli agent result channel closed before the run finished")
172 })?;
173 terminal_outcome_into_payload(outcome)
174 }
175}
176
177fn terminal_outcome_into_payload(outcome: TerminalOutcome) -> Result<Payload, HarnessError> {
183 let TerminalOutcome {
184 final_line,
185 exit_code,
186 } = outcome;
187 if let Some(code) = exit_code
188 && code != 0
189 {
190 return Err(HarnessError::harness(format!(
191 "cli agent exited with a non-zero status: {code}"
192 )));
193 }
194 let output = json!({
195 "result": "completed",
196 "output": final_line,
197 "exit_code": exit_code,
198 });
199 let bytes = serde_json::to_vec(&output).map_err(|source| {
200 HarnessError::protocol(format!("cli agent result is not encodable: {source}"))
201 })?;
202 Ok(Payload::new(ContentType::Json, bytes))
203}
204
205impl Drop for CliSession {
206 fn drop(&mut self) {
207 if let Some(pump) = self.pump.take() {
210 pump.abort();
211 }
212 if let Ok(mut guard) = self.child.try_lock()
213 && let Some(child) = guard.as_mut()
214 {
215 let _ = child.start_kill();
216 }
217 }
218}
219
220#[cfg(test)]
221#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
222mod tests {
223 use super::*;
228 use aion_core::{ActivityEventKind, ActivityId, InterventionKind, MessageRole, WorkflowId};
229 use chrono::Utc;
230 use futures::StreamExt;
231 use uuid::Uuid;
232
233 fn identity() -> EventIdentity {
234 EventIdentity {
235 workflow_id: WorkflowId::new(Uuid::nil()),
236 activity_id: ActivityId::from_sequence_position(2),
237 attempt: 1,
238 }
239 }
240
241 fn session_over(text: &str) -> CliSession {
244 let stdout = std::io::Cursor::new(text.as_bytes().to_vec());
245 CliSession::start(stdout, identity(), None)
246 }
247
248 #[tokio::test]
249 async fn capabilities_are_always_empty() {
250 let session = session_over("");
251 assert!(
252 session.capabilities().is_empty(),
253 "the observability-only session advertises no interventions"
254 );
255 }
256
257 #[tokio::test]
258 async fn stdout_lines_demux_into_a_live_event_stream() {
259 let stdout = "\
260[info] starting\n\
261{\"type\":\"message\",\"text\":\"hello\"}\n\
262{\"type\":\"stop\",\"reason\":\"end_turn\"}\n";
263 let mut session = session_over(stdout);
264 let events: Vec<ActivityEvent> = session.events().collect().await;
265
266 assert_eq!(events.len(), 3, "every stdout line becomes an event");
267 assert!(matches!(events[0].kind, ActivityEventKind::Raw { .. }));
269 match &events[1].kind {
270 ActivityEventKind::Message { role, text } => {
271 assert_eq!(*role, MessageRole::Assistant);
272 assert_eq!(text, "hello");
273 }
274 other => panic!("expected Message, got {other:?}"),
275 }
276 assert!(matches!(events[2].kind, ActivityEventKind::Stop { .. }));
277 assert_eq!(events[0].worker_seq, 0);
279 assert_eq!(events[2].worker_seq, 2);
280 }
281
282 #[tokio::test]
283 async fn intervene_rejects_every_command_as_capability_not_supported() {
284 let session = session_over("{\"type\":\"stop\",\"reason\":\"end_turn\"}\n");
285 for kind in [
286 InterventionKind::InjectMessage {
287 text: "steer".to_owned(),
288 priority: aion_core::InjectPriority::Interrupt,
289 },
290 InterventionKind::Cancel {
291 reason: "stop".to_owned(),
292 },
293 ] {
294 let cmd = InterventionCommand {
295 workflow_id: WorkflowId::new(Uuid::nil()),
296 activity_id: ActivityId::from_sequence_position(2),
297 attempt: 1,
298 issued_by: None,
299 issued_at: Utc::now(),
300 kind,
301 };
302 let error = session.intervene(cmd).await.unwrap_err();
303 assert!(
304 matches!(error, HarnessError::CapabilityNotSupported { .. }),
305 "an observability-only session rejects every command, got {error:?}"
306 );
307 }
308 }
309
310 #[tokio::test]
311 async fn wait_result_captures_the_final_line_as_the_payload() {
312 let stdout = "\
313{\"type\":\"message\",\"text\":\"thinking\"}\n\
314the final answer is 42\n";
315 let mut session = session_over(stdout);
316 let _events: Vec<ActivityEvent> = session.events().collect().await;
318 let payload = session.wait_result().await.unwrap();
319 assert_eq!(payload.content_type(), &ContentType::Json);
320 let decoded = payload.to_json().unwrap();
321 assert_eq!(decoded["result"], json!("completed"));
322 assert_eq!(decoded["output"], json!("the final answer is 42"));
323 }
324
325 #[tokio::test]
326 async fn an_event_is_never_the_result_the_two_are_separate_channels() {
327 let mut session = session_over("{\"type\":\"message\",\"text\":\"not the result\"}\n");
330 let events: Vec<ActivityEvent> = session.events().collect().await;
331 assert_eq!(events.len(), 1);
332 let payload = session.wait_result().await.unwrap();
333 let decoded = payload.to_json().unwrap();
335 assert_eq!(decoded["result"], json!("completed"));
336 }
337}