1use std::sync::Arc;
20use std::sync::Mutex;
21
22use aion_core::{
23 ActivityEvent, ActivityEventKind, AgentOutcome, InterventionCapabilities, InterventionCommand,
24 InterventionKind, InterventionPrimitive, MessageRole, Payload, StopKind,
25};
26use aion_integrations::contract::{AgentHarness, AgentSession};
27use aion_integrations::error::HarnessError;
28use aion_integrations::spec::AgentRunSpec;
29use async_trait::async_trait;
30use chrono::Utc;
31use futures::stream::BoxStream;
32use tokio::sync::mpsc;
33use uuid::Uuid;
34
35pub type AppliedLog = Arc<Mutex<Vec<InterventionKind>>>;
37
38pub struct RunHandle {
46 keep_alive: mpsc::UnboundedSender<ActivityEvent>,
47}
48
49impl RunHandle {
50 pub fn end_run(self) {
52 drop(self.keep_alive);
53 }
54}
55
56#[derive(Clone)]
63pub struct MockAgentHarness {
64 applied: AppliedLog,
65 run_handle: Arc<Mutex<Option<RunHandle>>>,
66}
67
68impl Default for MockAgentHarness {
69 fn default() -> Self {
70 Self::new()
71 }
72}
73
74impl MockAgentHarness {
75 #[must_use]
77 pub fn new() -> Self {
78 Self {
79 applied: Arc::new(Mutex::new(Vec::new())),
80 run_handle: Arc::new(Mutex::new(None)),
81 }
82 }
83
84 #[must_use]
86 pub fn applied(&self) -> AppliedLog {
87 Arc::clone(&self.applied)
88 }
89
90 #[must_use]
96 pub fn take_run_handle(&self) -> Option<RunHandle> {
97 self.run_handle
98 .lock()
99 .ok()
100 .and_then(|mut guard| guard.take())
101 }
102
103 #[must_use]
107 pub fn advertised_capabilities() -> InterventionCapabilities {
108 InterventionCapabilities::from_primitives([
109 InterventionPrimitive::InjectMessage,
110 InterventionPrimitive::Cancel,
111 ])
112 }
113}
114
115pub struct MockAgentSession {
119 capabilities: InterventionCapabilities,
120 applied: AppliedLog,
121 events: Option<mpsc::UnboundedReceiver<ActivityEvent>>,
125 result: Payload,
126}
127
128#[async_trait]
129impl AgentHarness for MockAgentHarness {
130 type Session = MockAgentSession;
131
132 async fn start(&self, spec: AgentRunSpec) -> Result<Self::Session, HarnessError> {
133 let (tx, rx) = mpsc::unbounded_channel();
134 for event in fixed_transcript(&spec) {
137 let _ = tx.send(event);
138 }
139 if let Ok(mut guard) = self.run_handle.lock() {
140 *guard = Some(RunHandle { keep_alive: tx });
141 }
142 let session_id = format!(
148 "{}:{}:{}:{}",
149 spec.workflow_id, spec.run_id, spec.activity_id, spec.attempt
150 );
151 let result = AgentOutcome::new("working", "working", &StopKind::EndTurn, session_id)
152 .into_payload()
153 .map_err(|source| {
154 HarnessError::protocol(format!("mock agent result is not encodable: {source}"))
155 })?;
156 Ok(MockAgentSession {
157 capabilities: MockAgentHarness::advertised_capabilities(),
158 applied: Arc::clone(&self.applied),
159 events: Some(rx),
160 result,
161 })
162 }
163}
164
165fn fixed_transcript(spec: &AgentRunSpec) -> Vec<ActivityEvent> {
167 let base = ActivityEvent {
168 workflow_id: spec.workflow_id.clone(),
169 run_id: spec.run_id.clone(),
170 activity_id: spec.activity_id.clone(),
171 attempt: spec.attempt,
172 agent_id: Uuid::nil(),
173 agent_role: "mock".to_owned(),
174 emitted_at: Utc::now(),
175 worker_seq: 0,
176 store_seq: None,
177 ephemeral: false,
178 kind: ActivityEventKind::Message {
179 role: MessageRole::Assistant,
180 text: "working".to_owned(),
181 },
182 };
183 let stop = ActivityEvent {
184 worker_seq: 1,
185 kind: ActivityEventKind::Stop {
186 reason: StopKind::EndTurn,
187 },
188 ..base.clone()
189 };
190 vec![base, stop]
191}
192
193#[async_trait]
194impl AgentSession for MockAgentSession {
195 fn capabilities(&self) -> &InterventionCapabilities {
196 &self.capabilities
197 }
198
199 fn events(&mut self) -> BoxStream<'static, ActivityEvent> {
200 match self.events.take() {
201 Some(receiver) => Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new(
202 receiver,
203 )),
204 None => Box::pin(futures::stream::empty()),
205 }
206 }
207
208 async fn intervene(&self, cmd: InterventionCommand) -> Result<(), HarnessError> {
209 if !self.capabilities.supports(&cmd.kind) {
213 return Err(HarnessError::capability_not_supported(format!(
214 "{:?}",
215 cmd.kind.primitive()
216 )));
217 }
218 self.applied
219 .lock()
220 .map_err(|_poison| HarnessError::harness("applied-command lock poisoned"))?
221 .push(cmd.kind);
222 Ok(())
223 }
224
225 async fn wait_result(self) -> Result<Payload, HarnessError> {
226 Ok(self.result)
229 }
230}
231
232#[cfg(test)]
233#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
234mod tests {
235 use super::*;
236 use aion_core::{ActivityId, ApprovalDecision, ContentType, InjectPriority, RunId, WorkflowId};
237 use futures::StreamExt;
238
239 fn spec() -> AgentRunSpec {
240 AgentRunSpec::new(
243 WorkflowId::new(Uuid::nil()),
244 RunId::new(Uuid::from_u128(0x2c)),
245 ActivityId::from_sequence_position(4),
246 2,
247 "mock-activity",
248 Payload::new(ContentType::Json, b"\"in\"".to_vec()),
249 )
250 }
251
252 fn expected_session_id() -> String {
255 let spec = spec();
256 format!(
257 "{}:{}:{}:{}",
258 spec.workflow_id, spec.run_id, spec.activity_id, spec.attempt
259 )
260 }
261
262 fn command(kind: InterventionKind) -> InterventionCommand {
263 InterventionCommand {
264 workflow_id: WorkflowId::new(Uuid::nil()),
265 run_id: RunId::new_v4(),
266 activity_id: ActivityId::from_sequence_position(4),
267 attempt: 2,
268 issued_by: Some("operator".to_owned()),
269 issued_at: Utc::now(),
270 kind,
271 }
272 }
273
274 #[tokio::test]
275 async fn advertises_inject_message_and_cancel_only() {
276 let harness = MockAgentHarness::new();
277 let session = harness.start(spec()).await.unwrap();
278 let caps = session.capabilities();
279 assert!(caps.supports_primitive(InterventionPrimitive::InjectMessage));
280 assert!(caps.supports_primitive(InterventionPrimitive::Cancel));
281 assert!(!caps.supports_primitive(InterventionPrimitive::PauseResume));
282 assert!(!caps.supports_primitive(InterventionPrimitive::UpdateBudget));
283 assert!(!caps.supports_primitive(InterventionPrimitive::RespondToApproval));
284 }
285
286 #[tokio::test]
287 async fn accepts_an_advertised_command_and_records_it() {
288 let harness = MockAgentHarness::new();
289 let applied = harness.applied();
290 let session = harness.start(spec()).await.unwrap();
291
292 session
293 .intervene(command(InterventionKind::InjectMessage {
294 text: "steer".to_owned(),
295 priority: InjectPriority::Interrupt,
296 }))
297 .await
298 .unwrap();
299 session
300 .intervene(command(InterventionKind::Cancel {
301 reason: "stop".to_owned(),
302 }))
303 .await
304 .unwrap();
305
306 assert_eq!(applied.lock().unwrap().len(), 2);
307 }
308
309 #[tokio::test]
310 async fn rejects_respond_to_approval_as_capability_not_supported() {
311 let harness = MockAgentHarness::new();
312 let applied = harness.applied();
313 let session = harness.start(spec()).await.unwrap();
314
315 let error = session
316 .intervene(command(InterventionKind::RespondToApproval {
317 call_id: "c1".to_owned(),
318 decision: ApprovalDecision::Approve,
319 note: None,
320 }))
321 .await
322 .unwrap_err();
323 assert!(
324 matches!(error, HarnessError::CapabilityNotSupported { .. }),
325 "an advertised-unsupported primitive is cleanly NACKed, got {error:?}"
326 );
327 assert!(
328 applied.lock().unwrap().is_empty(),
329 "a gated command is never recorded as applied"
330 );
331 }
332
333 #[tokio::test]
334 async fn streams_a_transcript_and_returns_a_result() {
335 let harness = MockAgentHarness::new();
336 let mut session = harness.start(spec()).await.unwrap();
337 harness.take_run_handle().expect("a run handle").end_run();
339 let events: Vec<ActivityEvent> = session.events().collect().await;
340 assert_eq!(events.len(), 2, "the fixed transcript streams");
341 assert!(matches!(events[0].kind, ActivityEventKind::Message { .. }));
342 assert!(matches!(events[1].kind, ActivityEventKind::Stop { .. }));
343 let payload = session.wait_result().await.unwrap();
344 assert_eq!(payload.content_type(), &ContentType::Json);
345 let decoded = payload.to_json().unwrap();
346 assert_eq!(
347 decoded,
348 serde_json::json!({
349 "text": "working",
350 "final_message": "working",
351 "stop_reason": "end_turn",
352 "session_id": expected_session_id(),
353 }),
354 "the mock emits the same canonical agent-outcome record every real harness does"
355 );
356 }
357}