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