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, 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
22pub const MAX_MESSAGE_BYTES: usize = 1024 * 1024;
24
25pub const MAX_CAPABILITY_INPUT_BYTES: usize = 64 * 1024;
27
28#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum SessionFileOrigin {
55 User,
56 Agent,
57}
58
59#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct Submission {
70 pub id: String,
72 pub op: Op,
74}
75
76#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
82pub struct SessionContext {
83 pub bot_id: String,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub tenant_id: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub user_id: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub user_name: Option<String>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub workspace_id: Option<String>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub workspace_label: Option<String>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub origin_label: Option<String>,
103}
104
105#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
107pub struct ModelInfo {
108 pub model: String,
109 pub reasoning_effort: Option<String>,
110}
111
112#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum ToolDiscoveryMode {
116 Native,
118 #[default]
120 Rebuild,
121}
122
123#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum ActiveMessageDelivery {
151 Steer,
152 Queue,
153}
154
155#[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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(tag = "type", rename_all = "snake_case")]
294#[non_exhaustive]
295pub enum Op {
296 Message { message: MessageSubmission },
298 Interrupt { turn_id: String },
300 ExecApproval {
302 id: String,
303 decision: ReviewDecision,
304 },
305 CapabilityCommand {
307 capability: String,
308 command: String,
309 arguments: String,
310 #[serde(deserialize_with = "required_option")]
314 input: Option<String>,
315 #[serde(deserialize_with = "required_option")]
316 target: Option<MessageTarget>,
317 },
318 SetModel { route: String },
320 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
334pub struct Event {
335 #[serde(skip_serializing_if = "Option::is_none")]
337 pub submission_id: Option<String>,
338 pub msg: EventMsg,
340}
341
342#[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 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#[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#[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#[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 #[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 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 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}