Skip to main content

agentyk_core/
event.rs

1//! The event protocol.
2//!
3//! Every observable thing a session does is an [`Event`]: turn lifecycle,
4//! input/output messages, tool execution. Producers build an [`EventRequest`]
5//! (no id/sequence — those are assigned on append), and observers implement
6//! [`EventListener`].
7//!
8//! Most event types are **durable**: appended to the
9//! [`crate::event_log::EventLog`] and assigned a sequence. A few are
10//! **ephemeral** ([`EventData::is_ephemeral`]) — streaming deltas, delivered
11//! to listeners in real time but never persisted or sequenced
12//! (`sequence: None`). The engine host recorder branches on this; the event
13//! log itself only ever sees durable events.
14//!
15//! Event types use the everruns dot notation (`turn.started`,
16//! `input.message`, `tool.completed`, …) so the protocol stays recognizable
17//! when everruns-core is rebuilt on top of this crate.
18
19use std::panic::{AssertUnwindSafe, catch_unwind};
20use std::sync::Arc;
21use std::task::Poll;
22
23use async_trait::async_trait;
24use chrono::{DateTime, Utc};
25use serde::{Deserialize, Serialize};
26
27use crate::id::{EventId, MessageId, SessionId, TurnId};
28use crate::message::{Message, ToolCall};
29
30/// The dot-notation type string of every event this crate emits, as it
31/// appears in [`Event::event_type`] and on disk.
32///
33/// Match on these rather than on string literals: a typo in a literal is a
34/// filter that silently never fires.
35pub mod event_types {
36    /// A session branch was created from an immutable point in another session.
37    pub const SESSION_FORKED: &str = "session.forked";
38    /// A turn began; the input message follows.
39    pub const TURN_STARTED: &str = "turn.started";
40    /// A turn ended with a final assistant answer.
41    pub const TURN_COMPLETED: &str = "turn.completed";
42    /// A turn ended in failure — a driver error, or the iteration ceiling.
43    pub const TURN_FAILED: &str = "turn.failed";
44    /// A turn was stopped by its caller.
45    pub const TURN_CANCELLED: &str = "turn.cancelled";
46    /// A turn was stopped deliberately by policy — see
47    /// [`crate::turn::SealReason`].
48    pub const TURN_SEALED: &str = "turn.sealed";
49    /// The user message that opened the turn.
50    pub const INPUT_MESSAGE: &str = "input.message";
51    /// The model began generating an assistant message.
52    pub const OUTPUT_MESSAGE_STARTED: &str = "output.message.started";
53    /// An incremental text update. Ephemeral: never persisted.
54    pub const OUTPUT_MESSAGE_DELTA: &str = "output.message.delta";
55    /// The assistant message is complete, text and tool calls included.
56    pub const OUTPUT_MESSAGE_COMPLETED: &str = "output.message.completed";
57    /// A tool call is about to run, with the arguments it will run on.
58    pub const TOOL_STARTED: &str = "tool.started";
59    /// A tool call resolved, successfully or not.
60    pub const TOOL_COMPLETED: &str = "tool.completed";
61    /// Middleware blocked a tool call before it ran.
62    pub const TOOL_DENIED: &str = "tool.denied";
63    /// Middleware rewrote a tool call before it ran.
64    pub const TOOL_REWRITTEN: &str = "tool.rewritten";
65    /// A running tool reported progress. Ephemeral — never persisted.
66    pub const TOOL_PROGRESS: &str = "tool.progress";
67    /// A user hook executor failed under warn-on-error policy.
68    pub const HOOK_WARNING: &str = "hook.warning";
69    /// A pre-tool hook supplied a message intended for a user-facing surface.
70    pub const HOOK_USER_MESSAGE: &str = "hook.user_message";
71}
72
73/// The payload of an [`Event`] — what actually happened.
74///
75/// `#[non_exhaustive]`: new event types are added as the protocol grows, and
76/// a log written by a newer agentyk stays readable by an older one (unknown
77/// kinds degrade to [`EventData::Custom`], see [`Event`]).
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79#[serde(tag = "kind", rename_all = "snake_case")]
80#[non_exhaustive]
81pub enum EventData {
82    /// This session began as a fork of an immutable point in another session.
83    ///
84    /// Stores may materialize the inherited prefix or retain it by reference;
85    /// this event keeps the logical lineage observable either way.
86    SessionForked {
87        /// Session whose history this branch inherited.
88        parent_session_id: SessionId,
89        /// Inclusive durable sequence inherited from the parent.
90        parent_sequence: u64,
91    },
92    /// A turn began. The `input.message` event follows immediately.
93    TurnStarted {
94        /// Reason-step ceiling captured in the event so replay reconstructs
95        /// the same machine without an out-of-band checkpoint.
96        #[serde(default = "default_max_iterations")]
97        max_iterations: usize,
98    },
99    /// A turn ended with a final assistant answer.
100    TurnCompleted {
101        /// LLM completions performed within the turn.
102        iterations: usize,
103        /// Tool calls executed within the turn.
104        tool_calls: usize,
105    },
106    /// A turn ended in failure. Also covers hitting the iteration ceiling,
107    /// where no answer was ever produced.
108    TurnFailed {
109        /// Human-readable description of what went wrong.
110        error: String,
111    },
112    /// The turn was stopped via a [`crate::cancellation::CancellationToken`]
113    /// rather than failing or completing.
114    TurnCancelled,
115    /// The turn was deliberately sealed — see
116    /// [`crate::turn::SealReason`].
117    TurnSealed {
118        /// Why the turn was sealed rather than allowed to continue.
119        reason: crate::turn::SealReason,
120    },
121    /// The user message that opened the turn.
122    InputMessage {
123        /// The input, as it enters history.
124        message: Message,
125    },
126    /// The model started generating a response for this reason step. UIs can
127    /// show a "thinking" indicator until a delta or the completed event
128    /// arrives. `message_id` correlates this event with the deltas and the
129    /// completed event of the same logical assistant message (mirrors
130    /// everruns' streaming lifecycle correlation).
131    OutputMessageStarted {
132        /// Stable id shared by the started/delta/completed events of one
133        /// assistant message. `#[serde(default)]` so pre-0.1.1 logs (which
134        /// lack it) still deserialize, gaining a fresh id.
135        #[serde(default)]
136        message_id: MessageId,
137        /// The wire model name this step ran on, when the executor knows it —
138        /// which may differ per turn, via
139        /// [`crate::controls::TurnControls`].
140        #[serde(default, skip_serializing_if = "Option::is_none")]
141        model: Option<String>,
142        /// 1-based iteration within the turn.
143        #[serde(default, skip_serializing_if = "Option::is_none")]
144        iteration: Option<u32>,
145    },
146    /// Incremental text update during generation. **Ephemeral** — delivered
147    /// to listeners, never persisted. Carries the same `message_id` as its
148    /// started/completed siblings.
149    OutputMessageDelta {
150        /// Ties this delta to its `started`/`completed` siblings.
151        #[serde(default)]
152        message_id: MessageId,
153        /// Just-generated text, to append.
154        delta: String,
155        /// All text generated so far, `delta` included — for consumers that
156        /// would rather replace than append.
157        accumulated: String,
158    },
159    /// The reason step finished; `message` is the fully materialized
160    /// assistant message (text and/or tool calls). Same `message_id` as the
161    /// started event.
162    OutputMessageCompleted {
163        /// Same id as the `started` event and every delta between them.
164        #[serde(default)]
165        message_id: MessageId,
166        /// The finished assistant message — this is what enters history.
167        message: Message,
168        /// Token usage for this reason step.
169        #[serde(default)]
170        usage: crate::driver::Usage,
171    },
172    /// A tool call is about to run. The call carried here is the one that
173    /// will actually execute, so it reflects any middleware rewrite.
174    ToolStarted {
175        /// The call as it will run: id, name, and arguments.
176        call: ToolCall,
177    },
178    /// A tool call resolved. Recorded for denied calls too, carrying the
179    /// denial reason as an error result, because that is what the model sees.
180    ToolCompleted {
181        /// Correlates with the `tool.started` event for this call.
182        call_id: String,
183        /// The tool that ran.
184        name: String,
185        /// What the model receives as the result.
186        output: String,
187        /// Whether `output` describes a failure. Tool failures are results,
188        /// not turn failures: the model gets to react to them.
189        is_error: bool,
190        /// Structured result data for the host — see
191        /// [`crate::tool::ToolOutput::metadata`]. `Null` (and omitted from
192        /// serialization) unless the tool attached some, so logs written
193        /// before this field still deserialize.
194        #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
195        metadata: serde_json::Value,
196        /// Content parts the model receives alongside `output` — see
197        /// [`crate::tool::ToolOutput::parts`]. Recorded because they are part
198        /// of what the model saw: a replayed session must send the same tool
199        /// result, images included.
200        #[serde(default, skip_serializing_if = "Vec::is_empty")]
201        parts: Vec<crate::message::ContentPart>,
202    },
203    /// A tool that is still running reported progress. **Ephemeral** —
204    /// delivered to listeners, never persisted and never folded into
205    /// history, exactly like a streaming message delta. Emitted by a host in
206    /// response to [`crate::tool::ToolContext::report_progress`].
207    ToolProgress {
208        /// Correlates with the `tool.started` event for this call.
209        call_id: String,
210        /// The tool reporting.
211        name: String,
212        /// What it reported.
213        progress: crate::tool::ToolProgress,
214    },
215    /// A [`crate::middleware::TurnMiddleware`] or
216    /// [`crate::hook::Hook`] denied this call before it ran.
217    /// Recorded alongside (not instead of) the usual
218    /// `tool.started`/`tool.completed` pair — the denial reason also
219    /// becomes the (error) `tool.completed` output the model sees.
220    ToolDenied {
221        /// The call that was blocked.
222        call_id: String,
223        /// The tool that would have run.
224        name: String,
225        /// Why it was blocked. Also becomes the error result the model sees.
226        reason: String,
227    },
228    /// A [`crate::middleware::TurnMiddleware`] or [`crate::hook::Hook`]
229    /// rewrote this call before it ran — argument redaction, path rewriting.
230    /// Recorded so a mutation is
231    /// never invisible: `name` is the call as executed, and `tool.started`
232    /// carries the executed call. The everruns analogue of
233    /// `output.message.replaced` for the act phase.
234    ToolRewritten {
235        /// The call that was rewritten. Unchanged by the rewrite — the
236        /// pending batch is keyed by it.
237        call_id: String,
238        /// The tool name as executed, which a rewrite may have changed.
239        name: String,
240        /// The full call as it will execute. Required to resume after the
241        /// rewrite is persisted but before `tool.started`.
242        ///
243        /// `None` only represents a legacy log written before rewritten calls
244        /// were replayable; current emitters always write `Some`.
245        #[serde(default, skip_serializing_if = "Option::is_none")]
246        call: Option<ToolCall>,
247        /// Which middleware rewrote it, from
248        /// [`crate::middleware::TurnMiddleware::name`], when the executor
249        /// knows.
250        #[serde(default, skip_serializing_if = "Option::is_none")]
251        by: Option<String>,
252    },
253    /// Escape hatch for domain events this crate doesn't know about yet —
254    /// a capability or host emitting its own event without forking core.
255    /// `event_type` should follow the dot-notation convention (e.g.
256    /// `"budget.warning"`); as protocol coverage grows, well-known custom
257    /// types are expected to graduate into first-class variants (see
258    /// `knowledge/everruns-adoption.md`).
259    Custom {
260        /// Dot-notation type, e.g. `"budget.warning"`. Becomes
261        /// [`Event::event_type`].
262        event_type: String,
263        /// Whatever the emitter wants to carry. Core never interprets it.
264        #[serde(default)]
265        payload: serde_json::Value,
266        /// Whether replay must understand this event to advance turn state.
267        ///
268        /// Custom events emitted through [`Self::custom`] are observational.
269        /// Unknown serialized event variants are conservatively marked
270        /// state-bearing by [`Event`]'s deserializer.
271        #[serde(default)]
272        state_bearing: bool,
273    },
274}
275
276fn default_max_iterations() -> usize {
277    16
278}
279
280impl EventData {
281    /// Build a [`EventData::Custom`] event — the escape hatch for domain
282    /// events core has no variant for.
283    pub fn custom(event_type: impl Into<String>, payload: serde_json::Value) -> Self {
284        EventData::Custom {
285            event_type: event_type.into(),
286            payload,
287            state_bearing: false,
288        }
289    }
290
291    /// The dot-notation type string for this payload.
292    pub fn event_type(&self) -> &str {
293        match self {
294            EventData::SessionForked { .. } => event_types::SESSION_FORKED,
295            EventData::TurnStarted { .. } => event_types::TURN_STARTED,
296            EventData::TurnCompleted { .. } => event_types::TURN_COMPLETED,
297            EventData::TurnFailed { .. } => event_types::TURN_FAILED,
298            EventData::TurnCancelled => event_types::TURN_CANCELLED,
299            EventData::TurnSealed { .. } => event_types::TURN_SEALED,
300            EventData::InputMessage { .. } => event_types::INPUT_MESSAGE,
301            EventData::OutputMessageStarted { .. } => event_types::OUTPUT_MESSAGE_STARTED,
302            EventData::OutputMessageDelta { .. } => event_types::OUTPUT_MESSAGE_DELTA,
303            EventData::OutputMessageCompleted { .. } => event_types::OUTPUT_MESSAGE_COMPLETED,
304            EventData::ToolStarted { .. } => event_types::TOOL_STARTED,
305            EventData::ToolCompleted { .. } => event_types::TOOL_COMPLETED,
306            EventData::ToolDenied { .. } => event_types::TOOL_DENIED,
307            EventData::ToolRewritten { .. } => event_types::TOOL_REWRITTEN,
308            EventData::ToolProgress { .. } => event_types::TOOL_PROGRESS,
309            EventData::Custom { event_type, .. } => event_type,
310        }
311    }
312
313    /// Ephemeral events are delivered to listeners but never persisted —
314    /// see the module docs. Only `output.message.delta` is ephemeral today;
315    /// `Custom` events are durable by default (a custom *ephemeral* event
316    /// isn't representable yet — add one if a use case needs it).
317    pub fn is_ephemeral(&self) -> bool {
318        matches!(
319            self,
320            EventData::OutputMessageDelta { .. } | EventData::ToolProgress { .. }
321        )
322    }
323}
324
325/// A fully-recorded event. `id` and `ts` are always assigned on emission;
326/// `sequence` is `Some` (contiguous per session, starting at 1) for durable
327/// events and `None` for ephemeral ones — see [`EventData::is_ephemeral`].
328///
329/// Deserialization is **forward compatible**: an event whose `kind` this
330/// version doesn't know degrades to [`EventData::Custom`] carrying the
331/// original payload, rather than failing the read — see the custom
332/// [`Deserialize`] impl below.
333#[derive(Debug, Clone, PartialEq, Serialize)]
334#[non_exhaustive]
335pub struct Event {
336    /// Unique id, assigned on emission.
337    pub id: EventId,
338    /// Dot-notation type — see [`event_types`]. Denormalized from `data` so
339    /// consumers can filter without matching the payload.
340    #[serde(rename = "type")]
341    pub event_type: String,
342    /// When it was emitted.
343    pub ts: DateTime<Utc>,
344    /// The session it belongs to.
345    pub session_id: SessionId,
346    /// The turn it belongs to, for events emitted inside one.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub turn_id: Option<TurnId>,
349    /// Position in the session's durable order, starting at 1. `None` marks
350    /// an ephemeral event, which was delivered to listeners but never
351    /// persisted — see [`EventData::is_ephemeral`].
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub sequence: Option<u64>,
354    /// Host-owned provenance and presentation data.
355    ///
356    /// A multi-actor host uses this for values such as `participant_id` and
357    /// `agent_id` without making its identity catalog part of core.
358    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
359    pub metadata: serde_json::Value,
360    /// What happened.
361    pub data: EventData,
362}
363
364/// Mirrors [`Event`] with the payload left undecoded, so an unknown `kind`
365/// can be caught and degraded instead of failing the whole read.
366#[derive(Deserialize)]
367struct EventRepr {
368    id: EventId,
369    #[serde(rename = "type")]
370    event_type: String,
371    ts: DateTime<Utc>,
372    session_id: SessionId,
373    #[serde(default)]
374    turn_id: Option<TurnId>,
375    #[serde(default)]
376    sequence: Option<u64>,
377    #[serde(default)]
378    metadata: serde_json::Value,
379    data: serde_json::Value,
380}
381
382// Replay is the persistence contract: a log must stay readable, and an older
383// binary must not choke on a log a newer one wrote. `EventData` is internally
384// tagged, so an unrecognized `kind` would otherwise abort deserialization of
385// the whole line — and `JsonlEventLog::read` turns that into a failed read of
386// the entire session. Degrading to `Custom` keeps the event type and its full
387// payload, which is exactly what that variant exists for; nothing is lost but
388// the static type.
389//
390// This intentionally routes through `serde_json::Value`: the event log is a
391// JSON format by construction, and a self-describing intermediate is what
392// makes the fallback possible at all.
393impl<'de> Deserialize<'de> for Event {
394    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
395        let repr = EventRepr::deserialize(deserializer)?;
396        let data =
397            serde_json::from_value::<EventData>(repr.data.clone()).unwrap_or(EventData::Custom {
398                event_type: repr.event_type.clone(),
399                payload: repr.data,
400                state_bearing: true,
401            });
402        Ok(Event {
403            id: repr.id,
404            event_type: repr.event_type,
405            ts: repr.ts,
406            session_id: repr.session_id,
407            turn_id: repr.turn_id,
408            sequence: repr.sequence,
409            metadata: repr.metadata,
410            data,
411        })
412    }
413}
414
415/// An event about to be emitted — same shape as [`Event`] minus the fields
416/// assigned on emission.
417#[derive(Debug, Clone)]
418#[non_exhaustive]
419pub struct EventRequest {
420    /// The session this event belongs to.
421    pub session_id: SessionId,
422    /// The turn it belongs to, if it was emitted inside one.
423    pub turn_id: Option<TurnId>,
424    /// Host-owned provenance and presentation data copied to the event.
425    pub metadata: serde_json::Value,
426    /// What happened.
427    pub data: EventData,
428}
429
430impl EventRequest {
431    /// An event not tied to any particular turn.
432    pub fn new(session_id: SessionId, data: EventData) -> Self {
433        Self {
434            session_id,
435            turn_id: None,
436            metadata: serde_json::Value::Null,
437            data,
438        }
439    }
440
441    /// An event emitted during a turn — what an executor records.
442    pub fn with_turn(session_id: SessionId, turn_id: TurnId, data: EventData) -> Self {
443        Self {
444            session_id,
445            turn_id: Some(turn_id),
446            metadata: serde_json::Value::Null,
447            data,
448        }
449    }
450
451    /// Attach host-owned event metadata.
452    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
453        self.metadata = metadata;
454        self
455    }
456
457    /// Finalize into a durable [`Event`] with an assigned sequence — called
458    /// by [`crate::event_log::EventLog`] implementations.
459    pub fn into_event(self, id: EventId, ts: DateTime<Utc>, sequence: u64) -> Event {
460        Event {
461            id,
462            event_type: self.data.event_type().to_string(),
463            ts,
464            session_id: self.session_id,
465            turn_id: self.turn_id,
466            sequence: Some(sequence),
467            metadata: self.metadata,
468            data: self.data,
469        }
470    }
471
472    /// Finalize into an ephemeral [`Event`] (`sequence: None`) without going
473    /// through an [`crate::event_log::EventLog`] — called by the engine host
474    /// recorder for events where
475    /// [`EventData::is_ephemeral`] is true.
476    pub fn into_ephemeral_event(self, id: EventId, ts: DateTime<Utc>) -> Event {
477        Event {
478            id,
479            event_type: self.data.event_type().to_string(),
480            ts,
481            session_id: self.session_id,
482            turn_id: self.turn_id,
483            sequence: None,
484            metadata: self.metadata,
485            data: self.data,
486        }
487    }
488}
489
490/// Observes events after they are emitted. Attach listeners via
491/// `AgentBuilder::listener`. Receives both durable and ephemeral events —
492/// check [`Event::sequence`] (`None` for ephemeral) to distinguish.
493#[async_trait]
494pub trait EventListener: Send + Sync {
495    /// Called for every event, durable and ephemeral, in emission order.
496    ///
497    /// Listeners run inline on the turn's write path, so slow work here slows
498    /// the turn; hand it to a channel or task instead. A listener cannot
499    /// change the event or stop the turn — that is what middleware is for.
500    async fn on_event(&self, event: &Event);
501
502    /// Event types this listener wants to receive.
503    ///
504    /// `None` receives every event. A non-empty list receives only events
505    /// whose dot-notation [`Event::event_type`] appears in the list.
506    fn event_types(&self) -> Option<Vec<&'static str>> {
507        None
508    }
509
510    /// Human-readable name for diagnostics.
511    fn name(&self) -> &'static str {
512        "EventListener"
513    }
514}
515
516/// Listener that intentionally ignores every event.
517///
518/// Useful as a default in hosts where event observation is optional and in
519/// tests that need to satisfy an [`EventListener`] dependency.
520#[derive(Debug, Clone, Default)]
521pub struct NoopEventListener;
522
523#[async_trait]
524impl EventListener for NoopEventListener {
525    async fn on_event(&self, _event: &Event) {}
526
527    fn name(&self) -> &'static str {
528        "NoopEventListener"
529    }
530}
531
532/// Listener that forwards each event to an ordered collection of listeners.
533///
534/// Filtering and panic isolation are applied per inner listener: one listener
535/// that rejects an event or panics does not prevent later listeners from
536/// receiving it.
537#[derive(Default)]
538pub struct CompositeEventListener {
539    listeners: Vec<Arc<dyn EventListener>>,
540}
541
542impl CompositeEventListener {
543    /// Create a composite from listeners called in vector order.
544    pub fn new(listeners: Vec<Arc<dyn EventListener>>) -> Self {
545        Self { listeners }
546    }
547
548    /// Append a listener to the dispatch order.
549    pub fn add(&mut self, listener: Arc<dyn EventListener>) {
550        self.listeners.push(listener);
551    }
552
553    /// Number of registered listeners.
554    pub fn len(&self) -> usize {
555        self.listeners.len()
556    }
557
558    /// Whether no listeners are registered.
559    pub fn is_empty(&self) -> bool {
560        self.listeners.is_empty()
561    }
562}
563
564#[async_trait]
565impl EventListener for CompositeEventListener {
566    async fn on_event(&self, event: &Event) {
567        notify_event_listeners(&self.listeners, event).await;
568    }
569
570    fn name(&self) -> &'static str {
571        "CompositeEventListener"
572    }
573}
574
575/// Notify listeners in order, honoring their event filters and isolating
576/// panics.
577///
578/// Hosts with their own listener collection can use this to get the same
579/// dispatch semantics as [`CompositeEventListener`] without wrapping the
580/// collection first. Dispatch stays inline: a slow listener still delays the
581/// caller, while a panicking listener is abandoned and the next one runs.
582pub async fn notify_event_listeners(listeners: &[Arc<dyn EventListener>], event: &Event) {
583    for listener in listeners {
584        if let Some(event_types) = listener.event_types()
585            && !event_types.contains(&event.event_type.as_str())
586        {
587            continue;
588        }
589
590        let Ok(mut future) = catch_unwind(AssertUnwindSafe(|| listener.on_event(event))) else {
591            continue;
592        };
593        std::future::poll_fn(|context| {
594            match catch_unwind(AssertUnwindSafe(|| future.as_mut().poll(context))) {
595                Ok(Poll::Ready(())) | Err(_) => Poll::Ready(()),
596                Ok(Poll::Pending) => Poll::Pending,
597            }
598        })
599        .await;
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use std::pin::pin;
606    use std::sync::atomic::{AtomicU32, Ordering};
607    use std::task::{Context, Waker};
608
609    use super::*;
610
611    fn block_on<F: std::future::Future>(future: F) -> F::Output {
612        let mut future = pin!(future);
613        let waker = Waker::noop();
614        let mut context = Context::from_waker(waker);
615        loop {
616            if let Poll::Ready(output) = future.as_mut().poll(&mut context) {
617                return output;
618            }
619        }
620    }
621
622    struct CountingListener {
623        count: Arc<AtomicU32>,
624        event_types: Option<Vec<&'static str>>,
625    }
626
627    #[async_trait]
628    impl EventListener for CountingListener {
629        async fn on_event(&self, _event: &Event) {
630            self.count.fetch_add(1, Ordering::SeqCst);
631        }
632
633        fn event_types(&self) -> Option<Vec<&'static str>> {
634            self.event_types.clone()
635        }
636    }
637
638    #[test]
639    fn event_serde_roundtrip() {
640        let request = EventRequest::new(
641            SessionId::new(),
642            EventData::InputMessage {
643                message: Message::user("hi"),
644            },
645        )
646        .with_metadata(serde_json::json!({"participant_id": "part_123"}));
647        let event = request.into_event(EventId::new(), Utc::now(), 1);
648        assert_eq!(event.event_type, "input.message");
649        assert_eq!(event.sequence, Some(1));
650        let json = serde_json::to_string(&event).unwrap();
651        let back: Event = serde_json::from_str(&json).unwrap();
652        assert_eq!(event, back);
653        assert_eq!(back.metadata["participant_id"], "part_123");
654    }
655
656    #[test]
657    fn ephemeral_event_has_no_sequence() {
658        let request = EventRequest::new(
659            SessionId::new(),
660            EventData::OutputMessageDelta {
661                message_id: MessageId::new(),
662                delta: "hi".into(),
663                accumulated: "hi".into(),
664            },
665        );
666        assert!(request.data.is_ephemeral());
667        let event = request.into_ephemeral_event(EventId::new(), Utc::now());
668        assert_eq!(event.sequence, None);
669    }
670
671    #[test]
672    fn only_delta_is_ephemeral() {
673        assert!(!EventData::TurnStarted { max_iterations: 16 }.is_ephemeral());
674        assert!(
675            !EventData::OutputMessageStarted {
676                message_id: MessageId::new(),
677                model: None,
678                iteration: None
679            }
680            .is_ephemeral()
681        );
682        assert!(
683            !EventData::OutputMessageCompleted {
684                message_id: MessageId::new(),
685                message: Message::assistant("hi"),
686                usage: crate::driver::Usage::default(),
687            }
688            .is_ephemeral()
689        );
690    }
691
692    #[test]
693    fn legacy_turn_started_defaults_the_iteration_limit() {
694        let data: EventData = serde_json::from_value(serde_json::json!({
695            "kind": "turn_started"
696        }))
697        .unwrap();
698
699        assert_eq!(data, EventData::TurnStarted { max_iterations: 16 });
700    }
701
702    #[test]
703    fn legacy_tool_rewrite_remains_readable_but_is_marked_incomplete() {
704        let data: EventData = serde_json::from_value(serde_json::json!({
705            "kind": "tool_rewritten",
706            "call_id": "call_1",
707            "name": "save",
708            "by": "guard"
709        }))
710        .unwrap();
711
712        assert!(matches!(data, EventData::ToolRewritten { call: None, .. }));
713    }
714
715    #[test]
716    fn an_unknown_event_kind_degrades_to_custom_instead_of_failing() {
717        // A line written by a future version: `turn.compacted` is not a kind
718        // this build knows. Reading it must not fail — replay is the
719        // persistence contract, and one unreadable line fails a whole session
720        // read in `JsonlEventLog`.
721        let line = serde_json::json!({
722            "id": EventId::new(),
723            "type": "turn.compacted",
724            "ts": Utc::now(),
725            "session_id": SessionId::new(),
726            "sequence": 7,
727            "data": {"kind": "turn_compacted", "removed": 12, "note": "from a newer agentyk"},
728        })
729        .to_string();
730
731        let event: Event = serde_json::from_str(&line).expect("unknown kinds must stay readable");
732        assert_eq!(event.event_type, "turn.compacted");
733        assert_eq!(event.sequence, Some(7));
734
735        // The payload survives intact, so a host can still inspect or forward
736        // what it can't statically understand.
737        let EventData::Custom {
738            event_type,
739            ref payload,
740            ..
741        } = event.data
742        else {
743            panic!("expected an unknown kind to degrade to Custom");
744        };
745        assert_eq!(event_type, "turn.compacted");
746        assert_eq!(payload["removed"], 12);
747        assert_eq!(payload["kind"], "turn_compacted");
748    }
749
750    #[test]
751    fn known_event_kinds_still_deserialize_to_their_typed_variant() {
752        let request = EventRequest::new(
753            SessionId::new(),
754            EventData::ToolCompleted {
755                call_id: "call_0".into(),
756                name: "read_file".into(),
757                output: "hi".into(),
758                is_error: false,
759                metadata: serde_json::Value::Null,
760                parts: Vec::new(),
761            },
762        );
763        let event = request.into_event(EventId::new(), Utc::now(), 3);
764        let back: Event = serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
765        assert_eq!(back, event);
766        assert!(matches!(back.data, EventData::ToolCompleted { .. }));
767    }
768
769    #[test]
770    fn custom_event_carries_its_own_type_and_is_durable() {
771        let data = EventData::custom("budget.warning", serde_json::json!({"remaining": 10}));
772        assert_eq!(data.event_type(), "budget.warning");
773        assert!(!data.is_ephemeral());
774
775        let event =
776            EventRequest::new(SessionId::new(), data).into_event(EventId::new(), Utc::now(), 1);
777        let json = serde_json::to_string(&event).unwrap();
778        let back: Event = serde_json::from_str(&json).unwrap();
779        assert_eq!(event, back);
780        assert_eq!(back.event_type, "budget.warning");
781    }
782
783    #[test]
784    fn composite_filters_and_forwards() {
785        let matching_count = Arc::new(AtomicU32::new(0));
786        let skipped_count = Arc::new(AtomicU32::new(0));
787        let all_count = Arc::new(AtomicU32::new(0));
788        let composite = CompositeEventListener::new(vec![
789            Arc::new(CountingListener {
790                count: matching_count.clone(),
791                event_types: Some(vec![event_types::INPUT_MESSAGE]),
792            }),
793            Arc::new(CountingListener {
794                count: skipped_count.clone(),
795                event_types: Some(vec![event_types::TOOL_COMPLETED]),
796            }),
797            Arc::new(CountingListener {
798                count: all_count.clone(),
799                event_types: None,
800            }),
801        ]);
802        let event = EventRequest::new(
803            SessionId::new(),
804            EventData::InputMessage {
805                message: Message::user("hi"),
806            },
807        )
808        .into_event(EventId::new(), Utc::now(), 1);
809
810        block_on(composite.on_event(&event));
811
812        assert_eq!(matching_count.load(Ordering::SeqCst), 1);
813        assert_eq!(skipped_count.load(Ordering::SeqCst), 0);
814        assert_eq!(all_count.load(Ordering::SeqCst), 1);
815    }
816
817    #[test]
818    fn composite_continues_after_listener_panics() {
819        struct PanickingListener;
820
821        #[async_trait]
822        impl EventListener for PanickingListener {
823            async fn on_event(&self, _event: &Event) {
824                panic!("listener failed");
825            }
826        }
827
828        let count = Arc::new(AtomicU32::new(0));
829        let composite = CompositeEventListener::new(vec![
830            Arc::new(PanickingListener),
831            Arc::new(CountingListener {
832                count: count.clone(),
833                event_types: None,
834            }),
835        ]);
836        let event = EventRequest::new(
837            SessionId::new(),
838            EventData::InputMessage {
839                message: Message::user("hi"),
840            },
841        )
842        .into_event(EventId::new(), Utc::now(), 1);
843
844        block_on(composite.on_event(&event));
845
846        assert_eq!(count.load(Ordering::SeqCst), 1);
847    }
848
849    #[test]
850    fn built_in_listeners_report_everruns_names() {
851        assert_eq!(NoopEventListener.name(), "NoopEventListener");
852        let composite = CompositeEventListener::default();
853        assert_eq!(composite.name(), "CompositeEventListener");
854        assert!(composite.is_empty());
855        assert_eq!(composite.len(), 0);
856    }
857}