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