Skip to main content

mobius/protocol/
mod.rs

1//! The small event protocol shared by agent frontends.
2
3use std::sync::Arc;
4
5use serde::Deserialize;
6use serde::Deserializer;
7use serde::Serialize;
8use uuid::Uuid;
9
10pub use self::replay::events as replay_events;
11pub(crate) use self::replay::{
12    ATTACHMENT_CONTEXT_MARKER, ATTACHMENTS_FIELD, CONTEXT_COMPACTED_MARKER, INTERNAL_MESSAGE_FIELD,
13    MESSAGE_METADATA_FIELD, PROMPT_CACHE_BREAKPOINT_FIELD, REPLAY_REASONING_FIELD,
14    TOOL_ERROR_FIELD, internal_message_kind, is_internal_message, message_metadata,
15    strip_attachment_references, tool_complete_boundaries,
16};
17
18mod content;
19pub use content::{
20    ContentPart, ImageDetail, ImageReference, ImageRendition, ToolContent, ToolResponse,
21};
22pub(crate) use content::{content_part_text, content_parts, content_parts_mut};
23
24mod events;
25mod frontend;
26mod replay;
27
28pub use self::events::*;
29pub use self::frontend::*;
30
31/// Maximum total UTF-8 bytes accepted in one user-input submission.
32pub const MAX_MESSAGE_BYTES: usize = 1024 * 1024;
33
34/// Maximum UTF-8 bytes accepted in capability command input or a queued message edit.
35pub const MAX_CAPABILITY_INPUT_BYTES: usize = 64 * 1024;
36
37pub(crate) const MAX_TOOL_NAME_BYTES: usize = 256;
38pub(crate) const TOOL_LOAD_MARKER: &str = "tool_load";
39
40/// One immutable, session-bound file addressed by an opaque reference.
41///
42/// Only upload-origin references are valid in `MessageSubmission::attachments`.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct SessionFileReference {
46    /// The identifier.
47    pub id: String,
48    /// The name.
49    pub name: String,
50    /// The size.
51    pub size: u64,
52    /// The media type.
53    pub media_type: String,
54}
55
56/// Session-file policy advertised to frontends by the owning runtime.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct SessionFileLimits {
60    /// The max attachment references.
61    pub max_attachment_references: usize,
62    /// The max file bytes.
63    pub max_file_bytes: u64,
64    /// The max session files.
65    pub max_session_files: usize,
66    /// The max session bytes.
67    pub max_session_bytes: u64,
68    /// The max upload chunk bytes.
69    pub max_upload_chunk_bytes: usize,
70}
71
72/// Which side of a session produced one stored file.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum SessionFileOrigin {
76    /// Selects the user case.
77    User,
78    /// Selects the agent case.
79    Agent,
80}
81
82/// One stored session file together with its producer.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct SessionFileRecord {
86    /// The origin.
87    pub origin: SessionFileOrigin,
88    /// The file.
89    pub file: SessionFileReference,
90}
91
92/// A command submitted by a frontend.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct Submission {
95    /// Correlates all events produced by this command.
96    pub id: String,
97    /// Command payload.
98    pub op: Op,
99}
100
101impl Submission {
102    /// Uses a source's stable message identity for durable admission and retries.
103    #[must_use]
104    pub fn message(message: MessageSubmission) -> Self {
105        let id = match &message.author {
106            MessageAuthor::Source { message_id, .. } => message_id.clone(),
107            MessageAuthor::User => uuid::Uuid::new_v4().to_string(),
108        };
109        Self {
110            id,
111            op: Op::Message { message },
112        }
113    }
114}
115
116/// Frontend-visible context for the session owner, workspace, and origin.
117///
118/// These values are correlation metadata, not authentication or authorization.
119/// A remote host must derive them after authentication and inject tenant-scoped
120/// backends when it creates the agent.
121#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
122pub struct SessionContext {
123    /// Opaque identity of the framework host that owns this session.
124    pub owner_id: String,
125    /// Opaque tenant or organization identifier.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub tenant_id: Option<String>,
128    /// Opaque identifier for the user who owns the session.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub user_id: Option<String>,
131    /// Optional display label, such as the local operating-system user name.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub user_name: Option<String>,
134    /// Opaque workspace identifier; this is not a filesystem path.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub workspace_id: Option<String>,
137    /// Optional frontend-facing workspace label.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub workspace_label: Option<String>,
140    /// Optional label describing what created the session.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub origin_label: Option<String>,
143}
144
145impl SessionContext {
146    /// Validates the framework-neutral session ownership boundary.
147    /// # Errors
148    ///
149    /// Returns an error if the supplied value is invalid.
150    pub fn validate(&self) -> crate::Result<()> {
151        crate::validate_identifier(
152            "session owner ID",
153            &self.owner_id,
154            crate::MAX_IDENTIFIER_BYTES,
155        )
156    }
157}
158
159/// One model-requested function call.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct ToolCall {
162    /// The call identifier.
163    pub call_id: String,
164    /// The name.
165    pub name: String,
166    /// The arguments.
167    pub arguments: serde_json::Value,
168}
169
170/// A durable control item recording tool schemas materialized at one context position.
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172pub struct ToolLoad {
173    /// The catalog revision.
174    pub catalog_revision: String,
175    /// The tools.
176    pub tools: Vec<String>,
177}
178
179impl ToolLoad {
180    /// Converts the typed control item into checkpoint model context.
181    #[must_use]
182    pub fn to_input(&self) -> serde_json::Value {
183        serde_json::json!({
184            "type": TOOL_LOAD_MARKER,
185            "catalog_revision": self.catalog_revision,
186            "tools": self.tools,
187            INTERNAL_MESSAGE_FIELD: TOOL_LOAD_MARKER,
188        })
189    }
190
191    /// Decodes a tool-load control item while ignoring ordinary conversation input.
192    /// # Errors
193    ///
194    /// Returns an error if the input cannot be parsed or validated.
195    pub fn from_input(input: &serde_json::Value) -> crate::Result<Option<Self>> {
196        if input.get("type").and_then(serde_json::Value::as_str) != Some(TOOL_LOAD_MARKER) {
197            return Ok(None);
198        }
199        let load: Self = serde::Deserialize::deserialize(input)?;
200        if load.catalog_revision.trim().is_empty() || load.tools.is_empty() {
201            return Err(crate::Error::Checkpoint(
202                "invalid tool-load control item".into(),
203            ));
204        }
205        let mut names = std::collections::BTreeSet::new();
206        for name in &load.tools {
207            if name.trim().is_empty()
208                || name.len() > MAX_TOOL_NAME_BYTES
209                || !names.insert(name.as_str())
210            {
211                return Err(crate::Error::Checkpoint("invalid loaded tool name".into()));
212            }
213        }
214        Ok(Some(load))
215    }
216}
217
218/// Human-readable model settings exposed to frontends.
219#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
220pub struct ModelInfo {
221    /// The model.
222    pub model: String,
223    /// The reasoning effort.
224    pub reasoning_effort: Option<String>,
225}
226
227/// How a model route makes newly discovered tool schemas available.
228#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(rename_all = "snake_case")]
230pub enum ToolDiscoveryMode {
231    /// Append tool schemas at the discovery point without rebuilding the cached prefix.
232    Native,
233    /// Reissue the active context with a changed top-level tool envelope.
234    #[default]
235    Rebuild,
236}
237
238/// A provider capability used by bot compatibility checks.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "snake_case")]
241pub enum ModelCapability {
242    /// Provider-native image generation.
243    ImageGeneration,
244    /// Live provider voice.
245    RealtimeVoice,
246}
247
248/// One selectable runtime model route.
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct ModelChoice {
251    /// The route.
252    pub route: String,
253    /// The group.
254    pub group: String,
255    /// The model.
256    pub model: String,
257    /// The route variant: reasoning effort, image quality, or the voice of a voice model.
258    pub reasoning_effort: Option<String>,
259    /// Display label of the route variant.
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub variant_label: Option<String>,
262    /// The context window.
263    pub context_window: Option<i64>,
264    /// The supports image input.
265    pub supports_image_input: bool,
266    /// Whether this route can generate images natively.
267    pub supports_image_generation: bool,
268    /// The supports realtime voice.
269    pub supports_realtime_voice: bool,
270    /// The tool discovery.
271    pub tool_discovery: ToolDiscoveryMode,
272}
273
274impl ModelChoice {
275    /// Reports whether this route supports a neutral provider capability.
276    #[must_use]
277    pub const fn supports(&self, capability: ModelCapability) -> bool {
278        match capability {
279            ModelCapability::ImageGeneration => self.supports_image_generation,
280            ModelCapability::RealtimeVoice => self.supports_realtime_voice,
281        }
282    }
283}
284
285/// A non-user message's neutral source, assigned by the trusted host.
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
288pub enum MessageSource {
289    /// Coordination from another conversation within the recipient's existing permissions.
290    Session {
291        /// The source session identifier.
292        session_id: String,
293    },
294    /// Advisory evidence from a host-owned integration or lifecycle subscription.
295    External {
296        /// The configured source identifier.
297        source_id: String,
298        /// The immutable event identifier within that source.
299        event_id: String,
300    },
301}
302
303impl MessageSource {
304    /// Returns the source's stable identifier, without treating external events as sessions.
305    #[must_use]
306    pub fn id(&self) -> &str {
307        match self {
308            Self::Session { session_id } => session_id,
309            Self::External { source_id, .. } => source_id,
310        }
311    }
312}
313
314/// Who submitted one conversation message.
315#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
317pub enum MessageAuthor {
318    /// Selects the user case.
319    #[default]
320    User,
321    /// A message from a trusted host-attributed source, without new user authority.
322    Source {
323        /// The message identifier.
324        message_id: String,
325        /// The provenance of this message.
326        source: MessageSource,
327        /// Immediate causal parent carried through peer coordination.
328        cause_id: Option<String>,
329        /// Bounded causal ancestry established by the host.
330        ancestry: Vec<String>,
331        /// The handle.
332        handle: String,
333        /// Optional semantic icon for the sending peer.
334        #[serde(skip_serializing_if = "Option::is_none")]
335        symbol: Option<FrontendSymbol>,
336    },
337}
338
339impl MessageAuthor {
340    /// Retains source ancestry when a source turn coordinates with another session.
341    #[must_use]
342    pub fn causal_origin(&self) -> (Option<String>, Vec<String>) {
343        match self {
344            Self::User => (None, Vec::new()),
345            Self::Source {
346                message_id,
347                cause_id,
348                ancestry,
349                source,
350                ..
351            } => {
352                let mut ancestry = ancestry.clone();
353                let origin = cause_id.as_ref().or(match source {
354                    MessageSource::External { event_id, .. } => Some(event_id),
355                    MessageSource::Session { .. } => None,
356                });
357                for id in origin.into_iter().chain(std::iter::once(message_id)) {
358                    if !ancestry.contains(id) {
359                        ancestry.push(id.clone());
360                    }
361                }
362                (Some(message_id.clone()), ancestry)
363            }
364        }
365    }
366}
367
368/// Requested delivery for a message submitted while a turn is active.
369#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
370#[serde(rename_all = "snake_case")]
371pub enum ActiveMessageDelivery {
372    /// Selects the steer case.
373    Steer,
374    /// Selects the queue case.
375    Queue,
376}
377
378impl ActiveMessageDelivery {
379    /// Returns the delivery identifier used in configuration and protocol records.
380    #[must_use]
381    pub const fn id(self) -> &'static str {
382        match self {
383            Self::Steer => "steer",
384            Self::Queue => "queue",
385        }
386    }
387}
388
389impl std::str::FromStr for ActiveMessageDelivery {
390    type Err = crate::Error;
391
392    fn from_str(value: &str) -> crate::Result<Self> {
393        serde::Deserialize::deserialize(
394            serde::de::value::StrDeserializer::<serde::de::value::Error>::new(value),
395        )
396        .map_err(|_| crate::Error::Config(format!("unsupported messages delivery `{value}`")))
397    }
398}
399
400/// One provider-neutral conversation message submitted to an agent.
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
402#[serde(deny_unknown_fields)]
403pub struct MessageSubmission {
404    /// The author.
405    pub author: MessageAuthor,
406    /// The text.
407    pub text: String,
408    /// The attachments.
409    pub attachments: Vec<SessionFileReference>,
410    #[serde(deserialize_with = "required_option")]
411    /// The reply.
412    pub reply: Option<MessageReply>,
413    #[serde(deserialize_with = "required_option")]
414    /// The requested delivery.
415    pub requested_delivery: Option<ActiveMessageDelivery>,
416    #[serde(deserialize_with = "required_option")]
417    /// The target turn identifier.
418    pub target_turn_id: Option<String>,
419}
420
421impl MessageSubmission {
422    /// Validates neutral message and file-reference invariants at the agent boundary.
423    /// # Errors
424    ///
425    /// Returns an error if the supplied value is invalid.
426    pub fn validate(&self, limits: SessionFileLimits) -> crate::Result<()> {
427        if let Some(turn_id) = &self.target_turn_id {
428            crate::validate_identifier("target turn ID", turn_id, crate::MAX_IDENTIFIER_BYTES)?;
429        }
430        if let Some(reply) = &self.reply {
431            if reply.text.is_empty() {
432                return Err(crate::Error::Config(
433                    "quoted message cannot be empty".into(),
434                ));
435            }
436            if reply.text.len() > MAX_MESSAGE_BYTES {
437                return Err(crate::Error::Config(
438                    "quoted message exceeds size limit".into(),
439                ));
440            }
441        }
442        validate_message_content(&self.author, &self.text, &self.attachments)?;
443        validate_message_attachments(&self.attachments, limits)
444    }
445}
446
447pub(crate) fn validate_message_content(
448    author: &MessageAuthor,
449    text: &str,
450    attachments: &[SessionFileReference],
451) -> crate::Result<()> {
452    const MAX_HANDLE_BYTES: usize = 256;
453
454    if text.len() > MAX_MESSAGE_BYTES {
455        return Err(crate::Error::Config("message exceeds size limit".into()));
456    }
457    match author {
458        MessageAuthor::User if text.trim().is_empty() && attachments.is_empty() => {
459            return Err(crate::Error::Config("user message cannot be empty".into()));
460        }
461        MessageAuthor::Source {
462            message_id,
463            source,
464            cause_id,
465            ancestry,
466            handle,
467            symbol,
468        } => {
469            if ancestry.len() > 16
470                || ancestry
471                    .iter()
472                    .collect::<std::collections::BTreeSet<_>>()
473                    .len()
474                    != ancestry.len()
475            {
476                return Err(crate::Error::Config(
477                    "source causal ancestry exceeds its bound or repeats an event".into(),
478                ));
479            }
480            for id in cause_id.iter().chain(ancestry.iter()) {
481                crate::validate_identifier("source cause ID", id, crate::MAX_IDENTIFIER_BYTES)?;
482            }
483            crate::validate_identifier(
484                "source message ID",
485                message_id,
486                crate::MAX_IDENTIFIER_BYTES,
487            )?;
488            crate::validate_identifier(
489                "message source ID",
490                source.id(),
491                crate::MAX_IDENTIFIER_BYTES,
492            )?;
493            if let MessageSource::External { event_id, .. } = source {
494                crate::validate_identifier(
495                    "source event ID",
496                    event_id,
497                    crate::MAX_IDENTIFIER_BYTES,
498                )?;
499            }
500            crate::validate_identifier("source handle", handle, MAX_HANDLE_BYTES)?;
501            if let Some(symbol) = symbol {
502                crate::validate_identifier("source symbol", symbol.as_str(), MAX_HANDLE_BYTES)?;
503            }
504            if text.trim().is_empty() {
505                return Err(crate::Error::Config(
506                    "source message cannot be empty".into(),
507                ));
508            }
509            if !attachments.is_empty() {
510                return Err(crate::Error::Config(
511                    "source messages cannot carry attachments".into(),
512                ));
513            }
514        }
515        MessageAuthor::User => {}
516    }
517    let mut ids = std::collections::BTreeSet::new();
518    for attachment in attachments {
519        if !ids.insert(&attachment.id) {
520            return Err(crate::Error::Config(
521                "attachment IDs must be unique per message".into(),
522            ));
523        }
524        if Uuid::parse_str(&attachment.id).is_err() {
525            return Err(crate::Error::Config("attachment ID must be a UUID".into()));
526        }
527        crate::validate_identifier("attachment name", &attachment.name, 255)?;
528        crate::validate_identifier("attachment media type", &attachment.media_type, 127)?;
529        if attachment.size == 0 {
530            return Err(crate::Error::Config(
531                "attachment size must be positive".into(),
532            ));
533        }
534    }
535    Ok(())
536}
537
538fn validate_message_attachments(
539    attachments: &[SessionFileReference],
540    limits: SessionFileLimits,
541) -> crate::Result<()> {
542    if attachments.len() > limits.max_attachment_references {
543        return Err(crate::Error::Config(format!(
544            "message cannot reference more than {} attachments",
545            limits.max_attachment_references
546        )));
547    }
548    let mut bytes = 0_u64;
549    for attachment in attachments {
550        if attachment.size > limits.max_file_bytes {
551            return Err(crate::Error::Config(format!(
552                "attachment size must be 1–{} bytes",
553                limits.max_file_bytes
554            )));
555        }
556        bytes = bytes
557            .checked_add(attachment.size)
558            .ok_or_else(|| crate::Error::Config("attachment sizes overflowed".into()))?;
559    }
560    if bytes > limits.max_session_bytes {
561        return Err(crate::Error::Config(format!(
562            "message attachments exceed the {}-byte session limit",
563            limits.max_session_bytes
564        )));
565    }
566    Ok(())
567}
568
569/// Commands supported by the agent.
570#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
571#[serde(tag = "type", rename_all = "snake_case")]
572#[non_exhaustive]
573pub enum Op {
574    /// Submit one conversation message for middleware-owned delivery.
575    Message {
576        /// The submitted message.
577        message: MessageSubmission,
578    },
579    /// Abort one active turn.
580    Interrupt {
581        /// The active turn identifier.
582        turn_id: String,
583    },
584    /// Resolve a paused tool batch.
585    ExecApproval {
586        /// The identifier.
587        id: String,
588        /// The decision.
589        decision: ReviewDecision,
590    },
591    /// Invokes a command owned by one capability.
592    CapabilityCommand {
593        /// The capability.
594        capability: String,
595        /// The command.
596        command: String,
597        /// The arguments.
598        arguments: String,
599        /// Optional caller-editable text kept separate from routing arguments.
600        ///
601        /// When embedded in a frontend action, a present value is its caller-editable text.
602        #[serde(deserialize_with = "required_option")]
603        input: Option<String>,
604        #[serde(deserialize_with = "required_option")]
605        /// The target.
606        target: Option<MessageTarget>,
607    },
608    /// Selects one immutable registered model route.
609    SetModel {
610        /// The registered route name.
611        route: String,
612    },
613    /// Requests that the frontend reopen an existing session.
614    ResumeSession {
615        /// The session identifier to reopen.
616        session_id: String,
617    },
618}
619
620impl Op {
621    /// Invokes a capability command without editable input or a message target.
622    #[must_use]
623    pub fn command(
624        capability: impl Into<String>,
625        command: impl Into<String>,
626        arguments: impl Into<String>,
627    ) -> Self {
628        Self::CapabilityCommand {
629            capability: capability.into(),
630            command: command.into(),
631            arguments: arguments.into(),
632            input: None,
633            target: None,
634        }
635    }
636}
637
638/// Which participant produced text in an externally hosted conversation.
639#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
640#[serde(rename_all = "snake_case")]
641pub enum ConversationRole {
642    /// Selects the user case.
643    User,
644    /// Selects the assistant case.
645    Assistant,
646}
647
648fn required_option<'de, D, T>(deserializer: D) -> std::result::Result<Option<T>, D::Error>
649where
650    D: Deserializer<'de>,
651    T: Deserialize<'de>,
652{
653    Option::deserialize(deserializer)
654}
655
656/// An event emitted to a frontend.
657#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
658pub struct Event {
659    /// Submission ID that caused this event, if it was command-driven.
660    #[serde(skip_serializing_if = "Option::is_none")]
661    pub submission_id: Option<Arc<str>>,
662    /// Event payload.
663    pub msg: EventMsg,
664}
665
666/// Events supported by the minimal frontend contract.
667#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
668#[serde(tag = "type", rename_all = "snake_case")]
669#[non_exhaustive]
670pub enum EventMsg {
671    /// Selects the message delta case.
672    MessageDelta(MessageDeltaEvent),
673    /// Selects the error case.
674    Error(ErrorEvent),
675    /// Selects the warning case.
676    Warning(WarningEvent),
677    /// Selects the submission rejected case.
678    SubmissionRejected(SubmissionRejectedEvent),
679    /// Selects the session configured case.
680    SessionConfigured(SessionConfiguredEvent),
681    #[serde(rename = "turn_started")]
682    /// Selects the turn started case.
683    TurnStarted(TurnStartedEvent),
684    #[serde(rename = "turn_complete")]
685    /// Selects the turn complete case.
686    TurnComplete(TurnCompleteEvent),
687    /// Selects the turn aborted case.
688    TurnAborted(TurnAbortedEvent),
689    /// Selects the message case.
690    Message(MessageEvent),
691    /// Selects the assistant message case.
692    AssistantMessage(AssistantMessageEvent),
693    /// Selects the assistant content delta case.
694    AssistantContentDelta(AssistantContentDeltaEvent),
695    /// Selects the model step started case.
696    ModelStepStarted(ModelStepStartedEvent),
697    /// Selects the model step completed case.
698    ModelStepCompleted(ModelStepCompletedEvent),
699    /// Selects the session history case.
700    SessionHistory(SessionHistoryEvent),
701    /// Selects the model changed case.
702    ModelChanged(ModelChangedEvent),
703    /// Selects the session resume requested case.
704    SessionResumeRequested(SessionResumeRequestedEvent),
705    /// Selects the tool call begin case.
706    ToolCallBegin(ToolCallBeginEvent),
707    /// Selects the tool call end case.
708    ToolCallEnd(ToolCallEndEvent),
709    /// Selects the tool load case.
710    ToolLoad(ToolLoadEvent),
711    /// Selects the exec approval request case.
712    ExecApprovalRequest(ExecApprovalRequestEvent),
713    /// Selects the token count case.
714    TokenCount(TokenCountEvent),
715    /// Selects the context compacted case.
716    ContextCompacted,
717    /// Selects the web search begin case.
718    WebSearchBegin(WebSearchBeginEvent),
719    /// Selects the web search end case.
720    WebSearchEnd(WebSearchEndEvent),
721    /// Selects the frontend case.
722    Frontend(FrontendEvent),
723}
724
725impl EventMsg {
726    /// Returns the mutable durable transcript target carried by a complete message event.
727    pub(crate) fn message_target_mut(&mut self) -> Option<&mut Option<MessageTarget>> {
728        match self {
729            Self::Message(message) => Some(&mut message.message_target),
730            Self::AssistantMessage(message) => Some(&mut message.message_target),
731            _ => None,
732        }
733    }
734
735    pub(crate) fn message(&self) -> Option<&MessageEvent> {
736        match self {
737            Self::Message(message) => Some(message),
738            _ => None,
739        }
740    }
741}
742
743/// Provider-neutral streaming output before submission correlation is attached.
744#[derive(Debug, Clone, PartialEq, Eq)]
745pub enum ModelEvent {
746    /// Selects the text delta case.
747    TextDelta(String),
748    /// Selects the commentary delta case.
749    CommentaryDelta(String),
750    /// Selects the reasoning delta case.
751    ReasoningDelta(String),
752    /// One immutable, fully validated model call in final output order.
753    ///
754    /// Providers emit this before stream EOF when the complete call is known. The
755    /// agent may begin execution immediately; the final `ModelOutput` must contain
756    /// the same call and order.
757    ToolCallReady(ToolCall),
758    /// Selects the web search started case.
759    WebSearchStarted {
760        /// The call identifier.
761        call_id: String,
762    },
763    /// Selects the web search completed case.
764    WebSearchCompleted {
765        /// The call identifier.
766        call_id: String,
767        /// The action.
768        action: WebSearchAction,
769    },
770}
771
772/// Tracks streamed operations whose terminal event must be synthesized on failure.
773#[derive(Clone, Default)]
774pub(crate) struct ModelEventTracker {
775    pending_web_searches: std::sync::Arc<std::sync::Mutex<std::collections::BTreeSet<String>>>,
776}
777
778impl ModelEventTracker {
779    pub(crate) fn observe(&self, event: &ModelEvent) -> crate::Result<()> {
780        let mut pending = self
781            .pending_web_searches
782            .lock()
783            .map_err(|_| crate::Error::Stopped("model event tracker is unavailable".into()))?;
784        match event {
785            ModelEvent::WebSearchStarted { call_id } => {
786                pending.insert(call_id.clone());
787            }
788            ModelEvent::WebSearchCompleted { call_id, .. } => {
789                pending.remove(call_id);
790            }
791            _ => {}
792        }
793        Ok(())
794    }
795
796    pub(crate) fn interrupted(&self) -> crate::Result<Vec<ModelEvent>> {
797        let mut pending = self
798            .pending_web_searches
799            .lock()
800            .map_err(|_| crate::Error::Stopped("model event tracker is unavailable".into()))?;
801        Ok(std::mem::take(&mut *pending)
802            .into_iter()
803            .map(|call_id| ModelEvent::WebSearchCompleted {
804                call_id,
805                action: WebSearchAction::Interrupted,
806            })
807            .collect())
808    }
809}
810
811/// Provider-neutral action reported by hosted web search.
812#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
813#[serde(tag = "type", rename_all = "snake_case")]
814pub enum WebSearchAction {
815    /// Selects the search case.
816    Search {
817        /// The queries.
818        queries: Vec<String>,
819    },
820    /// Selects the open page case.
821    OpenPage {
822        /// The URL.
823        url: Option<String>,
824    },
825    /// Selects the find in page case.
826    FindInPage {
827        /// The URL.
828        url: Option<String>,
829        /// The pattern.
830        pattern: Option<String>,
831    },
832    /// Selects the interrupted case.
833    Interrupted,
834    /// Selects the other case.
835    Other,
836}
837
838impl ModelEvent {
839    /// Converts one normalized provider event into the frontend protocol.
840    ///
841    /// Internal tool-readiness events return `None` so they cannot duplicate the
842    /// frontend's execution-start event.
843    #[must_use]
844    pub fn into_event(
845        self,
846        session_id: Arc<str>,
847        turn_id: Arc<str>,
848        model_step_id: Arc<str>,
849    ) -> Option<EventMsg> {
850        Some(match self {
851            Self::ToolCallReady(_) => return None,
852            Self::TextDelta(delta) => EventMsg::AssistantContentDelta(AssistantContentDeltaEvent {
853                session_id,
854                turn_id,
855                model_step_id,
856                delta,
857                phase: ModelStepContentPhase::FinalAnswer,
858            }),
859            Self::CommentaryDelta(delta) => {
860                EventMsg::AssistantContentDelta(AssistantContentDeltaEvent {
861                    session_id,
862                    turn_id,
863                    model_step_id,
864                    delta,
865                    phase: ModelStepContentPhase::Commentary,
866                })
867            }
868            Self::ReasoningDelta(delta) => {
869                EventMsg::AssistantContentDelta(AssistantContentDeltaEvent {
870                    session_id,
871                    turn_id,
872                    model_step_id,
873                    delta,
874                    phase: ModelStepContentPhase::Reasoning,
875                })
876            }
877            Self::WebSearchStarted { call_id } => EventMsg::WebSearchBegin(WebSearchBeginEvent {
878                session_id,
879                turn_id,
880                model_step_id,
881                call_id,
882            }),
883            Self::WebSearchCompleted { call_id, action } => {
884                EventMsg::WebSearchEnd(WebSearchEndEvent {
885                    session_id,
886                    turn_id,
887                    model_step_id,
888                    call_id,
889                    action,
890                })
891            }
892        })
893    }
894}
895
896#[cfg(test)]
897mod tests {
898    use serde_json::json;
899
900    use super::*;
901
902    #[test]
903    fn model_events_keep_typed_correlation_and_web_search_fields() {
904        let session_id = Arc::from("session-1");
905        let turn_id = Arc::from("turn-1");
906        let model_step_id = Arc::from("step-1");
907        let delta = ModelEvent::CommentaryDelta("Checking".into())
908            .into_event(
909                Arc::clone(&session_id),
910                Arc::clone(&turn_id),
911                Arc::clone(&model_step_id),
912            )
913            .expect("frontend delta");
914        let search = ModelEvent::WebSearchCompleted {
915            call_id: "search-1".into(),
916            action: WebSearchAction::Search {
917                queries: vec!["möbius framework".into(), "möbius gateway".into()],
918            },
919        }
920        .into_event(
921            Arc::clone(&session_id),
922            Arc::clone(&turn_id),
923            Arc::clone(&model_step_id),
924        )
925        .expect("frontend search");
926        let EventMsg::AssistantContentDelta(delta_ids) = &delta else {
927            panic!("expected a content delta");
928        };
929        let EventMsg::WebSearchEnd(search_ids) = &search else {
930            panic!("expected a web search result");
931        };
932        assert!(Arc::ptr_eq(&delta_ids.session_id, &session_id));
933        assert!(Arc::ptr_eq(&delta_ids.turn_id, &turn_id));
934        assert!(Arc::ptr_eq(&delta_ids.model_step_id, &model_step_id));
935        assert!(Arc::ptr_eq(&search_ids.session_id, &session_id));
936        assert!(Arc::ptr_eq(&search_ids.turn_id, &turn_id));
937        assert!(Arc::ptr_eq(&search_ids.model_step_id, &model_step_id));
938
939        assert_eq!(
940            serde_json::to_value(delta).expect("serialize delta"),
941            json!({
942                "type": "assistant_content_delta",
943                "session_id": "session-1",
944                "turn_id": "turn-1",
945                "model_step_id": "step-1",
946                "delta": "Checking",
947                "phase": "commentary"
948            })
949        );
950        assert_eq!(
951            serde_json::to_value(search).expect("serialize web search"),
952            json!({
953                "type": "web_search_end",
954                "session_id": "session-1",
955                "turn_id": "turn-1",
956                "model_step_id": "step-1",
957                "call_id": "search-1",
958                "action": {
959                    "type": "search",
960                    "queries": ["möbius framework", "möbius gateway"]
961                }
962            })
963        );
964    }
965
966    #[test]
967    fn tool_call_ready_stays_internal_to_the_agent_loop() {
968        assert_eq!(
969            ModelEvent::ToolCallReady(crate::protocol::ToolCall {
970                call_id: "call-1".into(),
971                name: "read_file".into(),
972                arguments: json!({"path": "README.md"}),
973            })
974            .into_event(
975                Arc::from("session-1"),
976                Arc::from("turn-1"),
977                Arc::from("step-1")
978            ),
979            None
980        );
981    }
982
983    #[test]
984    fn interrupted_web_search_renders_a_terminal_warning_block() {
985        let event = EventMsg::WebSearchEnd(WebSearchEndEvent {
986            session_id: "session-1".into(),
987            turn_id: "turn-1".into(),
988            model_step_id: "step-1".into(),
989            call_id: "search-1".into(),
990            action: WebSearchAction::Interrupted,
991        });
992
993        assert_eq!(
994            serde_json::to_value(&event).expect("serialize interrupted search"),
995            json!({
996                "type": "web_search_end",
997                "session_id": "session-1",
998                "turn_id": "turn-1",
999                "model_step_id": "step-1",
1000                "call_id": "search-1",
1001                "action": {"type": "interrupted"}
1002            })
1003        );
1004        let block = event.presentation().expect("interrupted search renders");
1005        assert_eq!(block.capability, "web_search");
1006        assert_eq!(block.block.id.as_deref(), Some("step-1/search-1"));
1007        assert_eq!(block.block.state, FrontendBlockState::Complete);
1008        assert_eq!(block.block.tone, FrontendTone::Warning);
1009        assert_eq!(&*block.block.title, "Web search interrupted");
1010    }
1011
1012    #[test]
1013    fn retrying_model_step_has_a_provider_neutral_reconnect_notice() {
1014        let event = EventMsg::ModelStepCompleted(ModelStepCompletedEvent {
1015            session_id: "session-1".into(),
1016            turn_id: "turn-1".into(),
1017            model_step_id: "step-1".into(),
1018            step_index: 0,
1019            started_at_ms: 1,
1020            completed_at_ms: 2,
1021            outcome: ModelStepOutcome::Retrying,
1022            diagnostics: None,
1023        });
1024
1025        let rendered = event.presentation().expect("retry presentation");
1026
1027        assert_eq!(rendered.capability, "agent");
1028        assert_eq!(rendered.block.id.as_deref(), Some("step-1/retry"));
1029        assert_eq!(rendered.block.title, "Reconnecting…");
1030        assert_eq!(rendered.block.tone, FrontendTone::Warning);
1031        assert_eq!(rendered.block.state, FrontendBlockState::Complete);
1032    }
1033
1034    #[test]
1035    fn middleware_settings_have_a_generic_wire_shape() {
1036        let feature = MiddlewareFeature {
1037            id: "example".into(),
1038            label: "Example".into(),
1039            description: "Example capability".into(),
1040            required: false,
1041            settings: vec![FrontendSetting {
1042                id: "limit".into(),
1043                label: "Limit".into(),
1044                description: "Example limit".into(),
1045                composer: false,
1046                kind: FrontendSettingKind::Integer {
1047                    min: 1,
1048                    max: None,
1049                    step: 10,
1050                },
1051            }],
1052        };
1053
1054        assert_eq!(
1055            serde_json::to_value(feature).expect("serialize middleware setting"),
1056            json!({
1057                "id": "example",
1058                "label": "Example",
1059                "description": "Example capability",
1060                "required": false,
1061                "settings": [{
1062                    "id": "limit",
1063                    "label": "Limit",
1064                    "description": "Example limit",
1065                    "composer": false,
1066                    "type": "integer",
1067                    "min": 1,
1068                    "step": 10
1069                }]
1070            })
1071        );
1072    }
1073
1074    #[test]
1075    fn session_configured_has_a_stable_wire_shape() {
1076        let event = EventMsg::SessionConfigured(SessionConfiguredEvent {
1077            session_id: "session-1".into(),
1078            context: SessionContext {
1079                owner_id: "bot-1".into(),
1080                tenant_id: Some("tenant-1".into()),
1081                user_id: Some("user-1".into()),
1082                user_name: Some("Ada".into()),
1083                workspace_id: Some("workspace-1".into()),
1084                workspace_label: Some("Project One".into()),
1085                origin_label: Some("routine".into()),
1086            },
1087            model: ModelChangedEvent {
1088                route: "default".into(),
1089                model: "test-model".into(),
1090                reasoning_effort: Some("high".into()),
1091                model_context_window: Some(128_000),
1092            },
1093        });
1094
1095        assert_eq!(
1096            serde_json::to_value(event).expect("serialize session event"),
1097            json!({
1098                "type": "session_configured",
1099                "session_id": "session-1",
1100                "context": {
1101                    "owner_id": "bot-1",
1102                    "tenant_id": "tenant-1",
1103                    "user_id": "user-1",
1104                    "user_name": "Ada",
1105                    "workspace_id": "workspace-1",
1106                    "workspace_label": "Project One",
1107                    "origin_label": "routine"
1108                },
1109                "model": {
1110                    "route": "default",
1111                    "model": "test-model",
1112                    "reasoning_effort": "high",
1113                    "model_context_window": 128_000
1114                }
1115            })
1116        );
1117    }
1118
1119    #[test]
1120    fn session_resume_request_carries_the_target_context() {
1121        let event = EventMsg::SessionResumeRequested(SessionResumeRequestedEvent {
1122            session_id: "session-2".into(),
1123            context: SessionContext {
1124                owner_id: "bot-2".into(),
1125                workspace_label: Some("Project Two".into()),
1126                origin_label: Some("routine".into()),
1127                ..SessionContext::default()
1128            },
1129        });
1130
1131        assert_eq!(
1132            serde_json::to_value(event).expect("serialize resume event"),
1133            json!({
1134                "type": "session_resume_requested",
1135                "session_id": "session-2",
1136                "context": {
1137                    "owner_id": "bot-2",
1138                    "workspace_label": "Project Two",
1139                    "origin_label": "routine"
1140                }
1141            })
1142        );
1143    }
1144
1145    #[test]
1146    fn session_context_hard_requires_owner_identity() {
1147        assert!(serde_json::from_value::<SessionContext>(json!({})).is_err());
1148        assert_eq!(
1149            serde_json::from_value::<SessionContext>(json!({"owner_id": "bot-1"}))
1150                .expect("required owner context")
1151                .owner_id,
1152            "bot-1"
1153        );
1154    }
1155
1156    #[test]
1157    fn frontend_event_has_a_distinct_nested_discriminator() {
1158        let event = EventMsg::Frontend(FrontendEvent::Widget {
1159            capability: "subagents".into(),
1160            item: FrontendWidget {
1161                id: "status".into(),
1162                slot: FrontendSlot::ComposerHeader,
1163                text: "2 agents".into(),
1164                tone: FrontendTone::Neutral,
1165                symbol: Some(FrontendSymbol::Agent),
1166                icon_only: true,
1167                progress: None,
1168                content: None,
1169                action: None,
1170            },
1171        });
1172        let value = serde_json::to_value(&event).expect("serialize frontend event");
1173
1174        assert_eq!(value["type"], "frontend");
1175        assert_eq!(value["frontend_type"], "widget");
1176        assert_eq!(
1177            serde_json::from_value::<EventMsg>(value).expect("deserialize frontend event"),
1178            event
1179        );
1180    }
1181
1182    #[test]
1183    fn capability_surface_slots_have_stable_wire_names() {
1184        assert_eq!(
1185            serde_json::to_value(FrontendSlot::Navigation).expect("navigation slot"),
1186            json!("navigation")
1187        );
1188        assert_eq!(
1189            serde_json::to_value(FrontendSlot::ChatMenu).expect("chat menu slot"),
1190            json!("chat_menu")
1191        );
1192        assert_eq!(
1193            serde_json::to_value(FrontendSlot::TranscriptTail).expect("transcript tail slot"),
1194            json!("transcript_tail")
1195        );
1196    }
1197
1198    #[test]
1199    fn interrupt_has_a_targeted_wire_shape() {
1200        let submission = Submission {
1201            id: "cancel-1".into(),
1202            op: Op::Interrupt {
1203                turn_id: "turn-1".into(),
1204            },
1205        };
1206
1207        assert_eq!(
1208            serde_json::to_value(submission).expect("serialize interrupt"),
1209            json!({
1210                "id": "cancel-1",
1211                "op": {
1212                    "type": "interrupt",
1213                    "turn_id": "turn-1"
1214                }
1215            })
1216        );
1217    }
1218
1219    #[test]
1220    fn peer_message_symbol_is_optional_and_validated() {
1221        for (symbol, valid) in [(None, true), (Some("voice"), true), (Some(""), false)] {
1222            let author = MessageAuthor::Source {
1223                message_id: "message".into(),
1224                source: crate::protocol::MessageSource::Session {
1225                    session_id: "child".into(),
1226                },
1227                cause_id: None,
1228                ancestry: Vec::new(),
1229                handle: "voice agent".into(),
1230                symbol: symbol.map(|symbol| FrontendSymbol::Custom(symbol.into())),
1231            };
1232            let encoded = serde_json::to_value(&author).expect("encode");
1233            assert_eq!(
1234                serde_json::from_value::<MessageAuthor>(encoded).expect("decode"),
1235                author
1236            );
1237            assert_eq!(
1238                validate_message_content(&author, "Run the tests", &[]).is_ok(),
1239                valid
1240            );
1241        }
1242    }
1243
1244    #[test]
1245    fn message_submission_has_one_typed_payload() {
1246        let submission = Submission {
1247            id: "input-1".into(),
1248            op: Op::Message {
1249                message: MessageSubmission {
1250                    author: MessageAuthor::User,
1251                    text: "hello".into(),
1252                    attachments: Vec::new(),
1253                    reply: Some(MessageReply {
1254                        target: MessageTarget {
1255                            checkpoint_sequence: 7,
1256                            batch_item_count: 2,
1257                        },
1258                        text: "earlier".into(),
1259                    }),
1260                    requested_delivery: Some(ActiveMessageDelivery::Queue),
1261                    target_turn_id: Some("turn-1".into()),
1262                },
1263            },
1264        };
1265
1266        assert_eq!(
1267            serde_json::to_value(submission).expect("serialize input"),
1268            json!({
1269                "id": "input-1",
1270                "op": {
1271                    "type": "message",
1272                    "message": {
1273                        "author": {"type": "user"},
1274                        "text": "hello",
1275                        "attachments": [],
1276                        "reply": {
1277                            "target": {
1278                                "checkpoint_sequence": 7,
1279                                "batch_item_count": 2
1280                            },
1281                            "text": "earlier"
1282                        },
1283                        "requested_delivery": "queue",
1284                        "target_turn_id": "turn-1"
1285                    }
1286                }
1287            })
1288        );
1289    }
1290
1291    #[test]
1292    fn conversation_events_use_turn_and_text_wire_names() {
1293        let events = [
1294            EventMsg::TurnStarted(TurnStartedEvent {
1295                turn_id: "turn-1".into(),
1296                model_context_window: Some(128_000),
1297            }),
1298            EventMsg::Message(MessageEvent {
1299                author: MessageAuthor::User,
1300                delivery: MessageDelivery::Turn,
1301                text: "hello".into(),
1302                attachments: Vec::new(),
1303                reply: None,
1304                message_target: None,
1305            }),
1306            EventMsg::TurnComplete(TurnCompleteEvent {
1307                turn_id: "turn-1".into(),
1308            }),
1309        ];
1310
1311        assert_eq!(
1312            serde_json::to_value(events).expect("serialize conversation events"),
1313            json!([
1314                {
1315                    "type": "turn_started",
1316                    "turn_id": "turn-1",
1317                    "model_context_window": 128_000
1318                },
1319                {
1320                    "type": "message",
1321                    "author": {"type": "user"},
1322                    "delivery": "turn",
1323                    "text": "hello",
1324                    "attachments": [],
1325                    "reply": null,
1326                    "message_target": null
1327                },
1328                {
1329                    "type": "turn_complete",
1330                    "turn_id": "turn-1"
1331                }
1332            ])
1333        );
1334    }
1335
1336    #[test]
1337    fn stored_message_event_without_reply_decodes_as_no_reply() {
1338        let event: MessageEvent = serde_json::from_value(json!({
1339            "author": {"type": "user"},
1340            "delivery": "turn",
1341            "text": "before replies existed",
1342            "attachments": [],
1343            "message_target": null
1344        }))
1345        .expect("decode old stored message event");
1346
1347        assert_eq!(event.reply, None);
1348    }
1349
1350    #[test]
1351    fn system_event_omits_submission_correlation() {
1352        let event = Event {
1353            submission_id: None,
1354            msg: EventMsg::Warning(WarningEvent {
1355                message: "system notice".into(),
1356            }),
1357        };
1358
1359        assert_eq!(
1360            serde_json::to_value(event).expect("serialize system event"),
1361            json!({
1362                "msg": {
1363                    "type": "warning",
1364                    "message": "system notice"
1365                }
1366            })
1367        );
1368    }
1369
1370    #[test]
1371    fn submission_rejection_has_a_typed_wire_shape() {
1372        assert_eq!(
1373            serde_json::to_value(EventMsg::SubmissionRejected(SubmissionRejectedEvent {
1374                message: "message queue is full".into(),
1375            }))
1376            .expect("serialize rejection"),
1377            json!({
1378                "type": "submission_rejected",
1379                "message": "message queue is full"
1380            })
1381        );
1382    }
1383
1384    #[test]
1385    fn context_compacted_is_a_unit_event() {
1386        assert_eq!(
1387            serde_json::to_value(EventMsg::ContextCompacted).expect("serialize compaction"),
1388            json!({"type": "context_compacted"})
1389        );
1390    }
1391
1392    #[test]
1393    fn token_usage_overflow_does_not_partially_update_the_total() {
1394        let mut total = TokenUsage {
1395            input_tokens: 7,
1396            total_tokens: i64::MAX,
1397            ..TokenUsage::default()
1398        };
1399        let original = total.clone();
1400
1401        assert!(
1402            total
1403                .checked_add(&TokenUsage {
1404                    input_tokens: 1,
1405                    total_tokens: 1,
1406                    ..TokenUsage::default()
1407                })
1408                .is_none()
1409        );
1410        assert_eq!(total, original);
1411    }
1412
1413    #[test]
1414    fn symbols_round_trip_and_keep_unknown_names() {
1415        for symbol in [
1416            FrontendSymbol::Agent,
1417            FrontendSymbol::Brain,
1418            FrontendSymbol::Branch,
1419            FrontendSymbol::Chat,
1420            FrontendSymbol::Delete,
1421            FrontendSymbol::Edit,
1422            FrontendSymbol::Progress,
1423            FrontendSymbol::Promote,
1424            FrontendSymbol::Route,
1425            FrontendSymbol::Search,
1426            FrontendSymbol::Sparkle,
1427            FrontendSymbol::Storage,
1428            FrontendSymbol::Task,
1429        ] {
1430            let json = serde_json::to_string(&symbol).expect("symbol serializes");
1431            assert_eq!(json, format!("\"{}\"", symbol.as_str()));
1432            let decoded: FrontendSymbol = serde_json::from_str(&json).expect("symbol deserializes");
1433            assert_eq!(decoded, symbol);
1434        }
1435
1436        // A name this build has never heard of survives instead of failing the frame.
1437        let custom: FrontendSymbol =
1438            serde_json::from_str("\"telescope\"").expect("unknown symbol deserializes");
1439        assert_eq!(custom, FrontendSymbol::Custom("telescope".into()));
1440        assert_eq!(custom.as_str(), "telescope");
1441
1442        // A known name never lingers as a `Custom` once it has crossed the wire, so the two
1443        // spellings of one glyph cannot compare unequal.
1444        let normalized: FrontendSymbol = serde_json::from_str(
1445            &serde_json::to_string(&FrontendSymbol::Custom("edit".into()))
1446                .expect("custom serializes"),
1447        )
1448        .expect("custom deserializes");
1449        assert_eq!(normalized, FrontendSymbol::Edit);
1450    }
1451}