rightkit_agent_engine_protocol/event/
envelope.rs1use super::ProtocolEvent;
2use crate::TranscriptMessage;
3use serde::{Deserialize, Serialize};
4
5pub const EVENT_ENVELOPE_SCHEMA_V1: u16 = 1;
6
7#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct EventCorrelationV1 {
13 #[serde(default, skip_serializing_if = "Option::is_none")]
14 pub run_id: Option<String>,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub turn_id: Option<String>,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub task_id: Option<String>,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub tool_call_id: Option<String>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub attempt_id: Option<String>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub causal_parent_event_id: Option<String>,
25}
26
27impl EventCorrelationV1 {
28 pub fn from_event(event: &ProtocolEvent) -> Self {
33 let mut correlation = Self::default();
34 match event {
35 ProtocolEvent::SessionTurnFinished { turn_id, .. } => {
36 correlation.turn_id = Some(turn_id.clone());
37 }
38 ProtocolEvent::ToolCallStarted { call, .. }
39 | ProtocolEvent::ToolApprovalRequested { call, .. } => {
40 correlation.tool_call_id = Some(call.id.clone());
41 }
42 ProtocolEvent::ToolApprovalResolved { tool_call_id, .. }
43 | ProtocolEvent::ToolApprovalReviewed { tool_call_id, .. } => {
44 correlation.tool_call_id = Some(tool_call_id.clone());
45 }
46 ProtocolEvent::CredentialRequested { tool_call_id, .. } => {
47 correlation.tool_call_id = Some(tool_call_id.clone());
48 }
49 ProtocolEvent::CredentialResolved { tool_call_id, .. } => {
50 correlation.tool_call_id = Some(tool_call_id.clone());
51 }
52 ProtocolEvent::ToolResultAdded {
53 result: TranscriptMessage::Tool { tool_call_id, .. },
54 ..
55 } => {
56 correlation.tool_call_id = Some(tool_call_id.clone());
57 }
58 ProtocolEvent::TaskStatusUpdated { task_id, .. }
59 | ProtocolEvent::TaskArtifactUpdated { task_id, .. }
60 | ProtocolEvent::ArtifactLineageUpdated { task_id, .. } => {
61 correlation.task_id = Some(task_id.clone());
62 }
63 ProtocolEvent::SessionContextFolded { subtask_id, .. } => {
64 correlation.task_id = Some(subtask_id.clone());
65 }
66 ProtocolEvent::RunGovernanceRejected {
67 tool_call_id,
68 budget,
69 ownership,
70 origin,
71 ..
72 } => {
73 correlation.tool_call_id = Some(tool_call_id.clone());
74 correlation.run_id = budget
75 .as_ref()
76 .map(|value| value.run_id.clone())
77 .or_else(|| origin.as_ref().map(|value| value.run_id.clone()))
78 .or_else(|| ownership.as_ref().map(|value| value.target_run_id.clone()));
79 }
80 _ => {}
81 }
82 correlation
83 }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct EventEnvelopeV1 {
94 pub schema_version: u16,
95 pub event_id: String,
96 #[serde(default)]
97 pub correlation: EventCorrelationV1,
98 pub event: ProtocolEvent,
99}
100
101impl EventEnvelopeV1 {
102 pub fn canonical(event: ProtocolEvent) -> Result<Self, String> {
107 let correlation = EventCorrelationV1::from_event(&event);
108 Self::for_durable_event(event, correlation)
109 }
110
111 pub fn for_durable_event(
112 event: ProtocolEvent,
113 correlation: EventCorrelationV1,
114 ) -> Result<Self, String> {
115 if !event.is_durable_fact() {
116 return Err("live projection events do not receive durable event envelopes".into());
117 }
118 let meta = event.meta();
119 if meta.session_id.trim().is_empty() {
120 return Err("durable event envelope requires session_id".into());
121 }
122 if meta.seq < 1 {
123 return Err("durable event envelope requires committed sequence >= 1".into());
124 }
125 Ok(Self {
126 schema_version: EVENT_ENVELOPE_SCHEMA_V1,
127 event_id: stable_event_id(&meta.session_id, meta.seq),
128 correlation,
129 event,
130 })
131 }
132}
133
134pub fn stable_event_id(session_id: &str, seq: i64) -> String {
135 format!("evt-v1:{session_id}:{seq}")
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use crate::TranscriptMessage;
142
143 fn durable(seq: i64) -> ProtocolEvent {
144 ProtocolEvent::AssistantMessageCompleted {
145 session_id: "session-a".into(),
146 seq,
147 created_at: "t".into(),
148 message: TranscriptMessage::Assistant {
149 id: "m".into(),
150 created_at: "t".into(),
151 content: "done".into(),
152 reasoning_content: None,
153 tool_calls: Vec::new(),
154 pinned: false,
155 },
156 }
157 }
158
159 #[test]
160 fn replay_identity_is_stable_for_same_session_sequence() {
161 let first =
162 EventEnvelopeV1::for_durable_event(durable(7), EventCorrelationV1::default()).unwrap();
163 let second =
164 EventEnvelopeV1::for_durable_event(durable(7), EventCorrelationV1::default()).unwrap();
165 assert_eq!(first.event_id, second.event_id);
166 assert_eq!(first.event_id, "evt-v1:session-a:7");
167 assert_eq!(first.schema_version, EVENT_ENVELOPE_SCHEMA_V1);
168 }
169
170 #[test]
171 fn correlation_is_additive_and_does_not_replace_event_routing() {
172 let envelope = EventEnvelopeV1::for_durable_event(
173 durable(9),
174 EventCorrelationV1 {
175 run_id: Some("run-1".into()),
176 turn_id: Some("turn-1".into()),
177 tool_call_id: Some("tool-1".into()),
178 ..EventCorrelationV1::default()
179 },
180 )
181 .unwrap();
182 assert_eq!(envelope.correlation.run_id.as_deref(), Some("run-1"));
183 assert_eq!(envelope.event.meta().seq, 9);
184 assert_eq!(envelope.event.meta().session_id, "session-a");
185 }
186
187 #[test]
188 fn canonical_extracts_turn_task_and_tool_correlation() {
189 let turn = ProtocolEvent::SessionTurnFinished {
190 session_id: "session-a".into(),
191 seq: 10,
192 created_at: "t".into(),
193 turn_id: "turn-10".into(),
194 status: "completed".into(),
195 };
196 let envelope = EventEnvelopeV1::canonical(turn).unwrap();
197 assert_eq!(envelope.correlation.turn_id.as_deref(), Some("turn-10"));
198
199 let tool = ProtocolEvent::ToolCallStarted {
200 session_id: "session-a".into(),
201 seq: 11,
202 created_at: "t".into(),
203 call: crate::ToolCall {
204 id: "tool-11".into(),
205 name: "read_file".into(),
206 arguments_json: "{}".into(),
207 },
208 };
209 let envelope = EventEnvelopeV1::canonical(tool).unwrap();
210 assert_eq!(
211 envelope.correlation.tool_call_id.as_deref(),
212 Some("tool-11")
213 );
214
215 let task = ProtocolEvent::TaskStatusUpdated {
216 session_id: "session-a".into(),
217 seq: 12,
218 created_at: "t".into(),
219 task_id: "task-12".into(),
220 context_id: "ctx".into(),
221 state: "running".into(),
222 task: serde_json::json!({}),
223 };
224 let envelope = EventEnvelopeV1::canonical(task).unwrap();
225 assert_eq!(envelope.correlation.task_id.as_deref(), Some("task-12"));
226 }
227
228 #[test]
229 fn canonical_extracts_tool_result_and_run_governance_correlation() {
230 let result = ProtocolEvent::ToolResultAdded {
231 session_id: "session-a".into(),
232 seq: 13,
233 created_at: "t".into(),
234 result: TranscriptMessage::Tool {
235 id: "result".into(),
236 created_at: "t".into(),
237 tool_call_id: "tool-13".into(),
238 summary: "ok".into(),
239 output: None,
240 trust: None,
241 is_error: false,
242 pinned: false,
243 process_id: None,
244 },
245 };
246 let envelope = EventEnvelopeV1::canonical(result).unwrap();
247 assert_eq!(
248 envelope.correlation.tool_call_id.as_deref(),
249 Some("tool-13")
250 );
251
252 let rejected = ProtocolEvent::RunGovernanceRejected {
253 session_id: "session-a".into(),
254 seq: 14,
255 created_at: "t".into(),
256 kind: crate::event::RunFailureKind::OriginMissing,
257 tool_call_id: "tool-14".into(),
258 budget: None,
259 ownership: None,
260 origin: Some(crate::event::RunOriginFailure {
261 run_id: "run-14".into(),
262 tool_call_id: "tool-14".into(),
263 reason: "missing".into(),
264 }),
265 };
266 let envelope = EventEnvelopeV1::canonical(rejected).unwrap();
267 assert_eq!(envelope.correlation.run_id.as_deref(), Some("run-14"));
268 assert_eq!(
269 envelope.correlation.tool_call_id.as_deref(),
270 Some("tool-14")
271 );
272 }
273
274 #[test]
275 fn live_projection_is_rejected() {
276 let delta = ProtocolEvent::AssistantDelta {
277 session_id: "session-a".into(),
278 seq: 3,
279 created_at: "t".into(),
280 text: "partial".into(),
281 is_reasoning: false,
282 };
283 assert!(EventEnvelopeV1::for_durable_event(delta, EventCorrelationV1::default()).is_err());
284 }
285}