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