use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::ids::{ActivityId, WorkflowId};
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(tag = "role")]
pub enum MessageRole {
User,
Assistant,
System,
Tool,
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq)]
#[serde(tag = "detail")]
pub enum ProgressDetail {
UsageEstimate {
input_tokens: Option<u64>,
output_tokens: Option<u64>,
},
Note {
text: String,
},
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
#[serde(tag = "stop")]
pub enum StopKind {
EndTurn,
ToolUse,
LimitReached,
Cancelled,
Error {
message: String,
},
Other {
reason: String,
},
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq)]
#[serde(tag = "kind")]
pub enum ActivityEventKind {
Message {
role: MessageRole,
text: String,
},
ToolCall {
tool: String,
call_id: String,
#[ts(type = "unknown")]
input: serde_json::Value,
},
ToolResult {
call_id: String,
#[ts(type = "unknown")]
output: serde_json::Value,
is_error: bool,
},
Progress {
detail: ProgressDetail,
},
Stop {
reason: StopKind,
},
Raw {
source: String,
#[ts(type = "unknown")]
value: serde_json::Value,
},
Delta {
message_id: String,
text_fragment: String,
},
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq)]
pub struct ActivityEvent {
pub workflow_id: WorkflowId,
pub activity_id: ActivityId,
pub attempt: u32,
pub agent_id: Uuid,
pub agent_role: String,
pub emitted_at: DateTime<Utc>,
pub worker_seq: u64,
pub store_seq: Option<u64>,
pub ephemeral: bool,
pub kind: ActivityEventKind,
}
#[cfg(test)]
mod tests {
use chrono::{DateTime, Utc};
use serde::de::DeserializeOwned;
use serde_json::json;
use uuid::Uuid;
use super::{
ActivityEvent, ActivityEventKind, MessageRole, ProgressDetail, StopKind, WorkflowId,
};
use crate::ids::ActivityId;
fn fixed_time() -> DateTime<Utc> {
DateTime::from_timestamp(1_700_000_000, 0).unwrap_or_default()
}
fn round_trip<T>(value: &T) -> Result<T, serde_json::Error>
where
T: DeserializeOwned + serde::Serialize,
{
let json = serde_json::to_string(value)?;
serde_json::from_str::<T>(&json)
}
fn envelope(kind: ActivityEventKind, ephemeral: bool, store_seq: Option<u64>) -> ActivityEvent {
ActivityEvent {
workflow_id: WorkflowId::new(Uuid::nil()),
activity_id: ActivityId::from_sequence_position(7),
attempt: 2,
agent_id: Uuid::nil(),
agent_role: "orchestrator".to_owned(),
emitted_at: fixed_time(),
worker_seq: 42,
store_seq,
ephemeral,
kind,
}
}
#[test]
fn envelope_round_trips_through_json() -> Result<(), Box<dyn std::error::Error>> {
let event = envelope(
ActivityEventKind::Message {
role: MessageRole::Assistant,
text: "hello".to_owned(),
},
false,
Some(9),
);
let decoded = round_trip(&event)?;
assert_eq!(event, decoded);
Ok(())
}
#[test]
fn every_event_kind_round_trips() -> Result<(), Box<dyn std::error::Error>> {
let kinds = vec![
ActivityEventKind::Message {
role: MessageRole::User,
text: "steer".to_owned(),
},
ActivityEventKind::ToolCall {
tool: "read_file".to_owned(),
call_id: "call-1".to_owned(),
input: json!({ "path": "/tmp/x" }),
},
ActivityEventKind::ToolResult {
call_id: "call-1".to_owned(),
output: json!({ "bytes": 12 }),
is_error: false,
},
ActivityEventKind::Progress {
detail: ProgressDetail::UsageEstimate {
input_tokens: Some(100),
output_tokens: None,
},
},
ActivityEventKind::Progress {
detail: ProgressDetail::Note {
text: "thinking".to_owned(),
},
},
ActivityEventKind::Stop {
reason: StopKind::EndTurn,
},
ActivityEventKind::Stop {
reason: StopKind::Error {
message: "boom".to_owned(),
},
},
ActivityEventKind::Stop {
reason: StopKind::Other {
reason: "custom".to_owned(),
},
},
ActivityEventKind::Raw {
source: "unknown-harness".to_owned(),
value: json!({ "anything": [1, 2, 3] }),
},
];
for kind in kinds {
let event = envelope(kind, false, None);
let decoded = round_trip(&event)?;
assert_eq!(event, decoded);
}
Ok(())
}
#[test]
fn ephemeral_delta_round_trips_without_store_seq() -> Result<(), Box<dyn std::error::Error>> {
let event = envelope(
ActivityEventKind::Delta {
message_id: "msg-1".to_owned(),
text_fragment: "wor".to_owned(),
},
true,
None,
);
let decoded = round_trip(&event)?;
assert!(decoded.ephemeral);
assert_eq!(decoded.store_seq, None);
assert_eq!(event, decoded);
Ok(())
}
}