Skip to main content

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}