use std::sync::Arc;
use aion_core::{
ActivityEvent, AgentOutcome, InterventionCapabilities, InterventionCommand, Payload, StopKind,
};
use aion_integrations::contract::AgentSession;
use aion_integrations::error::HarnessError;
use async_trait::async_trait;
use futures::stream::BoxStream;
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader, Lines};
use tokio::sync::{Mutex, mpsc, oneshot};
use crate::demux::{self, EventIdentity};
enum ChildEnd {
NoChild,
Exited(i32),
Signalled,
StatusUnreadable {
detail: String,
},
}
struct TerminalOutcome {
final_line: Option<String>,
end: ChildEnd,
announced_stop: Option<StopKind>,
}
pub struct CliSession {
capabilities: InterventionCapabilities,
events: Option<mpsc::UnboundedReceiver<ActivityEvent>>,
result: Option<oneshot::Receiver<TerminalOutcome>>,
pump: Option<tokio::task::JoinHandle<()>>,
child: Arc<Mutex<Option<tokio::process::Child>>>,
}
impl CliSession {
pub(crate) fn start<R>(
stdout: R,
identity: EventIdentity,
child: Option<tokio::process::Child>,
) -> Self
where
R: AsyncRead + Unpin + Send + 'static,
{
let (event_tx, event_rx) = mpsc::unbounded_channel();
let (result_tx, result_rx) = oneshot::channel();
let child = Arc::new(Mutex::new(child));
let pump = tokio::spawn(pump_loop(
BufReader::new(stdout).lines(),
identity,
event_tx,
result_tx,
Arc::clone(&child),
));
Self {
capabilities: InterventionCapabilities::none(),
events: Some(event_rx),
result: Some(result_rx),
pump: Some(pump),
child,
}
}
}
async fn pump_loop<R>(
mut lines: Lines<BufReader<R>>,
identity: EventIdentity,
event_tx: mpsc::UnboundedSender<ActivityEvent>,
result_tx: oneshot::Sender<TerminalOutcome>,
child: Arc<Mutex<Option<tokio::process::Child>>>,
) where
R: AsyncRead + Unpin + Send,
{
let mut worker_seq: u64 = 0;
let mut final_line: Option<String> = None;
let mut announced_stop: Option<StopKind> = None;
loop {
match lines.next_line().await {
Ok(Some(line)) => {
if !line.trim().is_empty() {
final_line = Some(line.clone());
}
let event = demux::line_to_event(&identity, worker_seq, &line);
worker_seq = worker_seq.saturating_add(1);
if let aion_core::ActivityEventKind::Stop { reason } = &event.kind {
announced_stop = Some(reason.clone());
}
let _ = event_tx.send(event);
}
Ok(None) => break,
Err(error) => {
tracing::debug!(%error, "cli adapter: reader pump ended on a stdout read error");
break;
}
}
}
let end = wait_child_end(&child).await;
if matches!(end, ChildEnd::Signalled) {
let stop = demux::synthesized_stop_event(
&identity,
worker_seq,
StopKind::Other {
reason: "terminated by signal".to_owned(),
},
);
let _ = event_tx.send(stop);
}
let _ = result_tx.send(TerminalOutcome {
final_line,
end,
announced_stop,
});
}
async fn wait_child_end(child: &Arc<Mutex<Option<tokio::process::Child>>>) -> ChildEnd {
let mut guard = child.lock().await;
let Some(child) = guard.as_mut() else {
return ChildEnd::NoChild;
};
match child.wait().await {
Ok(status) => match status.code() {
Some(code) => ChildEnd::Exited(code),
None => ChildEnd::Signalled,
},
Err(error) => ChildEnd::StatusUnreadable {
detail: error.to_string(),
},
}
}
#[async_trait]
impl AgentSession for CliSession {
fn capabilities(&self) -> &InterventionCapabilities {
&self.capabilities
}
fn events(&mut self) -> BoxStream<'static, ActivityEvent> {
match self.events.take() {
Some(receiver) => Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new(
receiver,
)),
None => Box::pin(futures::stream::empty()),
}
}
async fn intervene(&self, cmd: InterventionCommand) -> Result<(), HarnessError> {
Err(HarnessError::capability_not_supported(format!(
"{:?}",
cmd.kind.primitive()
)))
}
async fn wait_result(mut self) -> Result<Payload, HarnessError> {
let receiver = self
.result
.take()
.ok_or_else(|| HarnessError::protocol("run result already awaited"))?;
let outcome = receiver.await.map_err(|_recv| {
HarnessError::transport("cli agent result channel closed before the run finished")
})?;
terminal_outcome_into_payload(outcome)
}
}
fn terminal_outcome_into_payload(outcome: TerminalOutcome) -> Result<Payload, HarnessError> {
let TerminalOutcome {
final_line,
end,
announced_stop,
} = outcome;
let stop = match end {
ChildEnd::Exited(code) if code != 0 => {
return Err(HarnessError::harness(format!(
"cli agent exited with a non-zero status: {code}"
)));
}
ChildEnd::StatusUnreadable { detail } => {
return Err(HarnessError::transport(format!(
"the cli agent's exit status could not be read, so the run has no honest \
terminal classification: {detail}"
)));
}
ChildEnd::Signalled => StopKind::Other {
reason: "terminated by signal".to_owned(),
},
ChildEnd::Exited(_) | ChildEnd::NoChild => announced_stop.unwrap_or(StopKind::EndTurn),
};
AgentOutcome::new(final_line.unwrap_or_default(), &stop)
.into_payload()
.map_err(|source| {
HarnessError::protocol(format!("cli agent result is not encodable: {source}"))
})
}
impl Drop for CliSession {
fn drop(&mut self) {
if let Some(pump) = self.pump.take() {
pump.abort();
}
if let Ok(mut guard) = self.child.try_lock()
&& let Some(child) = guard.as_mut()
{
let _ = child.start_kill();
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use aion_core::{
ActivityEventKind, ActivityId, ContentType, InterventionKind, MessageRole, RunId,
WorkflowId,
};
use chrono::Utc;
use futures::StreamExt;
use serde_json::json;
use uuid::Uuid;
fn identity() -> EventIdentity {
EventIdentity {
workflow_id: WorkflowId::new(Uuid::nil()),
run_id: RunId::new(Uuid::from_u128(0x11)),
activity_id: ActivityId::from_sequence_position(2),
attempt: 1,
}
}
fn session_over(text: &str) -> CliSession {
let stdout = std::io::Cursor::new(text.as_bytes().to_vec());
CliSession::start(stdout, identity(), None)
}
#[tokio::test]
async fn capabilities_are_always_empty() {
let session = session_over("");
assert!(
session.capabilities().is_empty(),
"the observability-only session advertises no interventions"
);
}
#[tokio::test]
async fn stdout_lines_demux_into_a_live_event_stream() {
let stdout = "\
[info] starting\n\
{\"type\":\"message\",\"text\":\"hello\"}\n\
{\"type\":\"stop\",\"reason\":\"end_turn\"}\n";
let mut session = session_over(stdout);
let events: Vec<ActivityEvent> = session.events().collect().await;
assert_eq!(events.len(), 3, "every stdout line becomes an event");
assert!(matches!(events[0].kind, ActivityEventKind::Raw { .. }));
match &events[1].kind {
ActivityEventKind::Message { role, text } => {
assert_eq!(*role, MessageRole::Assistant);
assert_eq!(text, "hello");
}
other => panic!("expected Message, got {other:?}"),
}
assert!(matches!(events[2].kind, ActivityEventKind::Stop { .. }));
assert_eq!(events[0].worker_seq, 0);
assert_eq!(events[2].worker_seq, 2);
}
#[tokio::test]
async fn intervene_rejects_every_command_as_capability_not_supported() {
let session = session_over("{\"type\":\"stop\",\"reason\":\"end_turn\"}\n");
for kind in [
InterventionKind::InjectMessage {
text: "steer".to_owned(),
priority: aion_core::InjectPriority::Interrupt,
},
InterventionKind::Cancel {
reason: "stop".to_owned(),
},
] {
let cmd = InterventionCommand {
workflow_id: WorkflowId::new(Uuid::nil()),
run_id: RunId::new_v4(),
activity_id: ActivityId::from_sequence_position(2),
attempt: 1,
issued_by: None,
issued_at: Utc::now(),
kind,
};
let error = session.intervene(cmd).await.unwrap_err();
assert!(
matches!(error, HarnessError::CapabilityNotSupported { .. }),
"an observability-only session rejects every command, got {error:?}"
);
}
}
#[tokio::test]
async fn a_run_that_announced_no_stop_falls_back_to_end_turn() {
let stdout = "\
{\"type\":\"message\",\"text\":\"thinking\"}\n\
the final answer is 42\n";
let mut session = session_over(stdout);
let _events: Vec<ActivityEvent> = session.events().collect().await;
let payload = session.wait_result().await.unwrap();
assert_eq!(payload.content_type(), &ContentType::Json);
let decoded = payload.to_json().unwrap();
assert_eq!(
decoded,
json!({ "text": "the final answer is 42", "stop_reason": "end_turn" }),
"with no announced stop, a cleanly-ended run reports the end_turn fallback"
);
}
#[tokio::test]
async fn the_announced_stop_reason_reaches_the_outcome_record() {
let stdout = "\
partial work\n\
{\"type\":\"stop\",\"reason\":\"limit_reached\"}\n";
let mut session = session_over(stdout);
let _events: Vec<ActivityEvent> = session.events().collect().await;
let decoded = session.wait_result().await.unwrap().to_json().unwrap();
assert_eq!(
decoded["stop_reason"],
json!("limit_reached"),
"the demuxed stop reason is the record's stop_reason, never re-derived: {decoded}"
);
}
#[tokio::test]
async fn the_last_announced_stop_wins() {
let stdout = "\
{\"type\":\"stop\",\"reason\":\"tool_use\"}\n\
{\"type\":\"message\",\"text\":\"done now\"}\n\
{\"type\":\"stop\",\"reason\":\"cancelled\"}\n";
let mut session = session_over(stdout);
let _events: Vec<ActivityEvent> = session.events().collect().await;
let decoded = session.wait_result().await.unwrap().to_json().unwrap();
assert_eq!(decoded["stop_reason"], json!("cancelled"));
}
#[tokio::test]
async fn an_event_is_never_the_result_the_two_are_separate_channels() {
let mut session = session_over("{\"type\":\"message\",\"text\":\"not the result\"}\n");
let events: Vec<ActivityEvent> = session.events().collect().await;
assert_eq!(events.len(), 1);
let payload = session.wait_result().await.unwrap();
let decoded = payload.to_json().unwrap();
assert_eq!(decoded["stop_reason"], json!("end_turn"));
assert_eq!(
decoded["text"],
json!("{\"type\":\"message\",\"text\":\"not the result\"}")
);
}
}