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}