Skip to main content

ag_ui/server/
verify.rs

1//! The ordering state machine.
2//!
3//! `TEXT_MESSAGE_CONTENT` without a preceding `TEXT_MESSAGE_START` is a bug
4//! that currently surfaces as a confused frontend, three network hops from
5//! where it was caused. Neither the TypeScript SDK (which verifies on the
6//! client) nor the .NET one (which does not verify) catches it on the server.
7//! This crate does, on by default.
8//!
9//! # What it rejects
10//!
11//! | Rule | Rejected |
12//! | --- | --- |
13//! | [`RunEnded`] | anything after `RUN_FINISHED` / `RUN_ERROR` |
14//! | [`DuplicateRunStarted`] | a second `RUN_STARTED` |
15//! | [`DuplicateStart`] | opening a message, reasoning block, tool call, step or subagent whose id is already open — or a subagent id that already finished in this run |
16//! | [`NotOpen`] | content or a terminator for something that was never opened, including `SUBAGENT_FINISHED` / `SUBAGENT_ERROR` for a subagent that is not active |
17//! | [`UnknownId`] | `TOOL_CALL_RESULT` for a call id that was never introduced, or a `parentSubagentRunId` that was never started |
18//! | [`OutOfOrder`] | `TOOL_CALL_RESULT` before the call's `TOOL_CALL_END` |
19//! | [`OpenAtFinish`] | `RUN_FINISHED` while a message, reasoning block, tool call, step or subagent is open |
20//! | [`OwnerMismatch`] | a tagged continuation, terminator or re-open whose `subagentRunId` is not the one that opened the entity — a message, reasoning block, tool call, activity, or the entity a `REASONING_ENCRYPTED_VALUE` names; a tool call tagged with one subagent whose parent message belongs to another |
21//!
22//! `RUN_ERROR` is exempt from [`OpenAtFinish`]: a run that blew up mid-message
23//! could not have closed it.
24//!
25//! # Subagents
26//!
27//! Every entity is opened *by someone* — a subagent, or the parent agent when
28//! the opener carries no `subagentRunId` — and the verifier remembers who.
29//! A later event that *names* a different owner is rejected; one that names
30//! none is accepted, because attribution is optional on every event and a
31//! bare continuation is what a pre-subagent producer sends — and it does not
32//! hand the entity to the parent either: the first writer stays the owner, as
33//! upstream records it. Steps are keyed by owner as well as name, so a
34//! subagent cannot close the parent's step, or a sibling's, and two agents may
35//! run a step of the same name at once. A `MESSAGES_SNAPSHOT` seeds ownership
36//! from the messages it carries and is authoritative; the `RUN_STARTED` input
37//! echo seeds it too, for ids not yet recorded; a `TOOL_CALL_RESULT` mints the
38//! tool message it names under its own attribution.
39//!
40//! What is deliberately *not* checked, because the protocol does not require
41//! it: that an attributing `subagentRunId` was announced by `SUBAGENT_STARTED`
42//! (attribution without lifecycle events is a supported mode), that a
43//! subagent's own messages are closed before its `SUBAGENT_FINISHED`, or that
44//! events stop after it — a `parentSubagentRunId` may even name a subagent
45//! that already finished, since a parent legitimately finishes before its
46//! child. What *is* required is that every started subagent is closed before
47//! `RUN_FINISHED`.
48//!
49//! [`RunEnded`]: crate::server::Rule::RunEnded
50//! [`DuplicateRunStarted`]: crate::server::Rule::DuplicateRunStarted
51//! [`DuplicateStart`]: crate::server::Rule::DuplicateStart
52//! [`NotOpen`]: crate::server::Rule::NotOpen
53//! [`UnknownId`]: crate::server::Rule::UnknownId
54//! [`OutOfOrder`]: crate::server::Rule::OutOfOrder
55//! [`OpenAtFinish`]: crate::server::Rule::OpenAtFinish
56//! [`OwnerMismatch`]: crate::server::Rule::OwnerMismatch
57//!
58//! # What it lets through
59//!
60//! The `*_CHUNK` events are self-contained by design, so a chunk carrying a new
61//! id registers that id rather than being rejected for having no start. The
62//! deprecated `THINKING_*` family is not tracked at all. State, activity, raw
63//! and custom events are unordered — though an activity delta, like any
64//! continuation, may not name an owner other than its activity's.
65//!
66//! # Cost
67//!
68//! A handful of maps and one lookup per event. Turning the `verify` feature
69//! off replaces the whole state machine with a zero-sized type whose
70//! `observe` is an inlined `Ok(())`. In debug builds a rejection additionally
71//! carries a dump of everything still open, which is the expensive part and is
72//! why it is debug-only.
73
74#[cfg(feature = "verify")]
75pub(crate) use enabled::Verifier;
76
77#[cfg(not(feature = "verify"))]
78pub(crate) use disabled::Verifier;
79
80#[cfg(feature = "verify")]
81mod enabled {
82    use std::collections::{HashMap, HashSet};
83    use std::fmt::Write as _;
84
85    use crate::{
86        Event, Message, MessageId, ReasoningEncryptedValueSubtype, StepName, SubagentRunId,
87        ToolCallId,
88    };
89
90    use crate::server::error::{Rule, VerificationError};
91
92    /// Who opened an entity: a subagent, or the parent agent when `None`.
93    type Owner = Option<SubagentRunId>;
94
95    /// Builds a rejection, appending the open-entity dump in debug builds.
96    fn reject(
97        event: &Event,
98        rule: Rule,
99        detail: impl Into<String>,
100        open: impl FnOnce() -> String,
101    ) -> VerificationError {
102        let mut detail = detail.into();
103        if cfg!(debug_assertions) {
104            detail.push_str(&open());
105        }
106        VerificationError::new(event.event_type(), rule, detail)
107    }
108
109    fn describe(owner: &Owner) -> String {
110        match owner {
111            None => "the parent agent".to_owned(),
112            Some(id) => format!("subagent {id:?}"),
113        }
114    }
115
116    /// Tracks what is open, and who opened it, so misordered events can be
117    /// named precisely.
118    #[derive(Debug, Default)]
119    pub(crate) struct Verifier {
120        started: bool,
121        ended: bool,
122        messages: HashMap<MessageId, Owner>,
123        reasoning: HashMap<MessageId, Owner>,
124        reasoning_messages: HashMap<MessageId, Owner>,
125        tool_calls: HashMap<ToolCallId, Owner>,
126        /// Every tool call ever introduced — by a start, a chunk or a
127        /// snapshot — and who owns it.
128        known_tool_calls: HashMap<ToolCallId, Owner>,
129        /// Every text message ever introduced and who owns it: the first
130        /// writer. A tool call belongs to the message its `parentMessageId`
131        /// names, so the owner has to outlive the message being open.
132        message_owners: HashMap<MessageId, Owner>,
133        /// The same for reasoning — the block and the message inside it share
134        /// an id and an owner. A bucket of its own, as upstream keeps it, so
135        /// a producer that reuses an id across the two kinds is not accused
136        /// of contradicting itself.
137        reasoning_owners: HashMap<MessageId, Owner>,
138        /// Every activity ever introduced and who owns it. Opened by a
139        /// snapshot, continued by deltas against the same id.
140        activity_owners: HashMap<MessageId, Owner>,
141        /// Open steps, keyed by owner as well as name: a subagent routinely
142        /// runs the same graph shape as its parent, and neither may close the
143        /// other's step.
144        steps: HashSet<(Owner, StepName)>,
145        active_subagents: HashSet<SubagentRunId>,
146        /// Ids closed in this run. An id names one invocation, so a second
147        /// `SUBAGENT_STARTED` for a closed one is a producer bug — but
148        /// attribution-only producers, which tag events and never announce,
149        /// are not required to have started anything.
150        closed_subagents: HashSet<SubagentRunId>,
151    }
152
153    impl Verifier {
154        pub(crate) fn new() -> Self {
155            Self::default()
156        }
157
158        /// Checks `event` against the state machine and folds it in.
159        pub(crate) fn observe(&mut self, event: &Event) -> Result<(), VerificationError> {
160            if self.ended {
161                return Err(self.fail(event, Rule::RunEnded, "the run already ended"));
162            }
163
164            match event {
165                Event::RunStarted(payload) => {
166                    if self.started {
167                        return Err(self.fail(
168                            event,
169                            Rule::DuplicateRunStarted,
170                            "the run already started",
171                        ));
172                    }
173                    self.started = true;
174                    // The input echo replays history the consumer applies, so
175                    // it seeds ownership like a snapshot does — for ids not
176                    // yet recorded, since it is history rather than a rewrite.
177                    if let Some(input) = &payload.input {
178                        self.seed_owners(&input.messages, false);
179                    }
180                }
181                Event::RunFinished(_) => {
182                    if let Some(detail) = self.first_open() {
183                        return Err(self.fail(event, Rule::OpenAtFinish, detail));
184                    }
185                    self.ended = true;
186                }
187                Event::RunError(_) => self.ended = true,
188
189                Event::TextMessageStart(payload) => {
190                    self.open(
191                        event,
192                        Kind::Message,
193                        &payload.message_id,
194                        &payload.subagent_run_id,
195                    )?;
196                }
197                Event::TextMessageContent(payload) => {
198                    self.require(
199                        event,
200                        Kind::Message,
201                        &payload.message_id,
202                        &payload.subagent_run_id,
203                    )?;
204                }
205                Event::TextMessageEnd(payload) => {
206                    self.close(
207                        event,
208                        Kind::Message,
209                        &payload.message_id,
210                        &payload.subagent_run_id,
211                    )?;
212                }
213                Event::TextMessageChunk(payload) => {
214                    if let Some(id) = &payload.message_id {
215                        // Self-contained: a chunk needs no bracketing events,
216                        // but it still says who the message belongs to.
217                        self.messages.remove(id);
218                        self.claim(event, Kind::Message, id, &payload.subagent_run_id)?;
219                    }
220                }
221
222                Event::ReasoningStart(payload) => {
223                    self.open(
224                        event,
225                        Kind::Reasoning,
226                        &payload.message_id,
227                        &payload.subagent_run_id,
228                    )?;
229                }
230                Event::ReasoningEnd(payload) => {
231                    self.close(
232                        event,
233                        Kind::Reasoning,
234                        &payload.message_id,
235                        &payload.subagent_run_id,
236                    )?;
237                }
238                Event::ReasoningMessageStart(payload) => {
239                    self.open(
240                        event,
241                        Kind::ReasoningMessage,
242                        &payload.message_id,
243                        &payload.subagent_run_id,
244                    )?;
245                }
246                Event::ReasoningMessageContent(payload) => {
247                    self.require(
248                        event,
249                        Kind::ReasoningMessage,
250                        &payload.message_id,
251                        &payload.subagent_run_id,
252                    )?;
253                }
254                Event::ReasoningMessageEnd(payload) => {
255                    self.close(
256                        event,
257                        Kind::ReasoningMessage,
258                        &payload.message_id,
259                        &payload.subagent_run_id,
260                    )?;
261                }
262                Event::ReasoningMessageChunk(payload) => {
263                    if let Some(id) = &payload.message_id {
264                        self.reasoning_messages.remove(id);
265                        self.claim(event, Kind::ReasoningMessage, id, &payload.subagent_run_id)?;
266                    }
267                }
268
269                Event::ToolCallStart(payload) => {
270                    let id = &payload.tool_call_id;
271                    let owner = self.resolve_tool_call_owner(
272                        event,
273                        id,
274                        payload.parent_message_id.as_ref(),
275                        &payload.subagent_run_id,
276                    )?;
277                    if self.tool_calls.contains_key(id) {
278                        return Err(self.fail(
279                            event,
280                            Rule::DuplicateStart,
281                            format!("tool call {id:?} is already open"),
282                        ));
283                    }
284                    self.tool_calls.insert(id.clone(), owner.clone());
285                    self.known_tool_calls.insert(id.clone(), owner);
286                }
287                Event::ToolCallArgs(payload) => {
288                    self.require_tool_call(event, &payload.tool_call_id, &payload.subagent_run_id)?;
289                }
290                Event::ToolCallEnd(payload) => {
291                    self.require_tool_call(event, &payload.tool_call_id, &payload.subagent_run_id)?;
292                    self.tool_calls.remove(&payload.tool_call_id);
293                }
294                Event::ToolCallChunk(payload) => {
295                    if let Some(id) = &payload.tool_call_id {
296                        let owner = self.resolve_tool_call_owner(
297                            event,
298                            id,
299                            payload.parent_message_id.as_ref(),
300                            &payload.subagent_run_id,
301                        )?;
302                        self.tool_calls.remove(id);
303                        self.known_tool_calls.insert(id.clone(), owner);
304                    }
305                }
306                // A result's attribution is its own — the party that executes
307                // a call can differ from the one that requested it — so the
308                // owner is not checked here, only the call's state.
309                Event::ToolCallResult(payload) => {
310                    let id = &payload.tool_call_id;
311                    if !self.known_tool_calls.contains_key(id) {
312                        return Err(self.fail(
313                            event,
314                            Rule::UnknownId,
315                            format!("tool call {id:?} was never started"),
316                        ));
317                    }
318                    if self.tool_calls.contains_key(id) {
319                        return Err(self.fail(
320                            event,
321                            Rule::OutOfOrder,
322                            format!("tool call {id:?} has no TOOL_CALL_END yet"),
323                        ));
324                    }
325                    // A result mints the tool message it names, under its own
326                    // attribution — so the newest mint wins, not the first
327                    // writer, and a re-open of that message by someone else
328                    // has an owner to disagree with.
329                    self.message_owners
330                        .insert(payload.message_id.clone(), payload.subagent_run_id.clone());
331                }
332
333                // An activity is opened by a snapshot and continued by deltas
334                // against the same id. Only a *replacing* snapshot re-mints
335                // it and so re-owns it; with `replace: false` the consumer
336                // leaves the existing message where it was, and so does the
337                // recorded owner.
338                Event::ActivitySnapshot(payload) => {
339                    let id = &payload.message_id;
340                    if payload.replace || !self.activity_owners.contains_key(id) {
341                        self.activity_owners
342                            .insert(id.clone(), payload.subagent_run_id.clone());
343                    }
344                }
345                Event::ActivityDelta(payload) => {
346                    if let Some(owner) = self.activity_owners.get(&payload.message_id) {
347                        let tag = &payload.subagent_run_id;
348                        if tag.is_some() && owner != tag {
349                            return Err(self.fail(
350                                event,
351                                Rule::OwnerMismatch,
352                                format!(
353                                    "activity {:?} belongs to {}, not {}",
354                                    payload.message_id,
355                                    describe(owner),
356                                    describe(tag)
357                                ),
358                            ));
359                        }
360                    }
361                }
362
363                // Continues an entity by id, and `subtype` says which kind —
364                // a tool call's owner lives in a different map from a
365                // message's.
366                Event::ReasoningEncryptedValue(payload) => {
367                    let tag = &payload.subagent_run_id;
368                    let (what, owner) = match payload.subtype {
369                        ReasoningEncryptedValueSubtype::ToolCall => (
370                            "tool call",
371                            self.known_tool_calls
372                                .get(&ToolCallId::new(payload.entity_id.clone())),
373                        ),
374                        // "message" spans both kinds; ids are unique per kind,
375                        // so at most one bucket answers.
376                        ReasoningEncryptedValueSubtype::Message => {
377                            let id = MessageId::new(payload.entity_id.clone());
378                            (
379                                "message",
380                                self.message_owners
381                                    .get(&id)
382                                    .or_else(|| self.reasoning_owners.get(&id)),
383                            )
384                        }
385                    };
386                    if let Some(owner) = owner {
387                        if tag.is_some() && owner != tag {
388                            return Err(self.fail(
389                                event,
390                                Rule::OwnerMismatch,
391                                format!(
392                                    "{what} {:?} belongs to {}, not {}",
393                                    payload.entity_id,
394                                    describe(owner),
395                                    describe(tag)
396                                ),
397                            ));
398                        }
399                    }
400                }
401
402                Event::StepStarted(payload) => {
403                    let key = (payload.subagent_run_id.clone(), payload.step_name.clone());
404                    if self.steps.contains(&key) {
405                        return Err(self.fail(
406                            event,
407                            Rule::DuplicateStart,
408                            format!(
409                                "step {:?} is already open under {}",
410                                payload.step_name,
411                                describe(&payload.subagent_run_id)
412                            ),
413                        ));
414                    }
415                    self.steps.insert(key);
416                }
417                Event::StepFinished(payload) => {
418                    let key = (payload.subagent_run_id.clone(), payload.step_name.clone());
419                    if !self.steps.remove(&key) {
420                        return Err(self.fail(
421                            event,
422                            Rule::NotOpen,
423                            format!(
424                                "step {:?} is not open under {}",
425                                payload.step_name,
426                                describe(&payload.subagent_run_id)
427                            ),
428                        ));
429                    }
430                }
431
432                Event::SubagentStarted(payload) => {
433                    let id = &payload.subagent_run_id;
434                    if self.active_subagents.contains(id) {
435                        return Err(self.fail(
436                            event,
437                            Rule::DuplicateStart,
438                            format!("subagent {id:?} is already active"),
439                        ));
440                    }
441                    if self.closed_subagents.contains(id) {
442                        return Err(self.fail(
443                            event,
444                            Rule::DuplicateStart,
445                            format!(
446                                "subagent {id:?} already finished in this run; an id names one invocation"
447                            ),
448                        ));
449                    }
450                    if let Some(parent) = &payload.parent_subagent_run_id {
451                        if !self.active_subagents.contains(parent)
452                            && !self.closed_subagents.contains(parent)
453                        {
454                            return Err(self.fail(
455                                event,
456                                Rule::UnknownId,
457                                format!("parent subagent {parent:?} was never started"),
458                            ));
459                        }
460                    }
461                    self.active_subagents.insert(id.clone());
462                }
463                Event::SubagentFinished(payload) => {
464                    self.close_subagent(event, &payload.subagent_run_id)?;
465                }
466                Event::SubagentError(payload) => {
467                    self.close_subagent(event, &payload.subagent_run_id)?;
468                }
469
470                // Authoritative: the snapshot restates the conversation, so its
471                // owners replace whatever was recorded.
472                Event::MessagesSnapshot(payload) => {
473                    self.seed_owners(&payload.messages, true);
474                }
475
476                _ => {}
477            }
478
479            Ok(())
480        }
481
482        /// Records the owners of replayed messages, and of the tool calls
483        /// they carry: authoritatively for a `MESSAGES_SNAPSHOT`, which
484        /// restates the conversation, and for ids not yet recorded when the
485        /// `RUN_STARTED` echo replays history.
486        fn seed_owners(&mut self, messages: &[Message], authoritative: bool) {
487            for message in messages {
488                let owner = message.subagent_run_id().cloned();
489                let bucket = match message {
490                    Message::Activity(_) => &mut self.activity_owners,
491                    Message::Reasoning(_) => &mut self.reasoning_owners,
492                    _ => &mut self.message_owners,
493                };
494                if authoritative || !bucket.contains_key(message.id()) {
495                    bucket.insert(message.id().clone(), owner.clone());
496                }
497                if let Message::Assistant(assistant) = message {
498                    for call in assistant.tool_calls.iter().flatten() {
499                        if authoritative || !self.known_tool_calls.contains_key(&call.id) {
500                            self.known_tool_calls.insert(call.id.clone(), owner.clone());
501                        }
502                    }
503                }
504            }
505        }
506
507        /// Records who `id` belongs to — the first writer — or rejects a claim
508        /// that disagrees with the recorded owner, and returns the owner in
509        /// force.
510        ///
511        /// An untagged claim on an owned message is accepted and does *not*
512        /// hand the message to the parent: attribution is optional per
513        /// event, so an absent tag agrees with any owner, and the consumer
514        /// keeps the message where it was. Upstream's verifier records the
515        /// first writer for the same reason.
516        fn claim(
517            &mut self,
518            event: &Event,
519            kind: Kind,
520            id: &MessageId,
521            tag: &Owner,
522        ) -> Result<Owner, VerificationError> {
523            if let Some(owner) = self.owners(kind).get(id).cloned() {
524                if tag.is_some() && &owner != tag {
525                    return Err(self.fail(
526                        event,
527                        Rule::OwnerMismatch,
528                        format!(
529                            "{} {id:?} belongs to {}, not {}",
530                            kind.owner_noun(),
531                            describe(&owner),
532                            describe(tag)
533                        ),
534                    ));
535                }
536                return Ok(owner);
537            }
538            self.owners_mut(kind).insert(id.clone(), tag.clone());
539            Ok(tag.clone())
540        }
541
542        /// The owner bucket an id of this kind claims through.
543        fn owners(&self, kind: Kind) -> &HashMap<MessageId, Owner> {
544            match kind {
545                Kind::Message => &self.message_owners,
546                Kind::Reasoning | Kind::ReasoningMessage => &self.reasoning_owners,
547            }
548        }
549
550        fn owners_mut(&mut self, kind: Kind) -> &mut HashMap<MessageId, Owner> {
551            match kind {
552                Kind::Message => &mut self.message_owners,
553                Kind::Reasoning | Kind::ReasoningMessage => &mut self.reasoning_owners,
554            }
555        }
556
557        /// Who a tool call belongs to: the tag when it carries one, otherwise
558        /// the owner of the message that carries the call, otherwise whoever
559        /// introduced the call before, otherwise the parent agent.
560        ///
561        /// A tag that disagrees with the parent message's owner cannot be
562        /// represented faithfully — `ToolCall` carries no attribution of its
563        /// own — and is rejected, as is an asserted owner that disagrees with
564        /// a recorded one.
565        fn resolve_tool_call_owner(
566            &self,
567            event: &Event,
568            id: &ToolCallId,
569            parent_message_id: Option<&MessageId>,
570            tag: &Owner,
571        ) -> Result<Owner, VerificationError> {
572            let inherited = parent_message_id
573                .and_then(|parent| self.message_owners.get(parent).map(|owner| (parent, owner)));
574            if let Some((parent, owner)) = inherited {
575                if tag.is_some() && owner != tag {
576                    return Err(self.fail(
577                        event,
578                        Rule::OwnerMismatch,
579                        format!(
580                            "tool call {id:?} is tagged {} but its parent message {parent:?} belongs to {}; \
581                             a tool call belongs to the message that carries it",
582                            describe(tag),
583                            describe(owner)
584                        ),
585                    ));
586                }
587            }
588            let asserted: Option<Owner> = if tag.is_some() {
589                Some(tag.clone())
590            } else {
591                inherited.map(|(_, owner)| owner.clone())
592            };
593            if let (Some(asserted), Some(known)) = (&asserted, self.known_tool_calls.get(id)) {
594                if asserted != known {
595                    return Err(self.fail(
596                        event,
597                        Rule::OwnerMismatch,
598                        format!(
599                            "tool call {id:?} belongs to {}, not {}",
600                            describe(known),
601                            describe(asserted)
602                        ),
603                    ));
604                }
605            }
606            Ok(asserted
607                .or_else(|| self.known_tool_calls.get(id).cloned())
608                .unwrap_or(None))
609        }
610
611        fn require_tool_call(
612            &self,
613            event: &Event,
614            id: &ToolCallId,
615            tag: &Owner,
616        ) -> Result<(), VerificationError> {
617            match self.tool_calls.get(id) {
618                None => Err(self.fail(
619                    event,
620                    Rule::NotOpen,
621                    format!("tool call {id:?} is not open"),
622                )),
623                Some(owner) if tag.is_some() && owner != tag => Err(self.fail(
624                    event,
625                    Rule::OwnerMismatch,
626                    format!(
627                        "tool call {id:?} belongs to {}, not {}",
628                        describe(owner),
629                        describe(tag)
630                    ),
631                )),
632                Some(_) => Ok(()),
633            }
634        }
635
636        fn close_subagent(
637            &mut self,
638            event: &Event,
639            id: &SubagentRunId,
640        ) -> Result<(), VerificationError> {
641            if !self.active_subagents.remove(id) {
642                return Err(self.fail(
643                    event,
644                    Rule::NotOpen,
645                    format!("subagent {id:?} is not active"),
646                ));
647            }
648            self.closed_subagents.insert(id.clone());
649            Ok(())
650        }
651
652        /// Opens an entity under the owner in force for its id — the first
653        /// writer's, so an untagged re-open of a subagent's message keeps
654        /// checking its continuations against that subagent.
655        ///
656        /// A reasoning block and the message inside it share an id and
657        /// claim the same owner, so `REASONING_START` under one subagent and
658        /// `REASONING_MESSAGE_START` under another is a contradiction here,
659        /// as it is upstream.
660        fn open(
661            &mut self,
662            event: &Event,
663            kind: Kind,
664            id: &MessageId,
665            tag: &Owner,
666        ) -> Result<(), VerificationError> {
667            let owner = self.claim(event, kind, id, tag)?;
668            if self.map(kind).contains_key(id) {
669                return Err(self.fail(
670                    event,
671                    Rule::DuplicateStart,
672                    format!("{} {id:?} is already open", kind.noun()),
673                ));
674            }
675            self.map_mut(kind).insert(id.clone(), owner);
676            Ok(())
677        }
678
679        fn require(
680            &self,
681            event: &Event,
682            kind: Kind,
683            id: &MessageId,
684            tag: &Owner,
685        ) -> Result<(), VerificationError> {
686            match self.map(kind).get(id) {
687                None => Err(self.fail(
688                    event,
689                    Rule::NotOpen,
690                    format!("{} {id:?} is not open", kind.noun()),
691                )),
692                Some(owner) if tag.is_some() && owner != tag => Err(self.fail(
693                    event,
694                    Rule::OwnerMismatch,
695                    format!(
696                        "{} {id:?} belongs to {}, not {}",
697                        kind.noun(),
698                        describe(owner),
699                        describe(tag)
700                    ),
701                )),
702                Some(_) => Ok(()),
703            }
704        }
705
706        fn close(
707            &mut self,
708            event: &Event,
709            kind: Kind,
710            id: &MessageId,
711            tag: &Owner,
712        ) -> Result<(), VerificationError> {
713            self.require(event, kind, id, tag)?;
714            self.map_mut(kind).remove(id);
715            Ok(())
716        }
717
718        fn map(&self, kind: Kind) -> &HashMap<MessageId, Owner> {
719            match kind {
720                Kind::Message => &self.messages,
721                Kind::Reasoning => &self.reasoning,
722                Kind::ReasoningMessage => &self.reasoning_messages,
723            }
724        }
725
726        fn map_mut(&mut self, kind: Kind) -> &mut HashMap<MessageId, Owner> {
727            match kind {
728                Kind::Message => &mut self.messages,
729                Kind::Reasoning => &mut self.reasoning,
730                Kind::ReasoningMessage => &mut self.reasoning_messages,
731            }
732        }
733
734        fn fail(&self, event: &Event, rule: Rule, detail: impl Into<String>) -> VerificationError {
735            reject(event, rule, detail, || self.dump())
736        }
737
738        /// The first thing still open at `RUN_FINISHED`, if any.
739        fn first_open(&self) -> Option<String> {
740            if let Some(id) = self.messages.keys().next() {
741                return Some(format!("message {id:?} is still open"));
742            }
743            if let Some(id) = self.reasoning_messages.keys().next() {
744                return Some(format!("reasoning message {id:?} is still open"));
745            }
746            if let Some(id) = self.reasoning.keys().next() {
747                return Some(format!("reasoning block {id:?} is still open"));
748            }
749            if let Some(id) = self.tool_calls.keys().next() {
750                return Some(format!("tool call {id:?} is still open"));
751            }
752            if let Some((owner, name)) = self.steps.iter().next() {
753                return Some(format!(
754                    "step {name:?} is still open under {}",
755                    describe(owner)
756                ));
757            }
758            if let Some(id) = self.active_subagents.iter().next() {
759                return Some(format!("subagent {id:?} is still active"));
760            }
761            None
762        }
763
764        /// Debug-build-only dump of everything currently open.
765        fn dump(&self) -> String {
766            let mut out = String::new();
767            let mut push = |label: &str, values: Vec<String>| {
768                if !values.is_empty() {
769                    let _ = write!(out, " {label}={:?}", values);
770                }
771            };
772            push("messages", strings(self.messages.keys()));
773            push("reasoning", strings(self.reasoning.keys()));
774            push(
775                "reasoning_messages",
776                strings(self.reasoning_messages.keys()),
777            );
778            push("tool_calls", strings(self.tool_calls.keys()));
779            push(
780                "steps",
781                strings(self.steps.iter().map(|(owner, name)| match owner {
782                    None => name.to_string(),
783                    Some(id) => format!("{id}/{name}"),
784                })),
785            );
786            push("subagents", strings(self.active_subagents.iter()));
787            if out.is_empty() {
788                " [nothing open]".to_owned()
789            } else {
790                format!(" [open:{out}]")
791            }
792        }
793    }
794
795    fn strings<T: ToString>(values: impl Iterator<Item = T>) -> Vec<String> {
796        let mut values: Vec<String> = values.map(|value| value.to_string()).collect();
797        values.sort();
798        values
799    }
800
801    /// The three id-keyed things a message id can open.
802    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
803    enum Kind {
804        Message,
805        Reasoning,
806        ReasoningMessage,
807    }
808
809    impl Kind {
810        const fn noun(self) -> &'static str {
811            match self {
812                Self::Message => "message",
813                Self::Reasoning => "reasoning block",
814                Self::ReasoningMessage => "reasoning message",
815            }
816        }
817
818        /// What an owner complaint calls the entity: the block and the
819        /// message inside it are one owned thing.
820        const fn owner_noun(self) -> &'static str {
821            match self {
822                Self::Message => "message",
823                Self::Reasoning | Self::ReasoningMessage => "reasoning message",
824            }
825        }
826    }
827}
828
829#[cfg(not(feature = "verify"))]
830mod disabled {
831    use crate::Event;
832
833    use crate::server::error::VerificationError;
834
835    /// The `verify` feature is off: every check compiles away.
836    #[derive(Debug, Default)]
837    pub(crate) struct Verifier;
838
839    impl Verifier {
840        #[inline]
841        pub(crate) fn new() -> Self {
842            Self
843        }
844
845        #[inline]
846        pub(crate) fn observe(&mut self, _event: &Event) -> Result<(), VerificationError> {
847            Ok(())
848        }
849    }
850}
851
852#[cfg(all(test, feature = "verify"))]
853mod tests {
854    use super::*;
855    use crate::{Event, TextMessageRole};
856
857    use crate::server::error::Rule;
858
859    fn verifier() -> Verifier {
860        let mut verifier = Verifier::new();
861        verifier
862            .observe(&Event::run_started("t", "r"))
863            .expect("RUN_STARTED must be accepted");
864        verifier
865    }
866
867    #[test]
868    fn a_well_formed_run_passes() {
869        let mut verifier = verifier();
870        for event in [
871            Event::step_started("plan"),
872            Event::text_message_start("m1", TextMessageRole::Assistant),
873            Event::text_message_content("m1", "hi"),
874            Event::text_message_end("m1"),
875            Event::tool_call_start("c1", "search"),
876            Event::tool_call_args("c1", "{}"),
877            Event::tool_call_end("c1"),
878            Event::tool_call_result("m2", "c1", "ok"),
879            Event::step_finished("plan"),
880            Event::run_finished_success("t", "r"),
881        ] {
882            verifier
883                .observe(&event)
884                .unwrap_or_else(|error| panic!("{event:?} should be accepted: {error}"));
885        }
886    }
887
888    #[test]
889    fn debug_builds_include_the_open_dump() {
890        let mut verifier = verifier();
891        verifier
892            .observe(&Event::text_message_start("m1", TextMessageRole::Assistant))
893            .expect("start");
894        let error = verifier
895            .observe(&Event::text_message_content("m2", "hi"))
896            .expect_err("m2 was never opened");
897        assert_eq!(error.rule, Rule::NotOpen);
898        assert!(
899            error.detail.contains("m1"),
900            "debug dump should name the open message: {}",
901            error.detail
902        );
903    }
904
905    #[test]
906    fn the_dump_names_open_subagents_and_owned_steps() {
907        let mut verifier = verifier();
908        verifier
909            .observe(&Event::subagent_started("s1", "researcher"))
910            .expect("start");
911        verifier
912            .observe(&Event::step_started("plan").with_subagent_run_id("s1"))
913            .expect("a subagent's step");
914        let error = verifier
915            .observe(&Event::run_finished_success("t", "r"))
916            .expect_err("the step and the subagent are open");
917        assert_eq!(error.rule, Rule::OpenAtFinish);
918        assert!(error.detail.contains("s1/plan"), "{}", error.detail);
919        assert!(error.detail.contains("subagents"), "{}", error.detail);
920    }
921}