Skip to main content

recursive/
event.rs

1//! Event-sink abstraction for consuming agent lifecycle events.
2//!
3//! This module defines [`AgentEvent`] (a serialisable, non-exhaustive enum of
4//! agent lifecycle events), the [`EventSink`] trait (the abstract consumer),
5//! and four concrete implementations:
6//!
7//! * [`ChannelSink`] — delivers events into a `tokio::sync::mpsc` channel.
8//! * [`BroadcastSink`] — delivers events into a `tokio::sync::broadcast` channel.
9//! * [`NullSink`] — discards every event (no-op).
10//! * [`CompositeSink`] — fans out to multiple inner sinks.
11
12use serde::{Deserialize, Serialize};
13use tokio::sync::broadcast;
14use tokio::sync::mpsc;
15
16// ---------------------------------------------------------------------------
17// AgentEvent
18// ---------------------------------------------------------------------------
19
20/// A serialisable, non-exhaustive agent lifecycle event.
21///
22/// Uses `String` for the finish reason to avoid coupling to the
23/// `FinishReason` type.  New variants can be added without a breaking change
24/// (see `#[non_exhaustive]`).
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26#[serde(tag = "type", rename_all = "snake_case")]
27#[non_exhaustive]
28pub enum AgentEvent {
29    /// Model generated text without tool calls.
30    AssistantText { text: String, step: usize },
31    /// Model requested to execute a tool.
32    ToolCall {
33        name: String,
34        id: String,
35        arguments: String,
36        step: usize,
37    },
38    /// Time taken for the LLM request (excluding tool execution), in ms.
39    Latency { step: usize, llm_ms: u64 },
40    /// Result of executing a tool call.
41    ToolResult {
42        id: String,
43        name: String,
44        output: String,
45        step: usize,
46        /// `true` if the tool execution produced an error (output begins with
47        /// `"ERROR: "` or was a permission denial). UI consumers should use
48        /// this field instead of inspecting the `output` string prefix.
49        #[serde(default)]
50        is_error: bool,
51    },
52    /// Token usage statistics from the LLM provider.
53    Usage {
54        input_tokens: u32,
55        output_tokens: u32,
56        /// Tokens served from the provider's prompt cache (cache-hit pricing applies).
57        cache_hit_tokens: u32,
58        /// Tokens written to the provider's prompt cache (full input pricing applies).
59        cache_miss_tokens: u32,
60        step: usize,
61    },
62    /// Goal-328: locally-estimated per-component token breakdown of the
63    /// prompt sent to the provider for `step`. Distinct from
64    /// [`Self::Usage`] (the provider's reported truth) — see
65    /// [`crate::llm::ContextBreakdown`] for the bucket definitions.
66    ///
67    /// Emitted once per step right after [`Self::Usage`] so consumers
68    /// see the provider truth first, then the local estimate. Steps
69    /// without an LLM call (denial-limit finishes, stuck-detection
70    /// short-circuits) skip the event entirely.
71    ContextBreakdown {
72        breakdown: crate::llm::ContextBreakdown,
73        step: usize,
74    },
75    /// Partial token from streaming response (if streaming enabled).
76    PartialToken { text: String, step: usize },
77    /// Partial reasoning / thinking delta from a streaming response.
78    /// Emitted live (before the answer's `PartialToken` deltas) for
79    /// providers that stream an explicit reasoning channel, so UI
80    /// layers can render the chain-of-thought as it is produced. The
81    /// authoritative, fully-joined reasoning string still arrives once
82    /// per step via [`AgentEvent::Reasoning`], which finalises the
83    /// streamed block.
84    PartialReasoning { text: String, step: usize },
85    /// Reasoning / thinking content from a model that exposes an
86    /// explicit reasoning channel (DeepSeek R1, OpenAI o1, etc.).
87    /// Carries the full reasoning text for the current step;
88    /// providers that stream reasoning tokens (DeepSeek's
89    /// `reasoning_content` SSE deltas) accumulate them and emit
90    /// the final, fully-joined string in this event. UI layers
91    /// render it as a `thinking…` block separate from the
92    /// assistant message body. Emitted exactly once per step
93    /// that produced reasoning content; steps without reasoning
94    /// skip the event.
95    Reasoning { text: String, step: usize },
96    /// No-LLM proactive pruning of old tool results by count.
97    /// Emitted with `pruned > 0` when the [`Microcompactor`] replaced
98    /// the content of old tool-result messages with a placeholder.
99    ///
100    /// [`Microcompactor`]: crate::compact::micro::Microcompactor
101    Microcompact { step: usize, pruned: usize },
102    /// Transcript was compacted to fit size constraints.
103    Compacted {
104        removed: usize,
105        kept: usize,
106        summary_chars: usize,
107        step: usize,
108    },
109    /// Proactive compaction was skipped — either the circuit breaker tripped
110    /// (`CompactionSkipReason::CircuitBreaker`) or the compaction call itself
111    /// failed (`CompactionSkipReason::Error`). Compaction is best-effort;
112    /// a skipped compaction is never fatal to the turn.
113    CompactionSkipped {
114        step: usize,
115        reason: CompactionSkipReason,
116    },
117    /// Agent run completed.
118    TurnFinished {
119        /// Human-readable reason for termination (e.g. "no_more_tool_calls").
120        reason: String,
121        /// Number of iterations executed.
122        steps: usize,
123    },
124    /// Goal-202: Agent is requesting permission to enter plan mode.
125    /// Emitted by `RequestPlanModeTool` before any exploration begins.
126    /// The TUI / HTTP surface should prompt the user and call
127    /// `AgentRuntime::approve_plan_mode_request` or `reject_plan_mode_request`.
128    PlanModeRequested { reason: String },
129    /// Goal-202: The user approved the plan-mode entry request.
130    PlanModeApproved,
131    /// Goal-202: The user rejected the plan-mode entry request.
132    PlanModeRejected { reason: String },
133
134    /// Agent has produced a plan and is waiting for confirmation.
135    PlanProposed {
136        plan_text: String,
137        tool_calls: Vec<serde_json::Value>,
138    },
139    /// Plan was confirmed, execution will proceed.
140    PlanConfirmed,
141    /// Plan was rejected with a reason.
142    PlanRejected { reason: String },
143
144    /// A complete message was just appended to the agent transcript.
145    ///
146    /// Fired exactly once per committed message inside the agent runtime:
147    /// once for the user message that starts a turn, once for the compaction
148    /// summary if cross-turn compaction fires, and once per message in the
149    /// kernel's output batch. Carries the full `Message` (role, content,
150    /// tool_calls, tool_call_id, reasoning_content) so persistence consumers
151    /// can write the canonical record without reassembling it from the finer
152    /// `AssistantText` / `ToolCall` / `ToolResult` streaming events.
153    ///
154    /// `usage` — token usage for this message (non-None for assistant messages
155    /// produced by an LLM call, g156).
156    ///
157    /// Not emitted for seeded transcript messages loaded from an existing
158    /// session on resume (those are already on disk).
159    MessageAppended {
160        message: crate::message::Message,
161        /// Token usage for this message (g156).
162        usage: Option<crate::session::UsageMeta>,
163    },
164
165    /// Variant of [`MessageAppended`] specifically for `Role::Tool` messages
166    /// that have an associated [`AuditMeta`] (Goal 153). The persistence sink
167    /// handles this identically to `MessageAppended` but populates the
168    /// `audit` field of [`crate::session::TranscriptEntry`].
169    ///
170    /// Emitting a separate variant (rather than `Option<AuditMeta>` on
171    /// `MessageAppended`) keeps the common path zero-cost and avoids
172    /// making audit an optional field on every event.
173    MessageAppendedWithAudit {
174        message: crate::message::Message,
175        audit: crate::tools::AuditMeta,
176    },
177
178    /// Cross-turn compaction just fired; a compact_boundary marker should be
179    /// written to the session JSONL (g157).
180    ///
181    /// `turn` — the turn index when compaction occurred.
182    /// `compacted_count` — how many messages were removed.
183    /// `summary_uuid` — UUID of the compaction summary message that replaced them.
184    /// `cache_hit_tokens` — tokens served from the provider's prompt cache on
185    ///   the just-completed turn (g336). Zero when no usage was reported.
186    /// `cache_miss_tokens` — tokens that missed the prompt cache on the
187    ///   just-completed turn (g336). Zero when no usage was reported.
188    /// `is_recompaction_in_chain` — true when this is not the first compaction
189    ///   in the session (g338). Signals a recompaction chain — the compaction
190    ///   strategy may be failing for this session.
191    /// `turns_since_previous_compact` — how many turns elapsed since the
192    ///   previous compaction (g338). `0` when no previous compaction.
193    /// `previous_compact_turn` — the turn index of the previous compaction,
194    ///   or `None` on the first compaction (g338).
195    CompactionBoundary {
196        turn: u32,
197        compacted_count: usize,
198        summary_uuid: Option<String>,
199        cache_hit_tokens: u32,
200        cache_miss_tokens: u32,
201        is_recompaction_in_chain: bool,
202        turns_since_previous_compact: u32,
203        previous_compact_turn: Option<u32>,
204    },
205
206    /// Goal-167: emitted when the agent updates its task checklist via
207    /// `todo_write`. Carries the complete replacement list so consumers
208    /// can render the current state without storing diffs.
209    TodoUpdated {
210        todos: Vec<crate::tools::todo::TodoItem>,
211    },
212
213    // ── Goal-168: /goal condition-based autonomous loop ──────────────
214    /// A `/goal` was set for this session.
215    GoalSet {
216        /// The completion condition as written by the user.
217        condition: String,
218        /// Hard cap on autonomous turns.
219        max_turns: u32,
220    },
221    /// The judge evaluated the condition and found it not yet met.
222    /// The loop will continue with another turn.
223    GoalContinuing {
224        /// The judge's explanation for why the condition is not yet met.
225        reason: String,
226        /// Number of turns elapsed so far.
227        turns: u32,
228    },
229    /// The judge evaluated the condition and confirmed it is met.
230    GoalAchieved {
231        /// The original condition string.
232        condition: String,
233        /// Total turns taken to reach the goal.
234        turns: u32,
235    },
236    /// The active goal was cleared — either by `/goal clear`, turn-budget
237    /// exhaustion, or an explicit `DELETE /sessions/:id/goal` call.
238    GoalCleared,
239
240    // ── Goal 210: hook progress events ────────────────────────────
241    /// A hook started executing.
242    HookStarted {
243        hook_event: String,
244        hook_name: String,
245        status_message: Option<String>,
246    },
247    /// A hook produced incremental stdout output.
248    HookProgress {
249        hook_event: String,
250        hook_name: String,
251        last_line: String,
252    },
253    /// A hook finished executing.
254    HookFinished {
255        hook_event: String,
256        hook_name: String,
257        outcome: String,
258        duration_ms: u64,
259    },
260    /// A hook produced a system message to show to the user.
261    HookSystemMessage { text: String },
262
263    /// Emitted when the LLM call fails with a retryable error (rate limit or timeout)
264    /// and the agent will back off before retrying.
265    LlmRetry {
266        /// Which step (ReAct iteration) triggered the retry.
267        step: usize,
268        /// Which retry attempt this is (1 = first retry after initial failure).
269        attempt: u32,
270        /// How many milliseconds the agent will sleep before the next attempt.
271        wait_ms: u64,
272        /// Short human-readable description of the error ("rate_limited" or "timeout").
273        reason: String,
274    },
275}
276
277/// Why a proactive compaction was skipped.
278///
279/// See [`AgentEvent::CompactionSkipped`].
280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
281#[serde(rename_all = "snake_case")]
282pub enum CompactionSkipReason {
283    /// The circuit breaker tripped after `MAX_CONSECUTIVE_COMPACT_FAILURES`
284    /// consecutive failures. Further proactive compaction attempts are
285    /// suppressed until a successful compaction resets the counter.
286    CircuitBreaker,
287    /// The compaction call itself failed (provider error, network issue, etc.).
288    Error,
289}
290
291// ---------------------------------------------------------------------------
292// EventSink trait
293// ---------------------------------------------------------------------------
294
295/// Abstract consumer of [`AgentEvent`]s.
296///
297/// Implementations are free to buffer, forward, filter, or discard events.
298/// The trait is designed to be object-safe so that a single `Box<dyn EventSink>`
299/// can hold any implementation.
300#[async_trait::async_trait]
301pub trait EventSink: Send + Sync + 'static {
302    /// Deliver one event to this sink.
303    ///
304    /// Implementations should not panic. If the sink is full or closed, the
305    /// event may be silently dropped (or an error logged).
306    async fn emit(&self, event: AgentEvent);
307}
308
309// ---------------------------------------------------------------------------
310// ChannelSink
311// ---------------------------------------------------------------------------
312
313/// An [`EventSink`] that sends events into a `tokio::sync::mpsc` channel.
314///
315/// If the channel is full (respects the bounded capacity) the event is
316/// silently dropped.
317pub struct ChannelSink {
318    tx: mpsc::UnboundedSender<AgentEvent>,
319}
320
321impl ChannelSink {
322    /// Create a new `ChannelSink` and return it together with the receiver
323    /// half.
324    pub fn new() -> (Self, mpsc::UnboundedReceiver<AgentEvent>) {
325        let (tx, rx) = mpsc::unbounded_channel();
326        (Self { tx }, rx)
327    }
328}
329
330#[async_trait::async_trait]
331impl EventSink for ChannelSink {
332    async fn emit(&self, event: AgentEvent) {
333        let _ = self.tx.send(event);
334    }
335}
336
337// ---------------------------------------------------------------------------
338// BroadcastSink
339// ---------------------------------------------------------------------------
340
341/// An [`EventSink`] that sends events into a `tokio::sync::broadcast` channel.
342///
343/// If all receivers are lagging behind the channel capacity, the oldest
344/// events are dropped (standard broadcast behaviour).
345pub struct BroadcastSink {
346    tx: broadcast::Sender<AgentEvent>,
347}
348
349impl BroadcastSink {
350    /// Create a new `BroadcastSink` with the given channel capacity.
351    pub fn new(capacity: usize) -> (Self, broadcast::Receiver<AgentEvent>) {
352        let (tx, rx) = broadcast::channel(capacity);
353        (Self { tx }, rx)
354    }
355}
356
357#[async_trait::async_trait]
358impl EventSink for BroadcastSink {
359    async fn emit(&self, event: AgentEvent) {
360        let _ = self.tx.send(event);
361    }
362}
363
364// ---------------------------------------------------------------------------
365// NullSink
366// ---------------------------------------------------------------------------
367
368/// An [`EventSink`] that discards every event.
369pub struct NullSink;
370
371#[async_trait::async_trait]
372impl EventSink for NullSink {
373    async fn emit(&self, _event: AgentEvent) {
374        // no-op
375    }
376}
377
378// ---------------------------------------------------------------------------
379// CompositeSink
380// ---------------------------------------------------------------------------
381
382/// An [`EventSink`] that fans out to multiple inner sinks.
383///
384/// Each inner sink receives every event. If any sink panics, the panic
385/// propagates (i.e. there is no `catch_unwind` barrier).
386pub struct CompositeSink {
387    sinks: Vec<Box<dyn EventSink>>,
388}
389
390impl CompositeSink {
391    /// Create a new `CompositeSink` from an iterator of sinks.
392    pub fn new(sinks: impl IntoIterator<Item = Box<dyn EventSink>>) -> Self {
393        Self {
394            sinks: sinks.into_iter().collect(),
395        }
396    }
397}
398
399#[async_trait::async_trait]
400impl EventSink for CompositeSink {
401    async fn emit(&self, event: AgentEvent) {
402        for sink in &self.sinks {
403            sink.emit(event.clone()).await;
404        }
405    }
406}
407
408// ---------------------------------------------------------------------------
409// Tests
410// ---------------------------------------------------------------------------
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    // -- ChannelSink -------------------------------------------------------
417
418    #[tokio::test]
419    async fn channel_sink_delivers_events() {
420        let (sink, mut rx) = ChannelSink::new();
421        let event = AgentEvent::AssistantText {
422            text: "hello".into(),
423            step: 0,
424        };
425
426        sink.emit(event.clone()).await;
427        let received = rx.recv().await.unwrap();
428        assert_eq!(received, event);
429    }
430
431    // -- BroadcastSink -----------------------------------------------------
432
433    #[tokio::test]
434    async fn broadcast_sink_delivers_to_multiple() {
435        let (sink, mut rx1) = BroadcastSink::new(16);
436        let mut rx2 = sink.tx.subscribe();
437
438        let event = AgentEvent::PlanConfirmed;
439        sink.emit(event.clone()).await;
440
441        assert_eq!(rx1.recv().await.unwrap(), event);
442        assert_eq!(rx2.recv().await.unwrap(), event);
443    }
444
445    // -- NullSink ----------------------------------------------------------
446
447    #[tokio::test]
448    async fn null_sink_does_not_panic() {
449        let sink = NullSink;
450        sink.emit(AgentEvent::PlanConfirmed).await;
451        // If we reach here, the test passes.
452    }
453
454    // -- CompositeSink -----------------------------------------------------
455
456    #[tokio::test]
457    async fn composite_sink_fans_out() {
458        let (sink1, mut rx1) = ChannelSink::new();
459        let (sink2, mut rx2) = ChannelSink::new();
460
461        let composite = CompositeSink::new(vec![
462            Box::new(sink1) as Box<dyn EventSink>,
463            Box::new(sink2) as Box<dyn EventSink>,
464        ]);
465
466        let event = AgentEvent::PlanConfirmed;
467        composite.emit(event.clone()).await;
468
469        assert_eq!(rx1.recv().await.unwrap(), event);
470        assert_eq!(rx2.recv().await.unwrap(), event);
471    }
472
473    // -- Serialisation round-trip ------------------------------------------
474
475    #[test]
476    fn agent_event_serialization_round_trip() {
477        let events = vec![
478            AgentEvent::AssistantText {
479                text: "hello".into(),
480                step: 0,
481            },
482            AgentEvent::ToolCall {
483                name: "foo".into(),
484                id: "1".into(),
485                arguments: "{}".into(),
486                step: 1,
487            },
488            AgentEvent::Latency {
489                step: 2,
490                llm_ms: 100,
491            },
492            AgentEvent::ToolResult {
493                id: "1".into(),
494                name: "foo".into(),
495                output: "ok".into(),
496                step: 3,
497                is_error: false,
498            },
499            AgentEvent::Usage {
500                input_tokens: 10,
501                output_tokens: 20,
502                cache_hit_tokens: 5,
503                cache_miss_tokens: 15,
504                step: 4,
505            },
506            AgentEvent::PartialToken {
507                text: "hel".into(),
508                step: 5,
509            },
510            AgentEvent::Microcompact { step: 5, pruned: 3 },
511            AgentEvent::Compacted {
512                removed: 5,
513                kept: 3,
514                summary_chars: 200,
515                step: 6,
516            },
517            AgentEvent::TurnFinished {
518                reason: "done".into(),
519                steps: 7,
520            },
521            AgentEvent::PlanProposed {
522                plan_text: "plan".into(),
523                tool_calls: vec![],
524            },
525            AgentEvent::PlanConfirmed,
526            AgentEvent::PlanRejected {
527                reason: "nope".into(),
528            },
529        ];
530
531        for event in &events {
532            let json = serde_json::to_string(event).unwrap();
533            let deserialized: AgentEvent = serde_json::from_str(&json).unwrap();
534            assert_eq!(*event, deserialized, "round-trip failed for {json}");
535        }
536    }
537
538    // -- MessageAppended unit tests ----------------------------------------
539
540    /// `MessageAppended` carries all three fields (content, tool_calls,
541    /// reasoning_content) through a JSON round-trip intact.
542    #[test]
543    fn message_appended_round_trips_through_event() {
544        use crate::llm::ToolCall as LlmToolCall;
545        use crate::message::Message;
546
547        let tc = LlmToolCall {
548            id: "call_1".into(),
549            name: "Read".into(),
550            arguments: serde_json::json!({"path": "/tmp/foo"}),
551        };
552        let msg = Message {
553            role: crate::message::Role::Assistant,
554            content: "some text".into(),
555            tool_calls: vec![tc.clone()],
556            tool_call_id: None,
557            reasoning_content: Some("my reasoning".into()),
558            is_compaction_summary: false,
559        };
560        let event = AgentEvent::MessageAppended {
561            message: msg.clone(),
562            usage: None,
563        };
564        let json = serde_json::to_string(&event).unwrap();
565        let deserialized: AgentEvent = serde_json::from_str(&json).unwrap();
566        assert_eq!(event, deserialized);
567        if let AgentEvent::MessageAppended { message: m, .. } = deserialized {
568            assert_eq!(m.content, "some text");
569            assert_eq!(m.reasoning_content.as_deref(), Some("my reasoning"));
570            assert_eq!(m.tool_calls.len(), 1);
571            assert_eq!(m.tool_calls[0].name, "Read");
572        } else {
573            panic!("deserialized to wrong variant");
574        }
575    }
576
577    /// `CompositeSink` fans out `MessageAppended` to both inner sinks.
578    #[tokio::test]
579    async fn composite_sink_preserves_message_appended() {
580        use crate::message::Message;
581
582        let (sink1, mut rx1) = ChannelSink::new();
583        let (sink2, mut rx2) = ChannelSink::new();
584        let composite = CompositeSink::new(vec![
585            Box::new(sink1) as Box<dyn EventSink>,
586            Box::new(sink2) as Box<dyn EventSink>,
587        ]);
588
589        let msg = Message::user("hello");
590        let event = AgentEvent::MessageAppended {
591            message: msg.clone(),
592            usage: None,
593        };
594        composite.emit(event.clone()).await;
595
596        let got1 = rx1.recv().await.unwrap();
597        let got2 = rx2.recv().await.unwrap();
598        assert_eq!(got1, event);
599        assert_eq!(got2, event);
600
601        // Other variant also propagates.
602        composite.emit(AgentEvent::PlanConfirmed).await;
603        assert_eq!(rx1.recv().await.unwrap(), AgentEvent::PlanConfirmed);
604        assert_eq!(rx2.recv().await.unwrap(), AgentEvent::PlanConfirmed);
605    }
606
607    #[test]
608    fn tool_result_is_error_defaults_to_false_when_absent() {
609        // kills `#[serde(default)]` removal mutation on is_error field
610        let json = r#"{"type":"tool_result","id":"x","name":"y","output":"ok","step":0}"#;
611        let event: AgentEvent = serde_json::from_str(json).expect("must deserialize");
612        match event {
613            AgentEvent::ToolResult { is_error, .. } => {
614                assert!(!is_error, "is_error must default to false when absent");
615            }
616            other => panic!("unexpected variant: {other:?}"),
617        }
618    }
619
620    #[test]
621    fn tool_result_is_error_true_round_trips() {
622        // kills `is_error: false` mutation in the serialization of ToolResult
623        let event = AgentEvent::ToolResult {
624            id: "c1".into(),
625            name: "Bash".into(),
626            output: "ERROR: oops".into(),
627            step: 3,
628            is_error: true,
629        };
630        let json = serde_json::to_string(&event).expect("serialize");
631        assert!(
632            json.contains("\"is_error\":true"),
633            "is_error=true must be in JSON; got: {json}"
634        );
635        let restored: AgentEvent = serde_json::from_str(&json).expect("round-trip");
636        assert_eq!(restored, event);
637    }
638
639    /// Regression test for Goal 278: MessageAppended no longer has parent_uuid field.
640    #[test]
641    fn additional_event_variants_round_trip() {
642        // kills mutations to event type tags and field names for variants
643        // not covered by agent_event_serialization_round_trip
644        let events: Vec<AgentEvent> = vec![
645            AgentEvent::PartialReasoning {
646                text: "thinking...".into(),
647                step: 0,
648            },
649            AgentEvent::Reasoning {
650                text: "full reasoning".into(),
651                step: 1,
652            },
653            AgentEvent::PlanModeRequested {
654                reason: "complex task".into(),
655            },
656            AgentEvent::PlanModeApproved,
657            AgentEvent::PlanModeRejected {
658                reason: "too risky".into(),
659            },
660            AgentEvent::CompactionBoundary {
661                turn: 5,
662                compacted_count: 10,
663                summary_uuid: Some("abc-123".into()),
664                cache_hit_tokens: 42,
665                cache_miss_tokens: 158,
666                is_recompaction_in_chain: true,
667                turns_since_previous_compact: 3,
668                previous_compact_turn: Some(2),
669            },
670            AgentEvent::GoalSet {
671                condition: "all tests pass".into(),
672                max_turns: 20,
673            },
674            AgentEvent::GoalContinuing {
675                reason: "not yet".into(),
676                turns: 3,
677            },
678            AgentEvent::GoalAchieved {
679                condition: "all tests pass".into(),
680                turns: 5,
681            },
682            AgentEvent::GoalCleared,
683            AgentEvent::HookStarted {
684                hook_event: "pre_tool".into(),
685                hook_name: "safety-check".into(),
686                status_message: Some("checking...".into()),
687            },
688            AgentEvent::HookProgress {
689                hook_event: "pre_tool".into(),
690                hook_name: "safety-check".into(),
691                last_line: "progress line".into(),
692            },
693            AgentEvent::HookFinished {
694                hook_event: "pre_tool".into(),
695                hook_name: "safety-check".into(),
696                outcome: "continue".into(),
697                duration_ms: 42,
698            },
699            AgentEvent::HookSystemMessage {
700                text: "hook message".into(),
701            },
702            AgentEvent::Microcompact { step: 3, pruned: 2 },
703            AgentEvent::LlmRetry {
704                step: 2,
705                attempt: 1,
706                wait_ms: 5000,
707                reason: "rate_limited".into(),
708            },
709        ];
710
711        for event in &events {
712            let json = serde_json::to_string(event)
713                .unwrap_or_else(|e| panic!("serialization failed for {event:?}: {e}"));
714            let back: AgentEvent = serde_json::from_str(&json)
715                .unwrap_or_else(|e| panic!("deserialization failed for {json}: {e}"));
716            assert_eq!(*event, back, "round-trip failed for event type");
717        }
718    }
719
720    #[test]
721    fn todo_updated_and_message_appended_with_audit_round_trip() {
722        // kills removal mutations for TodoUpdated and MessageAppendedWithAudit variants
723        use crate::tools::todo::{TodoItem, TodoStatus};
724
725        let todos = vec![TodoItem {
726            content: "do something".into(),
727            status: TodoStatus::InProgress,
728            active_form: Some("doing something".into()),
729        }];
730        let event = AgentEvent::TodoUpdated { todos };
731        let json = serde_json::to_string(&event).unwrap();
732        let back: AgentEvent = serde_json::from_str(&json).unwrap();
733        assert_eq!(event, back, "TodoUpdated must round-trip");
734
735        // MessageAppendedWithAudit
736        let msg = crate::message::Message::user("hello");
737        let audit = crate::tools::AuditMeta::synthetic_unknown_tool("Read");
738        let event2 = AgentEvent::MessageAppendedWithAudit {
739            message: msg,
740            audit,
741        };
742        let json2 = serde_json::to_string(&event2).unwrap();
743        let back2: AgentEvent = serde_json::from_str(&json2).unwrap();
744        assert_eq!(event2, back2, "MessageAppendedWithAudit must round-trip");
745    }
746
747    #[tokio::test]
748    async fn broadcast_sink_no_subscriber_does_not_panic() {
749        // kills `let _ = self.sender.send(event)` → unconditional panic mutation
750        // When the receiver is dropped, BroadcastSink::emit must silently succeed.
751        let (sink, rx) = BroadcastSink::new(16);
752        drop(rx); // nobody is listening
753                  // Must not panic
754        sink.emit(AgentEvent::PlanConfirmed).await;
755    }
756
757    #[tokio::test]
758    async fn channel_sink_delivers_in_order() {
759        // kills any reordering mutation in ChannelSink::emit
760        let (sink, mut rx) = ChannelSink::new();
761        sink.emit(AgentEvent::PlanConfirmed).await;
762        sink.emit(AgentEvent::PlanModeApproved).await;
763        assert_eq!(rx.recv().await.unwrap(), AgentEvent::PlanConfirmed);
764        assert_eq!(rx.recv().await.unwrap(), AgentEvent::PlanModeApproved);
765    }
766
767    #[test]
768    fn message_appended_no_longer_has_parent_uuid_field() {
769        // Source-grep check that src/event.rs doesn't define
770        // parent_uuid inside AgentEvent::MessageAppended.
771        let src = include_str!("event.rs");
772        let arm = src
773            .split("MessageAppended {")
774            .nth(1)
775            .expect("MessageAppended arm must exist")
776            .split("}")
777            .next()
778            .expect("MessageAppended arm must close");
779        assert!(
780            !arm.contains("parent_uuid"),
781            "MessageAppended arm must not reference parent_uuid: {arm}"
782        );
783    }
784}