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, how the child ended, and the last stop reason the agent announced) and
8//! hands it to [`AgentSession::wait_result`].
9//!
10//! The capability set is **always empty**: [`AgentSession::capabilities`] returns
11//! [`InterventionCapabilities::none`] and [`AgentSession::intervene`] rejects **every** command with
12//! [`HarnessError::CapabilityNotSupported`]. There is no control channel to route a command onto —
13//! the empty advertisement is a first-class tier, not a degenerate one, and the server never routes
14//! a command to a session that advertises nothing.
15
16use 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
30/// How the child process ended, kept honest by naming all three endings.
31///
32/// A flattened `Option<i32>` conflated two endings that must not share a fate:
33/// `None` used to mean BOTH "no child at all" (the in-memory reader path) and
34/// "terminated by a signal" — and a signal-killed agent fell through the
35/// non-zero-exit refusal into a result claiming the agent finished its turn.
36enum ChildEnd {
37    /// No child process exists — the session runs over a bare reader (tests,
38    /// in-memory drivers). End of stream is the only ending observable.
39    NoChild,
40    /// The child exited on its own, with this exit code.
41    Exited(i32),
42    /// The child was terminated by a signal: it did not end by its own choice.
43    Signalled,
44    /// The child's exit status could not be read at all (an OS-level `wait`
45    /// failure) — an ending that cannot honestly be classified as any of the
46    /// other three, carried so it refuses loudly instead of guessing.
47    StatusUnreadable {
48        /// The wait error as the OS reported it.
49        detail: String,
50    },
51}
52
53/// The terminal outcome the pump captures when stdout closes: the final line,
54/// how the child ended, and the last stop reason the agent itself announced.
55struct TerminalOutcome {
56    /// The last non-empty stdout line the agent printed, if any — its "final answer".
57    final_line: Option<String>,
58    /// How the child process ended.
59    end: ChildEnd,
60    /// The reason from the LAST `{"type":"stop",...}` line the demux produced,
61    /// if the run announced one — the agent's own account of why it stopped,
62    /// retained so the outcome record reports it instead of inventing one.
63    announced_stop: Option<StopKind>,
64}
65
66/// A live plain-stdout CLI agent run for one activity attempt.
67///
68/// Generic over the child's stdout read half so tests can drive it over an in-memory reader; in
69/// production it is the child process's stdout. The child handle is held so it is reaped on drop.
70pub struct CliSession {
71    capabilities: InterventionCapabilities,
72    events: Option<mpsc::UnboundedReceiver<ActivityEvent>>,
73    result: Option<oneshot::Receiver<TerminalOutcome>>,
74    pump: Option<tokio::task::JoinHandle<()>>,
75    /// The spawned child, reaped when the session is dropped. `None` for the in-memory test path.
76    child: Arc<Mutex<Option<tokio::process::Child>>>,
77}
78
79impl CliSession {
80    /// Builds a session over a child's stdout read half and starts the reader pump.
81    ///
82    /// `identity` stamps the run key onto every demuxed event. `child` (when present) is awaited by
83    /// the pump for the exit status and reaped on drop. The pump starts here so events stream from
84    /// the moment the session exists.
85    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
115/// The reader pump: demuxes every stdout line into an event until stdout closes, then captures the
116/// terminal outcome (final line + exit status) and sends it to the result waiter.
117///
118/// A send failure on the event channel means the consumer dropped the stream; the pump keeps
119/// reading so the terminal result is still captured, it just stops forwarding events.
120async 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                // The agent's own stop announcement is part of the terminal
141                // truth, not just transcript colour: the LAST one is retained
142                // so the outcome record can report the reason the demux
143                // already produced instead of inventing `end_turn`.
144                if let aion_core::ActivityEventKind::Stop { reason } = &event.kind {
145                    announced_stop = Some(reason.clone());
146                }
147                let _ = event_tx.send(event);
148            }
149            // End of stdout == end of run: capture the terminal outcome and stop.
150            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    // A signal death is an ending the agent could not announce, so the pump
160    // announces it: a synthesized terminal Stop event puts the detail
161    // ("terminated by signal") on the transcript — the one channel whose
162    // `StopKind::Other` carries a reason verbatim, where the outcome record's
163    // canonical vocabulary collapses it to `"other"`.
164    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    // A dropped result receiver (the caller gave up on wait_result) is benign.
175    let _ = result_tx.send(TerminalOutcome {
176        final_line,
177        end,
178        announced_stop,
179    });
180}
181
182/// Awaits the child's exit and reports how it ended — exit code, signal
183/// termination, or no child at all (the in-memory reader path).
184async 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            // On Unix an exit status without a code is a signal termination;
193            // it must never be mistaken for a clean exit.
194            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        // Always the empty set: observability-only, no control channel.
206        &self.capabilities
207    }
208
209    fn events(&mut self) -> BoxStream<'static, ActivityEvent> {
210        // The stream is taken once; a second call yields an empty stream rather than panicking.
211        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        // The empty capability set rejects EVERY command: there is no control channel at all. This
221        // is the first-class observability-only rejection, not an internal failure.
222        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
240/// Interprets the captured terminal outcome as the replay-authoritative [`Payload`].
241///
242/// A non-zero exit code is an application-level failure ([`HarnessError::Harness`]), and an
243/// unreadable exit status refuses rather than guesses. Every other ending yields the canonical
244/// [`AgentOutcome`] record — the same JSON result shape the worker captures from EVERY harness.
245/// `text` is the final stdout line (the agent's "answer"); a run that printed nothing yields the
246/// honest empty string, never an invented answer. `stop_reason` is the run's own account, through
247/// the crate's demux vocabulary:
248///
249/// - the LAST `{"type":"stop","reason":...}` line the agent printed, when it printed one — the
250///   reason the demux already produced, never re-derived;
251/// - [`StopKind::EndTurn`] ONLY when a cleanly-ended run announced no stop line at all
252///   (end-of-stdout with a zero exit, or the in-memory no-child path);
253/// - a signal-killed child is [`StopKind::Other`] (`terminated by signal`): the completion policy
254///   is unchanged — such a run completed before this and still does — but the record stops
255///   claiming the agent chose to end its turn, and it overrides any earlier announced stop
256///   because a stop line followed by a signal death was a mid-run announcement, not the ending.
257fn 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        // Abort the pump and best-effort kill the child so a dropped session leaks neither a task
290        // nor a process.
291        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    //! Fast unit tests of the session against an in-memory stdout reader — no real process. Canned
306    //! stdout lines feed the REAL pump + demux, so the session's demux-to-event path is exercised
307    //! end-to-end without spawning anything.
308
309    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    /// Build a session over an in-memory stdout containing `text` (no child to await, so the exit
329    /// code is `None` — a clean-exit result).
330    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        // A plain log line is Raw; the message and stop lines are mapped.
355        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        // Ordering is stamped monotonically.
365        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        // The fallback pin: NO `{"type":"stop"}` line anywhere in this stdout, so `end_turn`
401        // here is the documented ended-normally-saying-nothing fallback, not a retained reason.
402        let stdout = "\
403{\"type\":\"message\",\"text\":\"thinking\"}\n\
404the final answer is 42\n";
405        let mut session = session_over(stdout);
406        // Drain events so the pump reaches end-of-stdout and captures the terminal result.
407        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        // The discriminating case: the agent announces a NON-end_turn stop, and that exact
421        // reason must reach the record — a hardcoded `end_turn` fails here.
422        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        // A run that stops for tools and then ends its turn reports the LAST announcement.
438        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        // The events stream and the terminal result are distinct: a message event whose text looks
451        // like an answer is still only an event, and the result is captured separately.
452        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        // The result carries the final LINE, which is the message JSON line, not the extracted text.
457        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}