1use crate::messages::Notification;
34use crate::protocol_generated::types::{Thread, ThreadItem, Turn, TurnError, TurnStatus};
35use serde::de::Error as _;
36use serde::{Deserialize, Deserializer, Serialize, Serializer};
37use serde_json::Value;
38
39#[allow(clippy::large_enum_variant)]
44#[derive(Debug, Clone, PartialEq)]
45pub enum ExecEvent {
46 ThreadStarted { thread_id: String, thread: Thread },
49 TurnStarted { thread_id: String, turn: Turn },
51 TurnCompleted { thread_id: String, turn: Turn },
54 TurnFailed {
56 thread_id: String,
57 turn_id: String,
58 error: TurnError,
59 },
60 ItemStarted {
62 thread_id: String,
63 started_at_ms: i64,
64 item: ThreadItem,
65 },
66 ItemCompleted {
68 thread_id: String,
69 completed_at_ms: i64,
70 item: ThreadItem,
71 },
72 Raw {
76 method: String,
77 params: Option<Value>,
78 },
79}
80
81impl ExecEvent {
82 pub fn from_notification(notification: Notification) -> ExecEvent {
86 match notification {
87 Notification::ThreadStarted(n) => ExecEvent::ThreadStarted {
88 thread_id: n.thread.id.clone(),
89 thread: n.thread,
90 },
91 Notification::TurnStarted(n) => ExecEvent::TurnStarted {
92 thread_id: n.thread_id,
93 turn: n.turn,
94 },
95 Notification::TurnCompleted(n) => ExecEvent::TurnCompleted {
96 thread_id: n.thread_id,
97 turn: n.turn,
98 },
99 Notification::Error(n) => ExecEvent::TurnFailed {
100 thread_id: n.thread_id,
101 turn_id: n.turn_id,
102 error: n.error,
103 },
104 Notification::ItemStarted(n) => ExecEvent::ItemStarted {
105 thread_id: n.thread_id,
106 started_at_ms: n.started_at_ms,
107 item: n.item,
108 },
109 Notification::ItemCompleted(n) => ExecEvent::ItemCompleted {
110 thread_id: n.thread_id,
111 completed_at_ms: n.completed_at_ms,
112 item: n.item,
113 },
114 other => {
115 let method = other.method().to_string();
116 match other.into_envelope() {
117 Ok((_, params)) => ExecEvent::Raw { method, params },
118 Err(_) => ExecEvent::Raw {
121 method,
122 params: None,
123 },
124 }
125 }
126 }
127 }
128
129 pub fn tag(&self) -> &str {
131 match self {
132 ExecEvent::ThreadStarted { .. } => "thread.started",
133 ExecEvent::TurnStarted { .. } => "turn.started",
134 ExecEvent::TurnCompleted { .. } => "turn.completed",
135 ExecEvent::TurnFailed { .. } => "turn.failed",
136 ExecEvent::ItemStarted { .. } => "item.started",
137 ExecEvent::ItemCompleted { .. } => "item.completed",
138 ExecEvent::Raw { method, .. } => method,
139 }
140 }
141}
142
143#[allow(clippy::large_enum_variant)]
146#[derive(Serialize, Deserialize)]
147#[serde(tag = "type")]
148enum LifecycleWire {
149 #[serde(rename = "thread.started")]
150 ThreadStarted { thread_id: String, thread: Thread },
151 #[serde(rename = "turn.started")]
152 TurnStarted { thread_id: String, turn: Turn },
153 #[serde(rename = "turn.completed")]
154 TurnCompleted {
155 thread_id: String,
156 turn_id: String,
157 status: TurnStatus,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 duration_ms: Option<i64>,
160 turn: Turn,
161 },
162 #[serde(rename = "turn.failed")]
163 TurnFailed {
164 thread_id: String,
165 turn_id: String,
166 error: TurnError,
167 },
168 #[serde(rename = "item.started")]
169 ItemStarted {
170 thread_id: String,
171 started_at_ms: i64,
172 item: ThreadItem,
173 },
174 #[serde(rename = "item.completed")]
175 ItemCompleted {
176 thread_id: String,
177 completed_at_ms: i64,
178 item: ThreadItem,
179 },
180}
181
182impl Serialize for ExecEvent {
183 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
184 match self {
185 ExecEvent::Raw { method, params } => {
186 use serde::ser::SerializeMap;
187 let mut map = serializer.serialize_map(None)?;
188 map.serialize_entry("type", method)?;
189 if let Some(params) = params {
190 map.serialize_entry("params", params)?;
191 }
192 map.end()
193 }
194 ExecEvent::TurnCompleted { thread_id, turn } => LifecycleWire::TurnCompleted {
195 thread_id: thread_id.clone(),
196 turn_id: turn.id.clone(),
197 status: turn.status.clone(),
198 duration_ms: turn.duration_ms,
199 turn: turn.clone(),
200 }
201 .serialize(serializer),
202 ExecEvent::ThreadStarted { thread_id, thread } => LifecycleWire::ThreadStarted {
203 thread_id: thread_id.clone(),
204 thread: thread.clone(),
205 }
206 .serialize(serializer),
207 ExecEvent::TurnStarted { thread_id, turn } => LifecycleWire::TurnStarted {
208 thread_id: thread_id.clone(),
209 turn: turn.clone(),
210 }
211 .serialize(serializer),
212 ExecEvent::TurnFailed {
213 thread_id,
214 turn_id,
215 error,
216 } => LifecycleWire::TurnFailed {
217 thread_id: thread_id.clone(),
218 turn_id: turn_id.clone(),
219 error: error.clone(),
220 }
221 .serialize(serializer),
222 ExecEvent::ItemStarted {
223 thread_id,
224 started_at_ms,
225 item,
226 } => LifecycleWire::ItemStarted {
227 thread_id: thread_id.clone(),
228 started_at_ms: *started_at_ms,
229 item: item.clone(),
230 }
231 .serialize(serializer),
232 ExecEvent::ItemCompleted {
233 thread_id,
234 completed_at_ms,
235 item,
236 } => LifecycleWire::ItemCompleted {
237 thread_id: thread_id.clone(),
238 completed_at_ms: *completed_at_ms,
239 item: item.clone(),
240 }
241 .serialize(serializer),
242 }
243 }
244}
245
246impl<'de> Deserialize<'de> for ExecEvent {
247 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
248 let value = Value::deserialize(deserializer)?;
249 let tag = value
250 .get("type")
251 .and_then(|t| t.as_str())
252 .ok_or_else(|| D::Error::missing_field("type"))?;
253 match tag {
254 "thread.started" | "turn.started" | "turn.completed" | "turn.failed"
255 | "item.started" | "item.completed" => {
256 let wire: LifecycleWire =
257 serde_json::from_value(value).map_err(D::Error::custom)?;
258 Ok(match wire {
259 LifecycleWire::ThreadStarted { thread_id, thread } => {
260 ExecEvent::ThreadStarted { thread_id, thread }
261 }
262 LifecycleWire::TurnStarted { thread_id, turn } => {
263 ExecEvent::TurnStarted { thread_id, turn }
264 }
265 LifecycleWire::TurnCompleted {
266 thread_id, turn, ..
267 } => ExecEvent::TurnCompleted { thread_id, turn },
268 LifecycleWire::TurnFailed {
269 thread_id,
270 turn_id,
271 error,
272 } => ExecEvent::TurnFailed {
273 thread_id,
274 turn_id,
275 error,
276 },
277 LifecycleWire::ItemStarted {
278 thread_id,
279 started_at_ms,
280 item,
281 } => ExecEvent::ItemStarted {
282 thread_id,
283 started_at_ms,
284 item,
285 },
286 LifecycleWire::ItemCompleted {
287 thread_id,
288 completed_at_ms,
289 item,
290 } => ExecEvent::ItemCompleted {
291 thread_id,
292 completed_at_ms,
293 item,
294 },
295 })
296 }
297 method => Ok(ExecEvent::Raw {
298 method: method.to_string(),
299 params: value.get("params").cloned(),
300 }),
301 }
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
313 fn lifecycle_events_carry_the_consumer_fields_flat() {
314 let n = Notification::from_envelope(
315 "turn/completed",
316 Some(serde_json::json!({
317 "threadId": "t-1",
318 "turn": {"id": "turn-9", "status": "completed", "durationMs": 42,
319 "items": [], "threadId": "t-1"}
320 })),
321 )
322 .expect("typed");
323 let v = serde_json::to_value(ExecEvent::from_notification(n)).expect("serialize");
324 assert_eq!(v["type"], "turn.completed");
325 assert_eq!(v["turn_id"], "turn-9");
326 assert_eq!(v["status"], "completed");
327 assert_eq!(v["duration_ms"], 42);
328 assert_eq!(v["thread_id"], "t-1");
329 }
330
331 #[test]
333 fn thread_started_is_flat() {
334 let n = Notification::from_envelope(
335 "thread/started",
336 Some(serde_json::json!({
337 "thread": {"id": "t-7", "status": {"type": "idle"}, "items": [],
338 "cliVersion": "0.147.0", "createdAt": 1, "updatedAt": 1,
339 "cwd": "/", "ephemeral": false, "originator": "test",
340 "preset": null, "modelSlug": "m", "turns": []}
341 })),
342 )
343 .expect("typed");
344 let v = serde_json::to_value(ExecEvent::from_notification(n)).expect("serialize");
345 assert_eq!(v["type"], "thread.started");
346 assert_eq!(v["thread_id"], "t-7");
347 assert_eq!(v["thread"]["id"], "t-7");
348 }
349
350 #[test]
354 fn item_events_use_dotted_tags_and_camel_item_types() {
355 let n = Notification::from_envelope(
356 "item/completed",
357 Some(serde_json::json!({
358 "threadId": "t-1",
359 "completedAtMs": 5,
360 "item": {"type": "agentMessage", "id": "i-1", "text": "hi"}
361 })),
362 )
363 .expect("typed");
364 let v = serde_json::to_value(ExecEvent::from_notification(n)).expect("serialize");
365 assert_eq!(v["type"], "item.completed");
366 assert_eq!(v["item"]["type"], "agentMessage");
367 }
368
369 #[test]
374 fn forwarded_notifications_keep_slash_tags_and_params() {
375 let n = Notification::from_envelope(
378 "turn/diff/updated",
379 Some(serde_json::json!({"threadId": "t-1", "turnId": "u-1", "diff": "+x"})),
380 )
381 .expect("routes");
382 let v = serde_json::to_value(ExecEvent::from_notification(n)).expect("serialize");
383 assert_eq!(
384 v["type"], "turn/diff/updated",
385 "slash tag survives verbatim"
386 );
387 assert_eq!(
388 v["params"]["diff"], "+x",
389 "typed params ride under 'params'"
390 );
391
392 let n = Notification::from_envelope("somefuture/thing", Some(serde_json::json!({"x": 1})))
394 .expect("routes to Unknown");
395 let v = serde_json::to_value(ExecEvent::from_notification(n)).expect("serialize");
396 assert_eq!(v["type"], "somefuture/thing");
397 assert_eq!(v["params"]["x"], 1);
398
399 for method in [
401 "item/fileChange/patchUpdated",
402 "turn/plan/updated",
403 "item/plan/delta",
404 "item/agentMessage/delta",
405 ] {
406 let n =
407 Notification::from_envelope(method, Some(serde_json::json!({}))).expect("routes");
408 let v = serde_json::to_value(ExecEvent::from_notification(n)).expect("serialize");
409 assert_eq!(v["type"], method, "slash tag survives verbatim");
410 }
411 }
412
413 #[test]
417 fn turn_errors_are_turn_failed_never_bare_error() {
418 let n = Notification::from_envelope(
419 "error",
420 Some(serde_json::json!({
421 "threadId": "t-1", "turnId": "turn-1",
422 "error": {"message": "boom"}
423 })),
424 )
425 .expect("typed");
426 let v = serde_json::to_value(ExecEvent::from_notification(n)).expect("serialize");
427 assert_eq!(v["type"], "turn.failed");
428 assert_eq!(v["error"]["message"], "boom");
429 }
430
431 #[test]
434 fn events_round_trip_including_dynamic_raw_tags() {
435 let raw = ExecEvent::Raw {
436 method: "somefuture/thing".into(),
437 params: Some(serde_json::json!({"x": 1})),
438 };
439 let v = serde_json::to_value(&raw).expect("serialize");
440 let back: ExecEvent = serde_json::from_value(v).expect("deserialize");
441 assert_eq!(back, raw);
442 }
443}