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