Skip to main content

treeship_core/session/
event.rs

1//! Session event model for Session Receipt v1.
2//!
3//! Events are the raw building blocks of a session. They are emitted by
4//! SDKs, CLI wrappers, and daemons, then composed into the receipt.
5
6use serde::{Deserialize, Serialize};
7
8/// All supported session event types.
9#[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        /// "created", "modified", or "deleted". Absent in legacy events.
91        #[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        /// Full command string (e.g. "npm test --runInBand"). Absent in legacy events.
119        #[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    /// LLM inference decision with model and token usage.
135    ///
136    // Cost is deliberately not captured. Pricing depends on provider,
137    // subscription tier, contract rates, and timing. Baking a dollar
138    // amount into a signed receipt would make old receipts falsely
139    // signed when pricing changes. Consumers (dashboards, billing tools)
140    // calculate cost from model + tokens + their pricing config.
141    /// A free-form note the agent wants on the timeline: what it was trying
142    /// to do, what it decided not to do, why it stopped. Text only; it makes
143    /// no claim about a model, a tool or a file, and the verifier treats it
144    /// as the agent's own words.
145    #[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        /// Provider e.g. "anthropic", "openrouter", "bedrock", "openai"
160        #[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/// A single session event with full context.
170#[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
196/// Generate a random event ID: `evt_<16 hex chars>`.
197pub 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
204/// Generate a random span ID: 16 hex chars (8 bytes, W3C compatible).
205pub 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
212/// Generate a random trace ID: 32 hex chars (16 bytes, W3C compatible).
213pub 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); // "evt_" + 16 hex
274
275        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}