Skip to main content

ag_ui/client/
verify.rs

1//! Client-side protocol verification.
2//!
3//! The TypeScript SDK puts its verifier on the *client*, and that is the right
4//! instinct for a consumer: the events arrive from someone else's process, and
5//! a stream that breaks the rules should produce one clear error rather than a
6//! confused UI. This module is that check, as an ordering state machine.
7//!
8//! [`crate::client::Session`] runs it by default. Turn it off with
9//! [`SessionBuilder::verify`](crate::client::SessionBuilder::verify) when talking to a
10//! producer whose quirks you have decided to live with.
11//!
12//! ```
13//! use ag_ui::client::verify::Verifier;
14//! use ag_ui::Event;
15//!
16//! let mut verifier = Verifier::new();
17//! verifier.verify(&Event::run_started("thread-1", "run-1"))?;
18//!
19//! // Content for a message that was never opened.
20//! let orphan = Event::text_message_content("msg-1", "Hello");
21//! assert!(verifier.verify(&orphan).is_err());
22//! # Ok::<(), ag_ui::client::Error>(())
23//! ```
24//!
25//! # The rules
26//!
27//! 1. `RUN_STARTED` opens the stream, and does so exactly once. Only `RAW` and
28//!    `CUSTOM` may precede it.
29//! 2. `RUN_FINISHED` and `RUN_ERROR` close it. Nothing may follow.
30//! 3. `TEXT_MESSAGE_CONTENT` and `TEXT_MESSAGE_END` require an open message
31//!    with the same id, and `TEXT_MESSAGE_START` may not re-open an id that is
32//!    already open.
33//! 4. The same, for `TOOL_CALL_*` and for `REASONING_MESSAGE_*`.
34//! 5. `TOOL_CALL_RESULT` may not answer a call that has not ended.
35//! 6. `STEP_FINISHED` requires a matching `STEP_STARTED`, and step names do not
36//!    nest with themselves.
37//! 7. Everything open must be closed before `RUN_FINISHED`.
38//! 8. An `interrupt` outcome must carry at least one interrupt — the one rule
39//!    the type system cannot express, checked by
40//!    [`RunOutcome::validate`](https://kimsoungryoul.github.io/ag-ui-rust/api/ag_ui/outcome/enum.RunOutcome.html#method.validate).
41//! 9. A continuation, terminator or re-open that *names* a subagent must name
42//!    the one that opened the entity — a message, a reasoning block or the
43//!    message inside it, a tool call, an activity, or whatever a
44//!    `REASONING_ENCRYPTED_VALUE` attaches to. One that names none is
45//!    accepted: attribution is optional per event, and a bare continuation is
46//!    what a pre-subagent producer sends. It does not hand the entity to the
47//!    parent either: the first writer stays the owner.
48//! 10. A tool call belongs to the message its `parentMessageId` names, so a
49//!     `TOOL_CALL_START` tagged with one subagent while that message belongs
50//!     to another is rejected; an untagged one inherits the message's owner.
51//! 11. Steps are scoped to the agent that opened them: a subagent cannot close
52//!     the parent's step, or a sibling's, and the same name may be open under
53//!     two owners at once.
54//! 12. `SUBAGENT_STARTED` names an invocation that is neither active nor
55//!     already finished in this run, and a `parentSubagentRunId` that was
56//!     started. `SUBAGENT_FINISHED` and `SUBAGENT_ERROR` name an active one.
57//! 13. Every started subagent is closed before `RUN_FINISHED` — not before
58//!     `RUN_ERROR`, where an unclosed subagent is the expected shape.
59//!
60//! What is deliberately *not* a rule: that one stream must close before the
61//! next opens. Everything here is keyed by id, exactly as the TypeScript
62//! verifier keys its `activeMessages` / `activeToolCalls` maps. Two messages
63//! may stream at once, two tool calls may stream at once, and a tool call may
64//! open inside the message that narrates it — which is what every provider
65//! doing parallel tool calls actually sends. Nor must an attributing
66//! `subagentRunId` have been announced: attribution without lifecycle events
67//! is a supported mode. Events outside these families (state, activity, raw,
68//! custom) are unordered and never close anything. A `MESSAGES_SNAPSHOT`
69//! seeds ownership from the messages it carries and is authoritative; the
70//! `RUN_STARTED` input echo seeds it too, for ids not yet recorded; and a
71//! `TOOL_CALL_RESULT` mints the tool message it names under its own
72//! attribution.
73
74// The THINKING_* events are deprecated but a verifier still has to recognise
75// them.
76#![allow(deprecated)]
77
78use std::collections::{HashMap, HashSet};
79
80use crate::{
81    Event, EventType, Message, MessageId, ReasoningEncryptedValueSubtype, StepName, SubagentRunId,
82    ToolCallId,
83};
84
85use crate::client::error::{Error, Result};
86
87/// Who opened an entity: a subagent, or the parent agent when `None`.
88type Owner = Option<SubagentRunId>;
89
90fn describe(owner: &Owner) -> String {
91    match owner {
92        None => "the parent agent".to_owned(),
93        Some(id) => format!("subagent {:?}", id.as_str()),
94    }
95}
96
97/// An ordering state machine for one run's event stream.
98///
99/// One verifier per run: it is stateful, and its state is that run's progress.
100#[derive(Clone, Debug, Default)]
101pub struct Verifier {
102    started: bool,
103    finished: bool,
104    /// What is open, by id, with the owner that opened it — several at once
105    /// is legal, the same id twice is not. `Vec` rather than a map so a
106    /// complaint names whichever was opened first, which is the one a human
107    /// is looking for.
108    text: Vec<(MessageId, Owner)>,
109    tool: Vec<(ToolCallId, Owner)>,
110    reasoning: Vec<(MessageId, Owner)>,
111    /// Open steps, keyed by owner as well as name.
112    steps: Vec<(Owner, StepName)>,
113    /// Every text message ever introduced and who owns it: the first writer.
114    /// A tool call inherits the owner of the message that carries it, which
115    /// may have closed.
116    message_owners: HashMap<MessageId, Owner>,
117    /// The same for reasoning — the block and the message inside it share an
118    /// id and an owner. A bucket of its own, as upstream keeps it, so a
119    /// producer that reuses an id across the two kinds is not accused of
120    /// contradicting itself.
121    reasoning_owners: HashMap<MessageId, Owner>,
122    /// Every tool call ever introduced and who owns it.
123    tool_call_owners: HashMap<ToolCallId, Owner>,
124    /// Every activity ever introduced and who owns it: opened by a snapshot,
125    /// continued by deltas against the same id.
126    activity_owners: HashMap<MessageId, Owner>,
127    subagents: Vec<SubagentRunId>,
128    /// Ids closed in this run: an id names one invocation.
129    closed_subagents: HashSet<SubagentRunId>,
130}
131
132impl Verifier {
133    /// A verifier for a stream that has not started yet.
134    pub fn new() -> Self {
135        Self::default()
136    }
137
138    /// Whether the run has reached a terminal event.
139    pub fn is_finished(&self) -> bool {
140        self.finished
141    }
142
143    /// Checks one event against the rules, and records it.
144    ///
145    /// # Errors
146    ///
147    /// [`Error::Protocol`], naming the rule that was broken.
148    pub fn verify(&mut self, event: &Event) -> Result<()> {
149        let kind = event.event_type();
150
151        if self.finished {
152            return Err(Error::protocol(format!(
153                "{kind} arrived after the run had already finished"
154            )));
155        }
156
157        // RAW and CUSTOM are outside the protocol's vocabulary by definition,
158        // so they are outside its ordering too.
159        if matches!(kind, EventType::Raw | EventType::Custom) {
160            return Ok(());
161        }
162
163        if !self.started && kind != EventType::RunStarted {
164            return Err(Error::protocol(format!(
165                "{kind} arrived before RUN_STARTED"
166            )));
167        }
168
169        match event {
170            Event::RunStarted(e) => {
171                if self.started {
172                    return Err(Error::protocol("RUN_STARTED arrived twice in one stream"));
173                }
174                self.started = true;
175                // The input echo replays history the applier applies, so it
176                // seeds ownership like a snapshot does — for ids not yet
177                // recorded, since it is history rather than a rewrite.
178                if let Some(input) = &e.input {
179                    self.seed_owners(&input.messages, false);
180                }
181            }
182
183            Event::TextMessageStart(e) => {
184                let owner = self.claim(kind, &e.message_id, &e.subagent_run_id, false)?;
185                self.not_already_open(
186                    self.text.iter().map(|(id, _)| id),
187                    &e.message_id,
188                    "message",
189                    kind,
190                )?;
191                self.text.push((e.message_id.clone(), owner));
192            }
193            Event::TextMessageContent(e) => {
194                self.expect_text(&e.message_id, &e.subagent_run_id, kind)?;
195            }
196            Event::TextMessageEnd(e) => {
197                self.expect_text(&e.message_id, &e.subagent_run_id, kind)?;
198                self.text.retain(|(open, _)| open != &e.message_id);
199            }
200
201            Event::ToolCallStart(e) => {
202                let owner = self.resolve_tool_call_owner(
203                    kind,
204                    &e.tool_call_id,
205                    e.parent_message_id.as_ref(),
206                    &e.subagent_run_id,
207                )?;
208                self.not_already_open(
209                    self.tool.iter().map(|(id, _)| id),
210                    &e.tool_call_id,
211                    "tool call",
212                    kind,
213                )?;
214                self.tool.push((e.tool_call_id.clone(), owner.clone()));
215                self.tool_call_owners.insert(e.tool_call_id.clone(), owner);
216            }
217            Event::ToolCallArgs(e) => {
218                self.expect_tool(&e.tool_call_id, &e.subagent_run_id, kind)?;
219            }
220            Event::ToolCallEnd(e) => {
221                self.expect_tool(&e.tool_call_id, &e.subagent_run_id, kind)?;
222                self.tool.retain(|(open, _)| open != &e.tool_call_id);
223            }
224            // The call this answers has to be over. Anything *else* still
225            // streaming is none of this event's business — a result arriving
226            // while the assistant keeps narrating is ordinary. Its attribution
227            // is its own, too: the party that executes a call can differ from
228            // the one that requested it.
229            Event::ToolCallResult(e) => {
230                if self.tool.iter().any(|(open, _)| open == &e.tool_call_id) {
231                    return Err(Error::protocol(format!(
232                        "{kind} for tool call {:?}, which has not ended yet",
233                        e.tool_call_id.as_str()
234                    )));
235                }
236                // A result mints the tool message it names, under its own
237                // attribution — so the newest mint wins, and a re-open of
238                // that message by someone else has an owner to disagree with.
239                self.message_owners
240                    .insert(e.message_id.clone(), e.subagent_run_id.clone());
241            }
242
243            // The block and the message inside it share an id and claim the
244            // same owner: `REASONING_START` under one subagent and
245            // `REASONING_MESSAGE_START` under another is a contradiction. The
246            // block's bracketing is not otherwise tracked (rule 4 is about the
247            // message), only its ownership.
248            Event::ReasoningStart(e) => {
249                self.claim(kind, &e.message_id, &e.subagent_run_id, true)?;
250            }
251            Event::ReasoningEnd(e) => {
252                if let Some(owner) = self.reasoning_owners.get(&e.message_id) {
253                    Self::expect_owner(
254                        kind,
255                        "reasoning message",
256                        e.message_id.as_str(),
257                        owner,
258                        &e.subagent_run_id,
259                    )?;
260                }
261            }
262            Event::ReasoningMessageStart(e) => {
263                let owner = self.claim(kind, &e.message_id, &e.subagent_run_id, true)?;
264                self.not_already_open(
265                    self.reasoning.iter().map(|(id, _)| id),
266                    &e.message_id,
267                    "reasoning message",
268                    kind,
269                )?;
270                self.reasoning.push((e.message_id.clone(), owner));
271            }
272            Event::ReasoningMessageContent(e) => {
273                self.expect_reasoning(&e.message_id, &e.subagent_run_id, kind)?;
274            }
275            Event::ReasoningMessageEnd(e) => {
276                self.expect_reasoning(&e.message_id, &e.subagent_run_id, kind)?;
277                self.reasoning.retain(|(open, _)| open != &e.message_id);
278            }
279            // Continues an entity by id, and `subtype` says which kind — a
280            // tool call's owner lives in a different map from a message's.
281            Event::ReasoningEncryptedValue(e) => {
282                let (what, owner) = match e.subtype {
283                    ReasoningEncryptedValueSubtype::ToolCall => (
284                        "tool call",
285                        self.tool_call_owners
286                            .get(&ToolCallId::new(e.entity_id.clone())),
287                    ),
288                    // "message" spans both kinds; ids are unique per kind, so
289                    // at most one bucket answers.
290                    ReasoningEncryptedValueSubtype::Message => {
291                        let id = MessageId::new(e.entity_id.clone());
292                        (
293                            "message",
294                            self.message_owners
295                                .get(&id)
296                                .or_else(|| self.reasoning_owners.get(&id)),
297                        )
298                    }
299                };
300                if let Some(owner) = owner {
301                    Self::expect_owner(kind, what, &e.entity_id, owner, &e.subagent_run_id)?;
302                }
303            }
304
305            // An activity is opened by a snapshot and continued by deltas
306            // against the same id. Only a *replacing* snapshot re-mints it and
307            // so re-owns it; with `replace: false` the applier leaves the
308            // existing message where it was, and so does the recorded owner.
309            Event::ActivitySnapshot(e) => {
310                if e.replace || !self.activity_owners.contains_key(&e.message_id) {
311                    self.activity_owners
312                        .insert(e.message_id.clone(), e.subagent_run_id.clone());
313                }
314            }
315            Event::ActivityDelta(e) => {
316                if let Some(owner) = self.activity_owners.get(&e.message_id) {
317                    Self::expect_owner(
318                        kind,
319                        "activity",
320                        e.message_id.as_str(),
321                        owner,
322                        &e.subagent_run_id,
323                    )?;
324                }
325            }
326
327            Event::StepStarted(e) => {
328                let key = (e.subagent_run_id.clone(), e.step_name.clone());
329                if self.steps.contains(&key) {
330                    return Err(Error::protocol(format!(
331                        "STEP_STARTED for {:?}, which is already running under {}",
332                        e.step_name.as_str(),
333                        describe(&e.subagent_run_id)
334                    )));
335                }
336                self.steps.push(key);
337            }
338            Event::StepFinished(e) => {
339                let key = (e.subagent_run_id.clone(), e.step_name.clone());
340                match self.steps.iter().position(|open| open == &key) {
341                    Some(index) => {
342                        self.steps.remove(index);
343                    }
344                    None => {
345                        return Err(Error::protocol(format!(
346                            "STEP_FINISHED for {:?}, which never started under {}",
347                            e.step_name.as_str(),
348                            describe(&e.subagent_run_id)
349                        )));
350                    }
351                }
352            }
353
354            Event::SubagentStarted(e) => {
355                let id = &e.subagent_run_id;
356                if self.subagents.contains(id) {
357                    return Err(Error::protocol(format!(
358                        "SUBAGENT_STARTED for subagent {:?}, which is already active",
359                        id.as_str()
360                    )));
361                }
362                if self.closed_subagents.contains(id) {
363                    return Err(Error::protocol(format!(
364                        "SUBAGENT_STARTED for subagent {:?}, which already finished in this run; \
365                         an id names one invocation",
366                        id.as_str()
367                    )));
368                }
369                if let Some(parent) = &e.parent_subagent_run_id {
370                    if !self.subagents.contains(parent) && !self.closed_subagents.contains(parent) {
371                        return Err(Error::protocol(format!(
372                            "SUBAGENT_STARTED for subagent {:?} names parent {:?}, which was never started",
373                            id.as_str(),
374                            parent.as_str()
375                        )));
376                    }
377                }
378                self.subagents.push(id.clone());
379            }
380            Event::SubagentFinished(e) => self.close_subagent(&e.subagent_run_id, kind)?,
381            Event::SubagentError(e) => self.close_subagent(&e.subagent_run_id, kind)?,
382
383            // Authoritative: the snapshot restates the conversation, so its
384            // owners replace whatever was recorded.
385            Event::MessagesSnapshot(e) => self.seed_owners(&e.messages, true),
386
387            Event::RunFinished(e) => {
388                // Recorded before the checks: the run is over whether or not it
389                // ended tidily, and reporting the untidiness twice — once here
390                // and again from `finish` — helps nobody.
391                self.finished = true;
392                self.expect_all_closed("RUN_FINISHED")?;
393                if let Some(outcome) = &e.outcome {
394                    outcome.validate()?;
395                }
396            }
397            Event::RunError(_) => {
398                // A failing run is allowed to abandon whatever it had open —
399                // that is what failing means.
400                self.finished = true;
401            }
402
403            // Chunk events are self-contained; expanding them into brackets is
404            // [`crate::client::chunks`]'s job, and it runs before this one.
405            _ => {}
406        }
407
408        Ok(())
409    }
410
411    /// Checks the stream ended where it was supposed to.
412    ///
413    /// # Errors
414    ///
415    /// [`Error::Protocol`] when the transport ended the stream before the agent
416    /// finished the run — a truncated response, which otherwise looks exactly
417    /// like a short answer.
418    pub fn finish(&self) -> Result<()> {
419        if !self.started {
420            return Err(Error::protocol("the stream ended before RUN_STARTED"));
421        }
422        if !self.finished {
423            return Err(Error::protocol(
424                "the stream ended before RUN_FINISHED or RUN_ERROR",
425            ));
426        }
427        Ok(())
428    }
429
430    // ---- rules ----------------------------------------------------------
431
432    /// Records who a message belongs to — the first writer — or rejects a
433    /// claim that disagrees with the recorded owner, and returns the owner in
434    /// force. An untagged claim on an owned message is accepted and does
435    /// *not* hand the message to the parent: an absent tag agrees with any
436    /// owner, and the applier keeps the message where it was. Upstream's
437    /// verifier records the first writer for the same reason.
438    fn claim(
439        &mut self,
440        kind: EventType,
441        id: &MessageId,
442        tag: &Owner,
443        reasoning: bool,
444    ) -> Result<Owner> {
445        let (what, owners) = if reasoning {
446            ("reasoning message", &mut self.reasoning_owners)
447        } else {
448            ("message", &mut self.message_owners)
449        };
450        if let Some(owner) = owners.get(id) {
451            if tag.is_some() && owner != tag {
452                return Err(Error::protocol(format!(
453                    "{kind} for {what} {:?} names {}, but the {what} belongs to {}",
454                    id.as_str(),
455                    describe(tag),
456                    describe(owner)
457                )));
458            }
459            return Ok(owner.clone());
460        }
461        owners.insert(id.clone(), tag.clone());
462        Ok(tag.clone())
463    }
464
465    /// Records the owners of replayed messages, and of the tool calls they
466    /// carry: authoritatively for a `MESSAGES_SNAPSHOT`, which restates the
467    /// conversation, and for ids not yet recorded when the `RUN_STARTED` echo
468    /// replays history.
469    fn seed_owners(&mut self, messages: &[Message], authoritative: bool) {
470        for message in messages {
471            let owner = message.subagent_run_id().cloned();
472            let bucket = match message {
473                Message::Activity(_) => &mut self.activity_owners,
474                Message::Reasoning(_) => &mut self.reasoning_owners,
475                _ => &mut self.message_owners,
476            };
477            if authoritative || !bucket.contains_key(message.id()) {
478                bucket.insert(message.id().clone(), owner.clone());
479            }
480            if let Message::Assistant(assistant) = message {
481                for call in assistant.tool_calls.iter().flatten() {
482                    if authoritative || !self.tool_call_owners.contains_key(&call.id) {
483                        self.tool_call_owners.insert(call.id.clone(), owner.clone());
484                    }
485                }
486            }
487        }
488    }
489
490    /// Who a tool call belongs to: its tag, else the owner of the message that
491    /// carries it, else whoever introduced it before, else the parent agent —
492    /// rejecting a tag that disagrees with the carrying message, and an
493    /// asserted owner that disagrees with a recorded one.
494    fn resolve_tool_call_owner(
495        &self,
496        kind: EventType,
497        id: &ToolCallId,
498        parent_message_id: Option<&MessageId>,
499        tag: &Owner,
500    ) -> Result<Owner> {
501        let inherited = parent_message_id
502            .and_then(|parent| self.message_owners.get(parent).map(|owner| (parent, owner)));
503        if let Some((parent, owner)) = inherited {
504            if tag.is_some() && owner != tag {
505                return Err(Error::protocol(format!(
506                    "{kind} for tool call {:?} names {}, but its parent message {:?} belongs to {}; \
507                     a tool call belongs to the message that carries it",
508                    id.as_str(),
509                    describe(tag),
510                    parent.as_str(),
511                    describe(owner)
512                )));
513            }
514        }
515        let asserted: Option<Owner> = if tag.is_some() {
516            Some(tag.clone())
517        } else {
518            inherited.map(|(_, owner)| owner.clone())
519        };
520        if let (Some(asserted), Some(known)) = (&asserted, self.tool_call_owners.get(id)) {
521            if asserted != known {
522                return Err(Error::protocol(format!(
523                    "{kind} for tool call {:?} names {}, but the call belongs to {}",
524                    id.as_str(),
525                    describe(asserted),
526                    describe(known)
527                )));
528            }
529        }
530        Ok(asserted
531            .or_else(|| self.tool_call_owners.get(id).cloned())
532            .unwrap_or(None))
533    }
534
535    fn close_subagent(&mut self, id: &SubagentRunId, kind: EventType) -> Result<()> {
536        match self.subagents.iter().position(|active| active == id) {
537            Some(index) => {
538                self.subagents.remove(index);
539                self.closed_subagents.insert(id.clone());
540                Ok(())
541            }
542            None => Err(Error::protocol(format!(
543                "{kind} for subagent {:?}, which is not active",
544                id.as_str()
545            ))),
546        }
547    }
548
549    fn expect_text(&self, id: &MessageId, tag: &Owner, kind: EventType) -> Result<()> {
550        match self.text.iter().find(|(open, _)| open == id) {
551            None => Err(Error::protocol(format!(
552                "{kind} for message {:?}, which was never opened",
553                id.as_str()
554            ))),
555            Some((_, owner)) => Self::expect_owner(kind, "message", id.as_str(), owner, tag),
556        }
557    }
558
559    fn expect_tool(&self, id: &ToolCallId, tag: &Owner, kind: EventType) -> Result<()> {
560        match self.tool.iter().find(|(open, _)| open == id) {
561            None => Err(Error::protocol(format!(
562                "{kind} for tool call {:?}, which was never opened",
563                id.as_str()
564            ))),
565            Some((_, owner)) => Self::expect_owner(kind, "tool call", id.as_str(), owner, tag),
566        }
567    }
568
569    fn expect_reasoning(&self, id: &MessageId, tag: &Owner, kind: EventType) -> Result<()> {
570        match self.reasoning.iter().find(|(open, _)| open == id) {
571            None => Err(Error::protocol(format!(
572                "{kind} for reasoning message {:?}, which was never opened",
573                id.as_str()
574            ))),
575            Some((_, owner)) => {
576                Self::expect_owner(kind, "reasoning message", id.as_str(), owner, tag)
577            }
578        }
579    }
580
581    /// The whole of the attribution rule for continuations: a tag, when there
582    /// is one, names the opener.
583    fn expect_owner(
584        kind: EventType,
585        what: &str,
586        id: &str,
587        owner: &Owner,
588        tag: &Owner,
589    ) -> Result<()> {
590        if tag.is_some() && owner != tag {
591            return Err(Error::protocol(format!(
592                "{kind} for {what} {id:?} names {}, but the {what} was opened by {}",
593                describe(tag),
594                describe(owner)
595            )));
596        }
597        Ok(())
598    }
599
600    /// Rejects a start for an id that is already streaming.
601    ///
602    /// The whole of the concurrency rule: two ids may overlap, one id may not
603    /// overlap itself.
604    fn not_already_open<'a, T: PartialEq + AsRef<str> + 'a>(
605        &self,
606        mut open: impl Iterator<Item = &'a T>,
607        id: &T,
608        what: &str,
609        kind: EventType,
610    ) -> Result<()> {
611        if open.any(|open| open == id) {
612            return Err(Error::protocol(format!(
613                "{kind} for {what} {:?}, which is already open",
614                id.as_ref()
615            )));
616        }
617        Ok(())
618    }
619
620    fn expect_all_closed(&self, what: &str) -> Result<()> {
621        if let Some((id, _)) = self.text.first() {
622            return Err(Error::protocol(format!(
623                "{what} arrived while message {:?} was still open",
624                id.as_str()
625            )));
626        }
627        if let Some((id, _)) = self.tool.first() {
628            return Err(Error::protocol(format!(
629                "{what} arrived while tool call {:?} was still open",
630                id.as_str()
631            )));
632        }
633        if let Some((id, _)) = self.reasoning.first() {
634            return Err(Error::protocol(format!(
635                "{what} arrived while reasoning message {:?} was still open",
636                id.as_str()
637            )));
638        }
639        if let Some((owner, name)) = self.steps.first() {
640            return Err(Error::protocol(format!(
641                "{what} arrived while step {:?} was still running under {}",
642                name.as_str(),
643                describe(owner)
644            )));
645        }
646        if let Some(id) = self.subagents.first() {
647            return Err(Error::protocol(format!(
648                "{what} arrived while subagent {:?} was still active",
649                id.as_str()
650            )));
651        }
652        Ok(())
653    }
654}
655
656/// Verifies a whole run in one call.
657///
658/// The streaming form is [`Verifier`]; this is the convenience for recorded
659/// streams and tests.
660pub fn verify_all<'a>(events: impl IntoIterator<Item = &'a Event>) -> Result<()> {
661    let mut verifier = Verifier::new();
662    for event in events {
663        verifier.verify(event)?;
664    }
665    verifier.finish()
666}