1use std::sync::Arc;
4
5use serde::Deserialize;
6use serde::Deserializer;
7use serde::Serialize;
8use uuid::Uuid;
9
10pub use self::replay::events as replay_events;
11pub(crate) use self::replay::{
12 ATTACHMENT_CONTEXT_MARKER, ATTACHMENTS_FIELD, CONTEXT_COMPACTED_MARKER, INTERNAL_MESSAGE_FIELD,
13 MESSAGE_METADATA_FIELD, PROMPT_CACHE_BREAKPOINT_FIELD, REPLAY_REASONING_FIELD,
14 TOOL_ERROR_FIELD, internal_message_kind, is_internal_message, message_metadata,
15 strip_attachment_references, tool_complete_boundaries,
16};
17
18mod content;
19pub use content::{
20 ContentPart, ImageDetail, ImageReference, ImageRendition, ToolContent, ToolResponse,
21};
22pub(crate) use content::{content_part_text, content_parts, content_parts_mut};
23
24mod events;
25mod frontend;
26mod replay;
27
28pub use self::events::*;
29pub use self::frontend::*;
30
31pub const MAX_MESSAGE_BYTES: usize = 1024 * 1024;
33
34pub const MAX_CAPABILITY_INPUT_BYTES: usize = 64 * 1024;
36
37pub(crate) const MAX_TOOL_NAME_BYTES: usize = 256;
38pub(crate) const TOOL_LOAD_MARKER: &str = "tool_load";
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct SessionFileReference {
46 pub id: String,
48 pub name: String,
50 pub size: u64,
52 pub media_type: String,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct SessionFileLimits {
60 pub max_attachment_references: usize,
62 pub max_file_bytes: u64,
64 pub max_session_files: usize,
66 pub max_session_bytes: u64,
68 pub max_upload_chunk_bytes: usize,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum SessionFileOrigin {
76 User,
78 Agent,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct SessionFileRecord {
86 pub origin: SessionFileOrigin,
88 pub file: SessionFileReference,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct Submission {
95 pub id: String,
97 pub op: Op,
99}
100
101impl Submission {
102 #[must_use]
104 pub fn message(message: MessageSubmission) -> Self {
105 let id = match &message.author {
106 MessageAuthor::Source { message_id, .. } => message_id.clone(),
107 MessageAuthor::User => uuid::Uuid::new_v4().to_string(),
108 };
109 Self {
110 id,
111 op: Op::Message { message },
112 }
113 }
114}
115
116#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
122pub struct SessionContext {
123 pub owner_id: String,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub tenant_id: Option<String>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub user_id: Option<String>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub user_name: Option<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub workspace_id: Option<String>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub workspace_label: Option<String>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub origin_label: Option<String>,
143}
144
145impl SessionContext {
146 pub fn validate(&self) -> crate::Result<()> {
151 crate::validate_identifier(
152 "session owner ID",
153 &self.owner_id,
154 crate::MAX_IDENTIFIER_BYTES,
155 )
156 }
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct ToolCall {
162 pub call_id: String,
164 pub name: String,
166 pub arguments: serde_json::Value,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172pub struct ToolLoad {
173 pub catalog_revision: String,
175 pub tools: Vec<String>,
177}
178
179impl ToolLoad {
180 #[must_use]
182 pub fn to_input(&self) -> serde_json::Value {
183 serde_json::json!({
184 "type": TOOL_LOAD_MARKER,
185 "catalog_revision": self.catalog_revision,
186 "tools": self.tools,
187 INTERNAL_MESSAGE_FIELD: TOOL_LOAD_MARKER,
188 })
189 }
190
191 pub fn from_input(input: &serde_json::Value) -> crate::Result<Option<Self>> {
196 if input.get("type").and_then(serde_json::Value::as_str) != Some(TOOL_LOAD_MARKER) {
197 return Ok(None);
198 }
199 let load: Self = serde::Deserialize::deserialize(input)?;
200 if load.catalog_revision.trim().is_empty() || load.tools.is_empty() {
201 return Err(crate::Error::Checkpoint(
202 "invalid tool-load control item".into(),
203 ));
204 }
205 let mut names = std::collections::BTreeSet::new();
206 for name in &load.tools {
207 if name.trim().is_empty()
208 || name.len() > MAX_TOOL_NAME_BYTES
209 || !names.insert(name.as_str())
210 {
211 return Err(crate::Error::Checkpoint("invalid loaded tool name".into()));
212 }
213 }
214 Ok(Some(load))
215 }
216}
217
218#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
220pub struct ModelInfo {
221 pub model: String,
223 pub reasoning_effort: Option<String>,
225}
226
227#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(rename_all = "snake_case")]
230pub enum ToolDiscoveryMode {
231 Native,
233 #[default]
235 Rebuild,
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "snake_case")]
241pub enum ModelCapability {
242 ImageGeneration,
244 RealtimeVoice,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct ModelChoice {
251 pub route: String,
253 pub group: String,
255 pub model: String,
257 pub reasoning_effort: Option<String>,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub variant_label: Option<String>,
262 pub context_window: Option<i64>,
264 pub supports_image_input: bool,
266 pub supports_image_generation: bool,
268 pub supports_realtime_voice: bool,
270 pub tool_discovery: ToolDiscoveryMode,
272}
273
274impl ModelChoice {
275 #[must_use]
277 pub const fn supports(&self, capability: ModelCapability) -> bool {
278 match capability {
279 ModelCapability::ImageGeneration => self.supports_image_generation,
280 ModelCapability::RealtimeVoice => self.supports_realtime_voice,
281 }
282 }
283}
284
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
288pub enum MessageSource {
289 Session {
291 session_id: String,
293 },
294 External {
296 source_id: String,
298 event_id: String,
300 },
301}
302
303impl MessageSource {
304 #[must_use]
306 pub fn id(&self) -> &str {
307 match self {
308 Self::Session { session_id } => session_id,
309 Self::External { source_id, .. } => source_id,
310 }
311 }
312}
313
314#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
317pub enum MessageAuthor {
318 #[default]
320 User,
321 Source {
323 message_id: String,
325 source: MessageSource,
327 cause_id: Option<String>,
329 ancestry: Vec<String>,
331 handle: String,
333 #[serde(skip_serializing_if = "Option::is_none")]
335 symbol: Option<FrontendSymbol>,
336 },
337}
338
339impl MessageAuthor {
340 #[must_use]
342 pub fn causal_origin(&self) -> (Option<String>, Vec<String>) {
343 match self {
344 Self::User => (None, Vec::new()),
345 Self::Source {
346 message_id,
347 cause_id,
348 ancestry,
349 source,
350 ..
351 } => {
352 let mut ancestry = ancestry.clone();
353 let origin = cause_id.as_ref().or(match source {
354 MessageSource::External { event_id, .. } => Some(event_id),
355 MessageSource::Session { .. } => None,
356 });
357 for id in origin.into_iter().chain(std::iter::once(message_id)) {
358 if !ancestry.contains(id) {
359 ancestry.push(id.clone());
360 }
361 }
362 (Some(message_id.clone()), ancestry)
363 }
364 }
365 }
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
370#[serde(rename_all = "snake_case")]
371pub enum ActiveMessageDelivery {
372 Steer,
374 Queue,
376}
377
378impl ActiveMessageDelivery {
379 #[must_use]
381 pub const fn id(self) -> &'static str {
382 match self {
383 Self::Steer => "steer",
384 Self::Queue => "queue",
385 }
386 }
387}
388
389impl std::str::FromStr for ActiveMessageDelivery {
390 type Err = crate::Error;
391
392 fn from_str(value: &str) -> crate::Result<Self> {
393 serde::Deserialize::deserialize(
394 serde::de::value::StrDeserializer::<serde::de::value::Error>::new(value),
395 )
396 .map_err(|_| crate::Error::Config(format!("unsupported messages delivery `{value}`")))
397 }
398}
399
400#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
402#[serde(deny_unknown_fields)]
403pub struct MessageSubmission {
404 pub author: MessageAuthor,
406 pub text: String,
408 pub attachments: Vec<SessionFileReference>,
410 #[serde(deserialize_with = "required_option")]
411 pub reply: Option<MessageReply>,
413 #[serde(deserialize_with = "required_option")]
414 pub requested_delivery: Option<ActiveMessageDelivery>,
416 #[serde(deserialize_with = "required_option")]
417 pub target_turn_id: Option<String>,
419}
420
421impl MessageSubmission {
422 pub fn validate(&self, limits: SessionFileLimits) -> crate::Result<()> {
427 if let Some(turn_id) = &self.target_turn_id {
428 crate::validate_identifier("target turn ID", turn_id, crate::MAX_IDENTIFIER_BYTES)?;
429 }
430 if let Some(reply) = &self.reply {
431 if reply.text.is_empty() {
432 return Err(crate::Error::Config(
433 "quoted message cannot be empty".into(),
434 ));
435 }
436 if reply.text.len() > MAX_MESSAGE_BYTES {
437 return Err(crate::Error::Config(
438 "quoted message exceeds size limit".into(),
439 ));
440 }
441 }
442 validate_message_content(&self.author, &self.text, &self.attachments)?;
443 validate_message_attachments(&self.attachments, limits)
444 }
445}
446
447pub(crate) fn validate_message_content(
448 author: &MessageAuthor,
449 text: &str,
450 attachments: &[SessionFileReference],
451) -> crate::Result<()> {
452 const MAX_HANDLE_BYTES: usize = 256;
453
454 if text.len() > MAX_MESSAGE_BYTES {
455 return Err(crate::Error::Config("message exceeds size limit".into()));
456 }
457 match author {
458 MessageAuthor::User if text.trim().is_empty() && attachments.is_empty() => {
459 return Err(crate::Error::Config("user message cannot be empty".into()));
460 }
461 MessageAuthor::Source {
462 message_id,
463 source,
464 cause_id,
465 ancestry,
466 handle,
467 symbol,
468 } => {
469 if ancestry.len() > 16
470 || ancestry
471 .iter()
472 .collect::<std::collections::BTreeSet<_>>()
473 .len()
474 != ancestry.len()
475 {
476 return Err(crate::Error::Config(
477 "source causal ancestry exceeds its bound or repeats an event".into(),
478 ));
479 }
480 for id in cause_id.iter().chain(ancestry.iter()) {
481 crate::validate_identifier("source cause ID", id, crate::MAX_IDENTIFIER_BYTES)?;
482 }
483 crate::validate_identifier(
484 "source message ID",
485 message_id,
486 crate::MAX_IDENTIFIER_BYTES,
487 )?;
488 crate::validate_identifier(
489 "message source ID",
490 source.id(),
491 crate::MAX_IDENTIFIER_BYTES,
492 )?;
493 if let MessageSource::External { event_id, .. } = source {
494 crate::validate_identifier(
495 "source event ID",
496 event_id,
497 crate::MAX_IDENTIFIER_BYTES,
498 )?;
499 }
500 crate::validate_identifier("source handle", handle, MAX_HANDLE_BYTES)?;
501 if let Some(symbol) = symbol {
502 crate::validate_identifier("source symbol", symbol.as_str(), MAX_HANDLE_BYTES)?;
503 }
504 if text.trim().is_empty() {
505 return Err(crate::Error::Config(
506 "source message cannot be empty".into(),
507 ));
508 }
509 if !attachments.is_empty() {
510 return Err(crate::Error::Config(
511 "source messages cannot carry attachments".into(),
512 ));
513 }
514 }
515 MessageAuthor::User => {}
516 }
517 let mut ids = std::collections::BTreeSet::new();
518 for attachment in attachments {
519 if !ids.insert(&attachment.id) {
520 return Err(crate::Error::Config(
521 "attachment IDs must be unique per message".into(),
522 ));
523 }
524 if Uuid::parse_str(&attachment.id).is_err() {
525 return Err(crate::Error::Config("attachment ID must be a UUID".into()));
526 }
527 crate::validate_identifier("attachment name", &attachment.name, 255)?;
528 crate::validate_identifier("attachment media type", &attachment.media_type, 127)?;
529 if attachment.size == 0 {
530 return Err(crate::Error::Config(
531 "attachment size must be positive".into(),
532 ));
533 }
534 }
535 Ok(())
536}
537
538fn validate_message_attachments(
539 attachments: &[SessionFileReference],
540 limits: SessionFileLimits,
541) -> crate::Result<()> {
542 if attachments.len() > limits.max_attachment_references {
543 return Err(crate::Error::Config(format!(
544 "message cannot reference more than {} attachments",
545 limits.max_attachment_references
546 )));
547 }
548 let mut bytes = 0_u64;
549 for attachment in attachments {
550 if attachment.size > limits.max_file_bytes {
551 return Err(crate::Error::Config(format!(
552 "attachment size must be 1–{} bytes",
553 limits.max_file_bytes
554 )));
555 }
556 bytes = bytes
557 .checked_add(attachment.size)
558 .ok_or_else(|| crate::Error::Config("attachment sizes overflowed".into()))?;
559 }
560 if bytes > limits.max_session_bytes {
561 return Err(crate::Error::Config(format!(
562 "message attachments exceed the {}-byte session limit",
563 limits.max_session_bytes
564 )));
565 }
566 Ok(())
567}
568
569#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
571#[serde(tag = "type", rename_all = "snake_case")]
572#[non_exhaustive]
573pub enum Op {
574 Message {
576 message: MessageSubmission,
578 },
579 Interrupt {
581 turn_id: String,
583 },
584 ExecApproval {
586 id: String,
588 decision: ReviewDecision,
590 },
591 CapabilityCommand {
593 capability: String,
595 command: String,
597 arguments: String,
599 #[serde(deserialize_with = "required_option")]
603 input: Option<String>,
604 #[serde(deserialize_with = "required_option")]
605 target: Option<MessageTarget>,
607 },
608 SetModel {
610 route: String,
612 },
613 ResumeSession {
615 session_id: String,
617 },
618}
619
620impl Op {
621 #[must_use]
623 pub fn command(
624 capability: impl Into<String>,
625 command: impl Into<String>,
626 arguments: impl Into<String>,
627 ) -> Self {
628 Self::CapabilityCommand {
629 capability: capability.into(),
630 command: command.into(),
631 arguments: arguments.into(),
632 input: None,
633 target: None,
634 }
635 }
636}
637
638#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
640#[serde(rename_all = "snake_case")]
641pub enum ConversationRole {
642 User,
644 Assistant,
646}
647
648fn required_option<'de, D, T>(deserializer: D) -> std::result::Result<Option<T>, D::Error>
649where
650 D: Deserializer<'de>,
651 T: Deserialize<'de>,
652{
653 Option::deserialize(deserializer)
654}
655
656#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
658pub struct Event {
659 #[serde(skip_serializing_if = "Option::is_none")]
661 pub submission_id: Option<Arc<str>>,
662 pub msg: EventMsg,
664}
665
666#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
668#[serde(tag = "type", rename_all = "snake_case")]
669#[non_exhaustive]
670pub enum EventMsg {
671 MessageDelta(MessageDeltaEvent),
673 Error(ErrorEvent),
675 Warning(WarningEvent),
677 SubmissionRejected(SubmissionRejectedEvent),
679 SessionConfigured(SessionConfiguredEvent),
681 #[serde(rename = "turn_started")]
682 TurnStarted(TurnStartedEvent),
684 #[serde(rename = "turn_complete")]
685 TurnComplete(TurnCompleteEvent),
687 TurnAborted(TurnAbortedEvent),
689 Message(MessageEvent),
691 AssistantMessage(AssistantMessageEvent),
693 AssistantContentDelta(AssistantContentDeltaEvent),
695 ModelStepStarted(ModelStepStartedEvent),
697 ModelStepCompleted(ModelStepCompletedEvent),
699 SessionHistory(SessionHistoryEvent),
701 ModelChanged(ModelChangedEvent),
703 SessionResumeRequested(SessionResumeRequestedEvent),
705 ToolCallBegin(ToolCallBeginEvent),
707 ToolCallEnd(ToolCallEndEvent),
709 ToolLoad(ToolLoadEvent),
711 ExecApprovalRequest(ExecApprovalRequestEvent),
713 TokenCount(TokenCountEvent),
715 ContextCompacted,
717 WebSearchBegin(WebSearchBeginEvent),
719 WebSearchEnd(WebSearchEndEvent),
721 Frontend(FrontendEvent),
723}
724
725impl EventMsg {
726 pub(crate) fn message_target_mut(&mut self) -> Option<&mut Option<MessageTarget>> {
728 match self {
729 Self::Message(message) => Some(&mut message.message_target),
730 Self::AssistantMessage(message) => Some(&mut message.message_target),
731 _ => None,
732 }
733 }
734
735 pub(crate) fn message(&self) -> Option<&MessageEvent> {
736 match self {
737 Self::Message(message) => Some(message),
738 _ => None,
739 }
740 }
741}
742
743#[derive(Debug, Clone, PartialEq, Eq)]
745pub enum ModelEvent {
746 TextDelta(String),
748 CommentaryDelta(String),
750 ReasoningDelta(String),
752 ToolCallReady(ToolCall),
758 WebSearchStarted {
760 call_id: String,
762 },
763 WebSearchCompleted {
765 call_id: String,
767 action: WebSearchAction,
769 },
770}
771
772#[derive(Clone, Default)]
774pub(crate) struct ModelEventTracker {
775 pending_web_searches: std::sync::Arc<std::sync::Mutex<std::collections::BTreeSet<String>>>,
776}
777
778impl ModelEventTracker {
779 pub(crate) fn observe(&self, event: &ModelEvent) -> crate::Result<()> {
780 let mut pending = self
781 .pending_web_searches
782 .lock()
783 .map_err(|_| crate::Error::Stopped("model event tracker is unavailable".into()))?;
784 match event {
785 ModelEvent::WebSearchStarted { call_id } => {
786 pending.insert(call_id.clone());
787 }
788 ModelEvent::WebSearchCompleted { call_id, .. } => {
789 pending.remove(call_id);
790 }
791 _ => {}
792 }
793 Ok(())
794 }
795
796 pub(crate) fn interrupted(&self) -> crate::Result<Vec<ModelEvent>> {
797 let mut pending = self
798 .pending_web_searches
799 .lock()
800 .map_err(|_| crate::Error::Stopped("model event tracker is unavailable".into()))?;
801 Ok(std::mem::take(&mut *pending)
802 .into_iter()
803 .map(|call_id| ModelEvent::WebSearchCompleted {
804 call_id,
805 action: WebSearchAction::Interrupted,
806 })
807 .collect())
808 }
809}
810
811#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
813#[serde(tag = "type", rename_all = "snake_case")]
814pub enum WebSearchAction {
815 Search {
817 queries: Vec<String>,
819 },
820 OpenPage {
822 url: Option<String>,
824 },
825 FindInPage {
827 url: Option<String>,
829 pattern: Option<String>,
831 },
832 Interrupted,
834 Other,
836}
837
838impl ModelEvent {
839 #[must_use]
844 pub fn into_event(
845 self,
846 session_id: Arc<str>,
847 turn_id: Arc<str>,
848 model_step_id: Arc<str>,
849 ) -> Option<EventMsg> {
850 Some(match self {
851 Self::ToolCallReady(_) => return None,
852 Self::TextDelta(delta) => EventMsg::AssistantContentDelta(AssistantContentDeltaEvent {
853 session_id,
854 turn_id,
855 model_step_id,
856 delta,
857 phase: ModelStepContentPhase::FinalAnswer,
858 }),
859 Self::CommentaryDelta(delta) => {
860 EventMsg::AssistantContentDelta(AssistantContentDeltaEvent {
861 session_id,
862 turn_id,
863 model_step_id,
864 delta,
865 phase: ModelStepContentPhase::Commentary,
866 })
867 }
868 Self::ReasoningDelta(delta) => {
869 EventMsg::AssistantContentDelta(AssistantContentDeltaEvent {
870 session_id,
871 turn_id,
872 model_step_id,
873 delta,
874 phase: ModelStepContentPhase::Reasoning,
875 })
876 }
877 Self::WebSearchStarted { call_id } => EventMsg::WebSearchBegin(WebSearchBeginEvent {
878 session_id,
879 turn_id,
880 model_step_id,
881 call_id,
882 }),
883 Self::WebSearchCompleted { call_id, action } => {
884 EventMsg::WebSearchEnd(WebSearchEndEvent {
885 session_id,
886 turn_id,
887 model_step_id,
888 call_id,
889 action,
890 })
891 }
892 })
893 }
894}
895
896#[cfg(test)]
897mod tests {
898 use serde_json::json;
899
900 use super::*;
901
902 #[test]
903 fn model_events_keep_typed_correlation_and_web_search_fields() {
904 let session_id = Arc::from("session-1");
905 let turn_id = Arc::from("turn-1");
906 let model_step_id = Arc::from("step-1");
907 let delta = ModelEvent::CommentaryDelta("Checking".into())
908 .into_event(
909 Arc::clone(&session_id),
910 Arc::clone(&turn_id),
911 Arc::clone(&model_step_id),
912 )
913 .expect("frontend delta");
914 let search = ModelEvent::WebSearchCompleted {
915 call_id: "search-1".into(),
916 action: WebSearchAction::Search {
917 queries: vec!["möbius framework".into(), "möbius gateway".into()],
918 },
919 }
920 .into_event(
921 Arc::clone(&session_id),
922 Arc::clone(&turn_id),
923 Arc::clone(&model_step_id),
924 )
925 .expect("frontend search");
926 let EventMsg::AssistantContentDelta(delta_ids) = &delta else {
927 panic!("expected a content delta");
928 };
929 let EventMsg::WebSearchEnd(search_ids) = &search else {
930 panic!("expected a web search result");
931 };
932 assert!(Arc::ptr_eq(&delta_ids.session_id, &session_id));
933 assert!(Arc::ptr_eq(&delta_ids.turn_id, &turn_id));
934 assert!(Arc::ptr_eq(&delta_ids.model_step_id, &model_step_id));
935 assert!(Arc::ptr_eq(&search_ids.session_id, &session_id));
936 assert!(Arc::ptr_eq(&search_ids.turn_id, &turn_id));
937 assert!(Arc::ptr_eq(&search_ids.model_step_id, &model_step_id));
938
939 assert_eq!(
940 serde_json::to_value(delta).expect("serialize delta"),
941 json!({
942 "type": "assistant_content_delta",
943 "session_id": "session-1",
944 "turn_id": "turn-1",
945 "model_step_id": "step-1",
946 "delta": "Checking",
947 "phase": "commentary"
948 })
949 );
950 assert_eq!(
951 serde_json::to_value(search).expect("serialize web search"),
952 json!({
953 "type": "web_search_end",
954 "session_id": "session-1",
955 "turn_id": "turn-1",
956 "model_step_id": "step-1",
957 "call_id": "search-1",
958 "action": {
959 "type": "search",
960 "queries": ["möbius framework", "möbius gateway"]
961 }
962 })
963 );
964 }
965
966 #[test]
967 fn tool_call_ready_stays_internal_to_the_agent_loop() {
968 assert_eq!(
969 ModelEvent::ToolCallReady(crate::protocol::ToolCall {
970 call_id: "call-1".into(),
971 name: "read_file".into(),
972 arguments: json!({"path": "README.md"}),
973 })
974 .into_event(
975 Arc::from("session-1"),
976 Arc::from("turn-1"),
977 Arc::from("step-1")
978 ),
979 None
980 );
981 }
982
983 #[test]
984 fn interrupted_web_search_renders_a_terminal_warning_block() {
985 let event = EventMsg::WebSearchEnd(WebSearchEndEvent {
986 session_id: "session-1".into(),
987 turn_id: "turn-1".into(),
988 model_step_id: "step-1".into(),
989 call_id: "search-1".into(),
990 action: WebSearchAction::Interrupted,
991 });
992
993 assert_eq!(
994 serde_json::to_value(&event).expect("serialize interrupted search"),
995 json!({
996 "type": "web_search_end",
997 "session_id": "session-1",
998 "turn_id": "turn-1",
999 "model_step_id": "step-1",
1000 "call_id": "search-1",
1001 "action": {"type": "interrupted"}
1002 })
1003 );
1004 let block = event.presentation().expect("interrupted search renders");
1005 assert_eq!(block.capability, "web_search");
1006 assert_eq!(block.block.id.as_deref(), Some("step-1/search-1"));
1007 assert_eq!(block.block.state, FrontendBlockState::Complete);
1008 assert_eq!(block.block.tone, FrontendTone::Warning);
1009 assert_eq!(&*block.block.title, "Web search interrupted");
1010 }
1011
1012 #[test]
1013 fn retrying_model_step_has_a_provider_neutral_reconnect_notice() {
1014 let event = EventMsg::ModelStepCompleted(ModelStepCompletedEvent {
1015 session_id: "session-1".into(),
1016 turn_id: "turn-1".into(),
1017 model_step_id: "step-1".into(),
1018 step_index: 0,
1019 started_at_ms: 1,
1020 completed_at_ms: 2,
1021 outcome: ModelStepOutcome::Retrying,
1022 diagnostics: None,
1023 });
1024
1025 let rendered = event.presentation().expect("retry presentation");
1026
1027 assert_eq!(rendered.capability, "agent");
1028 assert_eq!(rendered.block.id.as_deref(), Some("step-1/retry"));
1029 assert_eq!(rendered.block.title, "Reconnecting…");
1030 assert_eq!(rendered.block.tone, FrontendTone::Warning);
1031 assert_eq!(rendered.block.state, FrontendBlockState::Complete);
1032 }
1033
1034 #[test]
1035 fn middleware_settings_have_a_generic_wire_shape() {
1036 let feature = MiddlewareFeature {
1037 id: "example".into(),
1038 label: "Example".into(),
1039 description: "Example capability".into(),
1040 required: false,
1041 settings: vec![FrontendSetting {
1042 id: "limit".into(),
1043 label: "Limit".into(),
1044 description: "Example limit".into(),
1045 composer: false,
1046 kind: FrontendSettingKind::Integer {
1047 min: 1,
1048 max: None,
1049 step: 10,
1050 },
1051 }],
1052 };
1053
1054 assert_eq!(
1055 serde_json::to_value(feature).expect("serialize middleware setting"),
1056 json!({
1057 "id": "example",
1058 "label": "Example",
1059 "description": "Example capability",
1060 "required": false,
1061 "settings": [{
1062 "id": "limit",
1063 "label": "Limit",
1064 "description": "Example limit",
1065 "composer": false,
1066 "type": "integer",
1067 "min": 1,
1068 "step": 10
1069 }]
1070 })
1071 );
1072 }
1073
1074 #[test]
1075 fn session_configured_has_a_stable_wire_shape() {
1076 let event = EventMsg::SessionConfigured(SessionConfiguredEvent {
1077 session_id: "session-1".into(),
1078 context: SessionContext {
1079 owner_id: "bot-1".into(),
1080 tenant_id: Some("tenant-1".into()),
1081 user_id: Some("user-1".into()),
1082 user_name: Some("Ada".into()),
1083 workspace_id: Some("workspace-1".into()),
1084 workspace_label: Some("Project One".into()),
1085 origin_label: Some("routine".into()),
1086 },
1087 model: ModelChangedEvent {
1088 route: "default".into(),
1089 model: "test-model".into(),
1090 reasoning_effort: Some("high".into()),
1091 model_context_window: Some(128_000),
1092 },
1093 });
1094
1095 assert_eq!(
1096 serde_json::to_value(event).expect("serialize session event"),
1097 json!({
1098 "type": "session_configured",
1099 "session_id": "session-1",
1100 "context": {
1101 "owner_id": "bot-1",
1102 "tenant_id": "tenant-1",
1103 "user_id": "user-1",
1104 "user_name": "Ada",
1105 "workspace_id": "workspace-1",
1106 "workspace_label": "Project One",
1107 "origin_label": "routine"
1108 },
1109 "model": {
1110 "route": "default",
1111 "model": "test-model",
1112 "reasoning_effort": "high",
1113 "model_context_window": 128_000
1114 }
1115 })
1116 );
1117 }
1118
1119 #[test]
1120 fn session_resume_request_carries_the_target_context() {
1121 let event = EventMsg::SessionResumeRequested(SessionResumeRequestedEvent {
1122 session_id: "session-2".into(),
1123 context: SessionContext {
1124 owner_id: "bot-2".into(),
1125 workspace_label: Some("Project Two".into()),
1126 origin_label: Some("routine".into()),
1127 ..SessionContext::default()
1128 },
1129 });
1130
1131 assert_eq!(
1132 serde_json::to_value(event).expect("serialize resume event"),
1133 json!({
1134 "type": "session_resume_requested",
1135 "session_id": "session-2",
1136 "context": {
1137 "owner_id": "bot-2",
1138 "workspace_label": "Project Two",
1139 "origin_label": "routine"
1140 }
1141 })
1142 );
1143 }
1144
1145 #[test]
1146 fn session_context_hard_requires_owner_identity() {
1147 assert!(serde_json::from_value::<SessionContext>(json!({})).is_err());
1148 assert_eq!(
1149 serde_json::from_value::<SessionContext>(json!({"owner_id": "bot-1"}))
1150 .expect("required owner context")
1151 .owner_id,
1152 "bot-1"
1153 );
1154 }
1155
1156 #[test]
1157 fn frontend_event_has_a_distinct_nested_discriminator() {
1158 let event = EventMsg::Frontend(FrontendEvent::Widget {
1159 capability: "subagents".into(),
1160 item: FrontendWidget {
1161 id: "status".into(),
1162 slot: FrontendSlot::ComposerHeader,
1163 text: "2 agents".into(),
1164 tone: FrontendTone::Neutral,
1165 symbol: Some(FrontendSymbol::Agent),
1166 icon_only: true,
1167 progress: None,
1168 content: None,
1169 action: None,
1170 },
1171 });
1172 let value = serde_json::to_value(&event).expect("serialize frontend event");
1173
1174 assert_eq!(value["type"], "frontend");
1175 assert_eq!(value["frontend_type"], "widget");
1176 assert_eq!(
1177 serde_json::from_value::<EventMsg>(value).expect("deserialize frontend event"),
1178 event
1179 );
1180 }
1181
1182 #[test]
1183 fn capability_surface_slots_have_stable_wire_names() {
1184 assert_eq!(
1185 serde_json::to_value(FrontendSlot::Navigation).expect("navigation slot"),
1186 json!("navigation")
1187 );
1188 assert_eq!(
1189 serde_json::to_value(FrontendSlot::ChatMenu).expect("chat menu slot"),
1190 json!("chat_menu")
1191 );
1192 assert_eq!(
1193 serde_json::to_value(FrontendSlot::TranscriptTail).expect("transcript tail slot"),
1194 json!("transcript_tail")
1195 );
1196 }
1197
1198 #[test]
1199 fn interrupt_has_a_targeted_wire_shape() {
1200 let submission = Submission {
1201 id: "cancel-1".into(),
1202 op: Op::Interrupt {
1203 turn_id: "turn-1".into(),
1204 },
1205 };
1206
1207 assert_eq!(
1208 serde_json::to_value(submission).expect("serialize interrupt"),
1209 json!({
1210 "id": "cancel-1",
1211 "op": {
1212 "type": "interrupt",
1213 "turn_id": "turn-1"
1214 }
1215 })
1216 );
1217 }
1218
1219 #[test]
1220 fn peer_message_symbol_is_optional_and_validated() {
1221 for (symbol, valid) in [(None, true), (Some("voice"), true), (Some(""), false)] {
1222 let author = MessageAuthor::Source {
1223 message_id: "message".into(),
1224 source: crate::protocol::MessageSource::Session {
1225 session_id: "child".into(),
1226 },
1227 cause_id: None,
1228 ancestry: Vec::new(),
1229 handle: "voice agent".into(),
1230 symbol: symbol.map(|symbol| FrontendSymbol::Custom(symbol.into())),
1231 };
1232 let encoded = serde_json::to_value(&author).expect("encode");
1233 assert_eq!(
1234 serde_json::from_value::<MessageAuthor>(encoded).expect("decode"),
1235 author
1236 );
1237 assert_eq!(
1238 validate_message_content(&author, "Run the tests", &[]).is_ok(),
1239 valid
1240 );
1241 }
1242 }
1243
1244 #[test]
1245 fn message_submission_has_one_typed_payload() {
1246 let submission = Submission {
1247 id: "input-1".into(),
1248 op: Op::Message {
1249 message: MessageSubmission {
1250 author: MessageAuthor::User,
1251 text: "hello".into(),
1252 attachments: Vec::new(),
1253 reply: Some(MessageReply {
1254 target: MessageTarget {
1255 checkpoint_sequence: 7,
1256 batch_item_count: 2,
1257 },
1258 text: "earlier".into(),
1259 }),
1260 requested_delivery: Some(ActiveMessageDelivery::Queue),
1261 target_turn_id: Some("turn-1".into()),
1262 },
1263 },
1264 };
1265
1266 assert_eq!(
1267 serde_json::to_value(submission).expect("serialize input"),
1268 json!({
1269 "id": "input-1",
1270 "op": {
1271 "type": "message",
1272 "message": {
1273 "author": {"type": "user"},
1274 "text": "hello",
1275 "attachments": [],
1276 "reply": {
1277 "target": {
1278 "checkpoint_sequence": 7,
1279 "batch_item_count": 2
1280 },
1281 "text": "earlier"
1282 },
1283 "requested_delivery": "queue",
1284 "target_turn_id": "turn-1"
1285 }
1286 }
1287 })
1288 );
1289 }
1290
1291 #[test]
1292 fn conversation_events_use_turn_and_text_wire_names() {
1293 let events = [
1294 EventMsg::TurnStarted(TurnStartedEvent {
1295 turn_id: "turn-1".into(),
1296 model_context_window: Some(128_000),
1297 }),
1298 EventMsg::Message(MessageEvent {
1299 author: MessageAuthor::User,
1300 delivery: MessageDelivery::Turn,
1301 text: "hello".into(),
1302 attachments: Vec::new(),
1303 reply: None,
1304 message_target: None,
1305 }),
1306 EventMsg::TurnComplete(TurnCompleteEvent {
1307 turn_id: "turn-1".into(),
1308 }),
1309 ];
1310
1311 assert_eq!(
1312 serde_json::to_value(events).expect("serialize conversation events"),
1313 json!([
1314 {
1315 "type": "turn_started",
1316 "turn_id": "turn-1",
1317 "model_context_window": 128_000
1318 },
1319 {
1320 "type": "message",
1321 "author": {"type": "user"},
1322 "delivery": "turn",
1323 "text": "hello",
1324 "attachments": [],
1325 "reply": null,
1326 "message_target": null
1327 },
1328 {
1329 "type": "turn_complete",
1330 "turn_id": "turn-1"
1331 }
1332 ])
1333 );
1334 }
1335
1336 #[test]
1337 fn stored_message_event_without_reply_decodes_as_no_reply() {
1338 let event: MessageEvent = serde_json::from_value(json!({
1339 "author": {"type": "user"},
1340 "delivery": "turn",
1341 "text": "before replies existed",
1342 "attachments": [],
1343 "message_target": null
1344 }))
1345 .expect("decode old stored message event");
1346
1347 assert_eq!(event.reply, None);
1348 }
1349
1350 #[test]
1351 fn system_event_omits_submission_correlation() {
1352 let event = Event {
1353 submission_id: None,
1354 msg: EventMsg::Warning(WarningEvent {
1355 message: "system notice".into(),
1356 }),
1357 };
1358
1359 assert_eq!(
1360 serde_json::to_value(event).expect("serialize system event"),
1361 json!({
1362 "msg": {
1363 "type": "warning",
1364 "message": "system notice"
1365 }
1366 })
1367 );
1368 }
1369
1370 #[test]
1371 fn submission_rejection_has_a_typed_wire_shape() {
1372 assert_eq!(
1373 serde_json::to_value(EventMsg::SubmissionRejected(SubmissionRejectedEvent {
1374 message: "message queue is full".into(),
1375 }))
1376 .expect("serialize rejection"),
1377 json!({
1378 "type": "submission_rejected",
1379 "message": "message queue is full"
1380 })
1381 );
1382 }
1383
1384 #[test]
1385 fn context_compacted_is_a_unit_event() {
1386 assert_eq!(
1387 serde_json::to_value(EventMsg::ContextCompacted).expect("serialize compaction"),
1388 json!({"type": "context_compacted"})
1389 );
1390 }
1391
1392 #[test]
1393 fn token_usage_overflow_does_not_partially_update_the_total() {
1394 let mut total = TokenUsage {
1395 input_tokens: 7,
1396 total_tokens: i64::MAX,
1397 ..TokenUsage::default()
1398 };
1399 let original = total.clone();
1400
1401 assert!(
1402 total
1403 .checked_add(&TokenUsage {
1404 input_tokens: 1,
1405 total_tokens: 1,
1406 ..TokenUsage::default()
1407 })
1408 .is_none()
1409 );
1410 assert_eq!(total, original);
1411 }
1412
1413 #[test]
1414 fn symbols_round_trip_and_keep_unknown_names() {
1415 for symbol in [
1416 FrontendSymbol::Agent,
1417 FrontendSymbol::Brain,
1418 FrontendSymbol::Branch,
1419 FrontendSymbol::Chat,
1420 FrontendSymbol::Delete,
1421 FrontendSymbol::Edit,
1422 FrontendSymbol::Progress,
1423 FrontendSymbol::Promote,
1424 FrontendSymbol::Route,
1425 FrontendSymbol::Search,
1426 FrontendSymbol::Sparkle,
1427 FrontendSymbol::Storage,
1428 FrontendSymbol::Task,
1429 ] {
1430 let json = serde_json::to_string(&symbol).expect("symbol serializes");
1431 assert_eq!(json, format!("\"{}\"", symbol.as_str()));
1432 let decoded: FrontendSymbol = serde_json::from_str(&json).expect("symbol deserializes");
1433 assert_eq!(decoded, symbol);
1434 }
1435
1436 let custom: FrontendSymbol =
1438 serde_json::from_str("\"telescope\"").expect("unknown symbol deserializes");
1439 assert_eq!(custom, FrontendSymbol::Custom("telescope".into()));
1440 assert_eq!(custom.as_str(), "telescope");
1441
1442 let normalized: FrontendSymbol = serde_json::from_str(
1445 &serde_json::to_string(&FrontendSymbol::Custom("edit".into()))
1446 .expect("custom serializes"),
1447 )
1448 .expect("custom deserializes");
1449 assert_eq!(normalized, FrontendSymbol::Edit);
1450 }
1451}