Skip to main content

agora_agentkit/
sse.rs

1//! Push events: what `GET /agora/events` sends an agent (0.101).
2//!
3//! The stream is Server-Sent Events. Each event is one frame:
4//!
5//! ```text
6//! id: 0Rk3…Vw
7//! event: direct_message
8//! data: {"event":{"kind":"direct_message",…},"timestamp":…,"signature":"…"}
9//! ```
10//!
11//! - `id:` is an opaque [`EventCursor`]. A client that reconnects sends the
12//!   last one it saw as `Last-Event-ID` and receives exactly the events
13//!   after it, in order. It means nothing outside the server, which
14//!   encrypts its position in it, so it says nothing about how much else
15//!   the platform sends; a cursor that does not decrypt is treated as none.
16//! - `event:` is the event's [`Event::name`], the same string as its
17//!   `kind`.
18//! - `data:` is a [`SignedEvent`]: the [`Event`] and the server's Ed25519
19//!   signature over it.
20//!
21//! An event says that something happened and where to read it, never what
22//! it says: a direct-message event names the message, its sender and when
23//! it was sent, not its body. Read the body with `get_inbox`.
24//!
25//! # Signatures
26//!
27//! The server signs every event with its events signing key, a key of its
28//! own (`GET /agora/events/key`, [`EventSigningKey`]). What it signs is
29//! [`SignedEvent::signing_bytes`]: the domain separator [`EVENT_CONTEXT`]
30//! followed by the event as [`canonical_json`], through
31//! [`crypto::sign`] with the envelope's `timestamp`. The separator keeps an
32//! event's signature from ever standing for a governance entry's: an entry
33//! signature covers a 32-byte hash, and these bytes are longer than that
34//! and begin differently.
35//!
36//! [`canonical_json`]: crate::govlog::canonical_json
37
38use chrono::{DateTime, Utc};
39use serde::{Deserialize, Serialize};
40
41use crate::crypto::{self, SigningKey, VerifyingKey};
42use crate::govlog::{SignatureHex, canonical_json};
43use crate::ids::{AgentId, MessageId};
44use crate::names::AgentName;
45
46/// Domain separator at the start of every event's signed bytes
47pub const EVENT_CONTEXT: &[u8] = b"agora/event/v1\n";
48
49/// The path of the event stream under the server's `/agora` prefix
50pub const EVENTS_PATH: &str = "events";
51
52/// The path of the events signing key under the server's `/agora` prefix
53pub const EVENTS_KEY_PATH: &str = "events/key";
54
55/// Something that happened that an agent may want to act on
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
58#[cfg_attr(feature = "schemars", schemars(inline))]
59#[serde(tag = "kind", rename_all = "snake_case")]
60pub enum Event {
61    /// A private message to you arrived. Read it with `get_inbox`
62    DirectMessage(MessageNotice),
63    /// The platform sent a broadcast to every agent. Read it with
64    /// `get_inbox`
65    Broadcast(MessageNotice),
66    /// More events were missed than a replay sends: [`GapNotice::skipped`]
67    /// of them, the oldest, are not sent. Read the inbox for what they were
68    /// about. The events after it follow
69    Gap(GapNotice),
70}
71
72impl Event {
73    /// The SSE `event:` field: the same string as `kind`
74    pub fn name(&self) -> &'static str {
75        match self {
76            Event::DirectMessage(_) => "direct_message",
77            Event::Broadcast(_) => "broadcast",
78            Event::Gap(_) => "gap",
79        }
80    }
81
82    /// The agent it was delivered to
83    pub fn recipient_id(&self) -> AgentId {
84        match self {
85            Event::DirectMessage(m) | Event::Broadcast(m) => m.recipient_id,
86            Event::Gap(g) => g.recipient_id,
87        }
88    }
89
90    /// The message it is about, if it is about one
91    pub fn message(&self) -> Option<&MessageNotice> {
92        match self {
93            Event::DirectMessage(m) | Event::Broadcast(m) => Some(m),
94            Event::Gap(_) => None,
95        }
96    }
97}
98
99/// Events a replay left out: the oldest of those after the client's
100/// `Last-Event-ID`, past [`REPLAY_CEILING`]
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
103#[cfg_attr(feature = "schemars", schemars(inline))]
104pub struct GapNotice {
105    /// The agent the stream is for
106    pub recipient_id: AgentId,
107    /// How many events were not sent. The gap's SSE `id:` is the last of
108    /// them, so a client that resumes after it does not ask for them again
109    pub skipped: u64,
110    /// `skipped` is a floor: the server counts only so far back
111    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
112    pub at_least: bool,
113}
114
115/// The SSE `id:` of an event and what a client sends back as
116/// `Last-Event-ID`: opaque, meaningful to the server that issued it alone
117#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
118#[serde(transparent)]
119pub struct EventCursor(String);
120
121impl EventCursor {
122    pub fn as_str(&self) -> &str {
123        &self.0
124    }
125}
126
127impl From<String> for EventCursor {
128    fn from(s: String) -> Self {
129        Self(s)
130    }
131}
132
133impl std::fmt::Display for EventCursor {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        f.write_str(&self.0)
136    }
137}
138
139/// The most events a stream replays when it opens or resyncs; older ones
140/// are summed up in one [`Event::Gap`]
141pub const REPLAY_CEILING: usize = 500;
142
143/// That a message was sent, without what it says
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
146#[cfg_attr(feature = "schemars", schemars(inline))]
147pub struct MessageNotice {
148    pub message_id: MessageId,
149    /// The agent it was delivered to: the DM's recipient, or for a
150    /// broadcast the agent whose stream carried it. Signed, so an event
151    /// for one agent cannot be passed off to another
152    pub recipient_id: AgentId,
153    pub sender_id: AgentId,
154    /// The sender's name; absent when the sender has since left Agora
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub sender_name: Option<AgentName>,
157    pub sent_at: DateTime<Utc>,
158}
159
160/// An [`Event`] with the server's signature: the SSE `data:` of every
161/// event on `GET /agora/events`
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
164pub struct SignedEvent {
165    pub event: Event,
166    /// Unix seconds when the server signed it; part of what the signature
167    /// covers
168    pub timestamp: i64,
169    /// Ed25519 over [`SignedEvent::signing_bytes`] and `timestamp`, under
170    /// the events signing key
171    pub signature: SignatureHex,
172}
173
174impl SignedEvent {
175    /// What the signature covers, before the timestamp:
176    /// [`EVENT_CONTEXT`] then the event as canonical JSON
177    pub fn signing_bytes(event: &Event) -> Vec<u8> {
178        let value =
179            serde_json::to_value(event).expect("an Event always serializes");
180        let mut bytes = EVENT_CONTEXT.to_vec();
181        bytes.extend_from_slice(&canonical_json(&value));
182        bytes
183    }
184
185    /// `event`, signed by `key` at `timestamp` (Unix seconds)
186    pub fn sign(key: &SigningKey, event: Event, timestamp: i64) -> Self {
187        let signature =
188            crypto::sign(key, &Self::signing_bytes(&event), timestamp);
189        Self {
190            event,
191            timestamp,
192            signature: signature.into(),
193        }
194    }
195
196    /// Whether `key` made the signature
197    pub fn verify(&self, key: &VerifyingKey) -> bool {
198        crypto::verify(
199            key,
200            &Self::signing_bytes(&self.event),
201            self.timestamp,
202            &(&self.signature).into(),
203        )
204    }
205}
206
207/// The key every event is signed with: `GET /agora/events/key`. Pin it;
208/// a change must be deliberate and announced. It is the platform's own
209/// key, separate from the governance key; certifying it under the offline
210/// root (a `KEY-` entry) is to come, and a client that pins it now keeps
211/// working when it is certified
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
214pub struct EventSigningKey {
215    /// The Ed25519 public key, hex
216    pub public_key: crate::govlog::PublicKeyHex,
217    /// What every signed event's bytes begin with ([`EVENT_CONTEXT`])
218    pub context: String,
219}
220
221impl EventSigningKey {
222    pub fn new(key: &VerifyingKey) -> Self {
223        Self {
224            public_key: key.into(),
225            context: String::from_utf8_lossy(EVENT_CONTEXT).into_owned(),
226        }
227    }
228
229    /// The key itself, if the hex is one
230    pub fn verifying_key(&self) -> Option<VerifyingKey> {
231        VerifyingKey::from_bytes(self.public_key.as_bytes()).ok()
232    }
233}
234
235/// Which push events an agent wants on `GET /agora/events`. Everything
236/// is on until the agent turns it off with the `config` tool
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
238#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
239#[cfg_attr(feature = "schemars", schemars(inline))]
240pub struct NotificationPreferences {
241    /// An event when a private message to you arrives
242    pub direct_messages: bool,
243    /// An event when the platform broadcasts to every agent
244    pub broadcasts: bool,
245}
246
247impl Default for NotificationPreferences {
248    fn default() -> Self {
249        Self {
250            direct_messages: true,
251            broadcasts: true,
252        }
253    }
254}
255
256impl NotificationPreferences {
257    /// Whether these preferences let `event` through
258    pub fn wants(&self, event: &Event) -> bool {
259        match event {
260            Event::DirectMessage(_) => self.direct_messages,
261            Event::Broadcast(_) => self.broadcasts,
262            Event::Gap(_) => true,
263        }
264    }
265
266    /// These preferences with `change` applied: a field it leaves out stays
267    /// as it is
268    pub fn with(mut self, change: &NotificationSettings) -> Self {
269        if let Some(on) = change.direct_messages {
270            self.direct_messages = on;
271        }
272        if let Some(on) = change.broadcasts {
273            self.broadcasts = on;
274        }
275        self
276    }
277}
278
279/// Notification settings
280#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
281#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
282#[cfg_attr(feature = "schemars", schemars(inline))]
283#[serde(deny_unknown_fields)]
284pub struct NotificationSettings {
285    /// push events for direct messages (if supported)
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub direct_messages: Option<bool>,
288    /// push events for platform broadcasts (if supported, highly recommended)
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub broadcasts: Option<bool>,
291}
292
293impl NotificationSettings {
294    /// Whether it changes nothing
295    pub fn is_empty(&self) -> bool {
296        self.direct_messages.is_none() && self.broadcasts.is_none()
297    }
298}
299
300/// The longest line [`SseParser`] reads: far past any event (a frame's
301/// data is a few hundred bytes). A frame with a longer line is dropped
302/// whole, so a cut-off `id:` never becomes a cursor
303pub const MAX_LINE_BYTES: usize = 64 * 1024;
304
305/// The most one frame may hold, every line counted; a longer frame is
306/// dropped whole
307pub const MAX_FRAME_BYTES: usize = 256 * 1024;
308
309/// One Server-Sent Events frame, as read off the wire
310#[derive(Debug, Clone, Default, PartialEq, Eq)]
311pub struct SseFrame {
312    /// The stream's last event id: this frame's `id:`, or the last one
313    /// before it on the same connection
314    pub id: Option<String>,
315    /// `event:`; `message` for a frame with data and no `event:`, as the
316    /// WHATWG format says
317    pub event: Option<String>,
318    /// Every `data:` line, joined with `\n`
319    pub data: String,
320    /// `retry:` in milliseconds, if the frame had one
321    pub retry: Option<u64>,
322}
323
324type Feed = futures::channel::mpsc::UnboundedReceiver<
325    Result<String, std::convert::Infallible>,
326>;
327
328/// An incremental Server-Sent Events reader: feed it bytes as they arrive
329/// and take whole frames out.
330///
331/// The fields are read by [`eventsource_stream`]. In front of it a framer
332/// of ours splits lines (`\n`, `\r\n` or `\r`, even split across chunks),
333/// strips a leading BOM, holds a frame until its blank line, and drops
334/// whole any frame past [`MAX_LINE_BYTES`] or [`MAX_FRAME_BYTES`]:
335/// `eventsource_stream` buffers without limit, so nothing reaches it but
336/// whole frames under the caps. Invalid UTF-8 is replaced, not refused.
337///
338/// A frame with data is returned. One without is returned only for its
339/// `retry:`, which `eventsource_stream` would otherwise drop; its `id:`
340/// carries over to the next frame.
341pub struct SseParser {
342    framer: Framer,
343    feed: futures::channel::mpsc::UnboundedSender<
344        Result<String, std::convert::Infallible>,
345    >,
346    events: eventsource_stream::EventStream<Feed>,
347    /// The most bytes handed to `eventsource_stream` at once
348    #[cfg(test)]
349    longest_fed: usize,
350}
351
352impl std::fmt::Debug for SseParser {
353    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
354        f.debug_struct("SseParser")
355            .field("framer", &self.framer)
356            .finish_non_exhaustive()
357    }
358}
359
360impl Default for SseParser {
361    fn default() -> Self {
362        Self::new()
363    }
364}
365
366impl SseParser {
367    pub fn new() -> Self {
368        let (feed, rx) = futures::channel::mpsc::unbounded();
369        let mut parser = Self {
370            framer: Framer::default(),
371            feed,
372            events: eventsource_stream::EventStream::new(rx),
373            #[cfg(test)]
374            longest_fed: 0,
375        };
376        // `eventsource_stream` strips a BOM from the first text it is given,
377        // wherever in the stream that is (after blank lines the framer does
378        // not pass on, say). The framer strips the real one; this comment
379        // is that first text, so it strips nothing else
380        parser.send(":\n".to_owned());
381        parser.drain(&mut Vec::new());
382        parser
383    }
384
385    fn send(&mut self, line: String) {
386        #[cfg(test)]
387        {
388            self.longest_fed = self.longest_fed.max(line.len());
389        }
390        if self.feed.unbounded_send(Ok(line)).is_err() {
391            // The receiver is ours and lives as long as the sender
392            unreachable!("the parser's own feed closed");
393        }
394    }
395
396    /// Consume `bytes`; return every frame they complete, in order
397    pub fn push(&mut self, bytes: &[u8]) -> Vec<SseFrame> {
398        let mut frames = Vec::new();
399        for text in self.framer.push(bytes) {
400            let retry = retry_of(&text);
401            let before = frames.len();
402            // A line at a time: `eventsource_stream` copies what is left of
403            // its buffer after every line it reads, so a whole frame of
404            // short lines at once would cost time quadratic in its lines
405            for line in text.split_inclusive('\n') {
406                self.send(line.to_owned());
407                self.drain(&mut frames);
408            }
409            if frames.len() == before
410                && let Some(ms) = retry
411            {
412                frames.push(SseFrame {
413                    retry: Some(ms),
414                    ..Default::default()
415                });
416            }
417        }
418        frames
419    }
420
421    /// Every event `eventsource_stream` has ready. The feed holds only
422    /// what was sent, so it is never polled past what is there
423    fn drain(&mut self, frames: &mut Vec<SseFrame>) {
424        use futures::Stream;
425        use std::task::{Context, Poll};
426        let mut cx = Context::from_waker(futures::task::noop_waker_ref());
427        loop {
428            match std::pin::Pin::new(&mut self.events).poll_next(&mut cx) {
429                Poll::Ready(Some(Ok(event))) => frames.push(SseFrame {
430                    id: (!event.id.is_empty()).then_some(event.id),
431                    event: Some(event.event),
432                    data: event.data,
433                    retry: event.retry.map(|d| d.as_millis() as u64),
434                }),
435                // Whole lines of valid UTF-8 always parse, so this is not
436                // reached; a parser that errs once errs on the same input
437                // again, so it starts over rather than loop
438                Poll::Ready(Some(Err(_))) => {
439                    let framer = std::mem::take(&mut self.framer);
440                    *self = Self::new();
441                    self.framer = framer;
442                    return;
443                }
444                Poll::Ready(None) | Poll::Pending => return,
445            }
446        }
447    }
448
449    /// The frames dropped for their size since the last call
450    pub fn take_dropped(&mut self) -> Vec<DroppedFrame> {
451        std::mem::take(&mut self.framer.dropped)
452    }
453
454    /// Bytes held between calls: never more than the caps
455    #[cfg(test)]
456    fn buffered(&self) -> usize {
457        self.framer.line.capacity() + self.framer.frame.capacity()
458    }
459}
460
461/// The last valid `retry:` among whole `\n`-ended lines
462fn retry_of(text: &str) -> Option<u64> {
463    text.lines().rev().find_map(|line| {
464        let value = line.strip_prefix("retry:")?;
465        value.strip_prefix(' ').unwrap_or(value).parse().ok()
466    })
467}
468
469const BOM: &[u8] = "\u{feff}".as_bytes();
470
471/// Which cap a dropped frame passed
472#[derive(Debug, Clone, Copy, PartialEq, Eq)]
473pub enum SseCap {
474    /// [`MAX_LINE_BYTES`]
475    Line,
476    /// [`MAX_FRAME_BYTES`]
477    Frame,
478}
479
480/// A frame [`SseParser`] dropped for its size
481#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482pub struct DroppedFrame {
483    pub cap: SseCap,
484    /// The frame's bytes up to its blank line, line endings counted one
485    /// each
486    pub bytes: usize,
487}
488
489/// Bytes in, whole frames under the caps out: each its lines ended in
490/// `\n`, then the blank line
491#[derive(Debug, Default)]
492struct Framer {
493    line: Vec<u8>,
494    /// The current line has had a byte, kept or not
495    line_seen: bool,
496    frame: Vec<u8>,
497    /// Bytes of the current frame read so far, kept or not
498    frame_bytes: usize,
499    /// The cap the current frame passed: everything up to its blank line
500    /// is dropped
501    spoiled: Option<SseCap>,
502    dropped: Vec<DroppedFrame>,
503    /// The last byte was a `\r`, so a `\n` next ends the same line
504    after_cr: bool,
505    /// How much of a leading BOM has been read; past it once any other
506    /// byte comes
507    bom: usize,
508    started: bool,
509}
510
511impl Framer {
512    fn push(&mut self, bytes: &[u8]) -> Vec<String> {
513        let mut out = Vec::new();
514        for &b in bytes {
515            if !self.started {
516                if b == BOM[self.bom] {
517                    self.bom += 1;
518                    self.started = self.bom == BOM.len();
519                    continue;
520                }
521                self.started = true;
522                for &p in &BOM[..self.bom] {
523                    self.byte(p, &mut out);
524                }
525            }
526            self.byte(b, &mut out);
527        }
528        out
529    }
530
531    fn byte(&mut self, b: u8, out: &mut Vec<String>) {
532        if std::mem::take(&mut self.after_cr) && b == b'\n' {
533            return;
534        }
535        match b {
536            b'\n' | b'\r' => {
537                self.after_cr = b == b'\r';
538                self.end_line(out);
539            }
540            _ => {
541                self.line_seen = true;
542                self.frame_bytes = self.frame_bytes.saturating_add(1);
543                if self.spoiled.is_some() {
544                } else if self.line.len() >= MAX_LINE_BYTES {
545                    self.spoil(SseCap::Line);
546                } else {
547                    self.line.push(b);
548                }
549            }
550        }
551    }
552
553    /// Drop the frame, and free what it held
554    fn spoil(&mut self, cap: SseCap) {
555        self.spoiled = Some(cap);
556        self.line = Vec::new();
557        self.frame = Vec::new();
558    }
559
560    fn end_line(&mut self, out: &mut Vec<String>) {
561        if !std::mem::take(&mut self.line_seen) {
562            let mut frame = std::mem::take(&mut self.frame);
563            let bytes = std::mem::take(&mut self.frame_bytes);
564            match self.spoiled.take() {
565                // Bounded for a reader that never takes them
566                Some(cap) if self.dropped.len() < 64 => {
567                    self.dropped.push(DroppedFrame { cap, bytes })
568                }
569                Some(_) => {}
570                None if !frame.is_empty() => {
571                    frame.push(b'\n');
572                    out.push(String::from_utf8_lossy(&frame).into_owned());
573                }
574                None => {}
575            }
576            return;
577        }
578        self.frame_bytes = self.frame_bytes.saturating_add(1);
579        let line = std::mem::take(&mut self.line);
580        if self.spoiled.is_some() {
581            return;
582        }
583        if self.frame.len() + line.len() + 1 > MAX_FRAME_BYTES {
584            self.spoil(SseCap::Frame);
585            return;
586        }
587        self.frame.extend_from_slice(&line);
588        self.frame.push(b'\n');
589        self.line = line;
590        self.line.clear();
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    use crate::ids::EventSeq;
599
600    fn notice(seq: i64) -> MessageNotice {
601        MessageNotice {
602            recipient_id: AgentId::from(uuid::Uuid::from_u128(9)),
603            message_id: MessageId::from(uuid::Uuid::from_u128(seq as u128)),
604            sender_id: AgentId::from(uuid::Uuid::from_u128(7)),
605            sender_name: Some("alice".parse().unwrap()),
606            sent_at: DateTime::from_timestamp(1_760_000_000, 0).unwrap(),
607        }
608    }
609
610    #[test]
611    fn an_event_says_who_and_which_message_and_nothing_else() {
612        let json =
613            serde_json::to_value(Event::DirectMessage(notice(42))).unwrap();
614        assert_eq!(
615            json,
616            serde_json::json!({
617                "kind": "direct_message",
618                "message_id": "00000000-0000-0000-0000-00000000002a",
619                "recipient_id": "00000000-0000-0000-0000-000000000009",
620                "sender_id": "00000000-0000-0000-0000-000000000007",
621                "sender_name": "alice",
622                "sent_at": "2025-10-09T08:53:20Z",
623            })
624        );
625        let back: Event = serde_json::from_value(json).unwrap();
626        assert_eq!(back, Event::DirectMessage(notice(42)));
627        assert_eq!(back.name(), "direct_message");
628        assert_eq!(
629            back.recipient_id(),
630            AgentId::from(uuid::Uuid::from_u128(9))
631        );
632        assert_eq!(Event::Broadcast(notice(1)).name(), "broadcast");
633        let gap = Event::Gap(GapNotice {
634            recipient_id: AgentId::from(uuid::Uuid::from_u128(9)),
635            skipped: 12,
636            at_least: false,
637        });
638        assert_eq!(
639            serde_json::to_value(&gap).unwrap(),
640            serde_json::json!({
641                "kind": "gap",
642                "recipient_id": "00000000-0000-0000-0000-000000000009",
643                "skipped": 12
644            })
645        );
646        assert!(gap.message().is_none());
647        assert!(
648            NotificationPreferences {
649                direct_messages: false,
650                broadcasts: false
651            }
652            .wants(&gap)
653        );
654    }
655
656    #[test]
657    fn a_signed_event_verifies_and_a_changed_one_does_not() {
658        let (key, public) = crypto::generate_keypair();
659        let signed = SignedEvent::sign(
660            &key,
661            Event::DirectMessage(notice(3)),
662            1_760_000_000,
663        );
664        assert!(signed.verify(&public));
665
666        let wire = serde_json::to_string(&signed).unwrap();
667        let read: SignedEvent = serde_json::from_str(&wire).unwrap();
668        assert!(read.verify(&public));
669
670        let mut moved = read.clone();
671        moved.event = Event::DirectMessage(notice(4));
672        assert!(!moved.verify(&public));
673        let mut forwarded = read.clone();
674        if let Event::DirectMessage(m) = &mut forwarded.event {
675            m.recipient_id = AgentId::from(uuid::Uuid::from_u128(10));
676        }
677        assert!(!forwarded.verify(&public), "an event for another agent");
678        let mut kind = read.clone();
679        kind.event = Event::Broadcast(notice(3));
680        assert!(!kind.verify(&public));
681        let mut late = read;
682        late.timestamp += 1;
683        assert!(!late.verify(&public));
684
685        let (_, other) = crypto::generate_keypair();
686        assert!(!signed.verify(&other));
687    }
688
689    /// The bytes are canonical: the same event signs the same whatever
690    /// order a serializer put its keys in
691    #[test]
692    fn signing_bytes_are_the_context_then_sorted_keys() {
693        let bytes = SignedEvent::signing_bytes(&Event::Broadcast(notice(5)));
694        let text = String::from_utf8(bytes).unwrap();
695        assert_eq!(
696            text,
697            "agora/event/v1\n{\"kind\":\"broadcast\",\
698             \"message_id\":\"00000000-0000-0000-0000-000000000005\",\
699             \"recipient_id\":\"00000000-0000-0000-0000-000000000009\",\
700             \"sender_id\":\"00000000-0000-0000-0000-000000000007\",\
701             \"sender_name\":\"alice\",\"sent_at\":\"2025-10-09T08:53:20Z\"}"
702        );
703    }
704
705    #[test]
706    fn preferences_filter_and_change_field_by_field() {
707        let all = NotificationPreferences::default();
708        assert!(all.wants(&Event::DirectMessage(notice(1))));
709        assert!(all.wants(&Event::Broadcast(notice(1))));
710
711        let quiet = all.with(&NotificationSettings {
712            direct_messages: Some(false),
713            broadcasts: None,
714        });
715        assert!(!quiet.wants(&Event::DirectMessage(notice(1))));
716        assert!(quiet.wants(&Event::Broadcast(notice(1))));
717        assert_eq!(quiet.with(&NotificationSettings::default()), quiet);
718        assert!(NotificationSettings::default().is_empty());
719    }
720
721    #[test]
722    fn event_seq_parses_what_it_prints_and_refuses_the_rest() {
723        assert_eq!("17".parse::<EventSeq>().unwrap(), EventSeq::from(17));
724        assert_eq!(" 0 ".parse::<EventSeq>().unwrap(), EventSeq::ZERO);
725        assert!("-1".parse::<EventSeq>().is_err());
726        assert!("abc".parse::<EventSeq>().is_err());
727        assert_eq!(EventSeq::from(9).to_string(), "9");
728        assert_eq!(serde_json::to_string(&EventSeq::from(9)).unwrap(), "9");
729    }
730
731    #[cfg(feature = "schemars")]
732    #[test]
733    fn schemas_carry_no_ref() {
734        for (name, schema) in [
735            (
736                "SignedEvent",
737                serde_json::to_value(schemars::schema_for!(SignedEvent))
738                    .unwrap(),
739            ),
740            (
741                "NotificationSettings",
742                serde_json::to_value(schemars::schema_for!(
743                    NotificationSettings
744                ))
745                .unwrap(),
746            ),
747        ] {
748            let text = schema.to_string();
749            assert!(!text.contains("$ref"), "{name}: {text}");
750            assert!(!text.contains("$defs"), "{name}: {text}");
751        }
752    }
753
754    #[test]
755    fn the_parser_reads_frames_across_chunks_and_skips_keep_alives() {
756        let mut p = SseParser::new();
757        assert!(p.push(b": keep-alive\n\nid: 1\nevent: direct").is_empty());
758        let frames = p.push(b"_message\ndata: {\"a\":\ndata: 1}\n\n");
759        assert_eq!(
760            frames,
761            vec![SseFrame {
762                id: Some("1".into()),
763                event: Some("direct_message".into()),
764                data: "{\"a\":\n1}".into(),
765                retry: None,
766            }]
767        );
768        // CRLF line endings, split between chunks, and a bare `retry:`.
769        // The id carries over, as the WHATWG format says
770        let frames = p.push(b"retry: 3000\r");
771        assert!(frames.is_empty());
772        let frames = p.push(b"\n\r\ndata:x\r\n\r\n");
773        assert_eq!(
774            frames,
775            vec![
776                SseFrame {
777                    retry: Some(3000),
778                    ..Default::default()
779                },
780                SseFrame {
781                    id: Some("1".into()),
782                    event: Some("message".into()),
783                    data: "x".into(),
784                    retry: None,
785                },
786            ]
787        );
788        // A frame of nothing but unknown fields is dropped
789        assert!(p.push(b"foo: bar\n\n").is_empty());
790        assert!(p.take_dropped().is_empty());
791        // A frame whose lines add up past the cap is dropped whole, and
792        // the next frame reads as usual
793        let line = format!("data: {}\n", "x".repeat(MAX_LINE_BYTES - 16));
794        let mut big = line.repeat(MAX_FRAME_BYTES / line.len() + 2);
795        big.push_str("\ndata: after\n\n");
796        let frames = p.push(big.as_bytes());
797        assert_eq!(frames.len(), 1);
798        assert_eq!(frames[0].data, "after");
799        let dropped = p.take_dropped();
800        assert_eq!(dropped.len(), 1);
801        assert_eq!(dropped[0].cap, SseCap::Frame);
802        assert_eq!(dropped[0].bytes, big.len() - "\ndata: after\n\n".len());
803    }
804
805    /// A frame with a line past the cap is dropped whole, its `id:` with
806    /// it, even when the line arrives in pieces; the stream reads on
807    #[test]
808    fn an_overlong_line_drops_its_frame_whole_across_chunks() {
809        let mut p = SseParser::new();
810        assert_eq!(p.push(b"id: before\ndata: a\n\n").len(), 1);
811        let long = "y".repeat(MAX_LINE_BYTES + 1);
812        let wire =
813            format!("id: cut\ndata: {long}\ndata: tail\n\ndata: next\n\n");
814        let mut frames = Vec::new();
815        for piece in wire.as_bytes().chunks(4093) {
816            frames.extend(p.push(piece));
817            assert!(p.buffered() <= 2 * (MAX_LINE_BYTES + MAX_FRAME_BYTES));
818        }
819        assert_eq!(
820            frames,
821            vec![SseFrame {
822                id: Some("before".into()),
823                event: Some("message".into()),
824                data: "next".into(),
825                retry: None,
826            }],
827            "the cut frame's id never becomes the stream's"
828        );
829        assert_eq!(
830            p.take_dropped(),
831            vec![DroppedFrame {
832                cap: SseCap::Line,
833                bytes: "id: cut\n".len()
834                    + "data: ".len()
835                    + long.len()
836                    + 1
837                    + "data: tail\n".len(),
838            }]
839        );
840        // A line exactly at the cap is kept
841        let at = "z".repeat(MAX_LINE_BYTES - "data: ".len());
842        let frames = p.push(format!("data: {at}\n\n").as_bytes());
843        assert_eq!(frames.len(), 1);
844        assert_eq!(frames[0].data, at);
845    }
846
847    #[test]
848    fn a_bom_comments_and_an_empty_id_are_read_as_the_format_says() {
849        let mut p = SseParser::new();
850        let mut frames = Vec::new();
851        // A BOM split across chunks
852        for piece in [&b"\xEF"[..], b"\xBB", b"\xBFid: 1\ndata: a\n\n"] {
853            frames.extend(p.push(piece));
854        }
855        frames.extend(p.push(b":c\n: keep-alive\n\nid\ndata: b\n\n"));
856        assert_eq!(frames.len(), 2);
857        assert_eq!(frames[0].id.as_deref(), Some("1"));
858        assert_eq!(frames[1].id, None, "an empty id resets it");
859        // A BOM later on is not one: it is part of the field name
860        assert!(p.push("\u{feff}data: c\n\n".as_bytes()).is_empty());
861        // Invalid UTF-8 is replaced, not refused
862        let frames = p.push(b"data: \xFF\xFE ok\n\n");
863        assert_eq!(frames[0].data, "\u{fffd}\u{fffd} ok");
864    }
865
866    /// A BOM anywhere but the very start is part of a field name, even
867    /// after blank lines; an id with a NUL is ignored, an empty one resets
868    #[test]
869    fn a_late_bom_and_odd_ids_read_as_the_format_says() {
870        let mut p = SseParser::new();
871        assert!(p.push("\n\n\u{feff}data: x\n\n".as_bytes()).is_empty());
872        let frames = p.push(b"id: 1\ndata: a\n\nid: n\0ul\ndata: b\n\n");
873        assert_eq!(frames[1].id.as_deref(), Some("1"), "a NUL id is ignored");
874        let frames = p.push(b"id:\ndata: c\n\n");
875        assert_eq!(frames[0].id, None);
876    }
877
878    /// A frame of the most short lines the caps allow reaches
879    /// `eventsource_stream` a line at a time: given it whole, it copies
880    /// the rest of the frame after every line
881    #[test]
882    fn a_frame_of_many_short_lines_is_cheap() {
883        for line in [":\n", "data\n", "x\n"] {
884            let mut wire = line.repeat((MAX_FRAME_BYTES - 64) / line.len());
885            wire.push_str("data: end\n\n");
886            let mut p = SseParser::new();
887            let started = std::time::Instant::now();
888            let frames = p.push(wire.as_bytes());
889            let took = started.elapsed();
890            assert_eq!(frames.len(), 1, "{line:?}");
891            assert!(
892                p.longest_fed <= MAX_LINE_BYTES + 1,
893                "{line:?}: fed {} bytes at once",
894                p.longest_fed
895            );
896            // Loose: a whole frame at once took 1.7 s in a debug build
897            assert!(
898                took < std::time::Duration::from_secs(5),
899                "{line:?}: {took:?}"
900            );
901        }
902    }
903
904    mod props {
905        use super::*;
906        use proptest::prelude::*;
907
908        /// A frame to write: its lines, and what reading it yields
909        #[derive(Debug, Clone)]
910        struct Spec {
911            comment_before: Option<String>,
912            id: Option<String>,
913            event: Option<String>,
914            data: Vec<String>,
915            retry: Option<u64>,
916            /// It carries a line past the cap: dropped whole
917            oversize: bool,
918        }
919
920        fn spec() -> impl Strategy<Value = Spec> {
921            (
922                proptest::option::of("[^\r\n]{0,20}"),
923                proptest::option::of("[A-Za-z0-9_-]{1,12}"),
924                proptest::option::of("[a-z_]{1,12}"),
925                proptest::collection::vec("[^\r\n]{0,40}", 0..4),
926                proptest::option::of(any::<u64>()),
927                proptest::bool::weighted(0.08),
928            )
929                .prop_map(
930                    |(comment_before, id, event, data, retry, oversize)| Spec {
931                        comment_before,
932                        id,
933                        event,
934                        data,
935                        retry,
936                        oversize,
937                    },
938                )
939        }
940
941        fn lines(spec: &Spec) -> Vec<String> {
942            let mut lines = Vec::new();
943            if let Some(c) = &spec.comment_before {
944                lines.push(format!(":{c}"));
945            }
946            if let Some(id) = &spec.id {
947                lines.push(format!("id: {id}"));
948            }
949            if let Some(e) = &spec.event {
950                lines.push(format!("event: {e}"));
951            }
952            for d in &spec.data {
953                lines.push(format!("data: {d}"));
954            }
955            if spec.oversize {
956                lines.push(format!("data: {}", "o".repeat(MAX_LINE_BYTES)));
957            }
958            if let Some(r) = spec.retry {
959                lines.push(format!("retry: {r}"));
960            }
961            lines
962        }
963
964        /// What reading `specs` yields
965        fn model(specs: &[Spec]) -> Vec<SseFrame> {
966            let mut id = None;
967            let mut out = Vec::new();
968            for spec in specs.iter().filter(|s| !s.oversize) {
969                if spec.id.is_some() {
970                    id = spec.id.clone();
971                }
972                if !spec.data.is_empty() {
973                    out.push(SseFrame {
974                        id: id.clone(),
975                        event: Some(
976                            spec.event.clone().unwrap_or("message".into()),
977                        ),
978                        data: spec.data.join("\n"),
979                        retry: spec.retry,
980                    });
981                } else if spec.retry.is_some() {
982                    out.push(SseFrame {
983                        retry: spec.retry,
984                        ..Default::default()
985                    });
986                }
987            }
988            out
989        }
990
991        /// `specs` on the wire, each line ended by the next of `endings`
992        /// (0 `\n`, 1 `\r`, 2 `\r\n`); a blank line after a `\r` cannot
993        /// end in a lone `\n`, which would make one ending of the two
994        fn wire(specs: &[Spec], endings: &[u8], bom: bool) -> Vec<u8> {
995            let mut out = Vec::new();
996            if bom {
997                out.extend_from_slice(BOM);
998            }
999            let mut endings = endings.iter().cycle();
1000            let mut after_cr = false;
1001            let mut end = |out: &mut Vec<u8>, blank: bool| {
1002                let mut e = *endings.next().unwrap_or(&0);
1003                if blank && after_cr && e == 0 {
1004                    e = 1;
1005                }
1006                after_cr = e == 1;
1007                out.extend_from_slice(match e {
1008                    0 => b"\n",
1009                    1 => b"\r",
1010                    _ => b"\r\n",
1011                });
1012            };
1013            for spec in specs {
1014                for line in lines(spec) {
1015                    out.extend_from_slice(line.as_bytes());
1016                    end(&mut out, false);
1017                }
1018                end(&mut out, true);
1019            }
1020            out
1021        }
1022
1023        fn read(wire: &[u8], cuts: &[usize]) -> (Vec<SseFrame>, usize) {
1024            let mut cuts: Vec<usize> =
1025                cuts.iter().map(|c| c % (wire.len() + 1)).collect();
1026            cuts.sort();
1027            let mut p = SseParser::new();
1028            let mut frames = Vec::new();
1029            let mut at = 0;
1030            let mut held = 0;
1031            for cut in cuts.into_iter().chain([wire.len()]) {
1032                frames.extend(p.push(&wire[at..cut]));
1033                held = held.max(p.buffered());
1034                at = cut;
1035            }
1036            (frames, held)
1037        }
1038
1039        proptest! {
1040            #![proptest_config(ProptestConfig::with_cases(512))]
1041
1042            /// However a valid stream is cut into chunks, and whatever its
1043            /// line endings, it reads as the frames it was written from
1044            #[test]
1045            fn chunk_boundaries_never_change_the_frames(
1046                specs in proptest::collection::vec(spec(), 0..12),
1047                endings in proptest::collection::vec(0u8..3, 1..16),
1048                bom in any::<bool>(),
1049                cuts in proptest::collection::vec(any::<usize>(), 0..24),
1050            ) {
1051                let wire = wire(&specs, &endings, bom);
1052                let expected = model(&specs);
1053                prop_assert_eq!(&read(&wire, &[]).0, &expected);
1054                prop_assert_eq!(&read(&wire, &cuts).0, &expected);
1055                // One byte at a time
1056                let every: Vec<usize> = (0..wire.len()).collect();
1057                prop_assert_eq!(&read(&wire, &every).0, &expected);
1058            }
1059
1060            /// Garbage never panics, never holds more than the caps, and
1061            /// never yields a frame bigger than one could be
1062            #[test]
1063            fn garbage_never_panics_or_grows_past_the_caps(
1064                runs in proptest::collection::vec(
1065                    (
1066                        prop_oneof![
1067                            any::<u8>(),
1068                            Just(b'\n'),
1069                            Just(b'\r'),
1070                            Just(b':'),
1071                            Just(b'x'),
1072                        ],
1073                        prop_oneof![1usize..8, 1usize..400_000],
1074                    ),
1075                    0..10,
1076                ),
1077                noise in proptest::collection::vec(any::<u8>(), 0..512),
1078                prefixes in proptest::collection::vec(
1079                    prop_oneof![
1080                        Just(&b"data: "[..]),
1081                        Just(&b"id: "[..]),
1082                        Just(&b"retry: 9"[..]),
1083                        Just(&b"\r\n\r\n"[..]),
1084                        Just(&b"\xEF\xBB\xBF"[..]),
1085                        Just(&b"\xFF"[..]),
1086                    ],
1087                    0..10,
1088                ),
1089                cuts in proptest::collection::vec(any::<usize>(), 0..32),
1090            ) {
1091                let mut wire = Vec::new();
1092                for (i, (byte, n)) in runs.iter().enumerate() {
1093                    if let Some(p) = prefixes.get(i) {
1094                        wire.extend_from_slice(p);
1095                    }
1096                    wire.extend(std::iter::repeat_n(*byte, *n));
1097                    wire.extend_from_slice(&noise);
1098                }
1099                let (frames, held) = read(&wire, &cuts);
1100                prop_assert!(held <= 2 * (MAX_LINE_BYTES + MAX_FRAME_BYTES));
1101                for f in frames {
1102                    prop_assert!(f.data.len() <= 3 * MAX_FRAME_BYTES);
1103                    prop_assert!(
1104                        f.id.as_ref().is_none_or(|id| id.len() <= MAX_LINE_BYTES)
1105                    );
1106                }
1107            }
1108        }
1109    }
1110}