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