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