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