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::{
229 ActivityEventKind, ActivityId, InterventionKind, MessageRole, RunId, WorkflowId,
230 };
231 use chrono::Utc;
232 use futures::StreamExt;
233 use uuid::Uuid;
234
235 fn identity() -> EventIdentity {
236 EventIdentity {
237 workflow_id: WorkflowId::new(Uuid::nil()),
238 run_id: RunId::new(Uuid::from_u128(0x11)),
239 activity_id: ActivityId::from_sequence_position(2),
240 attempt: 1,
241 }
242 }
243
244 fn session_over(text: &str) -> CliSession {
247 let stdout = std::io::Cursor::new(text.as_bytes().to_vec());
248 CliSession::start(stdout, identity(), None)
249 }
250
251 #[tokio::test]
252 async fn capabilities_are_always_empty() {
253 let session = session_over("");
254 assert!(
255 session.capabilities().is_empty(),
256 "the observability-only session advertises no interventions"
257 );
258 }
259
260 #[tokio::test]
261 async fn stdout_lines_demux_into_a_live_event_stream() {
262 let stdout = "\
263[info] starting\n\
264{\"type\":\"message\",\"text\":\"hello\"}\n\
265{\"type\":\"stop\",\"reason\":\"end_turn\"}\n";
266 let mut session = session_over(stdout);
267 let events: Vec<ActivityEvent> = session.events().collect().await;
268
269 assert_eq!(events.len(), 3, "every stdout line becomes an event");
270 assert!(matches!(events[0].kind, ActivityEventKind::Raw { .. }));
272 match &events[1].kind {
273 ActivityEventKind::Message { role, text } => {
274 assert_eq!(*role, MessageRole::Assistant);
275 assert_eq!(text, "hello");
276 }
277 other => panic!("expected Message, got {other:?}"),
278 }
279 assert!(matches!(events[2].kind, ActivityEventKind::Stop { .. }));
280 assert_eq!(events[0].worker_seq, 0);
282 assert_eq!(events[2].worker_seq, 2);
283 }
284
285 #[tokio::test]
286 async fn intervene_rejects_every_command_as_capability_not_supported() {
287 let session = session_over("{\"type\":\"stop\",\"reason\":\"end_turn\"}\n");
288 for kind in [
289 InterventionKind::InjectMessage {
290 text: "steer".to_owned(),
291 priority: aion_core::InjectPriority::Interrupt,
292 },
293 InterventionKind::Cancel {
294 reason: "stop".to_owned(),
295 },
296 ] {
297 let cmd = InterventionCommand {
298 workflow_id: WorkflowId::new(Uuid::nil()),
299 run_id: RunId::new_v4(),
300 activity_id: ActivityId::from_sequence_position(2),
301 attempt: 1,
302 issued_by: None,
303 issued_at: Utc::now(),
304 kind,
305 };
306 let error = session.intervene(cmd).await.unwrap_err();
307 assert!(
308 matches!(error, HarnessError::CapabilityNotSupported { .. }),
309 "an observability-only session rejects every command, got {error:?}"
310 );
311 }
312 }
313
314 #[tokio::test]
315 async fn wait_result_captures_the_final_line_as_the_payload() {
316 let stdout = "\
317{\"type\":\"message\",\"text\":\"thinking\"}\n\
318the final answer is 42\n";
319 let mut session = session_over(stdout);
320 let _events: Vec<ActivityEvent> = session.events().collect().await;
322 let payload = session.wait_result().await.unwrap();
323 assert_eq!(payload.content_type(), &ContentType::Json);
324 let decoded = payload.to_json().unwrap();
325 assert_eq!(decoded["result"], json!("completed"));
326 assert_eq!(decoded["output"], json!("the final answer is 42"));
327 }
328
329 #[tokio::test]
330 async fn an_event_is_never_the_result_the_two_are_separate_channels() {
331 let mut session = session_over("{\"type\":\"message\",\"text\":\"not the result\"}\n");
334 let events: Vec<ActivityEvent> = session.events().collect().await;
335 assert_eq!(events.len(), 1);
336 let payload = session.wait_result().await.unwrap();
337 let decoded = payload.to_json().unwrap();
339 assert_eq!(decoded["result"], json!("completed"));
340 }
341}