#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::time::Duration;
use aion_integration_cli::CliHarness;
use aion_integrations::{
ActivityEventKind, ActivityId, AgentHarness, AgentRunSpec, AgentSession, ContentType,
InterventionCommand, InterventionKind, Payload, RunId, WorkflowId,
};
use chrono::Utc;
use futures::StreamExt;
const FAKE_AGENT_SCRIPT: &str = "\
printf '%s\\n' '[info] starting the run';\
printf '%s\\n' '{\"type\":\"message\",\"text\":\"hello from the fake cli agent\"}';\
printf '%s\\n' '{\"type\":\"tool_call\",\"tool\":\"search\",\"call_id\":\"c1\",\"input\":{\"q\":\"x\"}}';\
printf '%s\\n' '{\"type\":\"tool_result\",\"call_id\":\"c1\",\"output\":{\"hits\":2}}';\
printf '%s\\n' '{\"type\":\"stop\",\"reason\":\"tool_use\"}';\
printf '%s\\n' 'the final answer is 42';\
exit 0";
fn fake_cli_harness() -> CliHarness {
CliHarness::new("sh")
.with_arg("-c")
.with_arg(FAKE_AGENT_SCRIPT)
}
fn spec() -> AgentRunSpec {
AgentRunSpec::new(
WorkflowId::new_v4(),
RunId::new_v4(),
ActivityId::from_sequence_position(1),
1,
"cli-activity",
Payload::new(ContentType::Json, b"run the task".to_vec()),
)
}
fn expected_session_id(spec: &AgentRunSpec) -> String {
format!(
"{}:{}:{}:{}",
spec.workflow_id, spec.run_id, spec.activity_id, spec.attempt
)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cli_harness_streams_a_live_transcript_with_no_controls() {
let harness = fake_cli_harness();
let spec = spec();
let expected_sid = expected_session_id(&spec);
let mut session = harness.start(spec).await.expect("spawn the fake cli agent");
assert!(
session.capabilities().is_empty(),
"the observability-only harness offers NO intervention controls"
);
let cmd = InterventionCommand {
workflow_id: WorkflowId::new_v4(),
run_id: RunId::new_v4(),
activity_id: ActivityId::from_sequence_position(1),
attempt: 1,
issued_by: None,
issued_at: Utc::now(),
kind: InterventionKind::Cancel {
reason: "try to cancel".to_owned(),
},
};
let rejected = session.intervene(cmd).await;
assert!(
matches!(
rejected,
Err(aion_integrations::HarnessError::CapabilityNotSupported { .. })
),
"an observability-only session rejects every command, got {rejected:?}"
);
let mut events = session.events();
let collected = tokio::time::timeout(Duration::from_secs(30), async {
let mut out = Vec::new();
while let Some(event) = events.next().await {
let is_stop = matches!(event.kind, ActivityEventKind::Stop { .. });
out.push(event);
if is_stop {
if let Ok(Some(tail)) =
tokio::time::timeout(Duration::from_secs(2), events.next()).await
{
out.push(tail);
}
break;
}
}
out
})
.await
.expect("the transcript streams within the timeout");
drop(events);
assert!(
collected
.iter()
.any(|e| matches!(e.kind, ActivityEventKind::Raw { .. })),
"the plain log line demuxes to Raw"
);
assert!(
collected
.iter()
.any(|e| matches!(e.kind, ActivityEventKind::Message { .. })),
"the JSON message line demuxes to Message"
);
assert!(
collected
.iter()
.any(|e| matches!(e.kind, ActivityEventKind::ToolCall { .. })),
"the JSON tool_call line demuxes to ToolCall"
);
assert!(
collected
.iter()
.any(|e| matches!(e.kind, ActivityEventKind::ToolResult { .. })),
"the JSON tool_result line demuxes to ToolResult"
);
assert!(
collected
.iter()
.any(|e| matches!(e.kind, ActivityEventKind::Stop { .. })),
"the JSON stop line demuxes to Stop"
);
let payload = tokio::time::timeout(Duration::from_secs(30), session.wait_result())
.await
.expect("wait_result resolves")
.expect("a clean exit yields a result payload");
assert_eq!(payload.content_type(), &ContentType::Json);
let decoded = payload.to_json().expect("the result is JSON");
assert_eq!(
decoded,
serde_json::json!({
"text": "the final answer is 42",
"final_message": "the final answer is 42",
"stop_reason": "tool_use",
"session_id": expected_sid,
}),
"the record carries the demuxed stop reason, not an invented end_turn, \
and the identity-derived session handle"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_signal_killed_agent_is_not_reported_as_choosing_to_stop() {
let harness = CliHarness::new("sh")
.with_arg("-c")
.with_arg("printf '%s\\n' 'started'; kill -TERM $$; sleep 10");
let spec = spec();
let expected_sid = expected_session_id(&spec);
let mut session = harness.start(spec).await.expect("spawn the fake cli agent");
let events: Vec<_> = session.events().collect().await;
let synthesized = events.iter().any(|event| {
matches!(
&event.kind,
ActivityEventKind::Stop {
reason: aion_integrations::StopKind::Other { reason }
} if reason == "terminated by signal"
)
});
assert!(
synthesized,
"the transcript must carry the synthesized signal-death Stop event; got {events:?}"
);
let payload = tokio::time::timeout(Duration::from_secs(30), session.wait_result())
.await
.expect("wait_result resolves")
.expect("a signal-killed run still completes (policy unchanged)");
let decoded = payload.to_json().expect("the result is JSON");
assert_eq!(
decoded,
serde_json::json!({
"text": "started",
"final_message": "started",
"stop_reason": "other",
"session_id": expected_sid,
}),
"a signal termination is `other`, and the last line still travels as text"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cli_harness_reports_a_non_zero_exit_as_a_harness_failure() {
let harness = CliHarness::new("sh")
.with_arg("-c")
.with_arg("printf '%s\\n' '{\"type\":\"message\",\"text\":\"boom\"}'; exit 3");
let mut session = harness
.start(spec())
.await
.expect("spawn the fake cli agent");
let _events: Vec<_> = session.events().collect().await;
let result = tokio::time::timeout(Duration::from_secs(30), session.wait_result())
.await
.expect("wait_result resolves");
assert!(
matches!(result, Err(aion_integrations::HarnessError::Harness { .. })),
"a non-zero exit surfaces as a harness failure, got {result:?}"
);
}