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
157fn stop_kind(object: &Value) -> ActivityEventKind {
159 let reason = match object.get("reason").and_then(Value::as_str) {
160 Some("end_turn") => StopKind::EndTurn,
161 Some("tool_use") => StopKind::ToolUse,
162 Some("limit_reached") => StopKind::LimitReached,
163 Some("cancelled") => StopKind::Cancelled,
164 Some(other) => StopKind::Other {
165 reason: other.to_owned(),
166 },
167 None => StopKind::Other {
168 reason: "unknown".to_owned(),
169 },
170 };
171 ActivityEventKind::Stop { reason }
172}
173
174#[cfg(test)]
175#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
176mod tests {
177 use super::*;
178 use aion_core::{ActivityId, WorkflowId};
179 use serde_json::json;
180
181 fn identity() -> EventIdentity {
182 EventIdentity {
183 workflow_id: WorkflowId::new(Uuid::nil()),
184 run_id: aion_core::RunId::new(Uuid::from_u128(0x11)),
185 activity_id: ActivityId::from_sequence_position(5),
186 attempt: 2,
187 }
188 }
189
190 #[test]
191 fn message_line_maps_to_message_event() {
192 let line = json!({ "type": "message", "text": "working on it" }).to_string();
193 let event = line_to_event(&identity(), 0, &line);
194 assert_eq!(event.attempt, 2);
195 assert_eq!(event.agent_role, "cli");
196 assert_eq!(event.agent_id, Uuid::nil());
197 assert!(!event.ephemeral);
198 match event.kind {
199 ActivityEventKind::Message { role, text } => {
200 assert_eq!(role, MessageRole::Assistant);
201 assert_eq!(text, "working on it");
202 }
203 other => panic!("expected Message, got {other:?}"),
204 }
205 }
206
207 #[test]
208 fn tool_call_line_maps_to_tool_call_event() {
209 let line = json!({
210 "type": "tool_call",
211 "tool": "search",
212 "call_id": "c1",
213 "input": { "query": "x" },
214 })
215 .to_string();
216 let event = line_to_event(&identity(), 1, &line);
217 match event.kind {
218 ActivityEventKind::ToolCall {
219 tool,
220 call_id,
221 input,
222 } => {
223 assert_eq!(tool, "search");
224 assert_eq!(call_id, "c1");
225 assert_eq!(input, json!({ "query": "x" }));
226 }
227 other => panic!("expected ToolCall, got {other:?}"),
228 }
229 }
230
231 #[test]
232 fn tool_result_line_maps_to_tool_result_event() {
233 let line = json!({
234 "type": "tool_result",
235 "call_id": "c1",
236 "output": { "hits": 3 },
237 "is_error": false,
238 })
239 .to_string();
240 let event = line_to_event(&identity(), 2, &line);
241 match event.kind {
242 ActivityEventKind::ToolResult {
243 call_id,
244 output,
245 is_error,
246 } => {
247 assert_eq!(call_id, "c1");
248 assert_eq!(output, json!({ "hits": 3 }));
249 assert!(!is_error);
250 }
251 other => panic!("expected ToolResult, got {other:?}"),
252 }
253 }
254
255 #[test]
256 fn stop_line_maps_stop_reasons() {
257 for (label, expected) in [
258 ("end_turn", StopKind::EndTurn),
259 ("tool_use", StopKind::ToolUse),
260 ("limit_reached", StopKind::LimitReached),
261 ("cancelled", StopKind::Cancelled),
262 ] {
263 let line = json!({ "type": "stop", "reason": label }).to_string();
264 let event = line_to_event(&identity(), 3, &line);
265 match event.kind {
266 ActivityEventKind::Stop { reason } => assert_eq!(reason, expected),
267 other => panic!("expected Stop, got {other:?}"),
268 }
269 }
270 }
271
272 #[test]
273 fn unknown_stop_reason_falls_through_to_other() {
274 let line = json!({ "type": "stop", "reason": "weird" }).to_string();
275 let event = line_to_event(&identity(), 4, &line);
276 match event.kind {
277 ActivityEventKind::Stop {
278 reason: StopKind::Other { reason },
279 } => assert_eq!(reason, "weird"),
280 other => panic!("expected Stop::Other, got {other:?}"),
281 }
282 }
283
284 #[test]
285 fn a_plain_log_line_falls_through_to_raw_verbatim() {
286 let line = " [info] starting the run, reading config... ";
287 let event = line_to_event(&identity(), 5, line);
288 match event.kind {
289 ActivityEventKind::Raw { source, value } => {
290 assert_eq!(source, "stdout");
291 assert_eq!(value, Value::String(line.to_owned()));
292 }
293 other => panic!("expected Raw, got {other:?}"),
294 }
295 }
296
297 #[test]
298 fn a_json_object_with_unknown_type_is_raw_but_keeps_structure() {
299 let line = json!({ "type": "heartbeat", "seq": 4 }).to_string();
300 let event = line_to_event(&identity(), 6, &line);
301 match event.kind {
302 ActivityEventKind::Raw { source, value } => {
303 assert_eq!(source, "stdout");
304 assert_eq!(value["type"], json!("heartbeat"));
305 assert_eq!(value["seq"], json!(4));
306 }
307 other => panic!("expected Raw, got {other:?}"),
308 }
309 }
310
311 #[test]
312 fn a_bare_json_value_is_raw_not_mapped() {
313 let line = "[1, 2, 3]";
315 let event = line_to_event(&identity(), 7, line);
316 match event.kind {
317 ActivityEventKind::Raw { value, .. } => {
318 assert_eq!(value, Value::String(line.to_owned()));
319 }
320 other => panic!("expected Raw, got {other:?}"),
321 }
322 }
323
324 #[test]
325 fn worker_seq_is_carried_onto_the_event() {
326 let event = line_to_event(&identity(), 42, "anything");
327 assert_eq!(event.worker_seq, 42);
328 }
329}