Skip to main content

aion_integration_cli/
session.rs

1//! [`CliSession`] — a live plain-stdout CLI agent run behind the [`AgentSession`] seam.
2//!
3//! This is the **observability-only** case (case (b), §3A.1): the agent has no structured control
4//! channel at all. One background **pump task** owns the child's stdout, reads it line by line,
5//! demuxes each line into a neutral [`ActivityEvent`] ([`crate::demux`]), and forwards it to the
6//! [`AgentSession::events`] stream. When stdout closes (end of run) it captures the terminal result
7//! (the final stdout line + exit status) and hands it to [`AgentSession::wait_result`].
8//!
9//! The capability set is **always empty**: [`AgentSession::capabilities`] returns
10//! [`InterventionCapabilities::none`] and [`AgentSession::intervene`] rejects **every** command with
11//! [`HarnessError::CapabilityNotSupported`]. There is no control channel to route a command onto —
12//! the empty advertisement is a first-class tier, not a degenerate one, and the server never routes
13//! a command to a session that advertises nothing.
14
15use 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
30/// The terminal outcome the pump captures when stdout closes: the final line and the exit code.
31struct TerminalOutcome {
32    /// The last non-empty stdout line the agent printed, if any — its "final answer".
33    final_line: Option<String>,
34    /// The child's exit code, when it exited with one (`None` on signal termination).
35    exit_code: Option<i32>,
36}
37
38/// A live plain-stdout CLI agent run for one activity attempt.
39///
40/// Generic over the child's stdout read half so tests can drive it over an in-memory reader; in
41/// production it is the child process's stdout. The child handle is held so it is reaped on drop.
42pub struct CliSession {
43    capabilities: InterventionCapabilities,
44    events: Option<mpsc::UnboundedReceiver<ActivityEvent>>,
45    result: Option<oneshot::Receiver<TerminalOutcome>>,
46    pump: Option<tokio::task::JoinHandle<()>>,
47    /// The spawned child, reaped when the session is dropped. `None` for the in-memory test path.
48    child: Arc<Mutex<Option<tokio::process::Child>>>,
49}
50
51impl CliSession {
52    /// Builds a session over a child's stdout read half and starts the reader pump.
53    ///
54    /// `identity` stamps the run key onto every demuxed event. `child` (when present) is awaited by
55    /// the pump for the exit status and reaped on drop. The pump starts here so events stream from
56    /// the moment the session exists.
57    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
87/// The reader pump: demuxes every stdout line into an event until stdout closes, then captures the
88/// terminal outcome (final line + exit status) and sends it to the result waiter.
89///
90/// A send failure on the event channel means the consumer dropped the stream; the pump keeps
91/// reading so the terminal result is still captured, it just stops forwarding events.
92async 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            // End of stdout == end of run: capture the terminal outcome and stop.
114            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    // A dropped result receiver (the caller gave up on wait_result) is benign.
124    let _ = result_tx.send(TerminalOutcome {
125        final_line,
126        exit_code,
127    });
128}
129
130/// Awaits the child's exit and returns its exit code (`None` on signal termination or no child).
131async 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        // Always the empty set: observability-only, no control channel.
143        &self.capabilities
144    }
145
146    fn events(&mut self) -> BoxStream<'static, ActivityEvent> {
147        // The stream is taken once; a second call yields an empty stream rather than panicking.
148        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        // The empty capability set rejects EVERY command: there is no control channel at all. This
158        // is the first-class observability-only rejection, not an internal failure.
159        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
177/// Interprets the captured terminal outcome as the replay-authoritative [`Payload`].
178///
179/// A non-zero exit code is an application-level failure ([`HarnessError::Harness`]); a clean exit
180/// yields a JSON payload carrying the final stdout line (the agent's "answer") and the exit code —
181/// the same [`ContentType::Json`] result shape the worker captures from any harness.
182fn 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        // Abort the pump and best-effort kill the child so a dropped session leaks neither a task
208        // nor a process.
209        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    //! Fast unit tests of the session against an in-memory stdout reader — no real process. Canned
224    //! stdout lines feed the REAL pump + demux, so the session's demux-to-event path is exercised
225    //! end-to-end without spawning anything.
226
227    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    /// Build a session over an in-memory stdout containing `text` (no child to await, so the exit
245    /// code is `None` — a clean-exit result).
246    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        // A plain log line is Raw; the message and stop lines are mapped.
271        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        // Ordering is stamped monotonically.
281        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        // Drain events so the pump reaches end-of-stdout and captures the terminal result.
321        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        // The events stream and the terminal result are distinct: a message event whose text looks
332        // like an answer is still only an event, and the result is captured separately.
333        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        // The result carries the final LINE, which is the message JSON line, not the extracted text.
338        let decoded = payload.to_json().unwrap();
339        assert_eq!(decoded["result"], json!("completed"));
340    }
341}