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 result = AgentOutcome::new("working", &StopKind::EndTurn)
146 .into_payload()
147 .map_err(|source| {
148 HarnessError::protocol(format!("mock agent result is not encodable: {source}"))
149 })?;
150 Ok(MockAgentSession {
151 capabilities: MockAgentHarness::advertised_capabilities(),
152 applied: Arc::clone(&self.applied),
153 events: Some(rx),
154 result,
155 })
156 }
157}
158
159fn fixed_transcript(spec: &AgentRunSpec) -> Vec<ActivityEvent> {
161 let base = ActivityEvent {
162 workflow_id: spec.workflow_id.clone(),
163 run_id: spec.run_id.clone(),
164 activity_id: spec.activity_id.clone(),
165 attempt: spec.attempt,
166 agent_id: Uuid::nil(),
167 agent_role: "mock".to_owned(),
168 emitted_at: Utc::now(),
169 worker_seq: 0,
170 store_seq: None,
171 ephemeral: false,
172 kind: ActivityEventKind::Message {
173 role: MessageRole::Assistant,
174 text: "working".to_owned(),
175 },
176 };
177 let stop = ActivityEvent {
178 worker_seq: 1,
179 kind: ActivityEventKind::Stop {
180 reason: StopKind::EndTurn,
181 },
182 ..base.clone()
183 };
184 vec![base, stop]
185}
186
187#[async_trait]
188impl AgentSession for MockAgentSession {
189 fn capabilities(&self) -> &InterventionCapabilities {
190 &self.capabilities
191 }
192
193 fn events(&mut self) -> BoxStream<'static, ActivityEvent> {
194 match self.events.take() {
195 Some(receiver) => Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new(
196 receiver,
197 )),
198 None => Box::pin(futures::stream::empty()),
199 }
200 }
201
202 async fn intervene(&self, cmd: InterventionCommand) -> Result<(), HarnessError> {
203 if !self.capabilities.supports(&cmd.kind) {
207 return Err(HarnessError::capability_not_supported(format!(
208 "{:?}",
209 cmd.kind.primitive()
210 )));
211 }
212 self.applied
213 .lock()
214 .map_err(|_poison| HarnessError::harness("applied-command lock poisoned"))?
215 .push(cmd.kind);
216 Ok(())
217 }
218
219 async fn wait_result(self) -> Result<Payload, HarnessError> {
220 Ok(self.result)
223 }
224}
225
226#[cfg(test)]
227#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
228mod tests {
229 use super::*;
230 use aion_core::{ActivityId, ApprovalDecision, ContentType, InjectPriority, RunId, WorkflowId};
231 use futures::StreamExt;
232
233 fn spec() -> AgentRunSpec {
234 AgentRunSpec::new(
235 WorkflowId::new(Uuid::nil()),
236 RunId::new_v4(),
237 ActivityId::from_sequence_position(4),
238 2,
239 "mock-activity",
240 Payload::new(ContentType::Json, b"\"in\"".to_vec()),
241 )
242 }
243
244 fn command(kind: InterventionKind) -> InterventionCommand {
245 InterventionCommand {
246 workflow_id: WorkflowId::new(Uuid::nil()),
247 run_id: RunId::new_v4(),
248 activity_id: ActivityId::from_sequence_position(4),
249 attempt: 2,
250 issued_by: Some("operator".to_owned()),
251 issued_at: Utc::now(),
252 kind,
253 }
254 }
255
256 #[tokio::test]
257 async fn advertises_inject_message_and_cancel_only() {
258 let harness = MockAgentHarness::new();
259 let session = harness.start(spec()).await.unwrap();
260 let caps = session.capabilities();
261 assert!(caps.supports_primitive(InterventionPrimitive::InjectMessage));
262 assert!(caps.supports_primitive(InterventionPrimitive::Cancel));
263 assert!(!caps.supports_primitive(InterventionPrimitive::PauseResume));
264 assert!(!caps.supports_primitive(InterventionPrimitive::UpdateBudget));
265 assert!(!caps.supports_primitive(InterventionPrimitive::RespondToApproval));
266 }
267
268 #[tokio::test]
269 async fn accepts_an_advertised_command_and_records_it() {
270 let harness = MockAgentHarness::new();
271 let applied = harness.applied();
272 let session = harness.start(spec()).await.unwrap();
273
274 session
275 .intervene(command(InterventionKind::InjectMessage {
276 text: "steer".to_owned(),
277 priority: InjectPriority::Interrupt,
278 }))
279 .await
280 .unwrap();
281 session
282 .intervene(command(InterventionKind::Cancel {
283 reason: "stop".to_owned(),
284 }))
285 .await
286 .unwrap();
287
288 assert_eq!(applied.lock().unwrap().len(), 2);
289 }
290
291 #[tokio::test]
292 async fn rejects_respond_to_approval_as_capability_not_supported() {
293 let harness = MockAgentHarness::new();
294 let applied = harness.applied();
295 let session = harness.start(spec()).await.unwrap();
296
297 let error = session
298 .intervene(command(InterventionKind::RespondToApproval {
299 call_id: "c1".to_owned(),
300 decision: ApprovalDecision::Approve,
301 note: None,
302 }))
303 .await
304 .unwrap_err();
305 assert!(
306 matches!(error, HarnessError::CapabilityNotSupported { .. }),
307 "an advertised-unsupported primitive is cleanly NACKed, got {error:?}"
308 );
309 assert!(
310 applied.lock().unwrap().is_empty(),
311 "a gated command is never recorded as applied"
312 );
313 }
314
315 #[tokio::test]
316 async fn streams_a_transcript_and_returns_a_result() {
317 let harness = MockAgentHarness::new();
318 let mut session = harness.start(spec()).await.unwrap();
319 harness.take_run_handle().expect("a run handle").end_run();
321 let events: Vec<ActivityEvent> = session.events().collect().await;
322 assert_eq!(events.len(), 2, "the fixed transcript streams");
323 assert!(matches!(events[0].kind, ActivityEventKind::Message { .. }));
324 assert!(matches!(events[1].kind, ActivityEventKind::Stop { .. }));
325 let payload = session.wait_result().await.unwrap();
326 assert_eq!(payload.content_type(), &ContentType::Json);
327 let decoded = payload.to_json().unwrap();
328 assert_eq!(
329 decoded,
330 serde_json::json!({ "text": "working", "stop_reason": "end_turn" }),
331 "the mock emits the same canonical agent-outcome record every real harness does"
332 );
333 }
334}