protocol_droid/application/session/event_log.rs
1//! The session's typed event history.
2//!
3//! # Why the events are typed rather than a string
4//!
5//! The CLI's record mode replays this history, and a replay that has to parse
6//! a message to find out which transition it was is a replay that breaks when
7//! somebody rewords a message. Each variant is a fact, and a fact can be
8//! matched on.
9//!
10//! # Why the version is a `String`
11//!
12//! A protocol's version is that protocol's type: a date for MCP, a
13//! `Major.Minor` for A2A. Storing it as a string keeps this enum free of a
14//! dependency on either, and the engine renders it through [`Display`](fmt::Display)
15//! on the way in. It is a lossy choice and a deliberate one — the log is for
16//! asking "what happened", and the log answers that by naming the version
17//! that was agreed, not by being able to compare it.
18//!
19//! # Redaction happens on the way out
20//!
21//! These events carry the full request id, because the record mode has to
22//! correlate a notification with the request that caused it and a redacted id
23//! cannot do that. A sink that writes them to a log rather than to a file
24//! redacts at that boundary, which is where the redaction belongs — a value
25//! that cannot be logged is a value a record cannot hold.
26
27use core::fmt;
28use std::sync::Arc;
29
30use crate::domain::RequestId;
31use crate::domain::session::{AbortReason, Instant, RequestOrigin, SessionId, SessionState};
32
33/// One thing that happened to a session.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum SessionEvent {
36 /// The session came into existence.
37 Opened {
38 /// Which session.
39 session: SessionId,
40 /// When, as read from the injected clock.
41 at: Instant,
42 },
43 /// A protocol agreed what it speaks.
44 Negotiated {
45 /// Which session.
46 session: SessionId,
47 /// When.
48 at: Instant,
49 /// The version, as that protocol spells it.
50 version: String,
51 /// Whether the peer asked for this one and got it, or was answered with
52 /// something else. A bridge that was downgraded needs the difference,
53 /// and `server/discover` advertises what is supported rather than what
54 /// one client was given.
55 echoed: bool,
56 },
57 /// The peer confirmed the handshake and ordinary work became admissible.
58 Operating {
59 /// Which session.
60 session: SessionId,
61 /// When.
62 at: Instant,
63 },
64 /// A request became in flight.
65 Registered {
66 /// Which session.
67 session: SessionId,
68 /// When.
69 at: Instant,
70 /// Which request.
71 id: RequestId,
72 /// The method it named.
73 method: Arc<str>,
74 /// Which side issued it.
75 origin: RequestOrigin,
76 },
77 /// A request finished and is no longer in flight.
78 Completed {
79 /// Which session.
80 session: SessionId,
81 /// When.
82 at: Instant,
83 /// Which request.
84 id: RequestId,
85 /// The method it named.
86 method: Arc<str>,
87 },
88 /// A request was aborted before it finished.
89 Aborted {
90 /// Which session.
91 session: SessionId,
92 /// When.
93 at: Instant,
94 /// Which request.
95 id: RequestId,
96 /// The method it named.
97 method: Arc<str>,
98 /// Why.
99 reason: AbortReason,
100 },
101 /// A request was refused before it ran.
102 Refused {
103 /// Which session.
104 session: SessionId,
105 /// When.
106 at: Instant,
107 /// Which request, when the message carried one.
108 id: Option<RequestId>,
109 /// The method it named.
110 method: Arc<str>,
111 /// The state the session was in, which is what made the refusal.
112 state: SessionState,
113 },
114 /// The session left the operating state, for any reason.
115 StateChanged {
116 /// Which session.
117 session: SessionId,
118 /// When.
119 at: Instant,
120 /// Where it went.
121 state: SessionState,
122 },
123}
124
125impl SessionEvent {
126 /// Which session this concerns.
127 #[must_use]
128 pub const fn session(&self) -> SessionId {
129 match self {
130 Self::Opened { session, .. }
131 | Self::Negotiated { session, .. }
132 | Self::Operating { session, .. }
133 | Self::Registered { session, .. }
134 | Self::Completed { session, .. }
135 | Self::Aborted { session, .. }
136 | Self::Refused { session, .. }
137 | Self::StateChanged { session, .. } => *session,
138 }
139 }
140
141 /// When it happened, as read from the injected clock.
142 #[must_use]
143 pub const fn at(&self) -> Instant {
144 match self {
145 Self::Opened { at, .. }
146 | Self::Negotiated { at, .. }
147 | Self::Operating { at, .. }
148 | Self::Registered { at, .. }
149 | Self::Completed { at, .. }
150 | Self::Aborted { at, .. }
151 | Self::Refused { at, .. }
152 | Self::StateChanged { at, .. } => *at,
153 }
154 }
155}
156
157impl fmt::Display for SessionEvent {
158 /// One line, for a human reading a trace.
159 ///
160 /// The request id is redacted here rather than at the sink, because this is
161 /// the form that reaches a terminal; a sink writing the event *value* is
162 /// the record mode and needs the real id to correlate.
163 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
164 match self {
165 Self::Opened { session, at } => write!(formatter, "{at} {session} opened"),
166 Self::Negotiated {
167 session,
168 at,
169 version,
170 echoed,
171 } => write!(
172 formatter,
173 "{at} {session} negotiated {version} ({})",
174 if *echoed { "echoed" } else { "counter-offered" }
175 ),
176 Self::Operating { session, at } => write!(formatter, "{at} {session} operating"),
177 Self::Registered {
178 session,
179 at,
180 id,
181 method,
182 origin,
183 } => write!(
184 formatter,
185 "{at} {session} {} {} as {}",
186 origin_label(*origin),
187 method,
188 id.redacted()
189 ),
190 Self::Completed {
191 session,
192 at,
193 id,
194 method,
195 } => write!(
196 formatter,
197 "{at} {session} completed {} as {}",
198 method,
199 id.redacted()
200 ),
201 Self::Aborted {
202 session,
203 at,
204 id,
205 method,
206 reason,
207 } => write!(
208 formatter,
209 "{at} {session} aborted {} ({reason}) as {}",
210 method,
211 id.redacted()
212 ),
213 Self::Refused {
214 session,
215 at,
216 id,
217 method,
218 state,
219 } => write!(
220 formatter,
221 "{at} {session} refused {method} while {state}{}",
222 id.as_ref()
223 .map_or_else(String::new, |id| format!(" as {}", id.redacted()))
224 ),
225 Self::StateChanged { session, at, state } => {
226 write!(formatter, "{at} {session} is {state}")
227 }
228 }
229 }
230}
231
232/// The word an origin gets in a log line.
233const fn origin_label(origin: RequestOrigin) -> &'static str {
234 match origin {
235 RequestOrigin::Inbound => "received",
236 RequestOrigin::Outbound => "sent",
237 }
238}
239
240/// Where a session's history goes.
241///
242/// Declared by the engine, its only consumer. One method and no result: a log
243/// that can fail is a log the engine has to make a decision about mid-request,
244/// and the only correct decision — dropping the event — is one that hides the
245/// fact that it dropped it. A sink that cannot accept an event says so by
246/// not taking the type.
247pub trait EventLog {
248 /// Appends one event.
249 fn record(&self, event: SessionEvent);
250}
251
252/// An event log that goes nowhere.
253///
254/// Not a default parameter and not a blanket impl: choosing to discard history
255/// is a decision a caller makes at the call site, and the type makes it
256/// visible there rather than burying it in a default.
257#[derive(Debug, Clone, Copy, Default)]
258pub struct NullEventLog;
259
260impl EventLog for NullEventLog {
261 fn record(&self, _event: SessionEvent) {}
262}
263
264#[cfg(test)]
265mod tests {
266 use std::sync::Arc;
267 use std::time::Duration;
268
269 use super::{EventLog, NullEventLog, SessionEvent};
270 use crate::domain::RequestId;
271 use crate::domain::session::{
272 AbortReason, CancelReason, Instant, RequestOrigin, SessionId, SessionState,
273 };
274
275 fn at() -> Instant {
276 Instant::from_duration(Duration::from_millis(1_500))
277 }
278
279 fn session() -> SessionId {
280 SessionId::new(4)
281 }
282
283 /// The redaction boundary. An event *value* keeps the real id so a record
284 /// can correlate; its `Display` form does not, so a terminal line cannot
285 /// disclose a peer-chosen string.
286 #[test]
287 fn the_display_form_redacts_but_the_value_does_not() {
288 let id = RequestId::text("super-secret-credential");
289 let event = SessionEvent::Completed {
290 session: session(),
291 at: at(),
292 id: id.clone(),
293 method: Arc::from("tools/call"),
294 };
295 let line = event.to_string();
296 assert!(!line.contains("super-secret-credential"), "{line}");
297 assert!(line.contains(&id.redacted()), "{line}");
298 // The value is untouched, which is the half the record mode needs.
299 assert_matches::assert_matches!(&event, SessionEvent::Completed { id: kept, .. } if *kept == id);
300 }
301
302 #[test]
303 fn every_variant_renders_a_line() {
304 let id = RequestId::number(1);
305 let events = [
306 SessionEvent::Opened {
307 session: session(),
308 at: at(),
309 },
310 SessionEvent::Negotiated {
311 session: session(),
312 at: at(),
313 version: String::from("2025-06-18"),
314 echoed: true,
315 },
316 SessionEvent::Negotiated {
317 session: session(),
318 at: at(),
319 version: String::from("2026-07-28"),
320 echoed: false,
321 },
322 SessionEvent::Operating {
323 session: session(),
324 at: at(),
325 },
326 SessionEvent::Registered {
327 session: session(),
328 at: at(),
329 id: id.clone(),
330 method: Arc::from("tools/call"),
331 origin: RequestOrigin::Inbound,
332 },
333 SessionEvent::Registered {
334 session: session(),
335 at: at(),
336 id: id.clone(),
337 method: Arc::from("sampling/createMessage"),
338 origin: RequestOrigin::Outbound,
339 },
340 SessionEvent::Completed {
341 session: session(),
342 at: at(),
343 id: id.clone(),
344 method: Arc::from("tools/call"),
345 },
346 SessionEvent::Aborted {
347 session: session(),
348 at: at(),
349 id: id.clone(),
350 method: Arc::from("tools/call"),
351 reason: AbortReason::Cancelled(Some(CancelReason::new("user"))),
352 },
353 SessionEvent::Refused {
354 session: session(),
355 at: at(),
356 id: Some(id),
357 method: Arc::from("tools/call"),
358 state: SessionState::Uninitialised,
359 },
360 // A notification has no id, and the line has to read without one.
361 SessionEvent::Refused {
362 session: session(),
363 at: at(),
364 id: None,
365 method: Arc::from("notifications/initialized"),
366 state: SessionState::Operating,
367 },
368 SessionEvent::StateChanged {
369 session: session(),
370 at: at(),
371 state: SessionState::Closed,
372 },
373 ];
374 for event in &events {
375 let line = event.to_string();
376 assert!(line.contains("session#4"), "{line}");
377 assert!(!line.is_empty());
378 }
379 }
380
381 #[test]
382 fn the_null_log_accepts_and_discards() {
383 let log = NullEventLog;
384 log.record(SessionEvent::Opened {
385 session: session(),
386 at: at(),
387 });
388 }
389}