Skip to main content

mobius/protocol/
mod.rs

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