1use std::collections::{HashSet, VecDeque};
10
11use async_trait::async_trait;
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15
16use super::{Message, MessagePart, Role};
17
18pub fn canonical_json_bytes(value: &serde_json::Value) -> Vec<u8> {
25 fn canonicalize(value: &serde_json::Value) -> serde_json::Value {
26 match value {
27 serde_json::Value::Array(values) => {
28 serde_json::Value::Array(values.iter().map(canonicalize).collect())
29 }
30 serde_json::Value::Object(values) => {
31 let mut entries = values.iter().collect::<Vec<_>>();
32 entries.sort_unstable_by_key(|(key, _)| *key);
33 let mut canonical = serde_json::Map::new();
34 for (key, value) in entries {
35 canonical.insert(key.clone(), canonicalize(value));
36 }
37 serde_json::Value::Object(canonical)
38 }
39 scalar => scalar.clone(),
40 }
41 }
42
43 serde_json::to_vec(&canonicalize(value)).unwrap_or_else(|_| b"null".to_vec())
47}
48
49pub const SESSION_INBOX_ADMITTED_CAPACITY: usize = 4096;
54
55#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
57#[serde(transparent)]
58pub struct SessionMessageId(String);
59
60impl SessionMessageId {
61 pub fn new() -> Self {
62 Self(uuid::Uuid::new_v4().to_string())
63 }
64
65 pub fn parse(value: impl Into<String>) -> Result<Self, SessionMessageValidationError> {
66 let value = value.into();
67 let trimmed = value.trim();
68 if trimmed.is_empty() {
69 return Err(SessionMessageValidationError::EmptyMessageId);
70 }
71 if value != trimmed {
72 return Err(SessionMessageValidationError::NonCanonicalMessageId);
73 }
74 if value.len() > 256 {
75 return Err(SessionMessageValidationError::MessageIdTooLong);
76 }
77 if value.contains('/') || value.contains('\\') || value.contains("..") {
78 return Err(SessionMessageValidationError::UnsafeMessageId);
79 }
80 Ok(Self(value))
81 }
82
83 pub fn as_str(&self) -> &str {
84 &self.0
85 }
86
87 pub fn stable(namespace: &str, value: &serde_json::Value) -> Self {
90 let canonical = canonical_json_bytes(value);
91 let mut digest = Sha256::new();
92 digest.update((namespace.len() as u64).to_be_bytes());
93 digest.update(namespace.as_bytes());
94 digest.update((canonical.len() as u64).to_be_bytes());
95 digest.update(canonical);
96 Self(format!("stable-{}", hex::encode(digest.finalize())))
97 }
98
99 pub fn legacy(session_id: &str, index: usize, value: &serde_json::Value) -> Self {
102 let stable = Self::stable(
103 &format!("legacy_pending_injected_messages:{session_id}:{index}"),
104 value,
105 );
106 Self(format!("legacy-{index:08}-{}", stable.0))
107 }
108}
109
110impl<'de> Deserialize<'de> for SessionMessageId {
111 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
112 where
113 D: serde::Deserializer<'de>,
114 {
115 let value = String::deserialize(deserializer)?;
116 Self::parse(value).map_err(serde::de::Error::custom)
117 }
118}
119
120impl Default for SessionMessageId {
121 fn default() -> Self {
122 Self::new()
123 }
124}
125
126impl std::fmt::Display for SessionMessageId {
127 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 formatter.write_str(&self.0)
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(tag = "type", rename_all = "snake_case")]
135pub enum SessionMessageSource {
136 User,
137 Session { session_id: String },
138 Runtime { subsystem: String },
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "snake_case")]
145pub enum SessionMessageKind {
146 UserInput,
147 PeerMessage,
148 ChildOutcome,
149 RuntimeInstruction,
150}
151
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154pub struct SessionMessageContent {
155 #[serde(default)]
156 pub text: String,
157 #[serde(default, skip_serializing_if = "Vec::is_empty")]
158 pub parts: Vec<MessagePart>,
159}
160
161impl SessionMessageContent {
162 pub fn text(value: impl Into<String>) -> Self {
163 Self {
164 text: value.into(),
165 parts: Vec::new(),
166 }
167 }
168
169 fn is_semantic(&self) -> bool {
170 !self.text.trim().is_empty()
171 || self.parts.iter().any(|part| match part {
172 MessagePart::Text { text } => !text.trim().is_empty(),
173 MessagePart::ImageUrl { image_url } => !image_url.url.trim().is_empty(),
174 })
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub struct SessionProviderMessage {
188 pub content: SessionMessageContent,
189 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
190 pub metadata: serde_json::Map<String, serde_json::Value>,
191 #[serde(default)]
192 pub never_compress: bool,
193}
194
195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197pub struct SessionChildOutcome {
198 pub child_session_id: String,
199 pub status: String,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub result: Option<String>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub error: Option<String>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub provider_message: Option<SessionProviderMessage>,
206}
207
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211pub struct SessionRuntimeInstruction {
212 pub instruction: String,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub content: Option<SessionMessageContent>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub data: Option<serde_json::Value>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub provider_message: Option<SessionProviderMessage>,
219}
220
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
223#[serde(tag = "type", rename_all = "snake_case")]
224pub enum SessionMessageBody {
225 Content(SessionMessageContent),
226 ChildOutcome(SessionChildOutcome),
227 RuntimeInstruction(SessionRuntimeInstruction),
228}
229
230#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232pub struct SessionMessageEnvelope {
233 pub id: SessionMessageId,
234 pub source: SessionMessageSource,
235 pub target_session_id: String,
236 pub kind: SessionMessageKind,
237 pub body: SessionMessageBody,
238 pub created_at: DateTime<Utc>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub thread_id: Option<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub in_reply_to: Option<SessionMessageId>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub attempt: Option<u32>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub correlation_id: Option<String>,
247}
248
249impl SessionMessageEnvelope {
250 pub fn user_input(target_session_id: impl Into<String>, text: impl Into<String>) -> Self {
251 Self {
252 id: SessionMessageId::new(),
253 source: SessionMessageSource::User,
254 target_session_id: target_session_id.into(),
255 kind: SessionMessageKind::UserInput,
256 body: SessionMessageBody::Content(SessionMessageContent::text(text)),
257 created_at: Utc::now(),
258 thread_id: None,
259 in_reply_to: None,
260 attempt: None,
261 correlation_id: None,
262 }
263 }
264
265 pub fn validate(&self) -> Result<(), SessionMessageValidationError> {
266 SessionMessageId::parse(self.id.as_str())?;
267 if let Some(in_reply_to) = &self.in_reply_to {
268 SessionMessageId::parse(in_reply_to.as_str())
269 .map_err(|_| SessionMessageValidationError::InvalidInReplyTo)?;
270 }
271 if self.target_session_id.trim().is_empty() {
272 return Err(SessionMessageValidationError::EmptyTarget);
273 }
274 match (&self.source, self.kind, &self.body) {
275 (
276 SessionMessageSource::User,
277 SessionMessageKind::UserInput,
278 SessionMessageBody::Content(_),
279 )
280 | (
281 SessionMessageSource::Session { .. },
282 SessionMessageKind::PeerMessage,
283 SessionMessageBody::Content(_),
284 )
285 | (
286 SessionMessageSource::Session { .. } | SessionMessageSource::Runtime { .. },
287 SessionMessageKind::ChildOutcome,
288 SessionMessageBody::ChildOutcome(_),
289 )
290 | (
291 SessionMessageSource::Runtime { .. },
292 SessionMessageKind::RuntimeInstruction,
293 SessionMessageBody::RuntimeInstruction(_),
294 ) => {}
295 _ => return Err(SessionMessageValidationError::KindSourceBodyMismatch),
296 }
297 match &self.source {
298 SessionMessageSource::Session { session_id } if session_id.trim().is_empty() => {
299 return Err(SessionMessageValidationError::EmptySource)
300 }
301 SessionMessageSource::Runtime { subsystem } if subsystem.trim().is_empty() => {
302 return Err(SessionMessageValidationError::EmptySource)
303 }
304 _ => {}
305 }
306 match &self.body {
307 SessionMessageBody::Content(content) if !content.is_semantic() => {
308 return Err(SessionMessageValidationError::EmptyContent)
309 }
310 SessionMessageBody::ChildOutcome(outcome)
311 if outcome.child_session_id.trim().is_empty() =>
312 {
313 return Err(SessionMessageValidationError::EmptyChildSessionId)
314 }
315 SessionMessageBody::ChildOutcome(outcome) if outcome.status.trim().is_empty() => {
316 return Err(SessionMessageValidationError::EmptyChildStatus)
317 }
318 SessionMessageBody::ChildOutcome(SessionChildOutcome {
319 provider_message: Some(message),
320 ..
321 })
322 | SessionMessageBody::RuntimeInstruction(SessionRuntimeInstruction {
323 provider_message: Some(message),
324 ..
325 }) if !message.content.is_semantic() => {
326 return Err(SessionMessageValidationError::EmptyProviderContent)
327 }
328 SessionMessageBody::ChildOutcome(SessionChildOutcome {
329 provider_message: Some(message),
330 ..
331 })
332 | SessionMessageBody::RuntimeInstruction(SessionRuntimeInstruction {
333 provider_message: Some(message),
334 ..
335 }) if message.metadata.contains_key("session_message") => {
336 return Err(SessionMessageValidationError::ReservedProviderMetadata)
337 }
338 SessionMessageBody::RuntimeInstruction(instruction)
339 if instruction.instruction.trim().is_empty() =>
340 {
341 return Err(SessionMessageValidationError::EmptyRuntimeInstruction)
342 }
343 SessionMessageBody::RuntimeInstruction(SessionRuntimeInstruction {
344 content: Some(content),
345 ..
346 }) if !content.is_semantic() => {
347 return Err(SessionMessageValidationError::EmptyRuntimeContent)
348 }
349 _ => {}
350 }
351 Ok(())
352 }
353
354 pub fn idempotency_semantics(&self) -> serde_json::Value {
360 let body = match &self.body {
361 SessionMessageBody::Content(content) => serde_json::json!({
362 "type": "content",
363 "content": content,
364 }),
365 SessionMessageBody::ChildOutcome(outcome) => serde_json::json!({
366 "type": "child_outcome",
367 "child_session_id": outcome.child_session_id,
368 "status": outcome.status,
369 "result": outcome.result,
370 "error": outcome.error,
371 }),
372 SessionMessageBody::RuntimeInstruction(instruction) => serde_json::json!({
373 "type": "runtime_instruction",
374 "instruction": instruction.instruction,
375 "content": instruction.content,
376 "data": instruction.data,
377 }),
378 };
379 serde_json::json!({
380 "source": &self.source,
381 "target_session_id": &self.target_session_id,
382 "kind": &self.kind,
383 "body": body,
384 "thread_id": &self.thread_id,
385 "in_reply_to": &self.in_reply_to,
386 "correlation_id": &self.correlation_id,
387 })
388 }
389
390 pub fn to_provider_message(&self) -> Result<Message, SessionMessageValidationError> {
393 self.validate()?;
394 let (text, parts, provider_metadata, never_compress) = match &self.body {
395 SessionMessageBody::Content(content) => (
396 content.text.clone(),
397 content.parts.clone(),
398 serde_json::Map::new(),
399 false,
400 ),
401 SessionMessageBody::ChildOutcome(outcome) => {
402 if let Some(provider_message) = &outcome.provider_message {
403 (
404 provider_message.content.text.clone(),
405 provider_message.content.parts.clone(),
406 provider_message.metadata.clone(),
407 provider_message.never_compress,
408 )
409 } else {
410 let mut text = format!(
411 "Child session `{}` finished with status `{}`.",
412 outcome.child_session_id, outcome.status
413 );
414 if let Some(result) = outcome.result.as_deref().filter(|v| !v.trim().is_empty())
415 {
416 text.push_str("\n\nResult:\n");
417 text.push_str(result);
418 }
419 if let Some(error) = outcome.error.as_deref().filter(|v| !v.trim().is_empty()) {
420 text.push_str("\n\nError:\n");
421 text.push_str(error);
422 }
423 (text, Vec::new(), serde_json::Map::new(), false)
424 }
425 }
426 SessionMessageBody::RuntimeInstruction(instruction) => {
427 if let Some(provider_message) = &instruction.provider_message {
428 (
429 provider_message.content.text.clone(),
430 provider_message.content.parts.clone(),
431 provider_message.metadata.clone(),
432 provider_message.never_compress,
433 )
434 } else {
435 let text = instruction
436 .content
437 .as_ref()
438 .map(|content| content.text.clone())
439 .unwrap_or_else(|| instruction.instruction.clone());
440 let parts = instruction
441 .content
442 .as_ref()
443 .map(|content| content.parts.clone())
444 .unwrap_or_default();
445 (text, parts, serde_json::Map::new(), false)
446 }
447 }
448 };
449
450 let mut message = if parts.is_empty() {
451 Message::user(text)
452 } else {
453 Message::user_with_parts(text, parts)
454 };
455 message.id = self.id.to_string();
456 message.created_at = self.created_at;
457 message.never_compress = never_compress;
458 let mut metadata = provider_metadata;
459 metadata.insert(
460 "session_message".to_string(),
461 serde_json::json!({
462 "id": self.id,
463 "idempotency_semantics": self.idempotency_semantics(),
464 "source": self.source,
465 "target_session_id": self.target_session_id,
466 "kind": self.kind,
467 "body": self.body,
468 "created_at": self.created_at,
469 "thread_id": self.thread_id,
470 "in_reply_to": self.in_reply_to,
471 "attempt": self.attempt,
472 "correlation_id": self.correlation_id,
473 }),
474 );
475 message.metadata = Some(serde_json::Value::Object(metadata));
476 Ok(message)
477 }
478}
479
480pub fn is_matching_session_message(message: &Message, envelope: &SessionMessageEnvelope) -> bool {
487 let Ok(expected) = envelope.to_provider_message() else {
488 return false;
489 };
490 let provider_metadata_matches = || {
491 let mut actual = message.metadata.as_ref()?.as_object()?.clone();
492 let mut wanted = expected.metadata.as_ref()?.as_object()?.clone();
493 actual.remove("session_message");
494 wanted.remove("session_message");
495 Some(actual == wanted)
496 };
497 let canonical_marker_matches = || {
498 Some(
499 message.metadata.as_ref()?.get("session_message")?
500 == expected.metadata.as_ref()?.get("session_message")?,
501 )
502 };
503 message.role == Role::User
504 && message.id == envelope.id.as_str()
505 && message.content == expected.content
506 && message.content_parts == expected.content_parts
507 && message.never_compress == expected.never_compress
508 && provider_metadata_matches() == Some(true)
509 && canonical_marker_matches() == Some(true)
510}
511
512#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
513pub enum SessionMessageValidationError {
514 #[error("session message id must not be empty")]
515 EmptyMessageId,
516 #[error("session message id must not contain leading or trailing whitespace")]
517 NonCanonicalMessageId,
518 #[error("session message id exceeds 256 bytes")]
519 MessageIdTooLong,
520 #[error("session message id contains an unsafe path component")]
521 UnsafeMessageId,
522 #[error("target session id must not be empty")]
523 EmptyTarget,
524 #[error("source identity must not be empty")]
525 EmptySource,
526 #[error("message content must contain non-empty text or at least one part")]
527 EmptyContent,
528 #[error("child outcome session id must not be empty")]
529 EmptyChildSessionId,
530 #[error("child outcome status must not be empty")]
531 EmptyChildStatus,
532 #[error("runtime instruction name must not be empty")]
533 EmptyRuntimeInstruction,
534 #[error("runtime instruction content must contain non-empty text or at least one part")]
535 EmptyRuntimeContent,
536 #[error("provider presentation must contain non-empty text or at least one part")]
537 EmptyProviderContent,
538 #[error("provider presentation metadata uses the reserved session_message key")]
539 ReservedProviderMetadata,
540 #[error("in_reply_to must be a valid session message id")]
541 InvalidInReplyTo,
542 #[error("session message kind, source, and body are incompatible")]
543 KindSourceBodyMismatch,
544}
545
546#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
548pub struct AdmittedSessionMessage {
549 pub id: SessionMessageId,
550 pub sequence: u64,
551}
552
553#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
555pub struct SessionInboxAdmissionState {
556 #[serde(default)]
557 admitted: VecDeque<AdmittedSessionMessage>,
558 #[serde(skip)]
559 index: HashSet<SessionMessageId>,
560 #[serde(default)]
561 pub last_admitted_sequence: u64,
562 #[serde(default)]
567 pub last_observed_sequence: u64,
568}
569
570impl SessionInboxAdmissionState {
571 pub fn contains(&self, id: &SessionMessageId) -> bool {
572 self.index.contains(id) || self.admitted.iter().any(|entry| &entry.id == id)
573 }
574
575 pub fn contains_str(&self, id: &str) -> bool {
576 self.admitted.iter().any(|entry| entry.id.as_str() == id)
577 }
578
579 pub fn record(&mut self, id: SessionMessageId, sequence: u64) -> bool {
580 self.last_observed_sequence = self.last_observed_sequence.max(sequence);
581 if self.contains(&id) {
582 self.last_admitted_sequence = self.last_admitted_sequence.max(sequence);
583 return false;
584 }
585 self.index.insert(id.clone());
586 self.admitted
587 .push_back(AdmittedSessionMessage { id, sequence });
588 self.last_admitted_sequence = self.last_admitted_sequence.max(sequence);
589 while self.admitted.len() > SESSION_INBOX_ADMITTED_CAPACITY {
590 if let Some(evicted) = self.admitted.pop_front() {
591 self.index.remove(&evicted.id);
592 }
593 }
594 true
595 }
596
597 pub fn rebuild_index(&mut self) {
598 self.index = self.admitted.iter().map(|entry| entry.id.clone()).collect();
599 }
600
601 pub fn merge_from(&mut self, other: &Self) {
602 for entry in &other.admitted {
603 self.record(entry.id.clone(), entry.sequence);
604 }
605 self.last_admitted_sequence = self
606 .last_admitted_sequence
607 .max(other.last_admitted_sequence);
608 self.last_observed_sequence = self
609 .last_observed_sequence
610 .max(other.last_observed_sequence);
611 }
612
613 pub fn observe(&mut self, sequence: u64) {
617 self.last_observed_sequence = self.last_observed_sequence.max(sequence);
618 }
619
620 pub fn pending_activation_generation(&self) -> Option<u64> {
623 (self.last_observed_sequence > self.last_admitted_sequence)
624 .then_some(self.last_observed_sequence)
625 }
626
627 pub fn len(&self) -> usize {
628 self.admitted.len()
629 }
630
631 pub fn is_empty(&self) -> bool {
632 self.admitted.is_empty()
633 }
634}
635
636#[derive(Debug, Clone, PartialEq, Eq)]
639pub struct SessionInboxReceipt {
640 pub id: SessionMessageId,
641 pub generation: u64,
642}
643
644#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
646#[serde(rename_all = "snake_case")]
647pub enum SessionActivationPolicy {
648 #[default]
652 RespectSpecificWait,
653 InterruptSpecificWait,
657}
658
659#[derive(Debug, Clone, PartialEq)]
661pub struct SessionInboxClaim {
662 pub envelope: SessionMessageEnvelope,
663 pub generation: u64,
664 pub claim_id: String,
665}
666
667#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
668pub struct SessionInboxBacklog {
669 pub pending: usize,
670 pub claimed: usize,
671 pub generation: u64,
672 pub activation_generation: u64,
679 pub interrupt_generation: u64,
682 pub oldest_generation: Option<u64>,
684}
685
686impl SessionInboxBacklog {
687 pub fn activation_pending(&self) -> bool {
690 self.oldest_generation
691 .is_some_and(|oldest| oldest <= self.activation_generation)
692 }
693
694 pub fn interrupt_pending(&self) -> bool {
695 self.oldest_generation
696 .is_some_and(|oldest| oldest <= self.interrupt_generation)
697 }
698}
699
700#[derive(Debug, Clone, Copy, PartialEq, Eq)]
701pub struct SessionInboxLimits {
702 pub max_payload_bytes: usize,
703 pub max_backlog: usize,
704 pub max_claim_batch: usize,
705}
706
707impl Default for SessionInboxLimits {
708 fn default() -> Self {
709 Self {
710 max_payload_bytes: 256 * 1024,
711 max_backlog: 1024,
712 max_claim_batch: 128,
713 }
714 }
715}
716
717#[derive(Debug, thiserror::Error)]
718pub enum SessionInboxError {
719 #[error("session inbox target not found: {0}")]
720 TargetNotFound(String),
721 #[error("session inbox payload is {actual} bytes, limit is {limit}")]
722 PayloadTooLarge { actual: usize, limit: usize },
723 #[error("session inbox backlog is full ({current}/{limit})")]
724 BacklogFull { current: usize, limit: usize },
725 #[error("session inbox claim is invalid: {0}")]
726 InvalidClaim(String),
727 #[error("session inbox storage failure: {0}")]
728 Storage(String),
729}
730
731#[async_trait]
734pub trait SessionInboxPort: Send + Sync {
735 async fn deliver(
736 &self,
737 envelope: &SessionMessageEnvelope,
738 ) -> Result<SessionInboxReceipt, SessionInboxError>;
739
740 async fn mark_activation_eligible(
746 &self,
747 target_session_id: &str,
748 generation: u64,
749 policy: SessionActivationPolicy,
750 ) -> Result<(), SessionInboxError>;
751
752 async fn claim(
753 &self,
754 target_session_id: &str,
755 limit: usize,
756 ) -> Result<Vec<SessionInboxClaim>, SessionInboxError>;
757
758 async fn was_admitted(
761 &self,
762 target_session_id: &str,
763 id: &SessionMessageId,
764 ) -> Result<bool, SessionInboxError>;
765
766 async fn ack(
770 &self,
771 target_session_id: &str,
772 claim: &SessionInboxClaim,
773 ) -> Result<(), SessionInboxError>;
774
775 async fn inspect(
776 &self,
777 target_session_id: &str,
778 ) -> Result<SessionInboxBacklog, SessionInboxError>;
779}
780
781#[derive(Debug, Clone, Copy, PartialEq, Eq)]
782pub enum SessionActivationDisposition {
783 ActiveNotified,
784 ActivationReserved,
785 ActivationCoalesced,
786}
787
788#[derive(Debug, thiserror::Error)]
789pub enum SessionActivationError {
790 #[error("session activation target not found: {0}")]
791 TargetNotFound(String),
792 #[error("session activation failed: {0}")]
793 Internal(String),
794}
795
796#[async_trait]
799pub trait SessionActivationPort: Send + Sync {
800 async fn request_activation(
801 &self,
802 target_session_id: &str,
803 inbox_generation: u64,
804 ) -> Result<SessionActivationDisposition, SessionActivationError>;
805}
806
807#[cfg(test)]
808mod tests {
809 use super::*;
810 use crate::{ImageUrlRef, Role};
811
812 #[test]
813 fn multimodal_envelope_round_trips_and_keeps_provider_role_valid() {
814 let envelope = SessionMessageEnvelope {
815 id: SessionMessageId::parse("msg-1").unwrap(),
816 source: SessionMessageSource::User,
817 target_session_id: "session-1".to_string(),
818 kind: SessionMessageKind::UserInput,
819 body: SessionMessageBody::Content(SessionMessageContent {
820 text: "inspect this".to_string(),
821 parts: vec![
822 MessagePart::Text {
823 text: "inspect this".to_string(),
824 },
825 MessagePart::ImageUrl {
826 image_url: ImageUrlRef {
827 url: "bamboo-attachment://session-1/image-1".to_string(),
828 detail: Some("high".to_string()),
829 },
830 },
831 ],
832 }),
833 created_at: Utc::now(),
834 thread_id: Some("thread-1".to_string()),
835 in_reply_to: Some(SessionMessageId::parse("msg-0").unwrap()),
836 attempt: Some(2),
837 correlation_id: Some("corr-1".to_string()),
838 };
839
840 let json = serde_json::to_string(&envelope).unwrap();
841 let restored: SessionMessageEnvelope = serde_json::from_str(&json).unwrap();
842 assert_eq!(restored, envelope);
843
844 let message = restored.to_provider_message().unwrap();
845 assert_eq!(message.role, Role::User);
846 assert_eq!(message.id, "msg-1");
847 assert_eq!(message.content_parts.as_ref().unwrap().len(), 2);
848 let metadata = message.metadata.unwrap();
849 assert_eq!(metadata["session_message"]["source"]["type"], "user");
850 assert_eq!(metadata["session_message"]["thread_id"], "thread-1");
851 }
852
853 #[test]
854 fn envelope_deserialization_rejects_noncanonical_and_oversized_ids() {
855 let envelope = SessionMessageEnvelope::user_input("target", "hello");
856 let mut wire = serde_json::to_value(&envelope).unwrap();
857
858 wire["id"] = serde_json::Value::String(" message-id ".to_string());
859 assert!(serde_json::from_value::<SessionMessageEnvelope>(wire.clone()).is_err());
860
861 wire["id"] = serde_json::Value::String(format!("{}x", " ".repeat(300)));
862 assert!(serde_json::from_value::<SessionMessageEnvelope>(wire.clone()).is_err());
863
864 wire["id"] = serde_json::Value::String("x".repeat(257));
865 assert!(serde_json::from_value::<SessionMessageEnvelope>(wire.clone()).is_err());
866
867 wire["id"] = serde_json::Value::String("message-id".to_string());
868 wire["in_reply_to"] = serde_json::Value::String(" reply-id ".to_string());
869 assert!(serde_json::from_value::<SessionMessageEnvelope>(wire).is_err());
870 }
871
872 #[test]
873 fn kind_source_body_mismatch_is_rejected() {
874 let mut envelope = SessionMessageEnvelope::user_input("target", "hello");
875 envelope.source = SessionMessageSource::Session {
876 session_id: "peer".to_string(),
877 };
878 assert_eq!(
879 envelope.validate().unwrap_err(),
880 SessionMessageValidationError::KindSourceBodyMismatch
881 );
882 }
883
884 #[test]
885 fn semantic_body_and_reply_validation_rejects_empty_values() {
886 let mut envelope = SessionMessageEnvelope::user_input("target", " ");
887 assert_eq!(
888 envelope.validate().unwrap_err(),
889 SessionMessageValidationError::EmptyContent
890 );
891
892 envelope.body = SessionMessageBody::Content(SessionMessageContent {
893 text: String::new(),
894 parts: vec![MessagePart::Text {
895 text: String::new(),
896 }],
897 });
898 assert_eq!(
899 envelope.validate().unwrap_err(),
900 SessionMessageValidationError::EmptyContent,
901 "an empty text part must not make an empty envelope semantic"
902 );
903 envelope.body = SessionMessageBody::Content(SessionMessageContent {
904 text: String::new(),
905 parts: vec![MessagePart::ImageUrl {
906 image_url: ImageUrlRef {
907 url: "bamboo-attachment://target/image".to_string(),
908 detail: None,
909 },
910 }],
911 });
912 assert!(envelope.validate().is_ok());
913
914 envelope.in_reply_to = Some(SessionMessageId(" ../bad ".to_string()));
915 assert_eq!(
916 envelope.validate().unwrap_err(),
917 SessionMessageValidationError::InvalidInReplyTo
918 );
919 }
920
921 #[test]
922 fn child_outcome_requires_identity_and_status() {
923 let mut envelope = SessionMessageEnvelope {
924 id: SessionMessageId::parse("child-outcome").unwrap(),
925 source: SessionMessageSource::Session {
926 session_id: "parent".to_string(),
927 },
928 target_session_id: "target".to_string(),
929 kind: SessionMessageKind::ChildOutcome,
930 body: SessionMessageBody::ChildOutcome(SessionChildOutcome {
931 child_session_id: " ".to_string(),
932 status: "completed".to_string(),
933 result: None,
934 error: None,
935 provider_message: None,
936 }),
937 created_at: Utc::now(),
938 thread_id: None,
939 in_reply_to: None,
940 attempt: None,
941 correlation_id: None,
942 };
943 assert_eq!(
944 envelope.validate().unwrap_err(),
945 SessionMessageValidationError::EmptyChildSessionId
946 );
947 let SessionMessageBody::ChildOutcome(outcome) = &mut envelope.body else {
948 unreachable!()
949 };
950 outcome.child_session_id = "child".to_string();
951 outcome.status = String::new();
952 assert_eq!(
953 envelope.validate().unwrap_err(),
954 SessionMessageValidationError::EmptyChildStatus
955 );
956 }
957
958 #[test]
959 fn runtime_instruction_requires_name_and_semantic_optional_content() {
960 let mut envelope = SessionMessageEnvelope {
961 id: SessionMessageId::parse("runtime").unwrap(),
962 source: SessionMessageSource::Runtime {
963 subsystem: "scheduler".to_string(),
964 },
965 target_session_id: "target".to_string(),
966 kind: SessionMessageKind::RuntimeInstruction,
967 body: SessionMessageBody::RuntimeInstruction(SessionRuntimeInstruction {
968 instruction: String::new(),
969 content: None,
970 data: Some(serde_json::json!({"wake": true})),
971 provider_message: None,
972 }),
973 created_at: Utc::now(),
974 thread_id: None,
975 in_reply_to: None,
976 attempt: None,
977 correlation_id: None,
978 };
979 assert_eq!(
980 envelope.validate().unwrap_err(),
981 SessionMessageValidationError::EmptyRuntimeInstruction
982 );
983 {
984 let SessionMessageBody::RuntimeInstruction(instruction) = &mut envelope.body else {
985 unreachable!()
986 };
987 instruction.instruction = "wake".to_string();
988 instruction.content = Some(SessionMessageContent::text(" "));
989 }
990 assert_eq!(
991 envelope.validate().unwrap_err(),
992 SessionMessageValidationError::EmptyRuntimeContent
993 );
994 let SessionMessageBody::RuntimeInstruction(instruction) = &mut envelope.body else {
995 unreachable!()
996 };
997 instruction.content = Some(SessionMessageContent::text("resume"));
998 assert!(envelope.validate().is_ok());
999 }
1000
1001 #[test]
1002 fn durable_transcript_marker_requires_full_envelope_semantics() {
1003 let envelope = SessionMessageEnvelope::user_input("target", "hello");
1004 let message = envelope.to_provider_message().unwrap();
1005 assert!(is_matching_session_message(&message, &envelope));
1006
1007 let mut forged = message.clone();
1008 forged.metadata.as_mut().unwrap()["session_message"]["target_session_id"] =
1009 serde_json::Value::String("other".to_string());
1010 assert!(!is_matching_session_message(&forged, &envelope));
1011 forged = message.clone();
1012 let mut different = envelope.clone();
1013 different.body = SessionMessageBody::Content(SessionMessageContent::text("different"));
1014 forged.metadata = different.to_provider_message().unwrap().metadata;
1015 assert!(!is_matching_session_message(&forged, &envelope));
1016 forged = message.clone();
1017 forged.content = "different".to_string();
1018 assert!(!is_matching_session_message(&forged, &envelope));
1019 forged = message;
1020 forged.role = Role::Assistant;
1021 assert!(!is_matching_session_message(&forged, &envelope));
1022 }
1023
1024 #[test]
1025 fn provider_presentation_proof_is_exact_and_marker_is_reserved() {
1026 let mut provider_metadata = serde_json::Map::new();
1027 provider_metadata.insert("hidden_from_ui".to_string(), serde_json::Value::Bool(true));
1028 provider_metadata.insert(
1029 "runtime_kind".to_string(),
1030 serde_json::Value::String("guardian_verdict".to_string()),
1031 );
1032 let mut envelope = SessionMessageEnvelope {
1033 id: SessionMessageId::parse("child-proof").unwrap(),
1034 source: SessionMessageSource::Runtime {
1035 subsystem: "child_completion".to_string(),
1036 },
1037 target_session_id: "parent".to_string(),
1038 kind: SessionMessageKind::ChildOutcome,
1039 body: SessionMessageBody::ChildOutcome(SessionChildOutcome {
1040 child_session_id: "child".to_string(),
1041 status: "completed".to_string(),
1042 result: Some("approved".to_string()),
1043 error: None,
1044 provider_message: Some(SessionProviderMessage {
1045 content: SessionMessageContent {
1046 text: "exact guardian resume".to_string(),
1047 parts: vec![MessagePart::ImageUrl {
1048 image_url: ImageUrlRef {
1049 url: "bamboo-attachment://parent/verdict".to_string(),
1050 detail: Some("high".to_string()),
1051 },
1052 }],
1053 },
1054 metadata: provider_metadata,
1055 never_compress: true,
1056 }),
1057 }),
1058 created_at: Utc::now(),
1059 thread_id: Some("wait-1".to_string()),
1060 in_reply_to: None,
1061 attempt: None,
1062 correlation_id: Some("child".to_string()),
1063 };
1064
1065 let message = envelope.to_provider_message().unwrap();
1066 assert!(is_matching_session_message(&message, &envelope));
1067
1068 let mut forged = message.clone();
1069 forged.content_parts = None;
1070 assert!(!is_matching_session_message(&forged, &envelope));
1071
1072 let mut forged = message.clone();
1073 forged.never_compress = false;
1074 assert!(!is_matching_session_message(&forged, &envelope));
1075
1076 let mut forged = message.clone();
1077 forged.metadata.as_mut().unwrap()["hidden_from_ui"] = serde_json::Value::Bool(false);
1078 assert!(!is_matching_session_message(&forged, &envelope));
1079
1080 let mut forged = message;
1081 forged.metadata.as_mut().unwrap()["session_message"]["idempotency_semantics"]["body"]
1082 ["status"] = serde_json::Value::String("forged".to_string());
1083 assert!(!is_matching_session_message(&forged, &envelope));
1084
1085 let SessionMessageBody::ChildOutcome(outcome) = &mut envelope.body else {
1086 unreachable!()
1087 };
1088 outcome.provider_message.as_mut().unwrap().metadata.insert(
1089 "session_message".to_string(),
1090 serde_json::json!({"forged": true}),
1091 );
1092 assert_eq!(
1093 envelope.validate().unwrap_err(),
1094 SessionMessageValidationError::ReservedProviderMetadata
1095 );
1096 }
1097
1098 #[test]
1099 fn admission_state_is_bounded_and_mergeable() {
1100 let mut state = SessionInboxAdmissionState::default();
1101 for sequence in 1..=(SESSION_INBOX_ADMITTED_CAPACITY as u64 + 1) {
1102 state.record(
1103 SessionMessageId::parse(format!("message-{sequence}")).unwrap(),
1104 sequence,
1105 );
1106 }
1107 assert_eq!(state.len(), SESSION_INBOX_ADMITTED_CAPACITY);
1108 assert_eq!(
1109 state.last_admitted_sequence,
1110 SESSION_INBOX_ADMITTED_CAPACITY as u64 + 1
1111 );
1112 assert!(!state.contains(&SessionMessageId::parse("message-1").unwrap()));
1113
1114 let json = serde_json::to_string(&state).unwrap();
1115 let mut restored: SessionInboxAdmissionState = serde_json::from_str(&json).unwrap();
1116 restored.rebuild_index();
1117 assert!(restored.contains(
1118 &SessionMessageId::parse(format!("message-{}", SESSION_INBOX_ADMITTED_CAPACITY + 1))
1119 .unwrap()
1120 ));
1121 }
1122
1123 #[test]
1124 fn legacy_ids_are_deterministic_and_position_sensitive() {
1125 let value = serde_json::json!({"content": "hello"});
1126 assert_eq!(
1127 SessionMessageId::legacy("session", 0, &value),
1128 SessionMessageId::legacy("session", 0, &value)
1129 );
1130 assert_ne!(
1131 SessionMessageId::legacy("session", 0, &value),
1132 SessionMessageId::legacy("session", 1, &value)
1133 );
1134 }
1135
1136 #[test]
1137 fn stable_ids_use_canonical_sha256_and_distinguish_inputs() {
1138 let left = serde_json::json!({"b": [2, 3], "a": 1});
1139 let mut right_map = serde_json::Map::new();
1140 right_map.insert("a".to_string(), serde_json::json!(1));
1141 right_map.insert("b".to_string(), serde_json::json!([2, 3]));
1142 let right = serde_json::Value::Object(right_map);
1143 let stable = SessionMessageId::stable("runtime", &left);
1144 assert_eq!(stable, SessionMessageId::stable("runtime", &right));
1145 assert_eq!(stable.as_str().len(), "stable-".len() + 64);
1146 assert_ne!(
1147 stable,
1148 SessionMessageId::stable("runtime", &serde_json::json!({"a": 1, "b": [2, 4]}))
1149 );
1150 assert_ne!(stable, SessionMessageId::stable("other", &left));
1151 }
1152}