1use aion_core::{ActivityEvent, ActivityEventKind, MessageRole, StopKind};
30use chrono::Utc;
31use serde_json::Value;
32use uuid::Uuid;
33
34#[derive(Clone, Debug)]
40pub struct EventIdentity {
41 pub workflow_id: aion_core::WorkflowId,
43 pub run_id: aion_core::RunId,
47 pub activity_id: aion_core::ActivityId,
49 pub attempt: u32,
51}
52
53const CLI_AGENT_ROLE: &str = "cli";
55
56const RAW_SOURCE: &str = "stdout";
58
59#[must_use]
65pub fn line_to_event(identity: &EventIdentity, worker_seq: u64, line: &str) -> ActivityEvent {
66 let kind = classify_line(line);
67 ActivityEvent {
68 workflow_id: identity.workflow_id.clone(),
69 run_id: identity.run_id.clone(),
70 activity_id: identity.activity_id.clone(),
71 attempt: identity.attempt,
72 agent_id: Uuid::nil(),
73 agent_role: CLI_AGENT_ROLE.to_owned(),
74 emitted_at: Utc::now(),
75 worker_seq,
76 store_seq: None,
77 ephemeral: false,
79 kind,
80 }
81}
82
83fn classify_line(line: &str) -> ActivityEventKind {
88 let Some(object) = serde_json::from_str::<Value>(line)
89 .ok()
90 .filter(Value::is_object)
91 else {
92 return raw_kind(line);
93 };
94 match object.get("type").and_then(Value::as_str) {
95 Some("message") => message_kind(&object),
96 Some("tool_call") => tool_call_kind(&object),
97 Some("tool_result") => tool_result_kind(&object),
98 Some("stop") => stop_kind(&object),
99 _ => ActivityEventKind::Raw {
102 source: RAW_SOURCE.to_owned(),
103 value: object,
104 },
105 }
106}
107
108fn raw_kind(line: &str) -> ActivityEventKind {
110 ActivityEventKind::Raw {
111 source: RAW_SOURCE.to_owned(),
112 value: Value::String(line.to_owned()),
113 }
114}
115
116fn string_field(object: &Value, key: &str) -> String {
118 object
119 .get(key)
120 .and_then(Value::as_str)
121 .unwrap_or_default()
122 .to_owned()
123}
124
125fn message_kind(object: &Value) -> ActivityEventKind {
130 ActivityEventKind::Message {
131 role: MessageRole::Assistant,
132 text: string_field(object, "text"),
133 }
134}
135
136fn tool_call_kind(object: &Value) -> ActivityEventKind {
138 ActivityEventKind::ToolCall {
139 tool: string_field(object, "tool"),
140 call_id: string_field(object, "call_id"),
141 input: object.get("input").cloned().unwrap_or(Value::Null),
142 }
143}
144
145fn tool_result_kind(object: &Value) -> ActivityEventKind {
147 ActivityEventKind::ToolResult {
148 call_id: string_field(object, "call_id"),
149 output: object.get("output").cloned().unwrap_or(Value::Null),
150 is_error: object
151 .get("is_error")
152 .and_then(Value::as_bool)
153 .unwrap_or(false),
154 }
155}
156
157#[must_use]
165pub(crate) fn synthesized_stop_event(
166 identity: &EventIdentity,
167 worker_seq: u64,
168 reason: StopKind,
169) -> ActivityEvent {
170 ActivityEvent {
171 workflow_id: identity.workflow_id.clone(),
172 run_id: identity.run_id.clone(),
173 activity_id: identity.activity_id.clone(),
174 attempt: identity.attempt,
175 agent_id: Uuid::nil(),
176 agent_role: CLI_AGENT_ROLE.to_owned(),
177 emitted_at: Utc::now(),
178 worker_seq,
179 store_seq: None,
180 ephemeral: false,
181 kind: ActivityEventKind::Stop { reason },
182 }
183}
184
185fn stop_kind(object: &Value) -> ActivityEventKind {
187 let reason = match object.get("reason").and_then(Value::as_str) {
188 Some("end_turn") => StopKind::EndTurn,
189 Some("tool_use") => StopKind::ToolUse,
190 Some("limit_reached") => StopKind::LimitReached,
191 Some("cancelled") => StopKind::Cancelled,
192 Some(other) => StopKind::Other {
193 reason: other.to_owned(),
194 },
195 None => StopKind::Other {
196 reason: "unknown".to_owned(),
197 },
198 };
199 ActivityEventKind::Stop { reason }
200}
201
202#[cfg(test)]
203#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
204mod tests {
205 use super::*;
206 use aion_core::{ActivityId, WorkflowId};
207 use serde_json::json;
208
209 fn identity() -> EventIdentity {
210 EventIdentity {
211 workflow_id: WorkflowId::new(Uuid::nil()),
212 run_id: aion_core::RunId::new(Uuid::from_u128(0x11)),
213 activity_id: ActivityId::from_sequence_position(5),
214 attempt: 2,
215 }
216 }
217
218 #[test]
219 fn message_line_maps_to_message_event() {
220 let line = json!({ "type": "message", "text": "working on it" }).to_string();
221 let event = line_to_event(&identity(), 0, &line);
222 assert_eq!(event.attempt, 2);
223 assert_eq!(event.agent_role, "cli");
224 assert_eq!(event.agent_id, Uuid::nil());
225 assert!(!event.ephemeral);
226 match event.kind {
227 ActivityEventKind::Message { role, text } => {
228 assert_eq!(role, MessageRole::Assistant);
229 assert_eq!(text, "working on it");
230 }
231 other => panic!("expected Message, got {other:?}"),
232 }
233 }
234
235 #[test]
236 fn tool_call_line_maps_to_tool_call_event() {
237 let line = json!({
238 "type": "tool_call",
239 "tool": "search",
240 "call_id": "c1",
241 "input": { "query": "x" },
242 })
243 .to_string();
244 let event = line_to_event(&identity(), 1, &line);
245 match event.kind {
246 ActivityEventKind::ToolCall {
247 tool,
248 call_id,
249 input,
250 } => {
251 assert_eq!(tool, "search");
252 assert_eq!(call_id, "c1");
253 assert_eq!(input, json!({ "query": "x" }));
254 }
255 other => panic!("expected ToolCall, got {other:?}"),
256 }
257 }
258
259 #[test]
260 fn tool_result_line_maps_to_tool_result_event() {
261 let line = json!({
262 "type": "tool_result",
263 "call_id": "c1",
264 "output": { "hits": 3 },
265 "is_error": false,
266 })
267 .to_string();
268 let event = line_to_event(&identity(), 2, &line);
269 match event.kind {
270 ActivityEventKind::ToolResult {
271 call_id,
272 output,
273 is_error,
274 } => {
275 assert_eq!(call_id, "c1");
276 assert_eq!(output, json!({ "hits": 3 }));
277 assert!(!is_error);
278 }
279 other => panic!("expected ToolResult, got {other:?}"),
280 }
281 }
282
283 #[test]
284 fn stop_line_maps_stop_reasons() {
285 for (label, expected) in [
286 ("end_turn", StopKind::EndTurn),
287 ("tool_use", StopKind::ToolUse),
288 ("limit_reached", StopKind::LimitReached),
289 ("cancelled", StopKind::Cancelled),
290 ] {
291 let line = json!({ "type": "stop", "reason": label }).to_string();
292 let event = line_to_event(&identity(), 3, &line);
293 match event.kind {
294 ActivityEventKind::Stop { reason } => assert_eq!(reason, expected),
295 other => panic!("expected Stop, got {other:?}"),
296 }
297 }
298 }
299
300 #[test]
301 fn unknown_stop_reason_falls_through_to_other() {
302 let line = json!({ "type": "stop", "reason": "weird" }).to_string();
303 let event = line_to_event(&identity(), 4, &line);
304 match event.kind {
305 ActivityEventKind::Stop {
306 reason: StopKind::Other { reason },
307 } => assert_eq!(reason, "weird"),
308 other => panic!("expected Stop::Other, got {other:?}"),
309 }
310 }
311
312 #[test]
313 fn a_plain_log_line_falls_through_to_raw_verbatim() {
314 let line = " [info] starting the run, reading config... ";
315 let event = line_to_event(&identity(), 5, line);
316 match event.kind {
317 ActivityEventKind::Raw { source, value } => {
318 assert_eq!(source, "stdout");
319 assert_eq!(value, Value::String(line.to_owned()));
320 }
321 other => panic!("expected Raw, got {other:?}"),
322 }
323 }
324
325 #[test]
326 fn a_json_object_with_unknown_type_is_raw_but_keeps_structure() {
327 let line = json!({ "type": "heartbeat", "seq": 4 }).to_string();
328 let event = line_to_event(&identity(), 6, &line);
329 match event.kind {
330 ActivityEventKind::Raw { source, value } => {
331 assert_eq!(source, "stdout");
332 assert_eq!(value["type"], json!("heartbeat"));
333 assert_eq!(value["seq"], json!(4));
334 }
335 other => panic!("expected Raw, got {other:?}"),
336 }
337 }
338
339 #[test]
340 fn a_bare_json_value_is_raw_not_mapped() {
341 let line = "[1, 2, 3]";
343 let event = line_to_event(&identity(), 7, line);
344 match event.kind {
345 ActivityEventKind::Raw { value, .. } => {
346 assert_eq!(value, Value::String(line.to_owned()));
347 }
348 other => panic!("expected Raw, got {other:?}"),
349 }
350 }
351
352 #[test]
353 fn worker_seq_is_carried_onto_the_event() {
354 let event = line_to_event(&identity(), 42, "anything");
355 assert_eq!(event.worker_seq, 42);
356 }
357}