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 activity_id: aion_core::ActivityId,
45 pub attempt: u32,
47}
48
49const CLI_AGENT_ROLE: &str = "cli";
51
52const RAW_SOURCE: &str = "stdout";
54
55#[must_use]
61pub fn line_to_event(identity: &EventIdentity, worker_seq: u64, line: &str) -> ActivityEvent {
62 let kind = classify_line(line);
63 ActivityEvent {
64 workflow_id: identity.workflow_id.clone(),
65 activity_id: identity.activity_id.clone(),
66 attempt: identity.attempt,
67 agent_id: Uuid::nil(),
68 agent_role: CLI_AGENT_ROLE.to_owned(),
69 emitted_at: Utc::now(),
70 worker_seq,
71 store_seq: None,
72 ephemeral: false,
74 kind,
75 }
76}
77
78fn classify_line(line: &str) -> ActivityEventKind {
83 let Some(object) = serde_json::from_str::<Value>(line)
84 .ok()
85 .filter(Value::is_object)
86 else {
87 return raw_kind(line);
88 };
89 match object.get("type").and_then(Value::as_str) {
90 Some("message") => message_kind(&object),
91 Some("tool_call") => tool_call_kind(&object),
92 Some("tool_result") => tool_result_kind(&object),
93 Some("stop") => stop_kind(&object),
94 _ => ActivityEventKind::Raw {
97 source: RAW_SOURCE.to_owned(),
98 value: object,
99 },
100 }
101}
102
103fn raw_kind(line: &str) -> ActivityEventKind {
105 ActivityEventKind::Raw {
106 source: RAW_SOURCE.to_owned(),
107 value: Value::String(line.to_owned()),
108 }
109}
110
111fn string_field(object: &Value, key: &str) -> String {
113 object
114 .get(key)
115 .and_then(Value::as_str)
116 .unwrap_or_default()
117 .to_owned()
118}
119
120fn message_kind(object: &Value) -> ActivityEventKind {
125 ActivityEventKind::Message {
126 role: MessageRole::Assistant,
127 text: string_field(object, "text"),
128 }
129}
130
131fn tool_call_kind(object: &Value) -> ActivityEventKind {
133 ActivityEventKind::ToolCall {
134 tool: string_field(object, "tool"),
135 call_id: string_field(object, "call_id"),
136 input: object.get("input").cloned().unwrap_or(Value::Null),
137 }
138}
139
140fn tool_result_kind(object: &Value) -> ActivityEventKind {
142 ActivityEventKind::ToolResult {
143 call_id: string_field(object, "call_id"),
144 output: object.get("output").cloned().unwrap_or(Value::Null),
145 is_error: object
146 .get("is_error")
147 .and_then(Value::as_bool)
148 .unwrap_or(false),
149 }
150}
151
152fn stop_kind(object: &Value) -> ActivityEventKind {
154 let reason = match object.get("reason").and_then(Value::as_str) {
155 Some("end_turn") => StopKind::EndTurn,
156 Some("tool_use") => StopKind::ToolUse,
157 Some("limit_reached") => StopKind::LimitReached,
158 Some("cancelled") => StopKind::Cancelled,
159 Some(other) => StopKind::Other {
160 reason: other.to_owned(),
161 },
162 None => StopKind::Other {
163 reason: "unknown".to_owned(),
164 },
165 };
166 ActivityEventKind::Stop { reason }
167}
168
169#[cfg(test)]
170#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
171mod tests {
172 use super::*;
173 use aion_core::{ActivityId, WorkflowId};
174 use serde_json::json;
175
176 fn identity() -> EventIdentity {
177 EventIdentity {
178 workflow_id: WorkflowId::new(Uuid::nil()),
179 activity_id: ActivityId::from_sequence_position(5),
180 attempt: 2,
181 }
182 }
183
184 #[test]
185 fn message_line_maps_to_message_event() {
186 let line = json!({ "type": "message", "text": "working on it" }).to_string();
187 let event = line_to_event(&identity(), 0, &line);
188 assert_eq!(event.attempt, 2);
189 assert_eq!(event.agent_role, "cli");
190 assert_eq!(event.agent_id, Uuid::nil());
191 assert!(!event.ephemeral);
192 match event.kind {
193 ActivityEventKind::Message { role, text } => {
194 assert_eq!(role, MessageRole::Assistant);
195 assert_eq!(text, "working on it");
196 }
197 other => panic!("expected Message, got {other:?}"),
198 }
199 }
200
201 #[test]
202 fn tool_call_line_maps_to_tool_call_event() {
203 let line = json!({
204 "type": "tool_call",
205 "tool": "search",
206 "call_id": "c1",
207 "input": { "query": "x" },
208 })
209 .to_string();
210 let event = line_to_event(&identity(), 1, &line);
211 match event.kind {
212 ActivityEventKind::ToolCall {
213 tool,
214 call_id,
215 input,
216 } => {
217 assert_eq!(tool, "search");
218 assert_eq!(call_id, "c1");
219 assert_eq!(input, json!({ "query": "x" }));
220 }
221 other => panic!("expected ToolCall, got {other:?}"),
222 }
223 }
224
225 #[test]
226 fn tool_result_line_maps_to_tool_result_event() {
227 let line = json!({
228 "type": "tool_result",
229 "call_id": "c1",
230 "output": { "hits": 3 },
231 "is_error": false,
232 })
233 .to_string();
234 let event = line_to_event(&identity(), 2, &line);
235 match event.kind {
236 ActivityEventKind::ToolResult {
237 call_id,
238 output,
239 is_error,
240 } => {
241 assert_eq!(call_id, "c1");
242 assert_eq!(output, json!({ "hits": 3 }));
243 assert!(!is_error);
244 }
245 other => panic!("expected ToolResult, got {other:?}"),
246 }
247 }
248
249 #[test]
250 fn stop_line_maps_stop_reasons() {
251 for (label, expected) in [
252 ("end_turn", StopKind::EndTurn),
253 ("tool_use", StopKind::ToolUse),
254 ("limit_reached", StopKind::LimitReached),
255 ("cancelled", StopKind::Cancelled),
256 ] {
257 let line = json!({ "type": "stop", "reason": label }).to_string();
258 let event = line_to_event(&identity(), 3, &line);
259 match event.kind {
260 ActivityEventKind::Stop { reason } => assert_eq!(reason, expected),
261 other => panic!("expected Stop, got {other:?}"),
262 }
263 }
264 }
265
266 #[test]
267 fn unknown_stop_reason_falls_through_to_other() {
268 let line = json!({ "type": "stop", "reason": "weird" }).to_string();
269 let event = line_to_event(&identity(), 4, &line);
270 match event.kind {
271 ActivityEventKind::Stop {
272 reason: StopKind::Other { reason },
273 } => assert_eq!(reason, "weird"),
274 other => panic!("expected Stop::Other, got {other:?}"),
275 }
276 }
277
278 #[test]
279 fn a_plain_log_line_falls_through_to_raw_verbatim() {
280 let line = " [info] starting the run, reading config... ";
281 let event = line_to_event(&identity(), 5, line);
282 match event.kind {
283 ActivityEventKind::Raw { source, value } => {
284 assert_eq!(source, "stdout");
285 assert_eq!(value, Value::String(line.to_owned()));
286 }
287 other => panic!("expected Raw, got {other:?}"),
288 }
289 }
290
291 #[test]
292 fn a_json_object_with_unknown_type_is_raw_but_keeps_structure() {
293 let line = json!({ "type": "heartbeat", "seq": 4 }).to_string();
294 let event = line_to_event(&identity(), 6, &line);
295 match event.kind {
296 ActivityEventKind::Raw { source, value } => {
297 assert_eq!(source, "stdout");
298 assert_eq!(value["type"], json!("heartbeat"));
299 assert_eq!(value["seq"], json!(4));
300 }
301 other => panic!("expected Raw, got {other:?}"),
302 }
303 }
304
305 #[test]
306 fn a_bare_json_value_is_raw_not_mapped() {
307 let line = "[1, 2, 3]";
309 let event = line_to_event(&identity(), 7, line);
310 match event.kind {
311 ActivityEventKind::Raw { value, .. } => {
312 assert_eq!(value, Value::String(line.to_owned()));
313 }
314 other => panic!("expected Raw, got {other:?}"),
315 }
316 }
317
318 #[test]
319 fn worker_seq_is_carried_onto_the_event() {
320 let event = line_to_event(&identity(), 42, "anything");
321 assert_eq!(event.worker_seq, 42);
322 }
323}