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}