1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10#[serde(tag = "type", rename_all = "snake_case")]
11pub enum EventType {
12 #[serde(rename = "session.started")]
13 SessionStarted,
14
15 #[serde(rename = "session.closed")]
16 SessionClosed {
17 #[serde(skip_serializing_if = "Option::is_none")]
18 summary: Option<String>,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 duration_ms: Option<u64>,
21 },
22
23 #[serde(rename = "agent.started")]
24 AgentStarted {
25 #[serde(skip_serializing_if = "Option::is_none")]
26 parent_agent_instance_id: Option<String>,
27 },
28
29 #[serde(rename = "agent.spawned")]
30 AgentSpawned {
31 spawned_by_agent_instance_id: String,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 reason: Option<String>,
34 },
35
36 #[serde(rename = "agent.handoff")]
37 AgentHandoff {
38 from_agent_instance_id: String,
39 to_agent_instance_id: String,
40 #[serde(default, skip_serializing_if = "Vec::is_empty")]
41 artifacts: Vec<String>,
42 },
43
44 #[serde(rename = "agent.collaborated")]
45 AgentCollaborated {
46 #[serde(default, skip_serializing_if = "Vec::is_empty")]
47 collaborator_agent_instance_ids: Vec<String>,
48 },
49
50 #[serde(rename = "agent.returned")]
51 AgentReturned {
52 returned_to_agent_instance_id: String,
53 },
54
55 #[serde(rename = "agent.completed")]
56 AgentCompleted {
57 #[serde(skip_serializing_if = "Option::is_none")]
58 termination_reason: Option<String>,
59 },
60
61 #[serde(rename = "agent.failed")]
62 AgentFailed {
63 #[serde(skip_serializing_if = "Option::is_none")]
64 reason: Option<String>,
65 },
66
67 #[serde(rename = "agent.called_tool")]
68 AgentCalledTool {
69 tool_name: String,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 tool_input_digest: Option<String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 tool_output_digest: Option<String>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 duration_ms: Option<u64>,
76 },
77
78 #[serde(rename = "agent.read_file")]
79 AgentReadFile {
80 file_path: String,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 digest: Option<String>,
83 },
84
85 #[serde(rename = "agent.wrote_file")]
86 AgentWroteFile {
87 file_path: String,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 digest: Option<String>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 operation: Option<String>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 additions: Option<u32>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 deletions: Option<u32>,
97 },
98
99 #[serde(rename = "agent.opened_port")]
100 AgentOpenedPort {
101 port: u16,
102 #[serde(skip_serializing_if = "Option::is_none")]
103 protocol: Option<String>,
104 },
105
106 #[serde(rename = "agent.connected_network")]
107 AgentConnectedNetwork {
108 destination: String,
109 #[serde(skip_serializing_if = "Option::is_none")]
110 port: Option<u16>,
111 },
112
113 #[serde(rename = "agent.started_process")]
114 AgentStartedProcess {
115 process_name: String,
116 #[serde(skip_serializing_if = "Option::is_none")]
117 pid: Option<u32>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
120 command: Option<String>,
121 },
122
123 #[serde(rename = "agent.completed_process")]
124 AgentCompletedProcess {
125 process_name: String,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 exit_code: Option<i32>,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 duration_ms: Option<u64>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 command: Option<String>,
132 },
133
134 #[serde(rename = "agent.note")]
146 AgentNote {
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 text: Option<String>,
149 },
150
151 #[serde(rename = "agent.decision")]
152 AgentDecision {
153 #[serde(skip_serializing_if = "Option::is_none")]
154 model: Option<String>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 tokens_in: Option<u64>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 tokens_out: Option<u64>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 provider: Option<String>,
162 #[serde(skip_serializing_if = "Option::is_none")]
163 summary: Option<String>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 confidence: Option<f64>,
166 },
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct SessionEvent {
172 pub session_id: String,
173 pub event_id: String,
174 pub timestamp: String,
175 pub sequence_no: u64,
176 pub trace_id: String,
177 pub span_id: String,
178 #[serde(skip_serializing_if = "Option::is_none")]
179 pub parent_span_id: Option<String>,
180 pub agent_id: String,
181 pub agent_instance_id: String,
182 pub agent_name: String,
183 #[serde(skip_serializing_if = "Option::is_none")]
184 pub agent_role: Option<String>,
185 pub host_id: String,
186 #[serde(skip_serializing_if = "Option::is_none")]
187 pub tool_runtime_id: Option<String>,
188 #[serde(flatten)]
189 pub event_type: EventType,
190 #[serde(skip_serializing_if = "Option::is_none")]
191 pub artifact_ref: Option<String>,
192 #[serde(skip_serializing_if = "Option::is_none")]
193 pub meta: Option<serde_json::Value>,
194}
195
196pub fn generate_event_id() -> String {
198 let mut buf = [0u8; 8];
199 use rand::RngCore;
200 rand::thread_rng().fill_bytes(&mut buf);
201 format!("evt_{}", hex::encode(buf))
202}
203
204pub fn generate_span_id() -> String {
206 let mut buf = [0u8; 8];
207 use rand::RngCore;
208 rand::thread_rng().fill_bytes(&mut buf);
209 hex::encode(buf)
210}
211
212pub fn generate_trace_id() -> String {
214 let mut buf = [0u8; 16];
215 use rand::RngCore;
216 rand::thread_rng().fill_bytes(&mut buf);
217 hex::encode(buf)
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn event_type_serialization() {
226 let evt = EventType::AgentCalledTool {
227 tool_name: "read_file".into(),
228 tool_input_digest: None,
229 tool_output_digest: None,
230 duration_ms: Some(42),
231 };
232 let json = serde_json::to_string(&evt).unwrap();
233 assert!(json.contains("agent.called_tool"));
234 assert!(json.contains("read_file"));
235
236 let back: EventType = serde_json::from_str(&json).unwrap();
237 assert_eq!(back, evt);
238 }
239
240 #[test]
241 fn full_event_roundtrip() {
242 let event = SessionEvent {
243 session_id: "ssn_001".into(),
244 event_id: generate_event_id(),
245 timestamp: "2026-04-05T08:00:00Z".into(),
246 sequence_no: 1,
247 trace_id: generate_trace_id(),
248 span_id: generate_span_id(),
249 parent_span_id: None,
250 agent_id: "agent://claude-code".into(),
251 agent_instance_id: "ai_cc_1".into(),
252 agent_name: "claude-code".into(),
253 agent_role: Some("planner".into()),
254 host_id: "host_macbook".into(),
255 tool_runtime_id: Some("rt_cc_1".into()),
256 event_type: EventType::AgentStarted {
257 parent_agent_instance_id: None,
258 },
259 artifact_ref: None,
260 meta: None,
261 };
262
263 let json = serde_json::to_string_pretty(&event).unwrap();
264 let back: SessionEvent = serde_json::from_str(&json).unwrap();
265 assert_eq!(back.session_id, "ssn_001");
266 assert_eq!(back.agent_name, "claude-code");
267 }
268
269 #[test]
270 fn id_generation() {
271 let eid = generate_event_id();
272 assert!(eid.starts_with("evt_"));
273 assert_eq!(eid.len(), 4 + 16); let sid = generate_span_id();
276 assert_eq!(sid.len(), 16);
277
278 let tid = generate_trace_id();
279 assert_eq!(tid.len(), 32);
280 }
281}