use aion_core::{ActivityEvent, ActivityEventKind, MessageRole, StopKind};
use chrono::Utc;
use serde_json::Value;
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct EventIdentity {
pub workflow_id: aion_core::WorkflowId,
pub run_id: aion_core::RunId,
pub activity_id: aion_core::ActivityId,
pub attempt: u32,
}
const CLI_AGENT_ROLE: &str = "cli";
const RAW_SOURCE: &str = "stdout";
#[must_use]
pub fn line_to_event(identity: &EventIdentity, worker_seq: u64, line: &str) -> ActivityEvent {
let kind = classify_line(line);
ActivityEvent {
workflow_id: identity.workflow_id.clone(),
run_id: identity.run_id.clone(),
activity_id: identity.activity_id.clone(),
attempt: identity.attempt,
agent_id: Uuid::nil(),
agent_role: CLI_AGENT_ROLE.to_owned(),
emitted_at: Utc::now(),
worker_seq,
store_seq: None,
ephemeral: false,
kind,
}
}
fn classify_line(line: &str) -> ActivityEventKind {
let Some(object) = serde_json::from_str::<Value>(line)
.ok()
.filter(Value::is_object)
else {
return raw_kind(line);
};
match object.get("type").and_then(Value::as_str) {
Some("message") => message_kind(&object),
Some("tool_call") => tool_call_kind(&object),
Some("tool_result") => tool_result_kind(&object),
Some("stop") => stop_kind(&object),
_ => ActivityEventKind::Raw {
source: RAW_SOURCE.to_owned(),
value: object,
},
}
}
fn raw_kind(line: &str) -> ActivityEventKind {
ActivityEventKind::Raw {
source: RAW_SOURCE.to_owned(),
value: Value::String(line.to_owned()),
}
}
fn string_field(object: &Value, key: &str) -> String {
object
.get(key)
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned()
}
fn message_kind(object: &Value) -> ActivityEventKind {
ActivityEventKind::Message {
role: MessageRole::Assistant,
text: string_field(object, "text"),
}
}
fn tool_call_kind(object: &Value) -> ActivityEventKind {
ActivityEventKind::ToolCall {
tool: string_field(object, "tool"),
call_id: string_field(object, "call_id"),
input: object.get("input").cloned().unwrap_or(Value::Null),
}
}
fn tool_result_kind(object: &Value) -> ActivityEventKind {
ActivityEventKind::ToolResult {
call_id: string_field(object, "call_id"),
output: object.get("output").cloned().unwrap_or(Value::Null),
is_error: object
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false),
}
}
#[must_use]
pub(crate) fn synthesized_stop_event(
identity: &EventIdentity,
worker_seq: u64,
reason: StopKind,
) -> ActivityEvent {
ActivityEvent {
workflow_id: identity.workflow_id.clone(),
run_id: identity.run_id.clone(),
activity_id: identity.activity_id.clone(),
attempt: identity.attempt,
agent_id: Uuid::nil(),
agent_role: CLI_AGENT_ROLE.to_owned(),
emitted_at: Utc::now(),
worker_seq,
store_seq: None,
ephemeral: false,
kind: ActivityEventKind::Stop { reason },
}
}
fn stop_kind(object: &Value) -> ActivityEventKind {
let reason = match object.get("reason").and_then(Value::as_str) {
Some("end_turn") => StopKind::EndTurn,
Some("tool_use") => StopKind::ToolUse,
Some("limit_reached") => StopKind::LimitReached,
Some("cancelled") => StopKind::Cancelled,
Some(other) => StopKind::Other {
reason: other.to_owned(),
},
None => StopKind::Other {
reason: "unknown".to_owned(),
},
};
ActivityEventKind::Stop { reason }
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use aion_core::{ActivityId, WorkflowId};
use serde_json::json;
fn identity() -> EventIdentity {
EventIdentity {
workflow_id: WorkflowId::new(Uuid::nil()),
run_id: aion_core::RunId::new(Uuid::from_u128(0x11)),
activity_id: ActivityId::from_sequence_position(5),
attempt: 2,
}
}
#[test]
fn message_line_maps_to_message_event() {
let line = json!({ "type": "message", "text": "working on it" }).to_string();
let event = line_to_event(&identity(), 0, &line);
assert_eq!(event.attempt, 2);
assert_eq!(event.agent_role, "cli");
assert_eq!(event.agent_id, Uuid::nil());
assert!(!event.ephemeral);
match event.kind {
ActivityEventKind::Message { role, text } => {
assert_eq!(role, MessageRole::Assistant);
assert_eq!(text, "working on it");
}
other => panic!("expected Message, got {other:?}"),
}
}
#[test]
fn tool_call_line_maps_to_tool_call_event() {
let line = json!({
"type": "tool_call",
"tool": "search",
"call_id": "c1",
"input": { "query": "x" },
})
.to_string();
let event = line_to_event(&identity(), 1, &line);
match event.kind {
ActivityEventKind::ToolCall {
tool,
call_id,
input,
} => {
assert_eq!(tool, "search");
assert_eq!(call_id, "c1");
assert_eq!(input, json!({ "query": "x" }));
}
other => panic!("expected ToolCall, got {other:?}"),
}
}
#[test]
fn tool_result_line_maps_to_tool_result_event() {
let line = json!({
"type": "tool_result",
"call_id": "c1",
"output": { "hits": 3 },
"is_error": false,
})
.to_string();
let event = line_to_event(&identity(), 2, &line);
match event.kind {
ActivityEventKind::ToolResult {
call_id,
output,
is_error,
} => {
assert_eq!(call_id, "c1");
assert_eq!(output, json!({ "hits": 3 }));
assert!(!is_error);
}
other => panic!("expected ToolResult, got {other:?}"),
}
}
#[test]
fn stop_line_maps_stop_reasons() {
for (label, expected) in [
("end_turn", StopKind::EndTurn),
("tool_use", StopKind::ToolUse),
("limit_reached", StopKind::LimitReached),
("cancelled", StopKind::Cancelled),
] {
let line = json!({ "type": "stop", "reason": label }).to_string();
let event = line_to_event(&identity(), 3, &line);
match event.kind {
ActivityEventKind::Stop { reason } => assert_eq!(reason, expected),
other => panic!("expected Stop, got {other:?}"),
}
}
}
#[test]
fn unknown_stop_reason_falls_through_to_other() {
let line = json!({ "type": "stop", "reason": "weird" }).to_string();
let event = line_to_event(&identity(), 4, &line);
match event.kind {
ActivityEventKind::Stop {
reason: StopKind::Other { reason },
} => assert_eq!(reason, "weird"),
other => panic!("expected Stop::Other, got {other:?}"),
}
}
#[test]
fn a_plain_log_line_falls_through_to_raw_verbatim() {
let line = " [info] starting the run, reading config... ";
let event = line_to_event(&identity(), 5, line);
match event.kind {
ActivityEventKind::Raw { source, value } => {
assert_eq!(source, "stdout");
assert_eq!(value, Value::String(line.to_owned()));
}
other => panic!("expected Raw, got {other:?}"),
}
}
#[test]
fn a_json_object_with_unknown_type_is_raw_but_keeps_structure() {
let line = json!({ "type": "heartbeat", "seq": 4 }).to_string();
let event = line_to_event(&identity(), 6, &line);
match event.kind {
ActivityEventKind::Raw { source, value } => {
assert_eq!(source, "stdout");
assert_eq!(value["type"], json!("heartbeat"));
assert_eq!(value["seq"], json!(4));
}
other => panic!("expected Raw, got {other:?}"),
}
}
#[test]
fn a_bare_json_value_is_raw_not_mapped() {
let line = "[1, 2, 3]";
let event = line_to_event(&identity(), 7, line);
match event.kind {
ActivityEventKind::Raw { value, .. } => {
assert_eq!(value, Value::String(line.to_owned()));
}
other => panic!("expected Raw, got {other:?}"),
}
}
#[test]
fn worker_seq_is_carried_onto_the_event() {
let event = line_to_event(&identity(), 42, "anything");
assert_eq!(event.worker_seq, 42);
}
}