1use crate::Provider;
13use crate::generated::{session_document, session_persistence_version_authority};
14use crate::lifecycle::run_primitive::{TurnMetadataOverride, TurnRequestContext};
15use crate::lifecycle::{CoreBoundaryStageError, RunId};
16use crate::peer_meta::PeerMeta;
17use crate::realtime_transcript::{
18 RealtimeTranscriptApplyOutcome, RealtimeTranscriptEvent, RealtimeUserContentIdentity,
19 SESSION_REALTIME_TRANSCRIPT_STATE_KEY,
20};
21use crate::realtime_transcript_revision::{self, SessionRealtimeTranscriptState};
22use crate::realtime_transcript_sidecar::{
23 PreparedRealtimeTranscriptRebase, RealtimeTranscriptSidecarError,
24 RealtimeTranscriptSnapshotReasonV1, SessionRealtimeTranscriptProjection,
25};
26use crate::service::MobToolAuthorityContext;
27use crate::session_durable_config_authority;
28use crate::time_compat::SystemTime;
29#[cfg(target_arch = "wasm32")]
30use crate::tokio;
31use crate::tool_scope::ToolFilter;
32use crate::types::{
33 AssistantBlock, BlockAssistantMessage, ContentBlock, ContentInput, Message, SessionId,
34 StopReason, ToolDef, ToolName, ToolProvenance, ToolResult, Usage, UserMessage,
35};
36use serde::{Deserialize, Deserializer, Serialize, Serializer};
37use sha2::{Digest, Sha256};
38use std::collections::{BTreeMap, BTreeSet, HashMap};
39use std::sync::Arc;
40
41#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
46#[serde(transparent)]
47pub struct SessionLineageId(String);
48
49impl SessionLineageId {
50 pub fn new(value: impl Into<String>) -> Result<Self, InvalidSessionLineageId> {
51 let value = value.into();
52 if value.trim().is_empty() {
53 return Err(InvalidSessionLineageId);
54 }
55 Ok(Self(value))
56 }
57
58 #[must_use]
59 pub fn for_session(session_id: &SessionId) -> Self {
60 Self(format!("session:{session_id}"))
61 }
62
63 #[must_use]
64 pub fn as_str(&self) -> &str {
65 &self.0
66 }
67}
68
69impl std::fmt::Display for SessionLineageId {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 self.0.fmt(f)
72 }
73}
74
75impl<'de> Deserialize<'de> for SessionLineageId {
76 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
77 where
78 D: Deserializer<'de>,
79 {
80 let value = String::deserialize(deserializer)?;
81 Self::new(value).map_err(serde::de::Error::custom)
82 }
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct InvalidSessionLineageId;
87
88impl std::fmt::Display for InvalidSessionLineageId {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 f.write_str("session lineage id must not be empty")
91 }
92}
93
94impl std::error::Error for InvalidSessionLineageId {}
95
96#[derive(
100 Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
101)]
102#[serde(transparent)]
103pub struct SessionGeneration(u64);
104
105impl SessionGeneration {
106 pub const INITIAL: Self = Self(0);
107
108 #[must_use]
109 pub const fn new(value: u64) -> Self {
110 Self(value)
111 }
112
113 #[must_use]
114 pub const fn get(self) -> u64 {
115 self.0
116 }
117}
118
119mod digest_accumulator;
120mod head_metadata;
121mod import_0810;
122mod transcript_history;
123
124pub(crate) use digest_accumulator::TranscriptMessages;
125pub use head_metadata::{
126 SessionHeadMetadataCell, SessionHeadMetadataCellIdentity, SessionHeadMetadataCellMutation,
127 SessionHeadMetadataDigest, SessionHeadMetadataIdentity, SessionHeadMetadataProjection,
128 SessionHeadMetadataValueDigest,
129};
130pub(crate) use import_0810::is_released_checkpoint_metadata_key;
131pub use import_0810::{
132 ImportedReleased0810Session, Released0810ImportError, Released0810ImportEvidence,
133 Released0810ImportReceipt, import_released_0810_session,
134 released_0810_transcript_serialized_rows_digest,
135};
136#[cfg(test)]
137pub(crate) use transcript_history::graph::TRANSCRIPT_DIGEST_FORMAT_RELEASED_0810;
138pub(crate) use transcript_history::graph::import_released_0810_history;
139pub(crate) use transcript_history::validate::validate_transcript_history_state;
140use transcript_history::validate::{
141 assistant_tool_use_ids, message_role_name, validate_transcript_tool_result_shape,
142};
143pub use transcript_history::{
144 ProvenReleased0810RewriteRemap, TRANSCRIPT_HISTORY_FORMAT_CURRENT, TranscriptEndpointWitness,
145 TranscriptGraphPrefixAccumulator, TranscriptHistoryState, TranscriptParentAdvance,
146 TranscriptRevisionBody, TranscriptRevisionEdge, TranscriptRewriteAuditReceiptBatch,
147 TranscriptRewriteCommit, TranscriptRewriteParentTransition, TranscriptRewritePatch,
148 TranscriptRewritePrefixAccumulator, TranscriptRewriteRecord, ValidatedTranscriptHistory,
149 ValidatedTranscriptRewriteSuffix, extend_transcript_rewrite_prefix_accumulator,
150 remap_proven_released_0810_rewrite_record, transcript_history_full_body_materializations,
151 transcript_rewrite_prefix_digest,
152};
153
154pub use crate::generated::session_persistence_version_authority::SESSION_VERSION;
162
163pub use crate::generated::session_persistence_version_authority::SESSION_METADATA_SCHEMA_VERSION;
168
169pub fn session_version() -> u32 {
171 session_persistence_version_authority::session_envelope_version()
172}
173
174pub fn session_metadata_schema_version() -> u32 {
176 session_persistence_version_authority::session_metadata_schema_version()
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
184#[serde(tag = "type", rename_all = "snake_case")]
185pub enum TranscriptReplacement {
186 Message { message: Message },
188 UserContentBlock {
190 block_index: usize,
191 block: ContentBlock,
192 },
193 AssistantBlock {
195 block_index: usize,
196 block: AssistantBlock,
197 },
198 ToolResultContentBlock {
200 result_index: usize,
201 block_index: usize,
202 block: ContentBlock,
203 },
204}
205
206pub const SESSION_TRANSCRIPT_HISTORY_STATE_KEY: &str = "session_transcript_history_state_v1";
208
209pub const SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY: &str =
216 "session_transcript_rewrite_prefix_authority_v1";
217
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
220#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
221#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
222pub enum TranscriptRewriteSelection {
223 MessageRange { start: usize, end: usize },
227 EditMessageRange { range: TranscriptEditRewriteRange },
229 CompactionMessageRange { range: CompactionRewriteRange },
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
239#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
240#[serde(deny_unknown_fields)]
241pub struct TranscriptEditRewriteRange {
242 start: usize,
243 end: usize,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
248#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
249#[serde(deny_unknown_fields)]
250pub struct CompactionRewriteRange {
251 start: usize,
252 end: usize,
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum TranscriptRewriteSemantic {
258 Edit,
260 Compaction,
262}
263
264impl TranscriptRewriteSelection {
265 pub fn bounds(&self) -> (usize, usize) {
268 match self {
269 Self::MessageRange { start, end } => (*start, *end),
270 Self::EditMessageRange { range } => (range.start, range.end),
271 Self::CompactionMessageRange { range } => (range.start, range.end),
272 }
273 }
274
275 pub fn semantic(&self) -> TranscriptRewriteSemantic {
276 match self {
277 Self::MessageRange { .. } | Self::EditMessageRange { .. } => {
278 TranscriptRewriteSemantic::Edit
279 }
280 Self::CompactionMessageRange { .. } => TranscriptRewriteSemantic::Compaction,
281 }
282 }
283
284 fn into_current_edit_semantic(self) -> Self {
285 match self {
286 Self::MessageRange { start, end } => Self::EditMessageRange {
287 range: TranscriptEditRewriteRange { start, end },
288 },
289 current => current,
290 }
291 }
292
293 fn is_legacy_untyped(&self) -> bool {
294 matches!(self, Self::MessageRange { .. })
295 }
296
297 fn validated_compaction(
298 start: usize,
299 end: usize,
300 _authority: &crate::agent::compact::ValidatedCompactionRewrite,
301 ) -> Self {
302 Self::CompactionMessageRange {
303 range: CompactionRewriteRange { start, end },
304 }
305 }
306
307 fn migrated_legacy_compaction(start: usize, end: usize) -> Self {
308 Self::CompactionMessageRange {
309 range: CompactionRewriteRange { start, end },
310 }
311 }
312
313 #[cfg(test)]
314 pub(crate) fn typed_compaction_for_test(start: usize, end: usize) -> Self {
315 Self::CompactionMessageRange {
316 range: CompactionRewriteRange { start, end },
317 }
318 }
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
327#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
328#[serde(rename_all = "snake_case", deny_unknown_fields)]
329pub struct TranscriptRewriteReason {
330 pub kind: String,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub note: Option<String>,
333}
334
335impl TranscriptRewriteReason {
336 pub fn new(kind: impl Into<String>) -> Self {
337 Self {
338 kind: kind.into(),
339 note: None,
340 }
341 }
342}
343
344impl std::fmt::Display for TranscriptRewriteReason {
345 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349 match &self.note {
350 Some(note) => write!(f, "{}: {note}", self.kind),
351 None => f.write_str(&self.kind),
352 }
353 }
354}
355
356#[derive(Debug, Clone, thiserror::Error)]
358pub enum TranscriptEditError {
359 #[error("message index {message_index} out of bounds for {message_count} messages")]
360 MessageIndexOutOfBounds {
361 message_index: usize,
362 message_count: usize,
363 },
364 #[error("{block_kind} index {block_index} out of bounds for {block_count} blocks")]
365 BlockIndexOutOfBounds {
366 block_kind: &'static str,
367 block_index: usize,
368 block_count: usize,
369 },
370 #[error("replacement expected {expected} at message index {message_index}, found {actual}")]
371 MessageRoleMismatch {
372 message_index: usize,
373 expected: &'static str,
374 actual: &'static str,
375 },
376 #[error("invalid transcript rewrite range {start}..{end} for {message_count} messages")]
377 InvalidRewriteRange {
378 start: usize,
379 end: usize,
380 message_count: usize,
381 },
382 #[error("transcript rewrite does not change transcript revision {revision}")]
383 NoOpRewrite { revision: String },
384 #[error("transcript rewrite parent revision mismatch: expected {expected}, actual {actual}")]
385 RevisionConflict { expected: String, actual: String },
386 #[error("transcript history state is malformed: {0}")]
387 HistoryStateMalformed(String),
388 #[error("invalid transcript shape after rewrite: {0}")]
389 InvalidTranscriptShape(String),
390}
391
392fn canonicalize_digest_image_blocks(blocks: &mut [crate::types::ContentBlock]) {
393 for block in blocks.iter_mut() {
394 if let crate::types::ContentBlock::Image {
395 media_type,
396 data: crate::types::ImageData::Inline { data },
397 } = block
398 {
399 let blob_id = crate::blob::content_blob_id(media_type, data);
402 *block = crate::types::ContentBlock::Image {
403 media_type: media_type.clone(),
404 data: crate::types::ImageData::Blob { blob_id },
405 };
406 }
407 }
408}
409
410fn canonicalize_raw_json_for_digest(
418 raw: &serde_json::value::RawValue,
419) -> Box<serde_json::value::RawValue> {
420 crate::types::canonicalize_raw_json(raw).unwrap_or_else(|_| raw.to_owned())
424}
425
426fn canonicalize_digest_structured_blocks(blocks: &mut [crate::types::ContentBlock]) {
427 for block in blocks {
428 if let crate::types::ContentBlock::Structured { data } = block {
429 *data = canonicalize_raw_json_for_digest(data);
430 }
431 }
432}
433
434fn canonicalize_message_images_for_digest(messages: &[Message]) -> Vec<Message> {
443 let mut canonical = messages.to_vec();
444 for message in &mut canonical {
445 canonicalize_message_images_for_digest_in_place(message);
446 }
447 canonical
448}
449
450fn canonicalize_message_images_for_digest_in_place(message: &mut Message) {
451 match message {
452 Message::User(user) => canonicalize_digest_image_blocks(&mut user.content),
453 Message::ToolResults { results, .. } => {
454 for result in results.iter_mut() {
455 canonicalize_digest_image_blocks(&mut result.content);
456 }
457 }
458 Message::SystemNotice(notice) => {
459 for block in &mut notice.blocks {
460 match block {
461 crate::types::SystemNoticeBlock::Comms { content, .. }
462 | crate::types::SystemNoticeBlock::ExternalEvent { content, .. } => {
463 canonicalize_digest_image_blocks(content);
464 }
465 _ => {}
466 }
467 }
468 }
469 _ => {}
470 }
471}
472
473fn canonicalize_message_raw_json_for_digest_in_place(message: &mut Message) {
474 match message {
475 Message::User(user) => canonicalize_digest_structured_blocks(&mut user.content),
476 Message::ToolResults { results, .. } => {
477 for result in results.iter_mut() {
478 canonicalize_digest_structured_blocks(&mut result.content);
479 }
480 }
481 Message::SystemNotice(notice) => {
482 for block in &mut notice.blocks {
483 match block {
484 crate::types::SystemNoticeBlock::Comms { content, .. }
485 | crate::types::SystemNoticeBlock::ExternalEvent { content, .. } => {
486 canonicalize_digest_structured_blocks(content);
487 }
488 _ => {}
489 }
490 }
491 }
492 Message::BlockAssistant(assistant) => {
493 for block in &mut assistant.blocks {
494 if let crate::types::AssistantBlock::ToolUse { args, .. } = block {
495 *args = canonicalize_raw_json_for_digest(args);
496 }
497 }
498 }
499 _ => {}
500 }
501}
502
503pub fn validate_current_persisted_transcript_history_slice(
508 bytes: &[u8],
509) -> Result<u64, serde_json::Error> {
510 let rewrite_count =
511 transcript_history::graph::validate_current_transcript_history_slice(bytes)?;
512 u64::try_from(rewrite_count).map_err(|_| {
513 persisted_session_decode_error("persisted transcript-history occurrence count exceeds u64")
514 })
515}
516
517#[derive(Debug, Default)]
525pub(crate) struct SharedTranscriptHistoryState {
526 inner: std::sync::Mutex<Option<std::sync::Arc<TranscriptHistoryState>>>,
527}
528
529impl Clone for SharedTranscriptHistoryState {
530 fn clone(&self) -> Self {
531 Self {
532 inner: std::sync::Mutex::new(self.locked().clone()),
533 }
534 }
535}
536
537impl SharedTranscriptHistoryState {
538 fn locked(&self) -> std::sync::MutexGuard<'_, Option<std::sync::Arc<TranscriptHistoryState>>> {
539 self.inner
540 .lock()
541 .unwrap_or_else(std::sync::PoisonError::into_inner)
542 }
543
544 fn clear(&self) {
545 *self.locked() = None;
546 }
547
548 fn set(&self, state: std::sync::Arc<TranscriptHistoryState>) {
549 *self.locked() = Some(state);
550 }
551
552 fn get(&self) -> Option<std::sync::Arc<TranscriptHistoryState>> {
553 self.locked().clone()
554 }
555}
556
557fn digest_timestamp_sentinel() -> crate::types::MessageTimestamp {
561 chrono::DateTime::<chrono::Utc>::UNIX_EPOCH
562}
563
564pub(crate) fn canonicalize_messages_for_digest(messages: &[Message]) -> Vec<Message> {
584 let mut canonical = canonicalize_message_images_for_digest(messages);
585 for message in &mut canonical {
586 canonicalize_message_raw_json_for_digest_in_place(message);
587 erase_message_construction_bookkeeping(message);
588 }
589 canonical
590}
591
592pub(crate) fn canonicalize_released_0810_messages_for_digest(messages: &[Message]) -> Vec<Message> {
597 let mut canonical = canonicalize_message_images_for_digest(messages);
598 for message in &mut canonical {
599 erase_message_construction_bookkeeping(message);
600 }
601 canonical
602}
603
604fn erase_message_construction_bookkeeping(message: &mut Message) {
605 match message {
606 Message::System(system) => {
607 system.created_at = digest_timestamp_sentinel();
608 }
609 Message::SystemNotice(notice) => {
610 notice.created_at = digest_timestamp_sentinel();
611 }
612 Message::User(user) => {
613 user.identity = crate::types::TranscriptMessageIdentity::default();
614 user.created_at = digest_timestamp_sentinel();
615 }
616 Message::BlockAssistant(assistant) => {
617 assistant.identity = crate::types::TranscriptMessageIdentity::default();
618 assistant.created_at = digest_timestamp_sentinel();
619 }
620 Message::ToolResults { created_at, .. } => {
621 *created_at = digest_timestamp_sentinel();
622 }
623 }
624}
625
626pub(crate) fn canonicalize_message_for_digest(message: &Message) -> Message {
635 let mut canonical = message.clone();
636 canonicalize_message_images_for_digest_in_place(&mut canonical);
637 canonicalize_message_raw_json_for_digest_in_place(&mut canonical);
638 erase_message_construction_bookkeeping(&mut canonical);
639 canonical
640}
641
642pub fn transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
643 sha256_json_digest(&canonicalize_messages_for_digest(messages))
644}
645
646pub(crate) fn transcript_messages_digest_uncounted(
653 messages: &[Message],
654) -> Result<String, serde_json::Error> {
655 let canonical = canonicalize_messages_for_digest(messages);
656 let bytes = serde_json::to_vec(&canonical)?;
657 Ok(format!("sha256:{:x}", Sha256::digest(bytes)))
658}
659
660fn sha256_json_digest<T: Serialize + ?Sized>(value: &T) -> Result<String, serde_json::Error> {
661 crate::digest_observability::record_content_digest_computation();
662 let bytes = serde_json::to_vec(value)?;
663 crate::digest_observability::record_content_digest_bytes(bytes.len() as u64);
664 let digest = Sha256::digest(bytes);
665 let mut out = String::with_capacity(digest.len() * 2);
666 const HEX: &[u8; 16] = b"0123456789abcdef";
667 for byte in digest {
668 out.push(HEX[(byte >> 4) as usize] as char);
669 out.push(HEX[(byte & 0x0f) as usize] as char);
670 }
671 Ok(format!("sha256:{out}"))
672}
673
674#[derive(Debug, Default, Clone)]
689pub(crate) struct SessionHistoryCaches {
690 shared_state: SharedTranscriptHistoryState,
692 head_canonical_metadata: head_metadata::SessionHeadMetadataTracker,
701}
702
703fn head_canonical_metadata_cell_carries_key(key: &str) -> bool {
704 !import_0810::is_released_checkpoint_metadata_key(key)
705 && !matches!(
706 key,
707 SESSION_TRANSCRIPT_HISTORY_STATE_KEY
708 | SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY
709 | SESSION_REALTIME_TRANSCRIPT_STATE_KEY
710 )
711}
712
713#[cfg(test)]
714std::thread_local! {
715 static SESSION_HEAD_METADATA_CANONICALIZATION_COUNT: std::cell::Cell<u64> =
722 const { std::cell::Cell::new(0) };
723}
724
725#[cfg(test)]
726pub(crate) fn reset_session_head_metadata_canonicalization_count() {
727 SESSION_HEAD_METADATA_CANONICALIZATION_COUNT.set(0);
728}
729
730#[cfg(test)]
731pub(crate) fn session_head_metadata_canonicalization_count() -> u64 {
732 SESSION_HEAD_METADATA_CANONICALIZATION_COUNT.get()
733}
734
735#[cfg(test)]
736pub(crate) fn record_session_head_metadata_canonicalization() {
737 SESSION_HEAD_METADATA_CANONICALIZATION_COUNT.set(
738 SESSION_HEAD_METADATA_CANONICALIZATION_COUNT
739 .get()
740 .saturating_add(1),
741 );
742}
743
744#[derive(Debug, Clone)]
745pub struct Session {
746 version: u32,
749 id: SessionId,
751 pub(crate) messages: TranscriptMessages,
760 created_at: SystemTime,
762 updated_at: SystemTime,
764 metadata: serde_json::Map<String, serde_json::Value>,
766 realtime_transcript: Box<SessionRealtimeTranscriptProjection>,
775 history_caches: Box<SessionHistoryCaches>,
782 transcript_history_metadata_validation: TranscriptHistoryMetadataValidation,
790 usage: Usage,
792}
793
794#[derive(Debug, Clone, Copy, PartialEq, Eq)]
795enum TranscriptHistoryMetadataValidation {
796 Validated,
797 RequiresValidation,
798}
799
800#[derive(Deserialize)]
802#[serde(rename_all = "snake_case", deny_unknown_fields)]
803struct SessionSerde {
804 version: u32,
805 id: SessionId,
806 messages: Vec<Message>,
807 created_at: SystemTime,
808 updated_at: SystemTime,
809 #[serde(default)]
810 metadata: serde_json::Map<String, serde_json::Value>,
811 #[serde(default)]
812 usage: Usage,
813}
814
815#[derive(Serialize)]
819#[serde(rename_all = "snake_case")]
820struct SessionSerdeRef<'a> {
821 version: u32,
822 id: &'a SessionId,
823 messages: &'a [Message],
824 created_at: &'a SystemTime,
825 updated_at: &'a SystemTime,
826 metadata: &'a serde_json::Map<String, serde_json::Value>,
827 usage: &'a Usage,
828}
829
830struct SessionWholeBlobMetadataRef<'a> {
838 base: &'a serde_json::Map<String, serde_json::Value>,
839 history: Option<&'a TranscriptHistoryState>,
840 realtime: Option<&'a SessionRealtimeTranscriptState>,
841}
842
843impl Serialize for SessionWholeBlobMetadataRef<'_> {
844 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
845 where
846 S: Serializer,
847 {
848 use serde::ser::SerializeMap;
849
850 let mut map = serializer.serialize_map(None)?;
851 for (key, value) in self.base {
852 if key == SESSION_REALTIME_TRANSCRIPT_STATE_KEY
853 || (self.history.is_some()
854 && (key == SESSION_TRANSCRIPT_HISTORY_STATE_KEY
855 || key == SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY))
856 {
857 continue;
858 }
859 map.serialize_entry(key, value)?;
860 }
861 if let Some(history) = self.history {
862 map.serialize_entry(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, history)?;
863 map.serialize_entry(
864 SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY,
865 history.rewrite_prefix(),
866 )?;
867 }
868 if let Some(realtime) = self.realtime {
869 map.serialize_entry(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, realtime)?;
870 }
871 map.end()
872 }
873}
874
875#[derive(Serialize)]
876#[serde(rename_all = "snake_case")]
877struct SessionWholeBlobSerdeRef<'a> {
878 version: u32,
879 id: &'a SessionId,
880 messages: &'a [Message],
881 created_at: &'a SystemTime,
882 updated_at: &'a SystemTime,
883 metadata: SessionWholeBlobMetadataRef<'a>,
884 usage: &'a Usage,
885}
886
887fn persisted_envelope_ref<'a>(
895 session: &'a Session,
896 metadata_override: Option<&'a serde_json::Map<String, serde_json::Value>>,
897) -> SessionSerdeRef<'a> {
898 SessionSerdeRef {
899 version: session.version,
900 id: &session.id,
901 messages: session.messages(),
902 created_at: &session.created_at,
903 updated_at: &session.updated_at,
904 metadata: metadata_override.unwrap_or(&session.metadata),
905 usage: &session.usage,
906 }
907}
908
909impl Serialize for Session {
910 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
911 where
912 S: Serializer,
913 {
914 let _digest_site = crate::digest_observability::enter_digest_site(
915 crate::digest_observability::DIGEST_SITE_ENCODE,
916 );
917 if import_0810::contains_released_checkpoint_metadata(&self.metadata) {
918 return Err(<S::Error as serde::ser::Error>::custom(
919 "released checkpoint metadata cannot be serialized by the current Session domain",
920 ));
921 }
922 if self.transcript_history_metadata_validation
923 == TranscriptHistoryMetadataValidation::RequiresValidation
924 && (self
925 .metadata
926 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
927 || self.history_caches.shared_state.get().is_some())
928 {
929 return Err(<S::Error as serde::ser::Error>::custom(
930 "transcript-history graph lacks verified materialization or construction authority",
931 ));
932 }
933 let history = self.history_caches.shared_state.get();
934 let serde_repr = SessionWholeBlobSerdeRef {
935 version: self.version,
936 id: &self.id,
937 messages: self.messages(),
938 created_at: &self.created_at,
939 updated_at: &self.updated_at,
940 metadata: SessionWholeBlobMetadataRef {
941 base: &self.metadata,
942 history: history.as_deref(),
943 realtime: self.realtime_transcript.whole_blob_projection(),
944 },
945 usage: &self.usage,
946 };
947 serde_repr.serialize(serializer)
948 }
949}
950
951#[derive(Debug, Clone, Copy, PartialEq, Eq)]
952enum TranscriptHistoryWireKind {
953 Released0810,
954 Current,
955}
956
957fn transcript_history_wire_kind(
958 metadata: &serde_json::Map<String, serde_json::Value>,
959) -> Result<Option<TranscriptHistoryWireKind>, String> {
960 let Some(value) = metadata.get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY) else {
961 return Ok(None);
962 };
963 let object = value
964 .as_object()
965 .ok_or_else(|| "transcript-history graph must be an object".to_string())?;
966 match object.get("format") {
967 None => Ok(Some(TranscriptHistoryWireKind::Released0810)),
968 Some(serde_json::Value::String(format)) if format == TRANSCRIPT_HISTORY_FORMAT_CURRENT => {
969 Ok(Some(TranscriptHistoryWireKind::Current))
970 }
971 Some(serde_json::Value::String(format)) => {
972 Err(format!("unsupported transcript graph format {format}"))
973 }
974 Some(_) => Err("transcript graph format must be a string".to_string()),
975 }
976}
977
978fn compact_transcript_history_metadata_for_snapshot(
986 metadata: &mut serde_json::Map<String, serde_json::Value>,
987) -> Result<Option<std::sync::Arc<TranscriptHistoryState>>, String> {
988 let Some(value) = metadata.remove(SESSION_TRANSCRIPT_HISTORY_STATE_KEY) else {
989 return Ok(None);
990 };
991 let state: TranscriptHistoryState =
992 serde_json::from_value(value).map_err(|error| error.to_string())?;
993 metadata.remove(SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY);
999 Ok(Some(std::sync::Arc::new(state)))
1000}
1001
1002impl ValidatedTranscriptHistory {
1003 #[doc(hidden)]
1010 pub fn from_store_replayed_compact_graph(
1011 anchor_revision: String,
1012 anchor_messages: Vec<Message>,
1013 anchor_row_prefix: crate::session_store::SessionMessageRowPrefixAccumulator,
1014 edges: Vec<TranscriptRevisionEdge>,
1015 expected_rewrite_prefix: &TranscriptRewritePrefixAccumulator,
1016 expected_graph_prefix: &TranscriptGraphPrefixAccumulator,
1017 ) -> Result<Self, TranscriptEditError> {
1018 let state = TranscriptHistoryState::from_store_replayed_compact_graph(
1019 anchor_revision,
1020 anchor_messages,
1021 anchor_row_prefix,
1022 edges,
1023 expected_rewrite_prefix,
1024 expected_graph_prefix,
1025 )?;
1026 Ok(Self::adopt_session_validated(std::sync::Arc::new(state)))
1027 }
1028
1029 pub fn from_rewrite_records_with_proved<I>(
1040 records: I,
1041 proved: Option<&ValidatedTranscriptHistory>,
1042 ) -> Result<Option<Self>, TranscriptEditError>
1043 where
1044 I: IntoIterator<Item = TranscriptRewriteRecord>,
1045 {
1046 Ok(
1047 TranscriptHistoryState::from_rewrite_records_with_proved(records, proved)?.map(
1048 |state| {
1049 ValidatedTranscriptHistory::adopt_session_validated(std::sync::Arc::new(state))
1050 },
1051 ),
1052 )
1053 }
1054}
1055
1056impl<'de> Deserialize<'de> for Session {
1057 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1058 where
1059 D: Deserializer<'de>,
1060 {
1061 let _digest_site = crate::digest_observability::enter_digest_site(
1062 crate::digest_observability::DIGEST_SITE_DECODE,
1063 );
1064 let serde_repr = SessionSerde::deserialize(deserializer)?;
1065 let version = session_persistence_version_authority::restore_session_envelope_version(
1066 serde_repr.version,
1067 )
1068 .map_err(<D::Error as serde::de::Error>::custom)?;
1069 let mut metadata = serde_repr.metadata;
1070 if import_0810::contains_released_checkpoint_metadata(&metadata) {
1071 return Err(<D::Error as serde::de::Error>::custom(
1072 "embedded released checkpoint metadata requires the explicit one-time 0.8.10 importer",
1073 ));
1074 }
1075 let realtime_transcript = match metadata.remove(SESSION_REALTIME_TRANSCRIPT_STATE_KEY) {
1076 Some(value) => {
1077 let state = serde_json::from_value(value)
1078 .map_err(<D::Error as serde::de::Error>::custom)?;
1079 SessionRealtimeTranscriptProjection::from_inline_snapshot(&serde_repr.id, state)
1080 .map_err(<D::Error as serde::de::Error>::custom)?
1081 }
1082 None => SessionRealtimeTranscriptProjection::empty(&serde_repr.id),
1083 };
1084 let history_wire_kind = transcript_history_wire_kind(&metadata)
1085 .map_err(<D::Error as serde::de::Error>::custom)?;
1086 if matches!(
1087 history_wire_kind,
1088 Some(TranscriptHistoryWireKind::Released0810)
1089 ) {
1090 return Err(<D::Error as serde::de::Error>::custom(
1091 "released 0.8.10 transcript history requires the explicit one-time importer",
1092 ));
1093 }
1094 let history_caches = Box::<SessionHistoryCaches>::default();
1095 let mut session = Session {
1096 version,
1097 id: serde_repr.id,
1098 messages: TranscriptMessages::from_vec(serde_repr.messages),
1099 created_at: serde_repr.created_at,
1100 updated_at: serde_repr.updated_at,
1101 metadata,
1102 realtime_transcript: Box::new(realtime_transcript),
1103 history_caches,
1104 transcript_history_metadata_validation: if history_wire_kind.is_some() {
1105 TranscriptHistoryMetadataValidation::RequiresValidation
1106 } else {
1107 TranscriptHistoryMetadataValidation::Validated
1108 },
1109 usage: serde_repr.usage,
1110 };
1111 if let Some(TranscriptHistoryWireKind::Current) = history_wire_kind {
1112 let state = compact_transcript_history_metadata_for_snapshot(&mut session.metadata)
1113 .map_err(<D::Error as serde::de::Error>::custom)?
1114 .ok_or_else(|| {
1115 <D::Error as serde::de::Error>::custom(
1116 "transcript-history graph disappeared during ingress",
1117 )
1118 })?;
1119 let exact_live_prefix = state
1120 .derive_live_row_lineage_after_final_semantic_replay(session.messages())
1121 .map_err(<D::Error as serde::de::Error>::custom)?
1122 .ok_or_else(|| {
1123 <D::Error as serde::de::Error>::custom(
1124 "live transcript does not preserve the graph-proved audited endpoint",
1125 )
1126 })?;
1127 let endpoint_prefix = state
1128 .final_endpoint_witness()
1129 .ok_or_else(|| {
1130 <D::Error as serde::de::Error>::custom(
1131 "compact transcript graph has no final endpoint witness",
1132 )
1133 })?
1134 .row_prefix()
1135 .clone();
1136 if !session.install_exact_message_row_lineage(endpoint_prefix, exact_live_prefix) {
1137 return Err(<D::Error as serde::de::Error>::custom(
1138 "failed to install exact live message-row authority",
1139 ));
1140 }
1141 session.transcript_history_metadata_validation =
1142 TranscriptHistoryMetadataValidation::Validated;
1143 session
1144 .history_caches
1145 .shared_state
1146 .set(std::sync::Arc::clone(&state));
1147 }
1148 Ok(session)
1149 }
1150}
1151
1152#[derive(Deserialize)]
1161#[serde(rename_all = "snake_case")]
1162struct SessionMetadataDocumentSerde {
1163 version: u32,
1164 id: SessionId,
1165 #[serde(default)]
1166 metadata: serde_json::Map<String, serde_json::Value>,
1167}
1168
1169#[derive(Debug, Clone)]
1178pub struct SessionMetadataDocument {
1179 session_id: SessionId,
1180 metadata: serde_json::Map<String, serde_json::Value>,
1181}
1182
1183impl SessionMetadataDocument {
1184 pub fn session_id(&self) -> &SessionId {
1186 &self.session_id
1187 }
1188
1189 pub fn session_metadata_value(&self) -> Option<&serde_json::Value> {
1192 self.metadata.get(SESSION_METADATA_KEY)
1193 }
1194
1195 pub fn lifecycle_terminal_value(&self) -> Option<&serde_json::Value> {
1198 self.metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY)
1199 }
1200
1201 pub fn try_into_view(self) -> Result<PersistedSessionMetadataView, serde_json::Error> {
1204 PersistedSessionMetadataView::try_from_metadata_map(self.session_id, &self.metadata)
1205 }
1206}
1207
1208pub fn session_metadata_document_from_slice(
1215 bytes: &[u8],
1216) -> Result<SessionMetadataDocument, serde_json::Error> {
1217 let serde_repr: SessionMetadataDocumentSerde = serde_json::from_slice(bytes)?;
1218 session_persistence_version_authority::restore_session_envelope_version(serde_repr.version)
1219 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1220 if import_0810::contains_released_checkpoint_metadata(&serde_repr.metadata) {
1221 return Err(<serde_json::Error as serde::de::Error>::custom(
1222 "released 0.8.10 proof metadata requires the explicit one-time importer",
1223 ));
1224 }
1225 let history_wire_kind = transcript_history_wire_kind(&serde_repr.metadata)
1226 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1227 if matches!(
1228 history_wire_kind,
1229 Some(TranscriptHistoryWireKind::Released0810)
1230 ) {
1231 return Err(<serde_json::Error as serde::de::Error>::custom(
1232 "released 0.8.10 transcript history requires the explicit one-time importer",
1233 ));
1234 }
1235 Ok(SessionMetadataDocument {
1236 session_id: serde_repr.id,
1237 metadata: serde_repr.metadata,
1238 })
1239}
1240
1241#[derive(Debug, Clone)]
1248pub struct SerializedSessionArtifact {
1249 bytes: Arc<Vec<u8>>,
1254 raw_sha256: [u8; 32],
1255 row_sha256_token: Arc<str>,
1256}
1257
1258#[derive(Debug)]
1267pub struct DecodedWholeBlobSessionDocument {
1268 session: Session,
1269 row_sha256_token: String,
1270}
1271
1272impl DecodedWholeBlobSessionDocument {
1273 #[must_use]
1274 pub fn session(&self) -> &Session {
1275 &self.session
1276 }
1277
1278 #[must_use]
1279 pub fn row_sha256_token(&self) -> &str {
1280 &self.row_sha256_token
1281 }
1282
1283 #[must_use]
1284 pub fn into_session(self) -> Session {
1285 self.session
1286 }
1287}
1288
1289impl SerializedSessionArtifact {
1290 fn from_parts(bytes: Vec<u8>, raw_sha256: [u8; 32]) -> Self {
1291 Self {
1292 bytes: Arc::new(bytes),
1293 raw_sha256,
1294 row_sha256_token: Arc::from(row_sha256_token(raw_sha256)),
1295 }
1296 }
1297
1298 pub(crate) fn from_raw_bytes(bytes: Vec<u8>) -> Self {
1299 let raw_sha256 = sha256_key(&bytes);
1300 Self::from_parts(bytes, raw_sha256)
1301 }
1302
1303 #[must_use]
1304 pub fn bytes(&self) -> &[u8] {
1305 self.bytes.as_ref()
1306 }
1307
1308 #[must_use]
1309 pub fn bytes_arc(&self) -> Arc<Vec<u8>> {
1310 Arc::clone(&self.bytes)
1311 }
1312
1313 #[must_use]
1314 pub fn into_bytes(self) -> Vec<u8> {
1315 Arc::try_unwrap(self.bytes).unwrap_or_else(|shared| shared.as_ref().clone())
1316 }
1317
1318 #[must_use]
1319 pub const fn raw_sha256(&self) -> &[u8; 32] {
1320 &self.raw_sha256
1321 }
1322
1323 #[must_use]
1324 pub fn row_sha256_token(&self) -> &str {
1325 &self.row_sha256_token
1326 }
1327}
1328
1329struct SessionArtifactWriter {
1330 bytes: Vec<u8>,
1331 hasher: Sha256,
1332}
1333
1334impl SessionArtifactWriter {
1335 fn new() -> Self {
1336 Self {
1337 bytes: Vec::new(),
1338 hasher: Sha256::new(),
1339 }
1340 }
1341
1342 fn finish(self) -> SerializedSessionArtifact {
1343 crate::digest_observability::record_session_encode_bytes(self.bytes.len() as u64);
1344 let digest = self.hasher.finalize();
1345 let mut raw_sha256 = [0u8; 32];
1346 raw_sha256.copy_from_slice(&digest);
1347 SerializedSessionArtifact::from_parts(self.bytes, raw_sha256)
1348 }
1349}
1350
1351impl std::io::Write for SessionArtifactWriter {
1352 fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
1353 self.bytes.extend_from_slice(buffer);
1354 self.hasher.update(buffer);
1355 Ok(buffer.len())
1356 }
1357
1358 fn flush(&mut self) -> std::io::Result<()> {
1359 Ok(())
1360 }
1361}
1362
1363fn sha256_key(bytes: &[u8]) -> [u8; 32] {
1364 let digest = Sha256::digest(bytes);
1365 let mut key = [0u8; 32];
1366 key.copy_from_slice(&digest);
1367 key
1368}
1369
1370fn row_sha256_token(raw_sha256: [u8; 32]) -> String {
1371 use std::fmt::Write as _;
1372
1373 let mut token = String::with_capacity("row-sha256:".len() + 64);
1374 token.push_str("row-sha256:");
1375 for byte in raw_sha256 {
1376 let _ = write!(token, "{byte:02x}");
1377 }
1378 token
1379}
1380
1381fn persisted_session_decode_error(message: impl Into<String>) -> serde_json::Error {
1382 serde_json::Error::io(std::io::Error::new(
1383 std::io::ErrorKind::InvalidData,
1384 message.into(),
1385 ))
1386}
1387
1388impl Session {
1389 pub(crate) fn normalize_persisted_transcript_history_ingress(
1395 &mut self,
1396 ) -> Result<(), TranscriptEditError> {
1397 let history_wire_kind = transcript_history_wire_kind(&self.metadata)
1398 .map_err(TranscriptEditError::HistoryStateMalformed)?;
1399 let Some(history_wire_kind) = history_wire_kind else {
1400 return Ok(());
1401 };
1402 if matches!(history_wire_kind, TranscriptHistoryWireKind::Released0810) {
1403 return Err(TranscriptEditError::HistoryStateMalformed(
1404 "released 0.8.10 transcript history requires the explicit one-time importer"
1405 .to_string(),
1406 ));
1407 }
1408 let state = compact_transcript_history_metadata_for_snapshot(&mut self.metadata)
1409 .map_err(TranscriptEditError::HistoryStateMalformed)?
1410 .ok_or_else(|| {
1411 TranscriptEditError::HistoryStateMalformed(
1412 "transcript-history graph disappeared during ingress".to_string(),
1413 )
1414 })?;
1415 let exact_live_prefix = state
1416 .derive_live_row_lineage_after_final_semantic_replay(self.messages())
1417 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?
1418 .ok_or_else(|| {
1419 TranscriptEditError::HistoryStateMalformed(
1420 "live transcript does not preserve the graph-proved audited endpoint"
1421 .to_string(),
1422 )
1423 })?;
1424 let endpoint_prefix = state
1425 .final_endpoint_witness()
1426 .ok_or_else(|| {
1427 TranscriptEditError::HistoryStateMalformed(
1428 "compact transcript graph has no final endpoint witness".to_string(),
1429 )
1430 })?
1431 .row_prefix()
1432 .clone();
1433 if !self.install_exact_message_row_lineage(endpoint_prefix, exact_live_prefix) {
1434 return Err(TranscriptEditError::HistoryStateMalformed(
1435 "failed to install exact live message-row authority".to_string(),
1436 ));
1437 }
1438 self.transcript_history_metadata_validation =
1439 TranscriptHistoryMetadataValidation::Validated;
1440 self.history_caches
1441 .shared_state
1442 .set(std::sync::Arc::clone(&state));
1443 Ok(())
1444 }
1445
1446 #[allow(clippy::too_many_arguments)]
1454 pub(crate) fn from_head_parts(
1455 version: u32,
1456 id: SessionId,
1457 messages: Vec<Message>,
1458 exact_row_prefix: Option<crate::SessionMessageRowPrefixAccumulator>,
1459 created_at: SystemTime,
1460 updated_at: SystemTime,
1461 metadata: serde_json::Map<String, serde_json::Value>,
1462 usage: Usage,
1463 head_canonical_metadata: Option<Arc<SessionHeadMetadataProjection>>,
1464 ) -> Result<Self, String> {
1465 let version =
1466 session_persistence_version_authority::restore_session_envelope_version(version)
1467 .map_err(|err| err.to_string())?;
1468 if import_0810::contains_released_checkpoint_metadata(&metadata) {
1469 return Err(
1470 "embedded released checkpoint metadata requires the explicit one-time 0.8.10 importer"
1471 .to_string(),
1472 );
1473 }
1474 let transcript = TranscriptMessages::from_vec(messages);
1475 if let Some(prefix) = exact_row_prefix
1476 && !transcript.install_exact_row_prefix(prefix)
1477 {
1478 return Err(
1479 "exact message-row prefix count differs from materialized messages".to_string(),
1480 );
1481 }
1482 let realtime_transcript = Box::new(SessionRealtimeTranscriptProjection::empty(&id));
1483 let history_caches = Box::<SessionHistoryCaches>::default();
1484 let mut session = Self {
1485 version,
1486 id,
1487 messages: transcript,
1488 created_at,
1489 updated_at,
1490 transcript_history_metadata_validation: if metadata
1491 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
1492 {
1493 TranscriptHistoryMetadataValidation::RequiresValidation
1494 } else {
1495 TranscriptHistoryMetadataValidation::Validated
1496 },
1497 metadata,
1498 realtime_transcript,
1499 history_caches,
1500 usage,
1501 };
1502 if let Some(projection) = head_canonical_metadata {
1503 session
1504 .install_head_canonical_metadata_projection(&projection)
1505 .map_err(|error| {
1506 format!(
1507 "failed to install HeadCanonical metadata baseline for session {}: {error}",
1508 session.id
1509 )
1510 })?;
1511 }
1512 Ok(session)
1513 }
1514
1515 pub fn to_persisted_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
1522 Ok(self.to_persisted_artifact()?.into_bytes())
1523 }
1524
1525 pub fn to_persisted_artifact(&self) -> Result<SerializedSessionArtifact, serde_json::Error> {
1531 let mut writer = SessionArtifactWriter::new();
1532 serde_json::to_writer(&mut writer, self)?;
1533 Ok(writer.finish())
1534 }
1535
1536 pub fn from_persisted_bytes(serialized: &[u8]) -> Result<Self, serde_json::Error> {
1539 serde_json::from_slice(serialized)
1540 }
1541
1542 #[doc(hidden)]
1550 pub fn decode_whole_blob_document(
1551 serialized: &[u8],
1552 ) -> Result<DecodedWholeBlobSessionDocument, serde_json::Error> {
1553 let session = Self::from_persisted_bytes(serialized)?;
1554 let message_count = u64::try_from(session.messages().len()).map_err(|_| {
1555 <serde_json::Error as serde::de::Error>::custom(
1556 "WholeBlob transcript row count exceeds u64",
1557 )
1558 })?;
1559 if session.exact_message_row_prefix_at(message_count).is_none() {
1560 session.messages.mark_lazy_whole_blob_row_lineage();
1561 }
1562 Ok(DecodedWholeBlobSessionDocument {
1563 session,
1564 row_sha256_token: row_sha256_token(sha256_key(serialized)),
1565 })
1566 }
1567
1568 pub(crate) fn exact_message_row_prefix_at(
1571 &self,
1572 row_count: u64,
1573 ) -> Option<crate::SessionMessageRowPrefixAccumulator> {
1574 self.messages.exact_row_prefix_at(row_count)
1575 }
1576
1577 pub(crate) fn install_exact_message_row_prefix(
1580 &self,
1581 prefix: crate::SessionMessageRowPrefixAccumulator,
1582 ) -> bool {
1583 self.messages.install_exact_row_prefix(prefix)
1584 }
1585
1586 pub(crate) fn install_exact_message_row_lineage(
1587 &self,
1588 anchor: crate::SessionMessageRowPrefixAccumulator,
1589 current: crate::SessionMessageRowPrefixAccumulator,
1590 ) -> bool {
1591 self.messages.install_exact_row_lineage(anchor, current)
1592 }
1593
1594 pub(crate) fn exact_message_row_lineage_extends(
1595 &self,
1596 anchor: &crate::SessionMessageRowPrefixAccumulator,
1597 current_count: u64,
1598 ) -> bool {
1599 self.messages
1600 .exact_row_lineage_extends(anchor, current_count)
1601 }
1602}
1603
1604pub const SESSION_DEFERRED_TURN_STATE_KEY: &str = "session_deferred_turn_state";
1606
1607pub(crate) const SESSION_PENDING_CALLBACK_BATCH_KEY: &str = "session_pending_callback_batch_v1";
1611
1612pub const SESSION_BUILD_STATE_KEY: &str = "session_build_state";
1614
1615pub const SESSION_TOOL_VISIBILITY_STATE_KEY: &str = "session_tool_visibility_state_v1";
1617
1618pub const SESSION_LIFECYCLE_TERMINAL_KEY: &str = "session_lifecycle_terminal";
1620
1621pub const VIEW_IMAGE_TOOL_NAME: &str = "view_image";
1623
1624#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1625#[error("metadata key `{key}` is reserved for session authority")]
1626pub struct ReservedSessionMetadataKey {
1627 key: String,
1628}
1629
1630impl ReservedSessionMetadataKey {
1631 fn new(key: &str) -> Self {
1632 Self {
1633 key: key.to_string(),
1634 }
1635 }
1636}
1637
1638fn is_session_authority_metadata_key(key: &str) -> bool {
1639 key == SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY
1642 || crate::surface_metadata::ReservedMetadataKey::is_session_authority(key)
1643}
1644
1645#[allow(clippy::panic)]
1646fn fail_closed_generated_restore(authority: &'static str, err: serde_json::Error) -> ! {
1647 tracing::error!(
1648 authority,
1649 error = %err,
1650 "generated authority rejected durable restore"
1651 );
1652 panic!("generated {authority} authority rejected durable restore: {err}");
1653}
1654
1655#[derive(Clone)]
1661pub struct TransientTurnContextStateHandle {
1662 boundary: Arc<TransientTurnContextBoundaryCoordinator>,
1663}
1664
1665struct TransientTurnContextBoundaryCoordinator {
1666 incarnation_id: uuid::Uuid,
1667 lifecycle: std::sync::Mutex<TransientTurnContextBoundaryLifecycle>,
1668 notify: tokio::sync::Notify,
1669}
1670
1671struct TransientTurnContextBoundaryLifecycle {
1672 actor_live: bool,
1673 next_generation: u64,
1674 next_request_id: u64,
1675 window: TransientTurnContextBoundaryWindow,
1676}
1677
1678enum TransientTurnContextBoundaryWindow {
1679 Closed,
1680 Open {
1681 run_id: RunId,
1682 generation: u64,
1683 request: Option<RegisteredTransientTurnContextBoundaryRequest>,
1684 },
1685 Parked {
1686 run_id: RunId,
1687 generation: u64,
1688 request_id: u64,
1689 contexts: Vec<TurnRequestContext>,
1690 },
1691 Resolved {
1692 run_id: RunId,
1693 request_id: u64,
1694 contexts: Vec<TurnRequestContext>,
1695 resolution: TransientTurnContextBoundaryResolution,
1696 },
1697}
1698
1699struct RegisteredTransientTurnContextBoundaryRequest {
1700 request_id: u64,
1701 contexts: Vec<TurnRequestContext>,
1702}
1703
1704#[derive(Clone)]
1705enum TransientTurnContextBoundaryResolution {
1706 Committed,
1707 Aborted,
1708}
1709
1710impl Default for TransientTurnContextBoundaryCoordinator {
1711 fn default() -> Self {
1712 Self {
1713 incarnation_id: uuid::Uuid::new_v4(),
1714 lifecycle: std::sync::Mutex::new(TransientTurnContextBoundaryLifecycle {
1715 actor_live: true,
1716 next_generation: 0,
1717 next_request_id: 0,
1718 window: TransientTurnContextBoundaryWindow::Closed,
1719 }),
1720 notify: tokio::sync::Notify::new(),
1721 }
1722 }
1723}
1724
1725impl TransientTurnContextBoundaryCoordinator {
1726 fn lock(&self) -> std::sync::MutexGuard<'_, TransientTurnContextBoundaryLifecycle> {
1727 self.lifecycle.lock().unwrap_or_else(|poisoned| {
1728 tracing::warn!(
1729 "transient turn-context boundary lock poisoned; retaining exact actor authority"
1730 );
1731 poisoned.into_inner()
1732 })
1733 }
1734
1735 fn abort_request(&self, request_id: u64) -> Result<(), CoreBoundaryStageError> {
1736 let mut lifecycle = self.lock();
1737 let parked_owner = match &lifecycle.window {
1738 TransientTurnContextBoundaryWindow::Parked {
1739 run_id,
1740 request_id: current,
1741 contexts,
1742 ..
1743 } if *current == request_id => Some((run_id.clone(), contexts.clone())),
1744 _ => None,
1745 };
1746 if let Some((run_id, contexts)) = parked_owner {
1747 lifecycle.window = TransientTurnContextBoundaryWindow::Resolved {
1748 run_id,
1749 request_id,
1750 contexts,
1751 resolution: TransientTurnContextBoundaryResolution::Aborted,
1752 };
1753 drop(lifecycle);
1754 self.notify.notify_waiters();
1755 return Ok(());
1756 }
1757 match &mut lifecycle.window {
1758 TransientTurnContextBoundaryWindow::Open { request, .. }
1759 if request
1760 .as_ref()
1761 .is_some_and(|request| request.request_id == request_id) =>
1762 {
1763 *request = None;
1764 }
1765 TransientTurnContextBoundaryWindow::Resolved {
1766 request_id: current,
1767 ..
1768 } if *current == request_id => return Ok(()),
1769 _ => {
1770 return Err(CoreBoundaryStageError::stale(format!(
1771 "transient boundary request {request_id} no longer owns its actor window"
1772 )));
1773 }
1774 }
1775 drop(lifecycle);
1776 self.notify.notify_waiters();
1777 Ok(())
1778 }
1779
1780 fn close_run(&self, run_id: &RunId) {
1781 let mut lifecycle = self.lock();
1782 let owns_window = match &lifecycle.window {
1783 TransientTurnContextBoundaryWindow::Open {
1784 run_id: current, ..
1785 }
1786 | TransientTurnContextBoundaryWindow::Parked {
1787 run_id: current, ..
1788 }
1789 | TransientTurnContextBoundaryWindow::Resolved {
1790 run_id: current, ..
1791 } => current == run_id,
1792 TransientTurnContextBoundaryWindow::Closed => false,
1793 };
1794 if owns_window {
1795 lifecycle.window = TransientTurnContextBoundaryWindow::Closed;
1796 drop(lifecycle);
1797 self.notify.notify_waiters();
1798 }
1799 }
1800
1801 fn revoke_actor(&self) {
1802 let mut lifecycle = self.lock();
1803 lifecycle.actor_live = false;
1804 lifecycle.window = TransientTurnContextBoundaryWindow::Closed;
1805 drop(lifecycle);
1806 self.notify.notify_waiters();
1807 }
1808}
1809
1810#[must_use]
1812pub(crate) struct TransientTurnContextBoundaryRunGuard {
1813 boundary: Arc<TransientTurnContextBoundaryCoordinator>,
1814 run_id: RunId,
1815}
1816
1817impl Drop for TransientTurnContextBoundaryRunGuard {
1818 fn drop(&mut self) {
1819 self.boundary.close_run(&self.run_id);
1820 }
1821}
1822
1823struct PendingTransientTurnContextBoundaryPreparation {
1824 boundary: Arc<TransientTurnContextBoundaryCoordinator>,
1825 request_id: u64,
1826 armed: bool,
1827}
1828
1829impl Drop for PendingTransientTurnContextBoundaryPreparation {
1830 fn drop(&mut self) {
1831 if self.armed {
1832 let _ = self.boundary.abort_request(self.request_id);
1833 }
1834 }
1835}
1836
1837#[must_use = "prepared transient turn context must be committed or aborted"]
1839pub struct PreparedTransientTurnContextBoundary {
1840 state: TransientTurnContextStateHandle,
1841 expected_run_id: RunId,
1842 generation: u64,
1843 request_id: u64,
1844 armed: bool,
1845 _not_sync: std::marker::PhantomData<std::cell::Cell<()>>,
1846}
1847
1848impl std::fmt::Debug for PreparedTransientTurnContextBoundary {
1849 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1850 formatter
1851 .debug_struct("PreparedTransientTurnContextBoundary")
1852 .field("actor_incarnation", &self.state.boundary.incarnation_id)
1853 .field("expected_run_id", &self.expected_run_id)
1854 .field("generation", &self.generation)
1855 .field("request_id", &self.request_id)
1856 .finish_non_exhaustive()
1857 }
1858}
1859
1860impl PreparedTransientTurnContextBoundary {
1861 #[must_use]
1862 pub fn expected_run_id(&self) -> &RunId {
1863 &self.expected_run_id
1864 }
1865
1866 #[must_use]
1867 pub fn boundary_generation(&self) -> u64 {
1868 self.generation
1869 }
1870
1871 pub fn into_stage_output(
1875 self,
1876 session_snapshot: Option<Vec<u8>>,
1877 ) -> crate::lifecycle::CoreBoundaryStageOutput {
1878 debug_assert!(
1879 session_snapshot.is_none(),
1880 "transient turn context cannot carry a durable Session snapshot"
1881 );
1882 crate::lifecycle::CoreBoundaryStageOutput::prepared(None, Box::new(self))
1883 }
1884
1885 fn resolve(
1886 &mut self,
1887 resolution: TransientTurnContextBoundaryResolution,
1888 ) -> Result<(), CoreBoundaryStageError> {
1889 if !self.armed {
1890 return Err(CoreBoundaryStageError::stale(
1891 "prepared transient boundary authority was already resolved",
1892 ));
1893 }
1894 let mut lifecycle = self.state.boundary.lock();
1895 if !lifecycle.actor_live {
1896 self.armed = false;
1897 return Err(CoreBoundaryStageError::stale(format!(
1898 "actor incarnation {} was revoked",
1899 self.state.boundary.incarnation_id
1900 )));
1901 }
1902 let matches_exact = matches!(
1903 &lifecycle.window,
1904 TransientTurnContextBoundaryWindow::Parked {
1905 run_id,
1906 generation,
1907 request_id,
1908 ..
1909 } if run_id == &self.expected_run_id
1910 && *generation == self.generation
1911 && *request_id == self.request_id
1912 );
1913 if !matches_exact {
1914 self.armed = false;
1915 return Err(CoreBoundaryStageError::stale(
1916 "prepared transient boundary no longer owns the exact parked generation",
1917 ));
1918 }
1919 let contexts = match std::mem::replace(
1920 &mut lifecycle.window,
1921 TransientTurnContextBoundaryWindow::Closed,
1922 ) {
1923 TransientTurnContextBoundaryWindow::Parked { contexts, .. } => contexts,
1924 _ => {
1925 self.armed = false;
1926 return Err(CoreBoundaryStageError::stale(
1927 "prepared transient boundary lost its parked context",
1928 ));
1929 }
1930 };
1931 lifecycle.window = TransientTurnContextBoundaryWindow::Resolved {
1932 run_id: self.expected_run_id.clone(),
1933 request_id: self.request_id,
1934 contexts,
1935 resolution,
1936 };
1937 self.armed = false;
1938 drop(lifecycle);
1939 self.state.boundary.notify.notify_waiters();
1940 Ok(())
1941 }
1942}
1943
1944impl crate::lifecycle::core_executor::CoreBoundaryStageCommitAuthority
1945 for PreparedTransientTurnContextBoundary
1946{
1947 fn commit(&mut self) -> Result<(), CoreBoundaryStageError> {
1948 self.resolve(TransientTurnContextBoundaryResolution::Committed)
1949 }
1950
1951 fn abort(&mut self) -> Result<(), CoreBoundaryStageError> {
1952 self.resolve(TransientTurnContextBoundaryResolution::Aborted)
1953 }
1954}
1955
1956impl Drop for PreparedTransientTurnContextBoundary {
1957 fn drop(&mut self) {
1958 if self.armed {
1959 let _ = self.resolve(TransientTurnContextBoundaryResolution::Aborted);
1960 }
1961 }
1962}
1963
1964impl Default for TransientTurnContextStateHandle {
1965 fn default() -> Self {
1966 Self::new()
1967 }
1968}
1969
1970impl std::fmt::Debug for TransientTurnContextStateHandle {
1971 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1972 formatter
1973 .debug_struct("TransientTurnContextStateHandle")
1974 .field("actor_incarnation", &self.boundary.incarnation_id)
1975 .finish_non_exhaustive()
1976 }
1977}
1978
1979impl TransientTurnContextStateHandle {
1980 #[must_use]
1981 pub fn new() -> Self {
1982 Self {
1983 boundary: Arc::new(TransientTurnContextBoundaryCoordinator::default()),
1984 }
1985 }
1986
1987 pub(crate) fn begin_boundary_run(
1988 &self,
1989 run_id: RunId,
1990 ) -> Result<TransientTurnContextBoundaryRunGuard, CoreBoundaryStageError> {
1991 self.open_next_boundary(&run_id)?;
1992 Ok(TransientTurnContextBoundaryRunGuard {
1993 boundary: Arc::clone(&self.boundary),
1994 run_id,
1995 })
1996 }
1997
1998 pub(crate) fn open_next_boundary(&self, run_id: &RunId) -> Result<u64, CoreBoundaryStageError> {
1999 let mut lifecycle = self.boundary.lock();
2000 if !lifecycle.actor_live {
2001 return Err(CoreBoundaryStageError::stale(format!(
2002 "actor incarnation {} was revoked",
2003 self.boundary.incarnation_id
2004 )));
2005 }
2006 match &lifecycle.window {
2007 TransientTurnContextBoundaryWindow::Open {
2008 run_id: current,
2009 generation,
2010 ..
2011 } if current == run_id => return Ok(*generation),
2012 TransientTurnContextBoundaryWindow::Parked { .. }
2013 | TransientTurnContextBoundaryWindow::Resolved { .. } => {
2014 return Err(CoreBoundaryStageError::fault(
2015 "runner attempted to open a boundary while its predecessor was unresolved",
2016 ));
2017 }
2018 TransientTurnContextBoundaryWindow::Open {
2019 run_id: current, ..
2020 } => {
2021 return Err(CoreBoundaryStageError::stale(format!(
2022 "run {run_id} cannot replace boundary owned by {current}"
2023 )));
2024 }
2025 TransientTurnContextBoundaryWindow::Closed => {}
2026 }
2027 lifecycle.next_generation = lifecycle
2028 .next_generation
2029 .checked_add(1)
2030 .ok_or_else(|| CoreBoundaryStageError::fault("boundary generation overflow"))?;
2031 let generation = lifecycle.next_generation;
2032 lifecycle.window = TransientTurnContextBoundaryWindow::Open {
2033 run_id: run_id.clone(),
2034 generation,
2035 request: None,
2036 };
2037 drop(lifecycle);
2038 self.boundary.notify.notify_waiters();
2039 Ok(generation)
2040 }
2041
2042 pub async fn prepare_active_turn_boundary(
2043 &self,
2044 expected_run_id: &RunId,
2045 contexts: Vec<TurnRequestContext>,
2046 ) -> Result<PreparedTransientTurnContextBoundary, CoreBoundaryStageError> {
2047 if contexts.is_empty() {
2048 return Err(CoreBoundaryStageError::fault(
2049 "transient boundary preparation requires at least one context value",
2050 ));
2051 }
2052
2053 let request_id = {
2054 let mut lifecycle = self.boundary.lock();
2055 if !lifecycle.actor_live {
2056 return Err(CoreBoundaryStageError::stale(format!(
2057 "actor incarnation {} was revoked",
2058 self.boundary.incarnation_id
2059 )));
2060 }
2061 let (run_id, request) = match &mut lifecycle.window {
2062 TransientTurnContextBoundaryWindow::Open {
2063 run_id, request, ..
2064 } => (run_id, request),
2065 TransientTurnContextBoundaryWindow::Closed => {
2066 return Err(CoreBoundaryStageError::unavailable(format!(
2067 "run {expected_run_id} has no open cooperative model boundary"
2068 )));
2069 }
2070 TransientTurnContextBoundaryWindow::Parked { .. }
2071 | TransientTurnContextBoundaryWindow::Resolved { .. } => {
2072 return Err(CoreBoundaryStageError::unavailable(format!(
2073 "the next boundary for run {expected_run_id} was already claimed"
2074 )));
2075 }
2076 };
2077 if run_id != expected_run_id {
2078 return Err(CoreBoundaryStageError::stale(format!(
2079 "open boundary belongs to run {run_id}, not {expected_run_id}"
2080 )));
2081 }
2082 if request.is_some() {
2083 return Err(CoreBoundaryStageError::unavailable(format!(
2084 "the next boundary for run {expected_run_id} already has a preparation"
2085 )));
2086 }
2087 lifecycle.next_request_id = lifecycle
2088 .next_request_id
2089 .checked_add(1)
2090 .ok_or_else(|| CoreBoundaryStageError::fault("boundary request id overflow"))?;
2091 let request_id = lifecycle.next_request_id;
2092 let TransientTurnContextBoundaryWindow::Open { request, .. } = &mut lifecycle.window
2093 else {
2094 return Err(CoreBoundaryStageError::fault(
2095 "boundary window changed while registering preparation",
2096 ));
2097 };
2098 *request = Some(RegisteredTransientTurnContextBoundaryRequest {
2099 request_id,
2100 contexts,
2101 });
2102 request_id
2103 };
2104
2105 let mut pending = PendingTransientTurnContextBoundaryPreparation {
2106 boundary: Arc::clone(&self.boundary),
2107 request_id,
2108 armed: true,
2109 };
2110 self.boundary.notify.notify_waiters();
2111
2112 loop {
2113 let notified = self.boundary.notify.notified();
2114 tokio::pin!(notified);
2115 notified.as_mut().enable();
2116 let poll = {
2117 let lifecycle = self.boundary.lock();
2118 if lifecycle.actor_live {
2119 match &lifecycle.window {
2120 TransientTurnContextBoundaryWindow::Parked {
2121 run_id,
2122 generation,
2123 request_id: parked_request_id,
2124 ..
2125 } if *parked_request_id == request_id => {
2126 Ok(Some(PreparedTransientTurnContextBoundary {
2127 state: self.clone(),
2128 expected_run_id: run_id.clone(),
2129 generation: *generation,
2130 request_id,
2131 armed: true,
2132 _not_sync: std::marker::PhantomData,
2133 }))
2134 }
2135 TransientTurnContextBoundaryWindow::Open { request, .. }
2136 if request
2137 .as_ref()
2138 .is_some_and(|request| request.request_id == request_id) =>
2139 {
2140 Ok(None)
2141 }
2142 _ => Err(CoreBoundaryStageError::unavailable(format!(
2143 "run {expected_run_id} ended before transient boundary request {request_id} parked"
2144 ))),
2145 }
2146 } else {
2147 Err(CoreBoundaryStageError::stale(format!(
2148 "actor incarnation {} was revoked while preparing boundary",
2149 self.boundary.incarnation_id
2150 )))
2151 }
2152 };
2153 match poll {
2154 Ok(Some(prepared)) => {
2155 pending.armed = false;
2156 return Ok(prepared);
2157 }
2158 Ok(None) => notified.as_mut().await,
2159 Err(error) => return Err(error),
2160 }
2161 }
2162 }
2163
2164 pub(crate) async fn take_pending_at_exact_boundary(
2169 &self,
2170 run_id: &RunId,
2171 ) -> Result<Vec<TurnRequestContext>, CoreBoundaryStageError> {
2172 let request_id = {
2173 let mut lifecycle = self.boundary.lock();
2174 if !lifecycle.actor_live {
2175 return Err(CoreBoundaryStageError::stale(format!(
2176 "actor incarnation {} was revoked",
2177 self.boundary.incarnation_id
2178 )));
2179 }
2180 let (generation, request) = match &mut lifecycle.window {
2181 TransientTurnContextBoundaryWindow::Open {
2182 run_id: current,
2183 generation,
2184 request,
2185 } if current == run_id => (*generation, request.take()),
2186 TransientTurnContextBoundaryWindow::Open {
2187 run_id: current, ..
2188 } => {
2189 return Err(CoreBoundaryStageError::stale(format!(
2190 "runner {run_id} reached boundary owned by {current}"
2191 )));
2192 }
2193 TransientTurnContextBoundaryWindow::Closed => {
2194 return Err(CoreBoundaryStageError::unavailable(format!(
2195 "run {run_id} reached a boundary with no open generation"
2196 )));
2197 }
2198 TransientTurnContextBoundaryWindow::Parked { .. }
2199 | TransientTurnContextBoundaryWindow::Resolved { .. } => {
2200 return Err(CoreBoundaryStageError::fault(
2201 "runner re-entered an unresolved transient model boundary",
2202 ));
2203 }
2204 };
2205 let Some(request) = request else {
2206 lifecycle.window = TransientTurnContextBoundaryWindow::Closed;
2207 return Ok(Vec::new());
2208 };
2209 let request_id = request.request_id;
2210 lifecycle.window = TransientTurnContextBoundaryWindow::Parked {
2211 run_id: run_id.clone(),
2212 generation,
2213 request_id,
2214 contexts: request.contexts,
2215 };
2216 request_id
2217 };
2218 self.boundary.notify.notify_waiters();
2219
2220 struct RunnerParkGuard {
2221 boundary: Arc<TransientTurnContextBoundaryCoordinator>,
2222 request_id: u64,
2223 armed: bool,
2224 }
2225 impl Drop for RunnerParkGuard {
2226 fn drop(&mut self) {
2227 if self.armed {
2228 let _ = self.boundary.abort_request(self.request_id);
2229 }
2230 }
2231 }
2232 let mut park_guard = RunnerParkGuard {
2233 boundary: Arc::clone(&self.boundary),
2234 request_id,
2235 armed: true,
2236 };
2237
2238 loop {
2239 let notified = self.boundary.notify.notified();
2240 tokio::pin!(notified);
2241 notified.as_mut().enable();
2242 let poll = {
2243 let mut lifecycle = self.boundary.lock();
2244 if lifecycle.actor_live {
2245 match &lifecycle.window {
2246 TransientTurnContextBoundaryWindow::Parked {
2247 request_id: parked_request_id,
2248 ..
2249 } if *parked_request_id == request_id => Ok(None),
2250 TransientTurnContextBoundaryWindow::Resolved {
2251 run_id: resolved_run_id,
2252 request_id: resolved_request_id,
2253 ..
2254 } if resolved_run_id == run_id && *resolved_request_id == request_id => {
2255 let (resolution, contexts) = match std::mem::replace(
2256 &mut lifecycle.window,
2257 TransientTurnContextBoundaryWindow::Closed,
2258 ) {
2259 TransientTurnContextBoundaryWindow::Resolved {
2260 contexts,
2261 resolution,
2262 ..
2263 } => (resolution, contexts),
2264 _ => unreachable!("matched resolved transient boundary"),
2265 };
2266 let contexts = if matches!(
2267 resolution,
2268 TransientTurnContextBoundaryResolution::Committed
2269 ) {
2270 contexts
2271 } else {
2272 Vec::new()
2273 };
2274 Ok(Some(contexts))
2275 }
2276 _ => Err(CoreBoundaryStageError::stale(format!(
2277 "parked transient request {request_id} lost exact authority"
2278 ))),
2279 }
2280 } else {
2281 Err(CoreBoundaryStageError::stale(format!(
2282 "actor incarnation {} was revoked while parked",
2283 self.boundary.incarnation_id
2284 )))
2285 }
2286 };
2287 match poll {
2288 Ok(Some(contexts)) => {
2289 park_guard.armed = false;
2290 return Ok(contexts);
2291 }
2292 Ok(None) => notified.as_mut().await,
2293 Err(error) => {
2294 park_guard.armed = false;
2295 return Err(error);
2296 }
2297 }
2298 }
2299 }
2300
2301 #[doc(hidden)]
2302 pub fn revoke_boundary_actor(&self) {
2303 self.boundary.revoke_actor();
2304 }
2305}
2306#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2321#[serde(rename_all = "snake_case")]
2322pub enum SessionLifecycleTerminal {
2323 Active,
2325 Archived,
2327}
2328
2329impl SessionLifecycleTerminal {
2330 #[must_use]
2332 pub fn is_archived(self) -> bool {
2333 matches!(self, Self::Archived)
2334 }
2335}
2336
2337impl From<SessionLifecycleTerminal> for session_document::SessionDocumentLifecycle {
2338 fn from(value: SessionLifecycleTerminal) -> Self {
2339 match value {
2340 SessionLifecycleTerminal::Active => Self::Active,
2341 SessionLifecycleTerminal::Archived => Self::Archived,
2342 }
2343 }
2344}
2345
2346impl From<session_document::SessionDocumentLifecycle> for SessionLifecycleTerminal {
2347 fn from(value: session_document::SessionDocumentLifecycle) -> Self {
2348 match value {
2349 session_document::SessionDocumentLifecycle::Active => Self::Active,
2350 session_document::SessionDocumentLifecycle::Archived => Self::Archived,
2351 }
2352 }
2353}
2354
2355#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
2357#[serde(rename_all = "snake_case")]
2358pub struct SessionDeferredTurnState {
2359 #[serde(default, skip_serializing_if = "DeferredFirstTurnPhase::is_inactive")]
2360 pub(crate) first_turn_phase: DeferredFirstTurnPhase,
2361 #[serde(default, skip_serializing_if = "Option::is_none")]
2362 pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
2363 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2364 pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
2365}
2366
2367#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
2369#[serde(rename_all = "snake_case")]
2370pub enum DeferredFirstTurnPhase {
2371 #[default]
2373 Inactive,
2374 Pending,
2376 Consumed,
2378}
2379
2380impl DeferredFirstTurnPhase {
2381 pub fn is_inactive(&self) -> bool {
2382 matches!(self, Self::Inactive)
2383 }
2384}
2385
2386impl From<DeferredFirstTurnPhase> for session_document::SessionFirstTurnPhase {
2387 fn from(value: DeferredFirstTurnPhase) -> Self {
2388 match value {
2389 DeferredFirstTurnPhase::Inactive => Self::Inactive,
2390 DeferredFirstTurnPhase::Pending => Self::Pending,
2391 DeferredFirstTurnPhase::Consumed => Self::Consumed,
2392 }
2393 }
2394}
2395
2396impl From<session_document::SessionFirstTurnPhase> for DeferredFirstTurnPhase {
2397 fn from(value: session_document::SessionFirstTurnPhase) -> Self {
2398 match value {
2399 session_document::SessionFirstTurnPhase::Inactive => Self::Inactive,
2400 session_document::SessionFirstTurnPhase::Pending => Self::Pending,
2401 session_document::SessionFirstTurnPhase::Consumed => Self::Consumed,
2402 }
2403 }
2404}
2405
2406fn is_default_hook_run_overrides(value: &crate::HookRunOverrides) -> bool {
2407 value == &crate::HookRunOverrides::default()
2408}
2409
2410fn is_default_call_timeout_override(value: &crate::CallTimeoutOverride) -> bool {
2411 value == &crate::CallTimeoutOverride::default()
2412}
2413
2414fn is_tool_filter_all(value: &ToolFilter) -> bool {
2415 matches!(value, ToolFilter::All)
2416}
2417
2418fn is_zero(value: &u64) -> bool {
2419 *value == 0
2420}
2421
2422pub fn capability_base_filter_for_image_tool_results(image_tool_results: bool) -> ToolFilter {
2424 if image_tool_results {
2425 ToolFilter::All
2426 } else {
2427 ToolFilter::Deny([VIEW_IMAGE_TOOL_NAME.to_string()].into_iter().collect())
2428 }
2429}
2430
2431#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
2438#[serde(rename_all = "snake_case")]
2439pub struct ToolVisibilityWitness {
2440 #[serde(default, skip_serializing_if = "Option::is_none")]
2441 pub last_seen_provenance: Option<ToolProvenance>,
2442}
2443
2444impl ToolVisibilityWitness {
2445 pub fn has_identity_witness(&self) -> bool {
2446 self.last_seen_provenance.is_some()
2447 }
2448}
2449
2450#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2456#[serde(rename_all = "snake_case")]
2457pub struct DeferredToolLoadAuthority {
2458 pub name: ToolName,
2459 pub witness: ToolVisibilityWitness,
2460}
2461
2462impl DeferredToolLoadAuthority {
2463 pub fn new(name: impl Into<ToolName>, witness: ToolVisibilityWitness) -> Self {
2464 Self {
2465 name: name.into(),
2466 witness,
2467 }
2468 }
2469
2470 pub fn into_parts(self) -> (ToolName, ToolVisibilityWitness) {
2471 (self.name, self.witness)
2472 }
2473}
2474
2475#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
2478#[serde(rename_all = "snake_case")]
2479pub struct WitnessedToolFilter {
2480 pub filter: ToolFilter,
2481 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2482 pub witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
2483}
2484
2485impl WitnessedToolFilter {
2486 pub fn new(filter: ToolFilter, witnesses: BTreeMap<ToolName, ToolVisibilityWitness>) -> Self {
2487 Self { filter, witnesses }
2488 }
2489
2490 pub fn into_parts(self) -> (ToolFilter, BTreeMap<ToolName, ToolVisibilityWitness>) {
2491 (self.filter, self.witnesses)
2492 }
2493}
2494
2495#[derive(Debug, Clone, PartialEq, Eq)]
2502pub struct InheritedToolVisibilityAuthority {
2503 filter: ToolFilter,
2504 witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
2505}
2506
2507impl InheritedToolVisibilityAuthority {
2508 pub(crate) fn from_generated_composition_authority(
2509 filter: ToolFilter,
2510 witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
2511 ) -> Self {
2512 Self { filter, witnesses }
2513 }
2514
2515 pub fn filter(&self) -> &ToolFilter {
2516 &self.filter
2517 }
2518
2519 pub fn witnesses(&self) -> &BTreeMap<ToolName, ToolVisibilityWitness> {
2520 &self.witnesses
2521 }
2522
2523 pub(crate) fn into_initial_visibility_state(self) -> SessionToolVisibilityState {
2524 SessionToolVisibilityState {
2525 inherited_base_filter: self.filter,
2526 filter_witnesses: self.witnesses,
2527 ..Default::default()
2528 }
2529 }
2530}
2531
2532#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
2534#[serde(rename_all = "snake_case")]
2535pub struct SessionToolVisibilityState {
2536 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
2537 pub capability_base_filter: ToolFilter,
2538 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
2539 pub inherited_base_filter: ToolFilter,
2540 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
2541 pub active_filter: ToolFilter,
2542 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
2543 pub staged_filter: ToolFilter,
2544 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
2545 pub active_requested_deferred_names: BTreeSet<ToolName>,
2546 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
2547 pub staged_requested_deferred_names: BTreeSet<ToolName>,
2548 #[serde(default, skip_serializing_if = "is_zero")]
2549 pub active_revision: u64,
2550 #[serde(default, skip_serializing_if = "is_zero")]
2551 pub staged_revision: u64,
2552 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2553 pub requested_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
2554 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2555 pub filter_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
2556}
2557
2558impl SessionToolVisibilityState {
2559 #[cfg(test)]
2563 pub(crate) fn projected_boundary_applied(&self) -> Self {
2564 let mut projected = self.clone();
2565 projected.active_filter = self.staged_filter.clone();
2566 projected.active_requested_deferred_names = self.staged_requested_deferred_names.clone();
2567 projected.active_revision = self.staged_revision;
2568 projected
2569 }
2570}
2571
2572#[derive(Debug, Clone, PartialEq, Eq)]
2578pub struct AuthorizedSessionToolVisibilityState {
2579 state: SessionToolVisibilityState,
2580}
2581
2582impl AuthorizedSessionToolVisibilityState {
2583 pub(crate) fn from_generated_authority(state: SessionToolVisibilityState) -> Self {
2584 Self { state }
2585 }
2586
2587 pub fn as_state(&self) -> &SessionToolVisibilityState {
2588 &self.state
2589 }
2590
2591 pub fn into_state(self) -> SessionToolVisibilityState {
2592 self.state
2593 }
2594}
2595
2596#[derive(Debug, Clone, Serialize, Deserialize, Default)]
2599#[serde(rename_all = "snake_case")]
2600pub struct SessionBuildState {
2601 #[serde(default, skip_serializing_if = "Option::is_none")]
2602 pub output_schema: Option<crate::OutputSchema>,
2603 #[serde(default, skip_serializing_if = "is_default_hook_run_overrides")]
2604 pub hooks_override: crate::HookRunOverrides,
2605 #[serde(default, skip_serializing_if = "Option::is_none")]
2606 pub budget_limits: Option<crate::BudgetLimits>,
2607 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2608 pub recoverable_tool_defs: Vec<ToolDef>,
2609 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2610 pub silent_comms_intents: Vec<String>,
2611 #[serde(default, skip_serializing_if = "Option::is_none")]
2612 pub max_inline_peer_notifications: Option<i32>,
2613 #[serde(default, skip_serializing_if = "Option::is_none")]
2614 pub app_context: Option<serde_json::Value>,
2615 #[serde(default, skip_serializing_if = "Option::is_none")]
2616 pub additional_instructions: Option<Vec<String>>,
2617 #[serde(default, skip_serializing_if = "Option::is_none")]
2618 pub shell_env: Option<HashMap<String, String>>,
2619 #[serde(default, skip_serializing_if = "Option::is_none")]
2625 pub mob_tool_authority_context: Option<MobToolAuthorityContext>,
2626 #[serde(default, skip_serializing_if = "is_default_call_timeout_override")]
2627 pub call_timeout_override: crate::CallTimeoutOverride,
2628}
2629
2630#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2632#[serde(rename_all = "snake_case")]
2633pub struct PendingDeferredPrompt {
2634 pub prompt: ContentInput,
2635 pub accepted_at: SystemTime,
2636}
2637
2638#[derive(Debug, Clone, Serialize, Deserialize)]
2640#[serde(rename_all = "snake_case")]
2641pub struct PendingToolResultsMessage {
2642 pub results: Vec<ToolResult>,
2643 pub accepted_at: SystemTime,
2644}
2645
2646#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2648pub enum DeferredToolResultsIngressError {
2649 #[error("callback result ingress contains duplicate tool id '{0}'")]
2650 DuplicateToolUseId(String),
2651 #[error("callback result for tool id '{0}' conflicts with its staged payload")]
2652 ConflictingRedelivery(String),
2653 #[error("callback result tool id '{0}' is outside the staged pending set")]
2654 WrongToolUseId(String),
2655}
2656
2657#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2664#[serde(rename_all = "snake_case")]
2665pub(crate) struct PendingCallbackToolBatch {
2666 pub run_id: RunId,
2667 pub tool_use_order: Vec<String>,
2668 pub pending_tool_use_ids: Vec<String>,
2669 pub completed_results: Vec<ToolResult>,
2670 pub session_effects: Vec<crate::ops::SessionEffect>,
2671 pub async_ops: Vec<crate::ops::AsyncOpRef>,
2672}
2673
2674#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2675#[serde(tag = "state", rename_all = "snake_case")]
2676enum CallbackToolBatchState {
2677 Pending {
2678 batch: PendingCallbackToolBatch,
2679 },
2680 Applied {
2681 tool_use_order: Vec<String>,
2682 results: Vec<ToolResult>,
2683 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2684 async_ops: Vec<crate::ops::AsyncOpRef>,
2685 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2686 post_tool_messages: Vec<Message>,
2687 #[serde(default)]
2688 post_tool_messages_applied: bool,
2689 },
2690}
2691
2692pub(crate) enum ResolvedPendingCallbackToolResults {
2693 NoState,
2694 Pending {
2695 batch: PendingCallbackToolBatch,
2696 ordered_results: Vec<ToolResult>,
2697 },
2698 AlreadyApplied {
2699 async_ops: Vec<crate::ops::AsyncOpRef>,
2700 },
2701}
2702
2703#[derive(Debug, Clone, PartialEq, Eq)]
2706#[doc(hidden)]
2707pub enum CallbackResultIngress {
2708 NoPendingBatch,
2711 Pending { pending_tool_use_ids: Vec<String> },
2713 AlreadyApplied,
2715}
2716
2717#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2719pub(crate) enum PendingCallbackBatchError {
2720 #[error("a pending callback batch is already staged")]
2721 AlreadyStaged,
2722 #[error("no pending callback batch is staged")]
2723 Missing,
2724 #[error("pending callback batch is malformed: {0}")]
2725 Malformed(String),
2726 #[error("callback results contain duplicate tool id '{0}'")]
2727 DuplicateResult(String),
2728 #[error("mob authority replacement cannot cross a durable callback staging boundary")]
2729 NonDurableAuthorityEffect,
2730 #[error("callback result ids {actual:?} do not match pending ids {expected:?}")]
2731 ResultSetMismatch {
2732 expected: BTreeSet<String>,
2733 actual: BTreeSet<String>,
2734 },
2735 #[error("callback result redelivery conflicts with the already applied payload")]
2736 ConflictingRedelivery,
2737}
2738
2739fn unique_tool_results(
2740 results: Vec<ToolResult>,
2741) -> Result<BTreeMap<String, ToolResult>, PendingCallbackBatchError> {
2742 let mut by_id = BTreeMap::new();
2743 for result in results {
2744 let id = result.tool_use_id.clone();
2745 if by_id.insert(id.clone(), result).is_some() {
2746 return Err(PendingCallbackBatchError::DuplicateResult(id));
2747 }
2748 }
2749 Ok(by_id)
2750}
2751
2752fn validate_pending_callback_batch(
2753 messages: &[Message],
2754 batch: &PendingCallbackToolBatch,
2755) -> Result<(), PendingCallbackBatchError> {
2756 let Some(assistant) = messages.last() else {
2757 return Err(PendingCallbackBatchError::Malformed(
2758 "staged callback batch has no assistant transcript tail".to_string(),
2759 ));
2760 };
2761 let assistant_order = assistant_tool_use_ids(assistant)
2762 .into_iter()
2763 .map(str::to_string)
2764 .collect::<Vec<_>>();
2765 if assistant_order != batch.tool_use_order {
2766 return Err(PendingCallbackBatchError::Malformed(format!(
2767 "assistant tool ids {assistant_order:?} do not match staged order {:?}",
2768 batch.tool_use_order
2769 )));
2770 }
2771 let assistant_set = assistant_order.iter().cloned().collect::<BTreeSet<_>>();
2772 if assistant_set.len() != assistant_order.len() {
2773 return Err(PendingCallbackBatchError::Malformed(
2774 "assistant tool-use batch contains duplicate ids".to_string(),
2775 ));
2776 }
2777 let pending_set = batch
2778 .pending_tool_use_ids
2779 .iter()
2780 .cloned()
2781 .collect::<BTreeSet<_>>();
2782 if pending_set.len() != batch.pending_tool_use_ids.len() || pending_set.is_empty() {
2783 return Err(PendingCallbackBatchError::Malformed(
2784 "staged callback batch must contain at least one unique pending tool id".to_string(),
2785 ));
2786 }
2787 let completed = unique_tool_results(batch.completed_results.clone())?;
2788 let completed_set = completed.keys().cloned().collect::<BTreeSet<_>>();
2789 if !pending_set.is_disjoint(&completed_set)
2790 || pending_set
2791 .union(&completed_set)
2792 .cloned()
2793 .collect::<BTreeSet<_>>()
2794 != assistant_set
2795 {
2796 return Err(PendingCallbackBatchError::Malformed(format!(
2797 "pending ids {pending_set:?} plus completed ids {completed_set:?} do not partition assistant ids {assistant_set:?}"
2798 )));
2799 }
2800 if batch.session_effects.iter().any(|effect| {
2801 matches!(
2802 effect,
2803 crate::ops::SessionEffect::ReplaceMobToolAuthorityContext { .. }
2804 )
2805 }) {
2806 return Err(PendingCallbackBatchError::NonDurableAuthorityEffect);
2807 }
2808 Ok(())
2809}
2810
2811impl PartialEq for PendingToolResultsMessage {
2812 fn eq(&self, other: &Self) -> bool {
2813 self.accepted_at == other.accepted_at
2814 && serde_json::to_value(&self.results).ok() == serde_json::to_value(&other.results).ok()
2815 }
2816}
2817
2818#[derive(Debug, Clone, Default, PartialEq)]
2820pub struct ConsumedDeferredTurnInputs {
2821 pub(crate) restore_first_turn_pending: bool,
2822 pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
2823 pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
2824}
2825
2826impl ConsumedDeferredTurnInputs {
2827 pub fn is_empty(&self) -> bool {
2828 !self.restore_first_turn_pending
2829 && self.pending_initial_prompt.is_none()
2830 && self.pending_tool_results.is_empty()
2831 }
2832
2833 pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
2834 self.pending_initial_prompt.as_ref()
2835 }
2836
2837 pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
2838 &self.pending_tool_results
2839 }
2840}
2841
2842const SESSION_DOCUMENT_FIRST_TURN_KEY: &str = "first_turn";
2847
2848fn usize_to_u64(value: usize) -> u64 {
2849 u64::try_from(value).unwrap_or(u64::MAX)
2850}
2851
2852fn validate_deferred_turn_snapshot(
2860 state: SessionDeferredTurnState,
2861) -> Result<SessionDeferredTurnState, session_document::SessionDocumentError> {
2862 let mut authority = session_document::SessionDocumentMachineAuthority::new();
2863 let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
2864 authority.recover_session_first_turn_phase(
2868 key,
2869 state.first_turn_phase.into(),
2870 state.pending_initial_prompt.is_some(),
2871 usize_to_u64(state.pending_tool_results.len()),
2872 )?;
2873 Ok(state)
2874}
2875
2876impl SessionDeferredTurnState {
2877 pub fn first_turn_phase(&self) -> DeferredFirstTurnPhase {
2878 self.first_turn_phase
2879 }
2880
2881 pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
2882 self.pending_initial_prompt.as_ref()
2883 }
2884
2885 pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
2886 &self.pending_tool_results
2887 }
2888
2889 pub fn pending_tool_results_len(&self) -> usize {
2890 self.pending_tool_results.len()
2891 }
2892
2893 pub(crate) fn pending_initial_prompt_mut_for_blob_rewrite(
2894 &mut self,
2895 ) -> Option<&mut PendingDeferredPrompt> {
2896 self.pending_initial_prompt.as_mut()
2897 }
2898
2899 pub(crate) fn pending_tool_results_mut_for_blob_rewrite(
2900 &mut self,
2901 ) -> &mut [PendingToolResultsMessage] {
2902 &mut self.pending_tool_results
2903 }
2904
2905 fn document_authority(
2915 &self,
2916 ) -> (
2917 session_document::SessionDocumentMachineAuthority,
2918 session_document::SessionDocumentKey,
2919 ) {
2920 let mut authority = session_document::SessionDocumentMachineAuthority::new();
2921 let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
2922 if let Err(err) = authority.recover_session_first_turn_phase(
2923 key.clone(),
2924 self.first_turn_phase.into(),
2925 self.pending_initial_prompt.is_some(),
2926 usize_to_u64(self.pending_tool_results.len()),
2927 ) {
2928 tracing::warn!(
2929 error = %err,
2930 "generated session document authority rejected first-turn recovery"
2931 );
2932 }
2933 (authority, key)
2934 }
2935
2936 fn mirror_first_turn_phase(
2939 &mut self,
2940 effects: &[session_document::SessionDocumentEffect],
2941 ) -> Option<bool> {
2942 for effect in effects {
2943 if let session_document::SessionDocumentEffect::SessionFirstTurnPhaseResolved {
2944 phase,
2945 was_pending,
2946 } = effect
2947 {
2948 self.first_turn_phase = (*phase).into();
2949 return Some(*was_pending);
2950 }
2951 }
2952 None
2953 }
2954
2955 pub fn mark_initial_turn_pending(&mut self) {
2957 let (mut authority, key) = self.document_authority();
2958 match authority.mark_session_initial_turn_pending(key) {
2959 Ok(effects) => {
2960 self.mirror_first_turn_phase(&effects);
2961 }
2962 Err(err) => tracing::warn!(
2963 error = %err,
2964 "generated session document authority rejected pending mark"
2965 ),
2966 }
2967 }
2968
2969 pub fn mark_initial_turn_started(&mut self) -> bool {
2973 let (mut authority, key) = self.document_authority();
2974 match authority.start_session_initial_turn(key) {
2975 Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
2976 Err(err) => {
2977 tracing::warn!(
2978 error = %err,
2979 "generated session document authority rejected first-turn start"
2980 );
2981 false
2982 }
2983 }
2984 }
2985
2986 pub fn restore_initial_turn_pending(&mut self) {
2988 let (mut authority, key) = self.document_authority();
2993 match authority.restore_session_consumed_inputs(
2994 key.clone(),
2995 true,
2996 self.pending_initial_prompt.is_some(),
2997 usize_to_u64(self.pending_tool_results.len()),
2998 ) {
2999 Ok(_) => {
3000 if let Some(phase) = authority.session_first_turn_phase_for(&key) {
3003 self.first_turn_phase = phase.into();
3004 }
3005 }
3006 Err(err) => tracing::warn!(
3007 error = %err,
3008 "generated session document authority rejected pending restore"
3009 ),
3010 }
3011 }
3012
3013 pub fn allows_initial_turn_overrides(&self) -> bool {
3015 let (mut authority, key) = self.document_authority();
3016 match authority.resolve_session_first_turn_overrides_allowed(key) {
3017 Ok(effects) => effects
3018 .iter()
3019 .find_map(|effect| {
3020 match effect {
3021 session_document::SessionDocumentEffect::SessionFirstTurnOverridesResolved {
3022 allowed,
3023 } => Some(*allowed),
3024 _ => None,
3025 }
3026 })
3027 .unwrap_or(false),
3028 Err(err) => {
3029 tracing::warn!(
3030 error = %err,
3031 "generated session document authority rejected override resolution"
3032 );
3033 false
3034 }
3035 }
3036 }
3037
3038 pub fn stage_initial_prompt(&mut self, prompt: ContentInput, accepted_at: SystemTime) {
3040 let prompt_has_content = prompt.has_images() || !prompt.text_content().trim().is_empty();
3041 let (mut authority, key) = self.document_authority();
3042 match authority.stage_session_initial_prompt(key, prompt_has_content) {
3043 Ok(effects) => {
3044 let decision = effects.iter().find_map(|effect| {
3045 match effect {
3046 session_document::SessionDocumentEffect::SessionInitialPromptStageResolved {
3047 decision,
3048 } => Some(*decision),
3049 _ => None,
3050 }
3051 });
3052 match decision {
3053 Some(session_document::SessionInitialPromptStageDecision::Store) => {
3054 self.pending_initial_prompt = Some(PendingDeferredPrompt {
3055 prompt,
3056 accepted_at,
3057 });
3058 }
3059 Some(session_document::SessionInitialPromptStageDecision::Clear) => {
3060 self.pending_initial_prompt = None;
3061 }
3062 None => tracing::warn!(
3063 "generated session document authority returned no prompt-stage decision"
3064 ),
3065 }
3066 }
3067 Err(err) => tracing::warn!(
3068 error = %err,
3069 "generated session document authority rejected initial prompt stage"
3070 ),
3071 }
3072 }
3073
3074 pub fn try_stage_tool_results(
3076 &mut self,
3077 results: Vec<ToolResult>,
3078 accepted_at: SystemTime,
3079 ) -> Result<usize, DeferredToolResultsIngressError> {
3080 let mut incoming_by_id = BTreeMap::new();
3081 for result in &results {
3082 if incoming_by_id
3083 .insert(result.tool_use_id.clone(), result)
3084 .is_some()
3085 {
3086 return Err(DeferredToolResultsIngressError::DuplicateToolUseId(
3087 result.tool_use_id.clone(),
3088 ));
3089 }
3090 }
3091
3092 let mut staged_by_id = BTreeMap::new();
3093 for pending in &self.pending_tool_results {
3094 for result in &pending.results {
3095 match staged_by_id.insert(result.tool_use_id.clone(), result) {
3096 Some(previous) if previous != result => {
3097 return Err(DeferredToolResultsIngressError::ConflictingRedelivery(
3098 result.tool_use_id.clone(),
3099 ));
3100 }
3101 _ => {}
3102 }
3103 }
3104 }
3105 if !staged_by_id.is_empty() {
3106 for (id, incoming) in &incoming_by_id {
3107 match staged_by_id.get(id) {
3108 Some(staged) if *staged == *incoming => {}
3109 Some(_) => {
3110 return Err(DeferredToolResultsIngressError::ConflictingRedelivery(
3111 id.clone(),
3112 ));
3113 }
3114 None => {
3115 return Err(DeferredToolResultsIngressError::WrongToolUseId(id.clone()));
3116 }
3117 }
3118 }
3119 return Ok(0);
3120 }
3121
3122 let (mut authority, key) = self.document_authority();
3123 let accepted = match authority.stage_session_tool_results(key, usize_to_u64(results.len()))
3124 {
3125 Ok(effects) => effects.iter().find_map(|effect| match effect {
3126 session_document::SessionDocumentEffect::SessionToolResultsStageResolved {
3127 accepted_count,
3128 } => Some(*accepted_count),
3129 _ => None,
3130 }),
3131 Err(err) => {
3132 tracing::warn!(
3133 error = %err,
3134 "generated session document authority rejected tool-results stage"
3135 );
3136 return Ok(0);
3137 }
3138 };
3139 let Some(accepted) = accepted else {
3140 tracing::warn!(
3141 "generated session document authority returned no tool-results decision"
3142 );
3143 return Ok(0);
3144 };
3145 if accepted == 0 {
3146 return Ok(0);
3147 }
3148 let accepted = usize::try_from(accepted).unwrap_or(usize::MAX);
3149 self.pending_tool_results.push(PendingToolResultsMessage {
3150 results,
3151 accepted_at,
3152 });
3153 Ok(accepted)
3154 }
3155
3156 pub fn stage_tool_results(
3160 &mut self,
3161 results: Vec<ToolResult>,
3162 accepted_at: SystemTime,
3163 ) -> usize {
3164 match self.try_stage_tool_results(results, accepted_at) {
3165 Ok(accepted) => accepted,
3166 Err(error) => {
3167 tracing::warn!(%error, "deferred callback-result ingress was rejected");
3168 0
3169 }
3170 }
3171 }
3172
3173 pub fn has_pending_tool_results(&self) -> bool {
3175 !self.pending_tool_results.is_empty()
3176 }
3177
3178 pub fn consume_for_started_turn(&mut self) -> ConsumedDeferredTurnInputs {
3180 let (mut authority, key) = self.document_authority();
3181 let was_pending = match authority.consume_session_deferred_inputs(key) {
3182 Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
3183 Err(err) => {
3184 tracing::warn!(
3185 error = %err,
3186 "generated session document authority rejected started-turn consumption"
3187 );
3188 return ConsumedDeferredTurnInputs::default();
3189 }
3190 };
3191 ConsumedDeferredTurnInputs {
3192 restore_first_turn_pending: was_pending,
3193 pending_initial_prompt: self.pending_initial_prompt.take(),
3194 pending_tool_results: std::mem::take(&mut self.pending_tool_results),
3195 }
3196 }
3197
3198 pub fn restore_consumed_turn_inputs(&mut self, consumed: ConsumedDeferredTurnInputs) {
3200 if consumed.is_empty() {
3201 return;
3202 }
3203 let (mut authority, key) = self.document_authority();
3204 let effects = match authority.restore_session_consumed_inputs(
3205 key,
3206 consumed.restore_first_turn_pending,
3207 consumed.pending_initial_prompt.is_some(),
3208 usize_to_u64(consumed.pending_tool_results.len()),
3209 ) {
3210 Ok(effects) => effects,
3211 Err(err) => {
3212 tracing::warn!(
3213 error = %err,
3214 "generated session document authority rejected consumed input restore"
3215 );
3216 return;
3217 }
3218 };
3219 let Some((restore_first_turn_pending, restore_initial_prompt, restore_tool_results)) =
3220 effects.iter().find_map(|effect| match effect {
3221 session_document::SessionDocumentEffect::SessionConsumedInputsRestoreResolved {
3222 restore_first_turn_pending,
3223 restore_initial_prompt,
3224 restore_tool_results,
3225 } => Some((
3226 *restore_first_turn_pending,
3227 *restore_initial_prompt,
3228 *restore_tool_results,
3229 )),
3230 _ => None,
3231 })
3232 else {
3233 tracing::warn!(
3234 "generated session document authority returned no consumed-input restore decision"
3235 );
3236 return;
3237 };
3238 if restore_first_turn_pending {
3239 self.restore_initial_turn_pending();
3240 }
3241 if restore_initial_prompt && self.pending_initial_prompt.is_none() {
3242 self.pending_initial_prompt = consumed.pending_initial_prompt;
3243 }
3244 if restore_tool_results {
3245 let mut restored = consumed.pending_tool_results;
3246 restored.extend(std::mem::take(&mut self.pending_tool_results));
3247 self.pending_tool_results = restored;
3248 }
3249 }
3250}
3251
3252#[derive(Debug, Clone, PartialEq, Eq)]
3254pub enum SystemMessageAppendError {
3255 Conflict {
3256 key: String,
3257 existing_text: String,
3258 existing_source: Option<String>,
3259 },
3260}
3261
3262impl std::fmt::Display for SystemMessageAppendError {
3263 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3264 match self {
3265 Self::Conflict { key, .. } => {
3266 write!(
3267 f,
3268 "System-message append conflict for idempotency key `{key}`"
3269 )
3270 }
3271 }
3272 }
3273}
3274
3275impl std::error::Error for SystemMessageAppendError {}
3276
3277impl Session {
3278 #[doc(hidden)]
3281 pub fn classify_callback_result_ingress(
3282 &self,
3283 incoming: &[ToolResult],
3284 ) -> Result<CallbackResultIngress, crate::error::AgentError> {
3285 match self
3286 .resolve_pending_callback_tool_results(incoming.to_vec())
3287 .map_err(|error| {
3288 crate::error::AgentError::ConfigError(format!(
3289 "callback result ingress was rejected: {error}"
3290 ))
3291 })? {
3292 ResolvedPendingCallbackToolResults::NoState => {
3293 Ok(CallbackResultIngress::NoPendingBatch)
3294 }
3295 ResolvedPendingCallbackToolResults::AlreadyApplied { .. } => {
3296 Ok(CallbackResultIngress::AlreadyApplied)
3297 }
3298 ResolvedPendingCallbackToolResults::Pending { batch, .. } => {
3299 Ok(CallbackResultIngress::Pending {
3300 pending_tool_use_ids: batch.pending_tool_use_ids,
3301 })
3302 }
3303 }
3304 }
3305
3306 pub fn new() -> Self {
3308 let now = SystemTime::now();
3309 let id = SessionId::new();
3310 Self {
3311 version: session_version(),
3312 realtime_transcript: Box::new(SessionRealtimeTranscriptProjection::empty(&id)),
3313 id,
3314 messages: TranscriptMessages::default(),
3315 created_at: now,
3316 updated_at: now,
3317 metadata: serde_json::Map::new(),
3318 history_caches: Box::default(),
3319 transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
3320 usage: Usage::default(),
3321 }
3322 }
3323
3324 pub fn with_id(id: SessionId) -> Self {
3326 let mut session = Self::new();
3327 session.realtime_transcript = Box::new(SessionRealtimeTranscriptProjection::empty(&id));
3328 session.id = id;
3329 session
3330 }
3331
3332 pub fn id(&self) -> &SessionId {
3334 &self.id
3335 }
3336
3337 pub fn version(&self) -> u32 {
3339 self.version
3340 }
3341
3342 pub fn messages(&self) -> &[Message] {
3344 &self.messages
3345 }
3346
3347 pub fn transcript_content_digest(&self) -> Result<String, serde_json::Error> {
3357 self.messages.digest()
3358 }
3359
3360 pub fn transcript_prefix_digest(&self, count: usize) -> Result<String, serde_json::Error> {
3366 if count > self.messages.len() {
3367 return Err(<serde_json::Error as serde::ser::Error>::custom(format!(
3372 "transcript prefix digest requested for {count} messages but the transcript has {}",
3373 self.messages.len()
3374 )));
3375 }
3376 if let Some(witness) = self.messages.prefix_digest_witness(count) {
3377 return Ok(witness);
3378 }
3379 transcript_messages_digest(&self.messages[..count])
3380 }
3381
3382 #[doc(hidden)]
3385 #[must_use]
3386 pub fn transcript_mutation_epoch(&self) -> u64 {
3387 self.messages.mutation_epoch()
3388 }
3389
3390 #[allow(dead_code)] pub(crate) fn replace_messages_internal(
3397 &mut self,
3398 messages: Vec<Message>,
3399 reason: TranscriptRewriteReason,
3400 ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError> {
3401 if transcript_messages_digest(self.messages()).ok()
3402 == transcript_messages_digest(&messages).ok()
3403 {
3404 return Ok(None);
3405 }
3406 let commit = self.commit_transcript_rewrite(
3407 TranscriptRewriteSelection::MessageRange {
3408 start: 0,
3409 end: self.messages.len(),
3410 },
3411 messages,
3412 reason,
3413 Some("meerkat-core".to_string()),
3414 None,
3415 )?;
3416 Ok(Some(commit))
3417 }
3418
3419 pub(crate) fn replace_messages_for_compaction_internal(
3422 &mut self,
3423 messages: Vec<Message>,
3424 authority: &crate::agent::compact::ValidatedCompactionRewrite,
3425 ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError> {
3426 let parent_revision = self
3434 .transcript_content_digest()
3435 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
3436 if !authority.authorizes_parent_digest(
3437 &parent_revision,
3438 self.messages.len(),
3439 messages.len(),
3440 ) {
3441 return Err(TranscriptEditError::InvalidTranscriptShape(
3442 "validated compaction witness does not authorize this exact transcript rebuild"
3443 .to_string(),
3444 ));
3445 }
3446 if authority.is_no_op() {
3447 return Ok(None);
3448 }
3449 let summary_count = messages
3450 .iter()
3451 .filter(|message| {
3452 matches!(message, Message::User(user) if user.transcript_role.is_compaction_summary())
3453 })
3454 .count();
3455 if messages.len() >= self.messages.len() || summary_count != 1 {
3456 return Err(TranscriptEditError::InvalidTranscriptShape(
3457 "validated compaction rewrite must shrink the transcript and carry exactly one CompactionSummary"
3458 .to_string(),
3459 ));
3460 }
3461 let selection =
3462 TranscriptRewriteSelection::validated_compaction(0, self.messages.len(), authority);
3463 let commit = self.commit_transcript_rewrite_bound(
3464 selection,
3465 messages,
3466 TranscriptRewriteReason::new("compaction"),
3467 Some("meerkat-core".to_string()),
3468 Some(authority.parent_revision().to_string()),
3469 Some(authority.revision()),
3470 )?;
3471 Ok(Some(commit))
3472 }
3473
3474 #[cfg(any(test, feature = "test-support"))]
3481 #[doc(hidden)]
3482 pub fn stage_validated_compaction_for_test(
3483 &mut self,
3484 replacement: Vec<Message>,
3485 summary_tokens: u64,
3486 ) -> Result<
3487 (
3488 TranscriptRewriteCommit,
3489 crate::memory::CompactionProjectionIntent,
3490 ),
3491 String,
3492 > {
3493 let messages_before = self.messages.len();
3494 let authority = crate::agent::compact::ValidatedCompactionRewrite::for_test(
3495 self.messages(),
3496 &replacement,
3497 )
3498 .map_err(|error| error.to_string())?;
3499 let commit = self
3500 .replace_messages_for_compaction_internal(replacement, &authority)
3501 .map_err(|error| error.to_string())?
3502 .ok_or_else(|| "test compaction rewrite was a no-op".to_string())?;
3503 let projection = crate::memory::CompactionProjectionId::from_validated_transcript_rewrite(
3504 self.id().clone(),
3505 &commit,
3506 &authority,
3507 )
3508 .ok_or_else(|| {
3509 "core-owned test compaction did not mint a projection identity".to_string()
3510 })?;
3511 let intent = crate::memory::CompactionProjectionIntent {
3512 projection,
3513 summary_tokens,
3514 messages_before,
3515 messages_after: self.messages.len(),
3516 };
3517 self.add_compaction_projection_intent(intent.clone())
3518 .map_err(|error| error.to_string())?;
3519 Ok((commit, intent))
3520 }
3521
3522 pub fn replace_synthetic_notices(
3534 &mut self,
3535 kind: crate::types::SystemNoticeKind,
3536 replacements: Vec<Message>,
3537 ) -> Result<(), TranscriptEditError> {
3538 if !kind.is_synthetic_refresh_projection() {
3539 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
3540 "system notice kind {kind:?} is durable transcript content, not a synthetic refresh projection"
3541 )));
3542 }
3543 for (index, message) in replacements.iter().enumerate() {
3544 let matches_kind = matches!(
3545 message,
3546 Message::SystemNotice(notice)
3547 if notice.kind == kind && notice.is_synthetic_refresh_projection()
3548 );
3549 if !matches_kind {
3550 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
3551 "replacement {index} for synthetic notice kind {kind:?} is not a system notice of that kind"
3552 )));
3553 }
3554 }
3555
3556 let is_refresh_notice = |message: &Message| {
3568 matches!(
3569 message,
3570 Message::SystemNotice(notice)
3571 if notice.kind == kind && notice.is_synthetic_refresh_projection()
3572 )
3573 };
3574 let existing_count = self
3575 .messages
3576 .iter()
3577 .filter(|message| is_refresh_notice(message))
3578 .count();
3579 let tail_start = self.messages.len().saturating_sub(existing_count);
3580 let existing_are_contiguous_tail =
3581 self.messages[tail_start..].iter().all(&is_refresh_notice);
3582 if existing_count == replacements.len()
3583 && existing_are_contiguous_tail
3584 && self.messages[tail_start..]
3585 .iter()
3586 .zip(replacements.iter())
3587 .all(|(existing, replacement)| {
3588 canonicalize_message_for_digest(existing)
3589 == canonicalize_message_for_digest(replacement)
3590 })
3591 {
3592 return Ok(());
3593 }
3594
3595 let lowest_mutated_index = self
3596 .messages
3597 .iter()
3598 .position(&is_refresh_notice)
3599 .unwrap_or(self.messages.len());
3600 let mut refreshed = self
3601 .messages
3602 .iter()
3603 .filter(|message| !is_refresh_notice(message))
3604 .cloned()
3605 .collect::<Vec<_>>();
3606 refreshed.extend(replacements);
3607 let realtime_rebase = self.prepare_realtime_transcript_rebase_after_rewrite(
3608 &refreshed,
3609 RealtimeTranscriptSnapshotReasonV1::TranscriptRewrite,
3610 )?;
3611 if let Some(history) = self.validated_transcript_history_state()? {
3612 let head_len = history
3613 .final_endpoint_witness()
3614 .ok_or_else(|| {
3615 TranscriptEditError::HistoryStateMalformed(
3616 "compact graph has no final endpoint witness".to_string(),
3617 )
3618 })?
3619 .message_count();
3620 if self.messages.len() < head_len
3626 || self.messages[..head_len].iter().any(&is_refresh_notice)
3627 {
3628 return Err(TranscriptEditError::InvalidTranscriptShape(
3629 "synthetic notice refresh would rewrite the audited transcript prefix; route it through a typed transcript rewrite"
3630 .to_string(),
3631 ));
3632 }
3633 }
3634 let updated_at = SystemTime::now();
3635 if lowest_mutated_index == self.messages.len() {
3636 let appended = refreshed.split_off(lowest_mutated_index);
3639 self.messages.extend_batch(appended);
3640 } else {
3641 *self.messages.begin_in_place_scan() = refreshed;
3649 self.messages
3650 .finish_in_place_scan(Some(lowest_mutated_index));
3651 }
3652 self.mark_content_mutated(updated_at);
3653 self.realtime_transcript
3654 .apply_prepared_rebase(realtime_rebase);
3655 Ok(())
3656 }
3657
3658 pub fn created_at(&self) -> SystemTime {
3660 self.created_at
3661 }
3662
3663 pub fn updated_at(&self) -> SystemTime {
3665 self.updated_at
3666 }
3667
3668 pub fn push(&mut self, message: Message) {
3672 self.messages.push(message);
3677 self.mark_content_mutated(SystemTime::now());
3678 }
3679
3680 pub fn push_batch(&mut self, messages: Vec<Message>) {
3684 if messages.is_empty() {
3685 return;
3686 }
3687 self.messages.extend_batch(messages);
3691 self.mark_content_mutated(SystemTime::now());
3692 }
3693
3694 pub async fn externalize_media(
3705 &mut self,
3706 blob_store: &dyn crate::BlobStore,
3707 start: usize,
3708 ) -> Result<(), crate::blob::BlobStoreError> {
3709 let buffer = self.messages.begin_in_place_scan();
3714 let lowest_mutated = match crate::image_content::externalize_messages_from_reporting_lowest(
3715 blob_store, buffer, start,
3716 )
3717 .await
3718 {
3719 Ok(lowest_mutated) => lowest_mutated,
3720 Err(error) => {
3721 self.messages.finish_in_place_scan(Some(start));
3724 return Err(error);
3725 }
3726 };
3727 self.messages.finish_in_place_scan(lowest_mutated);
3728 Ok(())
3729 }
3730
3731 pub async fn hydrate_realtime_user_images(
3739 &mut self,
3740 blob_store: &dyn crate::BlobStore,
3741 max_decoded_bytes: usize,
3742 ) -> Result<(), crate::image_content::RealtimeUserImageHydrationError> {
3743 self.hydrate_realtime_user_images_with_usage(blob_store, max_decoded_bytes)
3744 .await
3745 .map(|_| ())
3746 }
3747
3748 pub async fn hydrate_realtime_user_images_with_usage(
3751 &mut self,
3752 blob_store: &dyn crate::BlobStore,
3753 max_decoded_bytes: usize,
3754 ) -> Result<usize, crate::image_content::RealtimeUserImageHydrationError> {
3755 let buffer = self.messages.begin_in_place_scan();
3757 let (decoded_total, lowest_mutated) =
3758 match crate::image_content::hydrate_user_images_for_realtime_projection_reporting_lowest(
3759 blob_store,
3760 buffer,
3761 max_decoded_bytes,
3762 )
3763 .await
3764 {
3765 Ok(outcome) => outcome,
3766 Err(error) => {
3767 self.messages.finish_in_place_scan(Some(0));
3768 return Err(error);
3769 }
3770 };
3771 self.messages.finish_in_place_scan(lowest_mutated);
3772 Ok(decoded_total)
3775 }
3776
3777 fn mark_content_mutated(&mut self, at: SystemTime) {
3779 self.updated_at = at;
3780 }
3781
3782 pub fn touch(&mut self) {
3786 self.mark_content_mutated(SystemTime::now());
3787 }
3788
3789 pub fn last_n(&self, n: usize) -> &[Message] {
3791 let start = self.messages.len().saturating_sub(n);
3792 &self.messages[start..]
3793 }
3794
3795 pub fn total_tokens(&self) -> u64 {
3797 self.usage.total_tokens()
3798 }
3799
3800 pub fn total_usage(&self) -> Usage {
3802 self.usage.clone()
3803 }
3804
3805 pub fn record_usage(&mut self, turn_usage: Usage) {
3807 self.usage.add(&turn_usage);
3808 self.mark_content_mutated(SystemTime::now());
3809 }
3810
3811 pub fn append_external_user_content(&mut self, content: ContentInput) {
3813 self.push(Message::User(UserMessage::with_blocks(
3814 content.into_blocks(),
3815 )));
3816 }
3817
3818 pub fn append_external_assistant_blocks(
3820 &mut self,
3821 blocks: Vec<AssistantBlock>,
3822 stop_reason: StopReason,
3823 usage: Usage,
3824 ) {
3825 if !blocks.is_empty() {
3826 self.push(Message::BlockAssistant(BlockAssistantMessage::new(
3827 blocks,
3828 stop_reason,
3829 )));
3830 }
3831 if usage != Usage::default() {
3832 self.record_usage(usage);
3833 }
3834 }
3835
3836 pub fn append_realtime_transcript_event(
3844 &mut self,
3845 event: RealtimeTranscriptEvent,
3846 ) -> RealtimeTranscriptApplyOutcome {
3847 let (commit, recorded) =
3848 self.realtime_transcript
3849 .apply_event(event)
3850 .unwrap_or_else(|err| {
3851 fail_closed_generated_restore(
3852 "realtime-transcript",
3853 <serde_json::Error as serde::de::Error>::custom(err),
3854 )
3855 });
3856 if recorded {
3857 self.mark_content_mutated(SystemTime::now());
3858 }
3859 self.push_batch(commit.messages);
3860 if commit.usage != Usage::default() {
3861 self.record_usage(commit.usage);
3862 }
3863 commit.outcome
3864 }
3865
3866 #[must_use]
3869 pub fn preflight_realtime_user_content_event(
3870 &self,
3871 event: &RealtimeTranscriptEvent,
3872 ) -> Option<crate::RealtimeUserContentApplyOutcome> {
3873 realtime_transcript_revision::preflight_realtime_user_content_event(
3874 self.realtime_transcript.state(),
3875 event,
3876 )
3877 .unwrap_or_else(|err| {
3878 fail_closed_generated_restore(
3879 "realtime-user-content-preflight",
3880 <serde_json::Error as serde::de::Error>::custom(err),
3881 )
3882 })
3883 }
3884
3885 #[must_use]
3904 pub fn in_flight_realtime_assistant_response_ids(&self) -> Vec<String> {
3905 realtime_transcript_revision::in_flight_realtime_assistant_response_ids(
3906 self.realtime_transcript.state(),
3907 )
3908 }
3909
3910 #[must_use]
3913 pub fn realtime_user_content_identities(&self) -> Vec<RealtimeUserContentIdentity> {
3914 realtime_transcript_revision::realtime_user_content_identities(
3915 self.realtime_transcript.state(),
3916 )
3917 }
3918
3919 #[must_use]
3922 pub fn pending_realtime_user_content_blob(
3923 &self,
3924 ) -> Option<crate::PendingRealtimeUserContentBlob> {
3925 realtime_transcript_revision::pending_realtime_user_content_blob(
3926 self.realtime_transcript.state(),
3927 )
3928 }
3929
3930 pub fn stage_pending_realtime_user_content_blob(
3933 &mut self,
3934 pending: crate::PendingRealtimeUserContentBlob,
3935 ) -> Result<
3936 crate::generated::session_document::RealtimeUserContentBlobStageDisposition,
3937 realtime_transcript_revision::RealtimeTranscriptShellError,
3938 > {
3939 match self
3940 .realtime_transcript
3941 .stage_pending_user_content_blob(pending)
3942 {
3943 Ok(disposition) => {
3944 if disposition
3945 == crate::generated::session_document::RealtimeUserContentBlobStageDisposition::StageNew
3946 {
3947 self.mark_content_mutated(SystemTime::now());
3948 }
3949 Ok(disposition)
3950 }
3951 Err(RealtimeTranscriptSidecarError::Reducer(error)) => Err(error),
3952 Err(error) => fail_closed_generated_restore(
3953 "realtime-user-content-stage",
3954 <serde_json::Error as serde::de::Error>::custom(error),
3955 ),
3956 }
3957 }
3958
3959 pub fn resolve_pending_realtime_user_content_blob_recovery(
3960 &self,
3961 request: Option<&crate::PendingRealtimeUserContentBlob>,
3962 pending_blob_valid: bool,
3963 ) -> Result<
3964 crate::generated::session_document::RealtimeUserContentBlobRecoveryDisposition,
3965 realtime_transcript_revision::RealtimeTranscriptShellError,
3966 > {
3967 realtime_transcript_revision::resolve_pending_realtime_user_content_blob_recovery(
3968 self.realtime_transcript.state(),
3969 request,
3970 pending_blob_valid,
3971 )
3972 }
3973
3974 pub fn clear_invalid_pending_realtime_user_content_blob(
3977 &mut self,
3978 request: Option<&crate::PendingRealtimeUserContentBlob>,
3979 ) -> Result<(), realtime_transcript_revision::RealtimeTranscriptShellError> {
3980 match self
3981 .realtime_transcript
3982 .clear_invalid_pending_user_content_blob(request)
3983 {
3984 Ok(()) => {
3985 self.mark_content_mutated(SystemTime::now());
3986 Ok(())
3987 }
3988 Err(RealtimeTranscriptSidecarError::Reducer(error)) => Err(error),
3989 Err(error) => fail_closed_generated_restore(
3990 "realtime-user-content-clear",
3991 <serde_json::Error as serde::de::Error>::custom(error),
3992 ),
3993 }
3994 }
3995
3996 #[must_use]
4000 pub fn realtime_user_content_tombstones(
4001 &self,
4002 ) -> Vec<crate::realtime_transcript::RealtimeUserContentTombstone> {
4003 realtime_transcript_revision::realtime_user_content_tombstones(
4004 self.realtime_transcript.state(),
4005 )
4006 }
4007
4008 fn prepare_realtime_transcript_rebase_after_rewrite(
4009 &self,
4010 messages: &[Message],
4011 reason: RealtimeTranscriptSnapshotReasonV1,
4012 ) -> Result<PreparedRealtimeTranscriptRebase, TranscriptEditError> {
4013 let state =
4014 realtime_transcript_revision::reconcile_realtime_transcript_state_after_rewrite(
4015 self.realtime_transcript.state().clone(),
4016 messages,
4017 )
4018 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4019 self.realtime_transcript
4020 .prepare_rebase_snapshot(state, reason)
4021 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))
4022 }
4023
4024 pub fn append_system_message(&mut self, content: impl Into<String>) {
4026 use crate::types::SystemMessage;
4027
4028 self.push(Message::System(SystemMessage::new(content)));
4029 }
4030
4031 pub fn append_system_message_idempotent(
4038 &mut self,
4039 content: impl Into<String>,
4040 source: Option<String>,
4041 idempotency_key: Option<String>,
4042 created_at: crate::types::MessageTimestamp,
4043 ) -> Result<crate::service::AppendSystemContextStatus, SystemMessageAppendError> {
4044 use crate::types::{SystemMessage, SystemMessageIdentity};
4045
4046 let content = content.into();
4047 if let Some(key) = idempotency_key.as_deref() {
4048 for message in self.messages() {
4049 let Message::System(existing) = message else {
4050 continue;
4051 };
4052 let Some(identity) = existing.identity.as_ref() else {
4053 continue;
4054 };
4055 if identity.idempotency_key.as_deref() != Some(key) {
4056 continue;
4057 }
4058 if existing.content == content && identity.source == source {
4059 return Ok(crate::service::AppendSystemContextStatus::Duplicate);
4060 }
4061 return Err(SystemMessageAppendError::Conflict {
4062 key: key.to_string(),
4063 existing_text: existing.content.clone(),
4064 existing_source: identity.source.clone(),
4065 });
4066 }
4067 }
4068
4069 let identity =
4070 (source.is_some() || idempotency_key.is_some()).then_some(SystemMessageIdentity {
4071 source,
4072 idempotency_key,
4073 });
4074 self.push(Message::System(SystemMessage {
4075 content,
4076 created_at,
4077 identity,
4078 }));
4079 Ok(crate::service::AppendSystemContextStatus::Applied)
4080 }
4081
4082 pub fn messages_for_model_boundary(&self) -> Vec<Message> {
4087 self.messages().to_vec()
4088 }
4089
4090 pub fn last_assistant_text(&self) -> Option<String> {
4097 self.messages.iter().rev().find_map(|m| match m {
4098 Message::BlockAssistant(a) => {
4099 let mut buf = String::new();
4100 for block in &a.blocks {
4101 match block {
4102 crate::types::AssistantBlock::Text { text, .. }
4103 | crate::types::AssistantBlock::Transcript { text, .. } => {
4104 buf.push_str(text);
4105 }
4106 _ => {}
4107 }
4108 }
4109 if buf.is_empty() { None } else { Some(buf) }
4110 }
4111 _ => None,
4112 })
4113 }
4114
4115 pub fn tool_call_count(&self) -> usize {
4117 self.messages
4118 .iter()
4119 .filter_map(|m| match m {
4120 Message::BlockAssistant(a) => Some(
4121 a.blocks
4122 .iter()
4123 .filter(|b| matches!(b, crate::types::AssistantBlock::ToolUse { .. }))
4124 .count(),
4125 ),
4126 _ => None,
4127 })
4128 .sum()
4129 }
4130
4131 pub fn metadata(&self) -> &serde_json::Map<String, serde_json::Value> {
4138 &self.metadata
4139 }
4140
4141 pub(crate) fn whole_blob_realtime_transcript_state(
4144 &self,
4145 ) -> Option<&SessionRealtimeTranscriptState> {
4146 self.realtime_transcript.whole_blob_projection()
4147 }
4148
4149 pub(crate) fn inject_realtime_whole_blob_projection(
4153 &self,
4154 metadata: &mut serde_json::Map<String, serde_json::Value>,
4155 ) -> Result<(), serde_json::Error> {
4156 if let Some(projection) = self.whole_blob_realtime_transcript_state() {
4157 metadata.insert(
4158 SESSION_REALTIME_TRANSCRIPT_STATE_KEY.to_string(),
4159 serde_json::to_value(projection)?,
4160 );
4161 }
4162 Ok(())
4163 }
4164
4165 pub(crate) fn realtime_component_event_prefix(
4168 &self,
4169 ) -> Result<crate::ComponentEventPrefixAuthority, RealtimeTranscriptSidecarError> {
4170 self.realtime_transcript.successor_prefix()
4171 }
4172
4173 pub(crate) fn realtime_component_event_acknowledged_prefix(
4175 &self,
4176 ) -> &crate::ComponentEventPrefixAuthority {
4177 self.realtime_transcript.acknowledged_prefix()
4178 }
4179
4180 #[doc(hidden)]
4187 pub fn activate_realtime_component_sidecar(
4188 &mut self,
4189 ) -> Result<(), RealtimeTranscriptSidecarError> {
4190 let Some(value) = self.metadata.get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY) else {
4191 return Ok(());
4194 };
4195 if !self.realtime_transcript.is_pristine() {
4196 return Err(RealtimeTranscriptSidecarError::Incoherent(
4197 "inline realtime projection cannot replace an active component sidecar".to_string(),
4198 ));
4199 }
4200 let state = serde_json::from_value(value.clone())?;
4201 let projection =
4202 SessionRealtimeTranscriptProjection::from_inline_snapshot(&self.id, state)?;
4203 self.metadata.remove(SESSION_REALTIME_TRANSCRIPT_STATE_KEY);
4204 *self.realtime_transcript = projection;
4205 Ok(())
4206 }
4207
4208 #[doc(hidden)]
4212 pub fn prepare_realtime_component_event_suffix(
4213 &self,
4214 ) -> Result<Option<crate::PreparedComponentEventSuffix>, RealtimeTranscriptSidecarError> {
4215 self.realtime_transcript.prepare_suffix()
4216 }
4217
4218 pub(crate) fn install_verified_realtime_component_sequence(
4220 &mut self,
4221 sequence: &crate::VerifiedComponentEventSequence,
4222 ) -> Result<(), RealtimeTranscriptSidecarError> {
4223 *self.realtime_transcript =
4224 SessionRealtimeTranscriptProjection::from_verified_sequence(&self.id, sequence)?;
4225 Ok(())
4226 }
4227
4228 pub(crate) fn acknowledge_realtime_component_event_suffix(
4231 &mut self,
4232 prepared: &crate::PreparedComponentEventSuffix,
4233 committed: &crate::ComponentEventPrefixAuthority,
4234 ) -> Result<(), RealtimeTranscriptSidecarError> {
4235 self.realtime_transcript
4236 .acknowledge_suffix(prepared, committed)
4237 }
4238
4239 pub(crate) fn head_canonical_metadata_projection(
4240 &self,
4241 ) -> Result<Arc<SessionHeadMetadataProjection>, serde_json::Error> {
4242 self.history_caches
4243 .head_canonical_metadata
4244 .projection(&self.metadata)
4245 .map_err(<serde_json::Error as serde::ser::Error>::custom)
4246 }
4247
4248 pub(crate) fn install_head_canonical_metadata_projection(
4249 &mut self,
4250 projection: &Arc<SessionHeadMetadataProjection>,
4251 ) -> Result<(), String> {
4252 self.history_caches
4253 .head_canonical_metadata
4254 .install_snapshot(projection)
4255 }
4256
4257 pub(crate) fn acknowledge_head_canonical_metadata_projection(
4258 &mut self,
4259 projection: &Arc<SessionHeadMetadataProjection>,
4260 ) -> Result<(), String> {
4261 self.history_caches
4262 .head_canonical_metadata
4263 .acknowledge(projection, &self.metadata)
4264 }
4265
4266 pub(crate) fn validate_head_canonical_metadata_acknowledgement(
4267 &self,
4268 projection: &Arc<SessionHeadMetadataProjection>,
4269 ) -> Result<(), String> {
4270 self.history_caches
4271 .head_canonical_metadata
4272 .validate_acknowledgement(projection, &self.metadata)
4273 }
4274
4275 fn mark_head_canonical_metadata_key_mutated(&mut self, key: &str) {
4276 self.history_caches
4277 .head_canonical_metadata
4278 .mark_key_mutated(key);
4279 }
4280
4281 fn adopt_head_canonical_metadata_baseline_from(&mut self, source: &Session) {
4282 self.history_caches.head_canonical_metadata =
4283 source.history_caches.head_canonical_metadata.clone();
4284 }
4285
4286 fn set_metadata_unchecked(&mut self, key: &str, value: serde_json::Value) {
4287 if self.metadata.get(key) == Some(&value) {
4294 return;
4295 }
4296 self.mark_head_canonical_metadata_key_mutated(key);
4297 self.metadata.insert(key.to_string(), value);
4298 if key == SESSION_TRANSCRIPT_HISTORY_STATE_KEY {
4299 self.metadata
4300 .remove(SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY);
4301 self.history_caches.shared_state.clear();
4302 self.transcript_history_metadata_validation =
4303 TranscriptHistoryMetadataValidation::RequiresValidation;
4304 }
4305 self.mark_content_mutated(SystemTime::now());
4306 }
4307
4308 fn install_validated_transcript_history_state(
4315 &mut self,
4316 state: TranscriptHistoryState,
4317 ) -> Result<(), serde_json::Error> {
4318 let state = std::sync::Arc::new(state);
4319 let unchanged = self
4320 .history_caches
4321 .shared_state
4322 .get()
4323 .is_some_and(|current| {
4324 current.graph_prefix() == state.graph_prefix()
4325 && current.rewrite_prefix() == state.rewrite_prefix()
4326 && current.head() == state.head()
4327 });
4328 self.metadata.remove(SESSION_TRANSCRIPT_HISTORY_STATE_KEY);
4329 self.metadata
4330 .remove(SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY);
4331 if unchanged {
4332 self.history_caches.shared_state.set(state);
4333 self.transcript_history_metadata_validation =
4334 TranscriptHistoryMetadataValidation::Validated;
4335 return Ok(());
4336 }
4337 self.history_caches.shared_state.set(state);
4338 self.transcript_history_metadata_validation =
4339 TranscriptHistoryMetadataValidation::Validated;
4340 Ok(())
4341 }
4342
4343 #[must_use]
4345 pub fn transcript_rewrite_prefix_authority(
4346 &self,
4347 ) -> Option<TranscriptRewritePrefixAccumulator> {
4348 if let Some(state) = self.history_caches.shared_state.get() {
4349 return Some(state.rewrite_prefix().clone());
4350 }
4351 serde_json::from_value(
4352 self.metadata
4353 .get(SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY)?
4354 .clone(),
4355 )
4356 .ok()
4357 }
4358
4359 #[cfg(test)]
4360 pub(crate) fn set_metadata_unchecked_for_test(&mut self, key: &str, value: serde_json::Value) {
4361 self.set_metadata_unchecked(key, value);
4362 }
4363
4364 fn fork_metadata_projection(&self) -> serde_json::Map<String, serde_json::Value> {
4365 let mut metadata = self.metadata.clone();
4366 metadata.retain(|key, _| !is_session_authority_metadata_key(key));
4367 metadata
4368 }
4369
4370 fn remove_metadata_unchecked(&mut self, key: &str) {
4371 let removed = self.metadata.remove(key).is_some();
4372 let mut changed = removed;
4373 if key == SESSION_TRANSCRIPT_HISTORY_STATE_KEY {
4374 changed |= self.history_caches.shared_state.get().is_some();
4375 changed |= self
4376 .metadata
4377 .remove(SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY)
4378 .is_some();
4379 self.history_caches.shared_state.clear();
4380 self.transcript_history_metadata_validation =
4381 TranscriptHistoryMetadataValidation::Validated;
4382 }
4383 if changed {
4384 self.mark_head_canonical_metadata_key_mutated(key);
4385 self.mark_content_mutated(SystemTime::now());
4386 }
4387 }
4388
4389 pub fn try_set_metadata(
4391 &mut self,
4392 key: &str,
4393 value: serde_json::Value,
4394 ) -> Result<(), ReservedSessionMetadataKey> {
4395 if is_session_authority_metadata_key(key) {
4396 return Err(ReservedSessionMetadataKey::new(key));
4397 }
4398 self.set_metadata_unchecked(key, value);
4399 Ok(())
4400 }
4401
4402 pub fn set_metadata(&mut self, key: &str, value: serde_json::Value) {
4407 if let Err(err) = self.try_set_metadata(key, value) {
4408 tracing::warn!(error = %err, "rejected raw session metadata mutation");
4409 }
4410 }
4411
4412 pub fn backfill_metadata_if_absent(&mut self, key: &str, value: serde_json::Value) -> bool {
4418 if is_session_authority_metadata_key(key) {
4419 tracing::warn!(
4420 metadata_key = key,
4421 "rejected raw session metadata backfill for authority key"
4422 );
4423 return false;
4424 }
4425 if self.metadata.contains_key(key) {
4426 false
4427 } else {
4428 self.metadata.insert(key.to_string(), value);
4429 self.mark_head_canonical_metadata_key_mutated(key);
4430 true
4431 }
4432 }
4433
4434 pub fn remove_metadata(&mut self, key: &str) {
4436 if is_session_authority_metadata_key(key) {
4437 tracing::warn!(
4438 metadata_key = key,
4439 "rejected raw session metadata removal for authority key"
4440 );
4441 return;
4442 }
4443 if self.metadata.remove(key).is_some() {
4444 self.mark_head_canonical_metadata_key_mutated(key);
4445 self.mark_content_mutated(SystemTime::now());
4446 }
4447 }
4448
4449 pub fn set_session_metadata(
4451 &mut self,
4452 metadata: SessionMetadata,
4453 ) -> Result<(), serde_json::Error> {
4454 let metadata =
4455 session_durable_config_authority::authorize_session_metadata_persist(metadata)
4456 .map_err(<serde_json::Error as serde::ser::Error>::custom)?
4457 .into_metadata();
4458 let value = serde_json::to_value(metadata)?;
4459 self.set_metadata_unchecked(SESSION_METADATA_KEY, value);
4460 Ok(())
4461 }
4462
4463 pub fn session_metadata(&self) -> Option<SessionMetadata> {
4468 match self.try_session_metadata() {
4469 Ok(metadata) => metadata,
4470 Err(err) => fail_closed_generated_restore("session-metadata", err),
4471 }
4472 }
4473
4474 pub fn try_session_metadata(&self) -> Result<Option<SessionMetadata>, serde_json::Error> {
4476 try_session_metadata_from_map(&self.metadata)
4477 }
4478
4479 pub fn set_deferred_turn_state(
4481 &mut self,
4482 state: SessionDeferredTurnState,
4483 ) -> Result<(), serde_json::Error> {
4484 let state = validate_deferred_turn_snapshot(state)
4485 .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
4486 let value = serde_json::to_value(state)?;
4487 self.set_metadata_unchecked(SESSION_DEFERRED_TURN_STATE_KEY, value);
4488 Ok(())
4489 }
4490
4491 pub fn try_deferred_turn_state(
4493 &self,
4494 ) -> Result<Option<SessionDeferredTurnState>, serde_json::Error> {
4495 self.metadata
4496 .get(SESSION_DEFERRED_TURN_STATE_KEY)
4497 .map(|value| {
4498 let state = serde_json::from_value(value.clone())?;
4499 validate_deferred_turn_snapshot(state)
4500 .map_err(<serde_json::Error as serde::de::Error>::custom)
4501 })
4502 .transpose()
4503 }
4504
4505 pub fn deferred_turn_state(&self) -> Option<SessionDeferredTurnState> {
4511 match self.try_deferred_turn_state() {
4512 Ok(state) => state,
4513 Err(err) => fail_closed_generated_restore("deferred-turn", err),
4514 }
4515 }
4516
4517 pub(crate) fn stage_pending_callback_tool_batch(
4520 &mut self,
4521 batch: PendingCallbackToolBatch,
4522 ) -> Result<(), PendingCallbackBatchError> {
4523 if matches!(
4524 self.callback_tool_batch_state()?,
4525 Some(CallbackToolBatchState::Pending { .. })
4526 ) {
4527 return Err(PendingCallbackBatchError::AlreadyStaged);
4528 }
4529 validate_pending_callback_batch(self.messages(), &batch)?;
4530 let value = serde_json::to_value(CallbackToolBatchState::Pending { batch })
4531 .map_err(|error| PendingCallbackBatchError::Malformed(error.to_string()))?;
4532 self.set_metadata_unchecked(SESSION_PENDING_CALLBACK_BATCH_KEY, value);
4533 Ok(())
4534 }
4535
4536 fn callback_tool_batch_state(
4537 &self,
4538 ) -> Result<Option<CallbackToolBatchState>, PendingCallbackBatchError> {
4539 self.metadata
4540 .get(SESSION_PENDING_CALLBACK_BATCH_KEY)
4541 .map(|value| {
4542 serde_json::from_value(value.clone())
4543 .map_err(|error| PendingCallbackBatchError::Malformed(error.to_string()))
4544 })
4545 .transpose()
4546 }
4547
4548 pub(crate) fn pending_callback_tool_batch(
4551 &self,
4552 ) -> Result<Option<PendingCallbackToolBatch>, PendingCallbackBatchError> {
4553 match self.callback_tool_batch_state()? {
4554 Some(CallbackToolBatchState::Pending { batch }) => {
4555 validate_pending_callback_batch(self.messages(), &batch)?;
4556 Ok(Some(batch))
4557 }
4558 Some(CallbackToolBatchState::Applied { .. }) | None => Ok(None),
4559 }
4560 }
4561
4562 pub(crate) fn resolve_pending_callback_tool_results(
4565 &self,
4566 incoming: Vec<ToolResult>,
4567 ) -> Result<ResolvedPendingCallbackToolResults, PendingCallbackBatchError> {
4568 let Some(state) = self.callback_tool_batch_state()? else {
4569 return Ok(ResolvedPendingCallbackToolResults::NoState);
4570 };
4571 let batch = match state {
4572 CallbackToolBatchState::Pending { batch } => batch,
4573 CallbackToolBatchState::Applied {
4574 tool_use_order,
4575 results,
4576 async_ops,
4577 ..
4578 } => {
4579 let incoming_by_id = unique_tool_results(incoming)?;
4580 let expected = tool_use_order.iter().cloned().collect::<BTreeSet<_>>();
4581 let actual = incoming_by_id.keys().cloned().collect::<BTreeSet<_>>();
4582 if actual != expected {
4583 return Err(PendingCallbackBatchError::ResultSetMismatch { expected, actual });
4584 }
4585 let delivered = tool_use_order
4586 .iter()
4587 .map(|id| incoming_by_id.get(id).cloned())
4588 .collect::<Option<Vec<_>>>()
4589 .ok_or_else(|| {
4590 PendingCallbackBatchError::Malformed(
4591 "applied callback receipt is missing an ordered result".to_string(),
4592 )
4593 })?;
4594 return if delivered == results {
4595 Ok(ResolvedPendingCallbackToolResults::AlreadyApplied { async_ops })
4596 } else {
4597 Err(PendingCallbackBatchError::ConflictingRedelivery)
4598 };
4599 }
4600 };
4601 validate_pending_callback_batch(self.messages(), &batch)?;
4602 let incoming_by_id = unique_tool_results(incoming)?;
4603 let expected = batch
4604 .pending_tool_use_ids
4605 .iter()
4606 .cloned()
4607 .collect::<BTreeSet<_>>();
4608 let actual = incoming_by_id.keys().cloned().collect::<BTreeSet<_>>();
4609 if actual != expected {
4610 return Err(PendingCallbackBatchError::ResultSetMismatch { expected, actual });
4611 }
4612 let mut all_by_id = unique_tool_results(batch.completed_results.clone())?;
4613 all_by_id.extend(incoming_by_id);
4614 let ordered = batch
4615 .tool_use_order
4616 .iter()
4617 .map(|id| {
4618 all_by_id.remove(id).ok_or_else(|| {
4619 PendingCallbackBatchError::Malformed(format!(
4620 "no result is available for assistant tool id '{id}'"
4621 ))
4622 })
4623 })
4624 .collect::<Result<Vec<_>, _>>()?;
4625 if !all_by_id.is_empty() {
4626 return Err(PendingCallbackBatchError::Malformed(format!(
4627 "results contain ids absent from assistant tool-use order: {:?}",
4628 all_by_id.keys().collect::<Vec<_>>()
4629 )));
4630 }
4631 Ok(ResolvedPendingCallbackToolResults::Pending {
4632 batch,
4633 ordered_results: ordered,
4634 })
4635 }
4636
4637 pub(crate) fn commit_pending_callback_tool_results(
4641 &mut self,
4642 batch: &PendingCallbackToolBatch,
4643 ordered_results: Vec<ToolResult>,
4644 post_tool_messages: Vec<Message>,
4645 ) -> Result<(), PendingCallbackBatchError> {
4646 let current = self
4647 .pending_callback_tool_batch()?
4648 .ok_or(PendingCallbackBatchError::Missing)?;
4649 if ¤t != batch {
4650 return Err(PendingCallbackBatchError::Malformed(
4651 "pending callback batch changed between prepare and commit".to_string(),
4652 ));
4653 }
4654 let actual_order = ordered_results
4655 .iter()
4656 .map(|result| result.tool_use_id.clone())
4657 .collect::<Vec<_>>();
4658 if actual_order != batch.tool_use_order {
4659 return Err(PendingCallbackBatchError::Malformed(format!(
4660 "resolved result order {actual_order:?} does not match assistant order {:?}",
4661 batch.tool_use_order
4662 )));
4663 }
4664 self.push(Message::tool_results(ordered_results.clone()));
4665 let pending_ids = batch
4666 .pending_tool_use_ids
4667 .iter()
4668 .cloned()
4669 .collect::<BTreeSet<_>>();
4670 let applied_callback_results = ordered_results
4671 .into_iter()
4672 .filter(|result| pending_ids.contains(&result.tool_use_id))
4673 .collect();
4674 let value = serde_json::to_value(CallbackToolBatchState::Applied {
4675 tool_use_order: batch.pending_tool_use_ids.clone(),
4676 results: applied_callback_results,
4677 async_ops: batch.async_ops.clone(),
4678 post_tool_messages,
4679 post_tool_messages_applied: false,
4680 })
4681 .map_err(|error| PendingCallbackBatchError::Malformed(error.to_string()))?;
4682 self.set_metadata_unchecked(SESSION_PENDING_CALLBACK_BATCH_KEY, value);
4683 Ok(())
4684 }
4685
4686 pub(crate) fn apply_pending_callback_resume_effects(
4691 &mut self,
4692 ) -> Result<Vec<crate::event::AssistantImageEvent>, PendingCallbackBatchError> {
4693 let Some(CallbackToolBatchState::Applied {
4694 tool_use_order,
4695 results,
4696 async_ops,
4697 post_tool_messages,
4698 post_tool_messages_applied,
4699 }) = self.callback_tool_batch_state()?
4700 else {
4701 return Ok(Vec::new());
4702 };
4703 if post_tool_messages_applied {
4704 return Ok(Vec::new());
4705 }
4706 let image_events = post_tool_messages
4707 .iter()
4708 .filter_map(|message| match message {
4709 Message::BlockAssistant(assistant) => Some(assistant.blocks.as_slice()),
4710 _ => None,
4711 })
4712 .flatten()
4713 .filter_map(crate::event::AssistantImageEvent::from_assistant_block)
4714 .collect::<Vec<_>>();
4715 let applied_state = CallbackToolBatchState::Applied {
4716 tool_use_order,
4717 results,
4718 async_ops,
4719 post_tool_messages: post_tool_messages.clone(),
4720 post_tool_messages_applied: true,
4721 };
4722 let value = serde_json::to_value(applied_state)
4723 .map_err(|error| PendingCallbackBatchError::Malformed(error.to_string()))?;
4724 self.push_batch(post_tool_messages);
4725 self.set_metadata_unchecked(SESSION_PENDING_CALLBACK_BATCH_KEY, value);
4726 Ok(image_events)
4727 }
4728
4729 pub fn set_lifecycle_terminal(
4738 &mut self,
4739 terminal: SessionLifecycleTerminal,
4740 ) -> Result<(), serde_json::Error> {
4741 let value = serde_json::to_value(terminal)?;
4742 self.set_metadata_unchecked(SESSION_LIFECYCLE_TERMINAL_KEY, value);
4743 Ok(())
4744 }
4745
4746 pub fn try_lifecycle_terminal(
4751 &self,
4752 ) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
4753 try_lifecycle_terminal_from_map(&self.metadata)
4754 }
4755
4756 pub fn lifecycle_terminal(&self) -> Option<SessionLifecycleTerminal> {
4762 match self.try_lifecycle_terminal() {
4763 Ok(state) => state,
4764 Err(err) => fail_closed_generated_restore("session-lifecycle-terminal", err),
4765 }
4766 }
4767
4768 pub fn set_build_state(&mut self, state: SessionBuildState) -> Result<(), serde_json::Error> {
4770 let state = session_durable_config_authority::authorize_session_build_state_persist(state)
4771 .map_err(<serde_json::Error as serde::ser::Error>::custom)?
4772 .into_state();
4773 let value = serde_json::to_value(state)?;
4774 self.set_metadata_unchecked(SESSION_BUILD_STATE_KEY, value);
4775 Ok(())
4776 }
4777
4778 pub fn build_state(&self) -> Option<SessionBuildState> {
4783 match self.try_build_state() {
4784 Ok(state) => state,
4785 Err(err) => fail_closed_generated_restore("session-build-state", err),
4786 }
4787 }
4788
4789 pub fn try_build_state(&self) -> Result<Option<SessionBuildState>, serde_json::Error> {
4791 let Some(value) = self.metadata.get(SESSION_BUILD_STATE_KEY) else {
4792 return Ok(None);
4793 };
4794 let state = serde_json::from_value::<SessionBuildState>(value.clone())?;
4795 session_durable_config_authority::restore_session_build_state(state)
4796 .map(Some)
4797 .map_err(<serde_json::Error as serde::de::Error>::custom)
4798 }
4799
4800 pub fn set_tool_visibility_state(
4802 &mut self,
4803 state: AuthorizedSessionToolVisibilityState,
4804 ) -> Result<(), serde_json::Error> {
4805 let value = serde_json::to_value(state.into_state())?;
4806 self.set_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY, value);
4807 Ok(())
4808 }
4809
4810 #[cfg(test)]
4815 pub(crate) fn clear_tool_visibility_state(&mut self) {
4816 self.remove_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY);
4817 }
4818
4819 pub fn tool_visibility_state(
4821 &self,
4822 ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
4823 self.try_tool_visibility_state()
4824 }
4825
4826 pub fn try_tool_visibility_state(
4829 &self,
4830 ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
4831 self.metadata
4832 .get(SESSION_TOOL_VISIBILITY_STATE_KEY)
4833 .map(|value| serde_json::from_value(value.clone()))
4834 .transpose()
4835 }
4836
4837 pub fn transcript_history_state(
4839 &self,
4840 ) -> Result<Option<TranscriptHistoryState>, serde_json::Error> {
4841 if let Some(state) = self.history_caches.shared_state.get() {
4842 return Ok(Some(state.as_ref().clone()));
4843 }
4844 self.metadata
4845 .get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
4846 .map(|value| serde_json::from_value(value.clone()))
4847 .transpose()
4848 }
4849
4850 pub fn transcript_history_state_shared(
4859 &self,
4860 ) -> Result<Option<std::sync::Arc<TranscriptHistoryState>>, serde_json::Error> {
4861 if let Some(state) = self.history_caches.shared_state.get() {
4862 return Ok(Some(state));
4863 }
4864 let Some(state) = self.transcript_history_state()? else {
4865 return Ok(None);
4866 };
4867 let state = std::sync::Arc::new(state);
4868 self.history_caches
4869 .shared_state
4870 .set(std::sync::Arc::clone(&state));
4871 Ok(Some(state))
4872 }
4873
4874 pub fn validated_transcript_history_state(
4885 &self,
4886 ) -> Result<Option<ValidatedTranscriptHistory>, TranscriptEditError> {
4887 let Some(state) = self
4888 .transcript_history_state_shared()
4889 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
4890 else {
4891 return Ok(None);
4892 };
4893 if self.transcript_history_metadata_validation
4894 == TranscriptHistoryMetadataValidation::Validated
4895 {
4896 return Ok(Some(ValidatedTranscriptHistory::adopt_session_validated(
4897 state,
4898 )));
4899 }
4900 Err(TranscriptEditError::HistoryStateMalformed(
4901 "transcript-history graph has structural bytes but no verified materialization or construction authority"
4902 .to_string(),
4903 ))
4904 }
4905
4906 pub fn already_validated_transcript_history_state(
4916 &self,
4917 ) -> Result<Option<ValidatedTranscriptHistory>, serde_json::Error> {
4918 if self.transcript_history_metadata_validation
4919 != TranscriptHistoryMetadataValidation::Validated
4920 {
4921 return Ok(None);
4922 }
4923 Ok(self
4924 .transcript_history_state_shared()?
4925 .map(ValidatedTranscriptHistory::adopt_session_validated))
4926 }
4927
4928 pub(crate) fn live_transcript_extends_history_head(
4937 &self,
4938 state: &TranscriptHistoryState,
4939 _live_revision: &str,
4940 ) -> Result<bool, TranscriptEditError> {
4941 let current_count = u64::try_from(self.messages.len()).map_err(|_| {
4942 TranscriptEditError::HistoryStateMalformed(
4943 "live transcript row count exceeds u64".to_string(),
4944 )
4945 })?;
4946 let endpoint = state.final_endpoint_witness().ok_or_else(|| {
4947 TranscriptEditError::HistoryStateMalformed(
4948 "compact transcript graph has no final endpoint witness".to_string(),
4949 )
4950 })?;
4951 Ok(self.exact_message_row_lineage_extends(endpoint.row_prefix(), current_count))
4952 }
4953
4954 pub fn compaction_projection_intents(
4957 &self,
4958 ) -> Result<Vec<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
4959 self.metadata
4960 .get(crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY)
4961 .map(|value| serde_json::from_value(value.clone()))
4962 .transpose()
4963 .map(Option::unwrap_or_default)
4964 }
4965
4966 pub fn validated_compaction_projection_intents(
4974 &self,
4975 ) -> Result<Vec<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
4976 let intents = self.compaction_projection_intents()?;
4977 if intents.is_empty() {
4978 return Ok(intents);
4979 }
4980 let history = self
4981 .validated_transcript_history_state()
4982 .map_err(|error| <serde_json::Error as serde::ser::Error>::custom(error.to_string()))?;
4983 let mut unique = std::collections::HashSet::new();
4984 for intent in &intents {
4985 if intent.projection.session_id() != self.id() {
4986 return Err(<serde_json::Error as serde::ser::Error>::custom(
4987 "compaction projection outbox intent has a foreign session id",
4988 ));
4989 }
4990 if !unique.insert(intent.projection.clone()) {
4991 return Err(<serde_json::Error as serde::ser::Error>::custom(
4992 "compaction projection outbox contains a duplicate rewrite identity",
4993 ));
4994 }
4995 let backed = history.as_ref().is_some_and(|history| {
4996 history.commits().any(|commit| {
4997 intent
4998 .projection
4999 .matches_transcript_rewrite(self.id(), commit)
5000 })
5001 });
5002 if !backed {
5003 return Err(<serde_json::Error as serde::ser::Error>::custom(format!(
5004 "compaction projection outbox intent {} has no matching TranscriptRewriteCommit",
5005 intent.projection.revision()
5006 )));
5007 }
5008 }
5009 Ok(intents)
5010 }
5011
5012 pub fn add_compaction_projection_intent(
5015 &mut self,
5016 intent: crate::memory::CompactionProjectionIntent,
5017 ) -> Result<(), serde_json::Error> {
5018 if intent.projection.session_id() != self.id() {
5019 return Err(<serde_json::Error as serde::ser::Error>::custom(
5020 "compaction projection intent session does not match snapshot session",
5021 ));
5022 }
5023 let history = self
5024 .validated_transcript_history_state()
5025 .map_err(|error| <serde_json::Error as serde::ser::Error>::custom(error.to_string()))?
5026 .ok_or_else(|| {
5027 <serde_json::Error as serde::ser::Error>::custom(
5028 "compaction projection intent requires transcript history state",
5029 )
5030 })?;
5031 let owns_commit = history.commits().any(|commit| {
5032 commit.parent_revision == intent.projection.parent_revision()
5033 && commit.revision == intent.projection.revision()
5034 && intent
5035 .projection
5036 .matches_transcript_rewrite(self.id(), commit)
5037 });
5038 if !owns_commit {
5039 return Err(<serde_json::Error as serde::ser::Error>::custom(
5040 "compaction projection intent is not backed by the session transcript graph",
5041 ));
5042 }
5043 let mut intents = self.validated_compaction_projection_intents()?;
5044 if let Some(existing) = intents
5045 .iter()
5046 .find(|existing| existing.projection == intent.projection)
5047 {
5048 if existing == &intent {
5049 return Ok(());
5050 }
5051 return Err(<serde_json::Error as serde::ser::Error>::custom(
5052 "compaction projection intent conflicts with an existing rewrite identity",
5053 ));
5054 }
5055 intents.push(intent);
5056 self.set_metadata_unchecked(
5057 crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
5058 serde_json::to_value(intents)?,
5059 );
5060 Ok(())
5061 }
5062
5063 pub fn complete_compaction_projection_intent(
5066 &mut self,
5067 projection: &crate::memory::CompactionProjectionId,
5068 ) -> Result<Option<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
5069 let mut intents = self.compaction_projection_intents()?;
5070 let Some(position) = intents
5071 .iter()
5072 .position(|intent| &intent.projection == projection)
5073 else {
5074 return Ok(None);
5075 };
5076 let completed = intents.remove(position);
5077 if intents.is_empty() {
5078 self.remove_metadata_unchecked(
5079 crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
5080 );
5081 } else {
5082 self.set_metadata_unchecked(
5083 crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
5084 serde_json::to_value(intents)?,
5085 );
5086 }
5087 Ok(Some(completed))
5088 }
5089
5090 pub fn validate_transcript_history_state(&self) -> Result<(), TranscriptEditError> {
5092 if self.transcript_history_metadata_validation
5093 == TranscriptHistoryMetadataValidation::Validated
5094 {
5095 return Ok(());
5096 }
5097 if self.history_caches.shared_state.get().is_some()
5098 || self
5099 .metadata
5100 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
5101 {
5102 return Err(TranscriptEditError::HistoryStateMalformed(
5103 "transcript-history graph has not crossed verified materialization or construction authority"
5104 .to_string(),
5105 ));
5106 }
5107 Ok(())
5108 }
5109
5110 pub fn clear_transcript_history_state(&mut self) {
5113 self.remove_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY);
5114 }
5115
5116 pub fn adopt_recovered_head_state(&mut self, head: &Session) -> Result<(), String> {
5145 const RECOVERY_OWNED_KEYS: [&str; 3] = [
5146 SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
5147 SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY,
5148 SESSION_LIFECYCLE_TERMINAL_KEY,
5149 ];
5150 let recovered_archived = self
5151 .try_lifecycle_terminal()
5152 .map_err(|error| format!("recovered lifecycle-terminal is malformed: {error}"))?
5153 == Some(SessionLifecycleTerminal::Archived);
5154 let head_terminal = head
5155 .try_lifecycle_terminal()
5156 .map_err(|error| format!("durable-head lifecycle-terminal is malformed: {error}"))?;
5157 let head_archived = head_terminal == Some(SessionLifecycleTerminal::Archived);
5158 let mut lifecycle_authority = session_document::SessionDocumentMachineAuthority::new();
5159 let lifecycle_merge = lifecycle_authority
5160 .resolve_session_document_lifecycle_merge(
5161 session_document::SessionDocumentKey::new(self.id.to_string()),
5162 recovered_archived,
5163 head_archived,
5164 )
5165 .map_err(|error| {
5166 format!("session document authority rejected recovered lifecycle merge: {error}")
5167 })?
5168 .into_iter()
5169 .find_map(|effect| {
5170 match effect {
5171 session_document::SessionDocumentEffect::SessionDocumentLifecycleMergeResolved {
5172 merge,
5173 } => Some(merge),
5174 _ => None,
5175 }
5176 })
5177 .ok_or_else(|| {
5178 "session document authority emitted no recovered lifecycle merge".to_string()
5179 })?;
5180 let SessionSerdeRef {
5184 version: _identity_version,
5185 id: _identity_id,
5186 messages: _recovery_owned_messages,
5187 created_at: _identity_created_at,
5188 updated_at: head_updated_at,
5189 metadata: head_metadata,
5190 usage: head_usage,
5191 } = persisted_envelope_ref(head, None);
5192 self.usage = head_usage.clone();
5193 self.adopt_head_canonical_metadata_baseline_from(head);
5197 self.metadata.retain(|key, _| {
5198 RECOVERY_OWNED_KEYS.contains(&key.as_str()) || head_metadata.contains_key(key)
5199 });
5200 for (key, value) in head_metadata {
5201 if RECOVERY_OWNED_KEYS.contains(&key.as_str()) {
5202 continue;
5203 }
5204 self.metadata.insert(key.clone(), value.clone());
5205 }
5206 match lifecycle_merge {
5207 session_document::SessionDocumentLifecycleMerge::CarryArchived => self
5208 .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
5209 .map_err(|error| {
5210 format!("failed to realize absorbing Archived terminal: {error}")
5211 })?,
5212 session_document::SessionDocumentLifecycleMerge::CarryAuthority => {
5213 match head_terminal {
5214 Some(terminal) => self.set_lifecycle_terminal(terminal).map_err(|error| {
5215 format!("failed to realize durable-head lifecycle terminal: {error}")
5216 })?,
5217 None => {
5218 self.remove_metadata_unchecked(SESSION_LIFECYCLE_TERMINAL_KEY);
5219 }
5220 }
5221 }
5222 }
5223 self.mark_content_mutated(*head_updated_at);
5224 Ok(())
5225 }
5226
5227 pub fn transcript_revision_body(
5229 &self,
5230 revision: &str,
5231 ) -> Result<Option<TranscriptRevisionBody>, serde_json::Error> {
5232 let Some(history) = self
5233 .validated_transcript_history_state()
5234 .map_err(|error| <serde_json::Error as serde::ser::Error>::custom(error.to_string()))?
5235 else {
5236 return Ok(None);
5237 };
5238 if !history.state().contains_revision(revision) {
5239 return Ok(None);
5240 }
5241 history
5242 .materialize_revision(revision)
5243 .map(Some)
5244 .map_err(|error| <serde_json::Error as serde::ser::Error>::custom(error.to_string()))
5245 }
5246
5247 pub fn transcript_revision_messages(
5249 &self,
5250 revision: &str,
5251 ) -> Result<Option<Vec<Message>>, serde_json::Error> {
5252 Ok(self
5253 .transcript_revision_body(revision)?
5254 .map(|body| body.messages))
5255 }
5256
5257 pub fn apply_transcript_history_state(
5259 &mut self,
5260 mut state: TranscriptHistoryState,
5261 ) -> Result<(), TranscriptEditError> {
5262 state.compact_mechanical_revision_bodies()?;
5263 self.apply_proved_transcript_history_state(state)
5264 }
5265
5266 pub fn apply_validated_transcript_history_state(
5276 &mut self,
5277 validated: ValidatedTranscriptHistory,
5278 ) -> Result<(), TranscriptEditError> {
5279 let mut state = validated.into_state();
5280 state.prune_mechanical_revision_bodies();
5284 self.apply_proved_transcript_history_state(state)
5285 }
5286
5287 pub fn install_validated_audited_transcript_history_preserving_live(
5298 &mut self,
5299 validated: ValidatedTranscriptHistory,
5300 ) -> Result<(), TranscriptEditError> {
5301 let mut state = validated.into_state();
5302 state.canonicalize_to_latest_audited_head();
5303 let live_revision = self
5304 .transcript_content_digest()
5305 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
5306 if !self.live_transcript_extends_history_head(&state, &live_revision)? {
5307 return Err(TranscriptEditError::HistoryStateMalformed(format!(
5308 "audited transcript head {} is not a prefix ancestor of live revision {live_revision}",
5309 state.head()
5310 )));
5311 }
5312 self.install_validated_transcript_history_state(state)
5317 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))
5318 }
5319
5320 pub fn with_validated_transcript_history_projection(
5331 &self,
5332 validated: ValidatedTranscriptHistory,
5333 ) -> Result<Self, TranscriptEditError> {
5334 let metadata = self
5335 .metadata
5336 .iter()
5337 .filter(|(key, _)| key.as_str() != SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
5338 .map(|(key, value)| (key.clone(), value.clone()))
5339 .collect();
5340 let mut projected = Self {
5341 version: self.version,
5342 id: self.id.clone(),
5343 messages: self.messages.clone(),
5344 created_at: self.created_at,
5345 updated_at: self.updated_at,
5346 metadata,
5347 realtime_transcript: self.realtime_transcript.clone(),
5348 history_caches: Box::default(),
5349 transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
5350 usage: self.usage.clone(),
5351 };
5352 projected.apply_validated_transcript_history_state(validated)?;
5353 Ok(projected)
5354 }
5355
5356 fn apply_proved_transcript_history_state(
5357 &mut self,
5358 state: TranscriptHistoryState,
5359 ) -> Result<(), TranscriptEditError> {
5360 let head_body = state.materialize_revision(state.head())?;
5361 let realtime_rebase = self.prepare_realtime_transcript_rebase_after_rewrite(
5362 &head_body.messages,
5363 RealtimeTranscriptSnapshotReasonV1::RecoveryRebase,
5364 )?;
5365 let mut updated_at = head_body.created_at;
5366 for commit in state.commits() {
5367 if commit.committed_at > updated_at {
5368 updated_at = commit.committed_at;
5369 }
5370 }
5371 self.install_validated_transcript_history_state(state)
5372 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
5373 self.realtime_transcript
5374 .apply_prepared_rebase(realtime_rebase);
5375 self.messages.replace(head_body.messages);
5377 self.mark_content_mutated(updated_at);
5378 Ok(())
5379 }
5380
5381 pub fn transcript_revision(&self) -> Result<String, serde_json::Error> {
5387 self.transcript_content_digest()
5388 }
5389
5390 pub fn transcript_rewrite_generation(&self) -> Result<u64, serde_json::Error> {
5395 Ok(self
5396 .transcript_history_state_shared()?
5397 .and_then(|state| state.last_commit().map(|commit| commit.rewrite_generation))
5398 .unwrap_or(0))
5399 }
5400
5401 pub fn commit_transcript_rewrite(
5403 &mut self,
5404 selection: TranscriptRewriteSelection,
5405 replacement: Vec<Message>,
5406 reason: TranscriptRewriteReason,
5407 actor: Option<String>,
5408 expected_parent_revision: Option<String>,
5409 ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
5410 let selection = selection.into_current_edit_semantic();
5411 if selection.semantic() == TranscriptRewriteSemantic::Compaction {
5412 return Err(TranscriptEditError::InvalidTranscriptShape(
5413 "typed compaction rewrites require a core-validated compaction witness".to_string(),
5414 ));
5415 }
5416 self.commit_transcript_rewrite_authorized(
5417 selection,
5418 replacement,
5419 reason,
5420 actor,
5421 expected_parent_revision,
5422 )
5423 }
5424
5425 fn commit_transcript_rewrite_authorized(
5426 &mut self,
5427 selection: TranscriptRewriteSelection,
5428 replacement: Vec<Message>,
5429 reason: TranscriptRewriteReason,
5430 actor: Option<String>,
5431 expected_parent_revision: Option<String>,
5432 ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
5433 self.commit_transcript_rewrite_bound(
5434 selection,
5435 replacement,
5436 reason,
5437 actor,
5438 expected_parent_revision,
5439 None,
5440 )
5441 }
5442
5443 fn commit_transcript_rewrite_bound(
5450 &mut self,
5451 selection: TranscriptRewriteSelection,
5452 replacement: Vec<Message>,
5453 reason: TranscriptRewriteReason,
5454 actor: Option<String>,
5455 expected_parent_revision: Option<String>,
5456 expected_revision: Option<&str>,
5457 ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
5458 let parent_revision = self
5459 .transcript_revision()
5460 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
5461 if let Some(expected) = expected_parent_revision
5462 && expected != parent_revision
5463 {
5464 return Err(TranscriptEditError::RevisionConflict {
5465 expected,
5466 actual: parent_revision,
5467 });
5468 }
5469
5470 let (start, end) = selection.bounds();
5471 let message_count = self.messages.len();
5472 if start > end || end > message_count {
5473 return Err(TranscriptEditError::InvalidRewriteRange {
5474 start,
5475 end,
5476 message_count,
5477 });
5478 }
5479
5480 let replacement_len = replacement.len();
5481 let mut rewritten = Vec::with_capacity(
5482 start
5483 .saturating_add(replacement_len)
5484 .saturating_add(message_count.saturating_sub(end)),
5485 );
5486 rewritten.extend_from_slice(&self.messages[..start]);
5487 rewritten.extend(replacement.iter().cloned());
5488 rewritten.extend_from_slice(&self.messages[end..]);
5489 validate_transcript_tool_result_shape(&rewritten)?;
5490 let revision = transcript_messages_digest(&rewritten)
5495 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
5496 if let Some(expected) = expected_revision
5497 && expected != revision
5498 {
5499 return Err(TranscriptEditError::InvalidTranscriptShape(
5500 "validated compaction witness does not authorize this exact transcript rebuild"
5501 .to_string(),
5502 ));
5503 }
5504 if revision == parent_revision {
5505 return Err(TranscriptEditError::NoOpRewrite { revision });
5506 }
5507 let original_span_digest = if start == 0 && end == message_count {
5508 self.transcript_content_digest()
5511 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
5512 } else {
5513 transcript_messages_digest(&self.messages[start..end])
5514 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
5515 };
5516 let replacement_digest = if start == 0 && start + replacement_len == rewritten.len() {
5517 revision.clone()
5519 } else {
5520 transcript_messages_digest(&rewritten[start..start + replacement_len])
5521 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
5522 };
5523 let realtime_rebase = self.prepare_realtime_transcript_rebase_after_rewrite(
5524 &rewritten,
5525 RealtimeTranscriptSnapshotReasonV1::TranscriptRewrite,
5526 )?;
5527 let prior_history = self.validated_transcript_history_state()?;
5528 let rewrite_generation = prior_history
5529 .as_ref()
5530 .and_then(|history| history.last_commit())
5531 .map_or(Some(1), |commit| commit.rewrite_generation.checked_add(1))
5532 .ok_or_else(|| {
5533 TranscriptEditError::HistoryStateMalformed(
5534 "transcript rewrite generation exhausted u64".to_string(),
5535 )
5536 })?;
5537 let committed_at = SystemTime::now();
5538 let commit = TranscriptRewriteCommit {
5539 rewrite_generation,
5540 parent_revision,
5541 revision,
5542 selection,
5543 original_span_digest,
5544 replacement_digest,
5545 messages_before: message_count,
5546 messages_after: rewritten.len(),
5547 reason,
5548 actor,
5549 committed_at,
5550 };
5551 self.finish_compact_transcript_rewrite(
5552 prior_history,
5553 commit,
5554 replacement,
5555 rewritten,
5556 realtime_rebase,
5557 )
5558 }
5559
5560 fn finish_compact_transcript_rewrite(
5561 &mut self,
5562 prior_history: Option<ValidatedTranscriptHistory>,
5563 commit: TranscriptRewriteCommit,
5564 replacement: Vec<Message>,
5565 rewritten: Vec<Message>,
5566 realtime_rebase: PreparedRealtimeTranscriptRebase,
5567 ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
5568 let parent_row_prefix = self
5569 .exact_message_row_prefix_at(u64::try_from(self.messages.len()).map_err(|_| {
5570 TranscriptEditError::HistoryStateMalformed(
5571 "live transcript row count exceeds u64".to_string(),
5572 )
5573 })?)
5574 .ok_or_else(|| {
5575 TranscriptEditError::HistoryStateMalformed(
5576 "live transcript has no exact row-lineage authority".to_string(),
5577 )
5578 })?;
5579 let (start, end) = commit.selection.bounds();
5580 let serialized_replacement = replacement
5581 .iter()
5582 .map(serde_json::to_vec)
5583 .collect::<Result<Vec<_>, _>>()
5584 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
5585 let start = u64::try_from(start).map_err(|_| {
5586 TranscriptEditError::HistoryStateMalformed(
5587 "rewrite start exceeds durable row coordinates".to_string(),
5588 )
5589 })?;
5590 let end = u64::try_from(end).map_err(|_| {
5591 TranscriptEditError::HistoryStateMalformed(
5592 "rewrite end exceeds durable row coordinates".to_string(),
5593 )
5594 })?;
5595 let result_row_prefix = parent_row_prefix
5596 .replace_serialized_range(start, end, &serialized_replacement)
5597 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
5598 let result_witness = TranscriptEndpointWitness::from_messages_with_row_prefix(
5599 &rewritten,
5600 result_row_prefix.clone(),
5601 )
5602 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
5603
5604 let state = match prior_history {
5605 None => {
5606 let parent = TranscriptRevisionBody {
5607 revision: commit.parent_revision.clone(),
5608 parent_revision: None,
5609 messages: self.messages.to_vec(),
5610 created_at: self.updated_at,
5611 };
5612 TranscriptHistoryState::from_authorized_first_rewrite(
5613 parent,
5614 parent_row_prefix,
5615 &commit.revision,
5616 &rewritten,
5617 commit.committed_at,
5618 result_row_prefix.clone(),
5619 replacement,
5620 commit.clone(),
5621 )?
5622 }
5623 Some(history) => {
5624 let mut state = history.state().clone();
5625 let endpoint = state.final_endpoint_witness().ok_or_else(|| {
5626 TranscriptEditError::HistoryStateMalformed(
5627 "compact transcript graph has no final endpoint witness".to_string(),
5628 )
5629 })?;
5630 if self.messages.len() < endpoint.message_count() {
5631 return Err(TranscriptEditError::HistoryStateMalformed(
5632 "live rewrite parent is shorter than the audited endpoint".to_string(),
5633 ));
5634 }
5635 let appended = self.messages[endpoint.message_count()..].to_vec();
5636 let serialized_appended = appended
5637 .iter()
5638 .map(serde_json::to_vec)
5639 .collect::<Result<Vec<_>, _>>()
5640 .map_err(|error| {
5641 TranscriptEditError::HistoryStateMalformed(error.to_string())
5642 })?;
5643 let exact_append_prefix = endpoint
5644 .row_prefix()
5645 .extend_serialized_rows(&serialized_appended)
5646 .map_err(|error| {
5647 TranscriptEditError::HistoryStateMalformed(error.to_string())
5648 })?;
5649 let parent_advance = if exact_append_prefix == parent_row_prefix {
5650 TranscriptParentAdvance::ExactAppend { appended }
5651 } else {
5652 return Err(TranscriptEditError::HistoryStateMalformed(
5653 "live rewrite parent is not an exact audited append".to_string(),
5654 ));
5655 };
5656 let messages_before_base = endpoint.message_count();
5657 state.append_authorized_rewrite(
5658 commit.clone(),
5659 messages_before_base,
5660 parent_advance,
5661 parent_row_prefix,
5662 replacement,
5663 result_witness,
5664 self.updated_at,
5665 commit.committed_at,
5666 )?;
5667 state
5668 }
5669 };
5670 self.install_validated_transcript_history_state(state)
5671 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
5672 self.realtime_transcript
5673 .apply_prepared_rebase(realtime_rebase);
5674 self.messages.replace(rewritten);
5675 self.mark_content_mutated(commit.committed_at);
5676 if !self.install_exact_message_row_prefix(result_row_prefix) {
5677 return Err(TranscriptEditError::HistoryStateMalformed(
5678 "failed to install rewrite result row-lineage authority".to_string(),
5679 ));
5680 }
5681 Ok(commit)
5682 }
5683
5684 pub fn set_mob_tool_authority_context(
5692 &mut self,
5693 authority_context: Option<MobToolAuthorityContext>,
5694 ) -> Result<(), serde_json::Error> {
5695 if let Some(authority_context) = authority_context.as_ref()
5696 && !authority_context.is_generated_authority_context()
5697 {
5698 return Err(<serde_json::Error as serde::de::Error>::custom(
5699 "mob authority context was not minted by generated authority",
5700 ));
5701 }
5702 let mut build_state = self.build_state().ok_or_else(|| {
5703 <serde_json::Error as serde::de::Error>::custom(format!(
5704 "session {} is missing session build state",
5705 self.id
5706 ))
5707 })?;
5708 build_state.mob_tool_authority_context = authority_context;
5709 self.set_build_state(build_state)
5710 }
5711
5712 pub fn mob_tool_authority_context(&self) -> Option<MobToolAuthorityContext> {
5717 self.build_state()
5718 .and_then(|state| state.mob_tool_authority_context)
5719 .filter(MobToolAuthorityContext::is_generated_authority_context)
5720 }
5721
5722 pub fn fork_at(&self, index: usize) -> Self {
5727 let now = SystemTime::now();
5728 let truncated = self.messages[..index.min(self.messages.len())].to_vec();
5729 let id = SessionId::new();
5730 Self {
5731 version: session_version(),
5732 realtime_transcript: Box::new(SessionRealtimeTranscriptProjection::empty(&id)),
5733 id,
5734 messages: TranscriptMessages::from_fresh_branch(truncated),
5735 created_at: now,
5736 updated_at: now,
5737 metadata: self.fork_metadata_projection(),
5738 history_caches: Box::default(),
5739 transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
5740 usage: self.usage.clone(),
5741 }
5742 }
5743
5744 pub fn fork_replacing(
5751 &self,
5752 message_index: usize,
5753 replacement: TranscriptReplacement,
5754 ) -> Result<Self, TranscriptEditError> {
5755 let Some(original) = self.messages.get(message_index) else {
5756 return Err(TranscriptEditError::MessageIndexOutOfBounds {
5757 message_index,
5758 message_count: self.messages.len(),
5759 });
5760 };
5761
5762 let replacement_message = match replacement {
5763 TranscriptReplacement::Message { message } => message,
5764 TranscriptReplacement::UserContentBlock { block_index, block } => {
5765 let Message::User(user) = original else {
5766 return Err(TranscriptEditError::MessageRoleMismatch {
5767 message_index,
5768 expected: "user",
5769 actual: message_role_name(original),
5770 });
5771 };
5772 if block_index >= user.content.len() {
5773 return Err(TranscriptEditError::BlockIndexOutOfBounds {
5774 block_kind: "user content block",
5775 block_index,
5776 block_count: user.content.len(),
5777 });
5778 }
5779 let mut edited = user.clone();
5780 edited.content[block_index] = block;
5781 Message::User(edited)
5782 }
5783 TranscriptReplacement::AssistantBlock { block_index, block } => {
5784 let Message::BlockAssistant(assistant) = original else {
5785 return Err(TranscriptEditError::MessageRoleMismatch {
5786 message_index,
5787 expected: "block_assistant",
5788 actual: message_role_name(original),
5789 });
5790 };
5791 if block_index >= assistant.blocks.len() {
5792 return Err(TranscriptEditError::BlockIndexOutOfBounds {
5793 block_kind: "assistant block",
5794 block_index,
5795 block_count: assistant.blocks.len(),
5796 });
5797 }
5798 let mut edited = assistant.clone();
5799 edited.blocks[block_index] = block;
5800 Message::BlockAssistant(edited)
5801 }
5802 TranscriptReplacement::ToolResultContentBlock {
5803 result_index,
5804 block_index,
5805 block,
5806 } => {
5807 let Message::ToolResults {
5808 results,
5809 created_at,
5810 } = original
5811 else {
5812 return Err(TranscriptEditError::MessageRoleMismatch {
5813 message_index,
5814 expected: "tool_results",
5815 actual: message_role_name(original),
5816 });
5817 };
5818 let Some(result) = results.get(result_index) else {
5819 return Err(TranscriptEditError::BlockIndexOutOfBounds {
5820 block_kind: "tool result",
5821 block_index: result_index,
5822 block_count: results.len(),
5823 });
5824 };
5825 if block_index >= result.content.len() {
5826 return Err(TranscriptEditError::BlockIndexOutOfBounds {
5827 block_kind: "tool result content block",
5828 block_index,
5829 block_count: result.content.len(),
5830 });
5831 }
5832 let mut edited_results = results.clone();
5833 edited_results[result_index].content[block_index] = block;
5834 Message::ToolResults {
5835 results: edited_results,
5836 created_at: *created_at,
5837 }
5838 }
5839 };
5840
5841 let mut forked = self.fork_at(message_index);
5842 forked.push(replacement_message);
5843 Ok(forked)
5844 }
5845
5846 pub fn fork(&self) -> Self {
5851 let now = SystemTime::now();
5852 let id = SessionId::new();
5853 Self {
5854 version: session_version(),
5855 realtime_transcript: Box::new(SessionRealtimeTranscriptProjection::empty(&id)),
5856 id,
5857 messages: self.messages.clone(),
5858 created_at: now,
5859 updated_at: now,
5860 metadata: self.fork_metadata_projection(),
5861 history_caches: Box::default(),
5862 transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
5863 usage: self.usage.clone(),
5864 }
5865 }
5866}
5867
5868impl Default for Session {
5869 fn default() -> Self {
5870 Self::new()
5871 }
5872}
5873
5874#[derive(Debug, Clone, Serialize, Deserialize)]
5876#[serde(rename_all = "snake_case")]
5877pub struct SessionMeta {
5878 pub id: SessionId,
5879 pub created_at: SystemTime,
5880 pub updated_at: SystemTime,
5881 pub message_count: usize,
5882 pub total_tokens: u64,
5883 #[serde(default)]
5884 pub metadata: serde_json::Map<String, serde_json::Value>,
5885}
5886
5887#[derive(Debug, Clone, Serialize, Deserialize)]
5889#[serde(rename_all = "snake_case")]
5890pub struct SessionMetadata {
5891 pub schema_version: u32,
5898 pub model: String,
5899 pub max_tokens: u32,
5900 #[serde(default = "crate::config::default_structured_output_retries")]
5901 pub structured_output_retries: u32,
5902 pub provider: Provider,
5903 #[serde(default, skip_serializing_if = "Option::is_none")]
5904 pub self_hosted_server_id: Option<String>,
5905 #[serde(default, skip_serializing_if = "Option::is_none")]
5908 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
5909 pub tooling: SessionTooling,
5910 #[serde(default)]
5911 pub keep_alive: bool,
5912 pub comms_name: Option<String>,
5913 #[serde(default, skip_serializing_if = "Option::is_none")]
5915 pub peer_meta: Option<PeerMeta>,
5916 #[serde(default, skip_serializing_if = "Option::is_none")]
5922 pub realm_id: Option<crate::RealmId>,
5923 #[serde(default, skip_serializing_if = "Option::is_none")]
5925 pub instance_id: Option<String>,
5926 #[serde(default, skip_serializing_if = "Option::is_none")]
5928 pub backend: Option<String>,
5929 #[serde(default, skip_serializing_if = "Option::is_none")]
5931 pub config_generation: Option<u64>,
5932 #[serde(default, skip_serializing_if = "Option::is_none")]
5942 pub auth_binding: Option<crate::AuthBindingRef>,
5943 #[serde(default, skip_serializing_if = "Option::is_none")]
5956 pub mob_member_binding: Option<crate::MobMemberBinding>,
5957}
5958
5959#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
5961#[serde(rename_all = "snake_case")]
5962pub struct SessionLlmIdentity {
5963 pub model: String,
5964 pub provider: Provider,
5965 #[serde(default, skip_serializing_if = "Option::is_none")]
5966 pub self_hosted_server_id: Option<String>,
5967 #[serde(default, skip_serializing_if = "Option::is_none")]
5969 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
5970 #[serde(default, skip_serializing_if = "Option::is_none")]
5983 pub auth_binding: Option<crate::AuthBindingRef>,
5984}
5985
5986pub struct SessionLlmIdentityOverride<'a> {
5994 pub model: Option<&'a str>,
5995 pub provider: Option<Provider>,
5996 pub self_hosted_server_id: Option<&'a str>,
6000 pub provider_params:
6001 Option<TurnMetadataOverride<&'a crate::lifecycle::run_primitive::ProviderParamsOverride>>,
6002 pub auth_binding: Option<TurnMetadataOverride<&'a crate::AuthBindingRef>>,
6003}
6004
6005#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
6006pub enum SessionLlmIdentityOverrideError {
6007 #[error("provider override requires model on an existing session")]
6008 ProviderRequiresModel,
6009 #[error("{0}")]
6010 ProviderModelMismatch(String),
6011 #[error("self-hosted provider requires a registered model alias; '{model}' is not configured")]
6012 MissingSelfHostedAlias { model: String },
6013 #[error("self_hosted_server_id requires provider 'self_hosted'")]
6014 SelfHostedServerRequiresSelfHostedProvider,
6015 #[error("self_hosted_server_id must not be empty")]
6016 EmptySelfHostedServerId,
6017 #[error(
6018 "self-hosted model '{model}' is configured on server '{configured}', not requested server '{requested}'"
6019 )]
6020 SelfHostedServerMismatch {
6021 model: String,
6022 requested: String,
6023 configured: String,
6024 },
6025}
6026
6027pub fn resolve_session_llm_identity_override(
6035 current: &SessionLlmIdentity,
6036 registry: &crate::ModelRegistry,
6037 overrides: SessionLlmIdentityOverride<'_>,
6038) -> Result<SessionLlmIdentity, SessionLlmIdentityOverrideError> {
6039 if overrides.provider.is_some() && overrides.model.is_none() {
6040 return Err(SessionLlmIdentityOverrideError::ProviderRequiresModel);
6041 }
6042
6043 let model = overrides
6044 .model
6045 .map(str::to_string)
6046 .unwrap_or_else(|| current.model.clone());
6047 let provider = if let Some(provider) = overrides.provider {
6048 provider
6049 } else if overrides.model.is_some() {
6050 registry
6051 .entry(&model)
6052 .map_or(current.provider, |entry| entry.provider)
6053 } else {
6054 current.provider
6055 };
6056
6057 if (overrides.model.is_some() || overrides.provider.is_some())
6058 && let Some(reason) = registry.provider_override_mismatch_reason(provider, &model)
6059 {
6060 return Err(SessionLlmIdentityOverrideError::ProviderModelMismatch(
6061 reason,
6062 ));
6063 }
6064
6065 let provider_params = match overrides.provider_params {
6066 Some(TurnMetadataOverride::Clear) => None,
6067 Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
6068 None => current.provider_params.clone(),
6069 };
6070 if overrides.self_hosted_server_id.is_some() && provider != Provider::SelfHosted {
6071 return Err(SessionLlmIdentityOverrideError::SelfHostedServerRequiresSelfHostedProvider);
6072 }
6073 let self_hosted_server_id = if provider == Provider::SelfHosted {
6074 if let Some(requested_server_id) = overrides.self_hosted_server_id {
6075 if requested_server_id.trim().is_empty() {
6076 return Err(SessionLlmIdentityOverrideError::EmptySelfHostedServerId);
6077 }
6078 let entry = registry
6079 .entry_for_provider(Provider::SelfHosted, &model)
6080 .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
6081 model: model.clone(),
6082 })?;
6083 let configured_server_id = entry
6084 .self_hosted
6085 .as_ref()
6086 .map(|server| server.server_id.as_str())
6087 .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
6088 model: model.clone(),
6089 })?;
6090 if configured_server_id != requested_server_id {
6091 return Err(SessionLlmIdentityOverrideError::SelfHostedServerMismatch {
6092 model,
6093 requested: requested_server_id.to_string(),
6094 configured: configured_server_id.to_string(),
6095 });
6096 }
6097 Some(requested_server_id.to_string())
6098 } else if overrides.model.is_none() {
6099 current.self_hosted_server_id.clone().or_else(|| {
6100 registry
6101 .entry_for_provider(Provider::SelfHosted, &model)
6102 .and_then(|entry| entry.self_hosted.as_ref())
6103 .map(|server| server.server_id.clone())
6104 })
6105 } else {
6106 let entry = registry
6107 .entry_for_provider(Provider::SelfHosted, &model)
6108 .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
6109 model: model.clone(),
6110 })?;
6111 entry
6112 .self_hosted
6113 .as_ref()
6114 .map(|server| server.server_id.clone())
6115 }
6116 } else {
6117 None
6118 };
6119
6120 let auth_binding = match overrides.auth_binding {
6121 Some(TurnMetadataOverride::Clear) => None,
6122 Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
6123 None if provider != current.provider => None,
6126 None => current.auth_binding.clone(),
6127 };
6128
6129 Ok(SessionLlmIdentity {
6130 model,
6131 provider,
6132 self_hosted_server_id,
6133 provider_params,
6134 auth_binding,
6135 })
6136}
6137
6138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
6145#[serde(rename_all = "snake_case")]
6146pub struct SessionLlmRequestPolicy {
6147 pub model: String,
6148 #[serde(default, skip_serializing_if = "Option::is_none")]
6150 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
6151 #[serde(default, skip_serializing_if = "Option::is_none")]
6153 pub provider_tool_defaults: Option<crate::lifecycle::run_primitive::ProviderTag>,
6154}
6155
6156impl SessionMetadata {
6157 pub fn llm_identity(&self) -> SessionLlmIdentity {
6159 SessionLlmIdentity {
6160 model: self.model.clone(),
6161 provider: self.provider,
6162 self_hosted_server_id: self.self_hosted_server_id.clone(),
6163 provider_params: self.provider_params.clone(),
6164 auth_binding: self.auth_binding.clone(),
6165 }
6166 }
6167
6168 pub fn apply_llm_identity(&mut self, identity: &SessionLlmIdentity) {
6170 self.model = identity.model.clone();
6171 self.provider = identity.provider;
6172 self.self_hosted_server_id = identity.self_hosted_server_id.clone();
6173 self.provider_params = identity.provider_params.clone();
6174 self.auth_binding = identity.auth_binding.clone();
6175 }
6176}
6177
6178pub const SESSION_METADATA_KEY: &str = "session_metadata";
6180
6181#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
6189#[serde(rename_all = "snake_case")]
6190pub enum ToolCategoryOverride {
6191 #[default]
6193 Inherit,
6194 Enable,
6196 Disable,
6198}
6199
6200impl ToolCategoryOverride {
6201 #[must_use]
6207 pub fn resolve(self, runtime_default: bool) -> bool {
6208 match self {
6209 Self::Enable => true,
6210 Self::Disable => false,
6211 Self::Inherit => runtime_default,
6212 }
6213 }
6214
6215 #[must_use]
6221 pub fn to_override(self) -> Option<bool> {
6222 match self {
6223 Self::Enable => Some(true),
6224 Self::Disable => Some(false),
6225 Self::Inherit => None,
6226 }
6227 }
6228
6229 #[must_use]
6237 pub fn from_effective(enabled: bool) -> Self {
6238 if enabled { Self::Enable } else { Self::Disable }
6239 }
6240
6241 #[must_use]
6251 pub fn from_override(value: Option<bool>) -> Self {
6252 match value {
6253 Some(true) => Self::Enable,
6254 Some(false) => Self::Disable,
6255 None => Self::Inherit,
6256 }
6257 }
6258}
6259
6260#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
6267#[serde(rename_all = "snake_case")]
6268pub struct SessionTooling {
6269 #[serde(default)]
6270 pub builtins: ToolCategoryOverride,
6271 #[serde(default)]
6272 pub shell: ToolCategoryOverride,
6273 #[serde(default)]
6274 pub comms: ToolCategoryOverride,
6275 #[serde(default)]
6277 pub mob: ToolCategoryOverride,
6278 #[serde(default)]
6280 pub memory: ToolCategoryOverride,
6281 #[serde(default)]
6283 pub schedule: ToolCategoryOverride,
6284 #[serde(default)]
6286 pub workgraph: ToolCategoryOverride,
6287 #[serde(default)]
6289 pub image_generation: ToolCategoryOverride,
6290 #[serde(default)]
6292 pub web_search: ToolCategoryOverride,
6293 #[serde(default, skip_serializing_if = "Option::is_none")]
6303 pub tool_access_policy: Option<crate::ops::ToolAccessPolicy>,
6304 #[serde(default, skip_serializing_if = "Option::is_none")]
6306 pub active_skills: Option<Vec<crate::skills::SkillKey>>,
6307}
6308
6309impl From<&Session> for SessionMeta {
6310 fn from(session: &Session) -> Self {
6311 Self {
6312 id: session.id.clone(),
6313 created_at: session.created_at,
6314 updated_at: session.updated_at,
6315 message_count: session.messages.len(),
6316 total_tokens: session.total_tokens(),
6317 metadata: session.metadata.clone(),
6318 }
6319 }
6320}
6321
6322pub fn try_session_metadata_from_map(
6331 metadata: &serde_json::Map<String, serde_json::Value>,
6332) -> Result<Option<SessionMetadata>, serde_json::Error> {
6333 let Some(value) = metadata.get(SESSION_METADATA_KEY) else {
6334 return Ok(None);
6335 };
6336 let mut metadata = serde_json::from_value::<SessionMetadata>(value.clone())?;
6337 metadata.schema_version =
6338 session_persistence_version_authority::restore_session_metadata_schema_version(
6339 metadata.schema_version,
6340 )
6341 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
6342 session_durable_config_authority::restore_session_metadata(metadata)
6343 .map(Some)
6344 .map_err(<serde_json::Error as serde::de::Error>::custom)
6345}
6346
6347pub fn try_lifecycle_terminal_from_map(
6354 metadata: &serde_json::Map<String, serde_json::Value>,
6355) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
6356 match metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY) {
6357 Some(value) => serde_json::from_value(value.clone()).map(Some),
6358 None => Ok(None),
6359 }
6360}
6361
6362#[derive(Debug, Clone)]
6370pub struct PersistedSessionMetadataView {
6371 pub session_id: SessionId,
6372 pub session_metadata: Option<SessionMetadata>,
6373 pub lifecycle_terminal: Option<SessionLifecycleTerminal>,
6374}
6375
6376impl PersistedSessionMetadataView {
6377 pub fn try_from_metadata_map(
6383 session_id: SessionId,
6384 metadata: &serde_json::Map<String, serde_json::Value>,
6385 ) -> Result<Self, serde_json::Error> {
6386 Ok(Self {
6387 session_id,
6388 session_metadata: try_session_metadata_from_map(metadata)?,
6389 lifecycle_terminal: try_lifecycle_terminal_from_map(metadata)?,
6390 })
6391 }
6392
6393 pub fn try_from_session(session: &Session) -> Result<Self, serde_json::Error> {
6395 Ok(Self {
6396 session_id: session.id().clone(),
6397 session_metadata: session.try_session_metadata()?,
6398 lifecycle_terminal: session.try_lifecycle_terminal()?,
6399 })
6400 }
6401
6402 pub fn mob_member_binding(&self) -> Option<&crate::MobMemberBinding> {
6405 self.session_metadata.as_ref()?.mob_member_binding.as_ref()
6406 }
6407}
6408
6409#[cfg(test)]
6410#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
6411mod tests {
6412
6413 #[test]
6423 fn ordinary_append_graph_is_independent_of_validation_cache_state()
6424 -> Result<(), Box<dyn std::error::Error>> {
6425 fn seeded() -> Result<Session, Box<dyn std::error::Error>> {
6426 let mut session = Session::new();
6427 session.push(Message::User(UserMessage::text("A".to_string())));
6428 session.push(Message::User(UserMessage::text("B".to_string())));
6429 session.commit_transcript_rewrite(
6430 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6431 vec![Message::User(UserMessage::text("B2".to_string()))],
6432 TranscriptRewriteReason::new("unit-test"),
6433 Some("unit-test".to_string()),
6434 None,
6435 )?;
6436 Ok(session)
6437 }
6438
6439 let mut subject = seeded()?;
6440 let mut control = subject.clone();
6441
6442 for index in 0..4 {
6443 let message = Message::User(UserMessage::text(format!("append {index}")));
6444 subject.push(message.clone());
6445
6446 let value = serde_json::to_value(
6448 control
6449 .transcript_history_state()?
6450 .ok_or_else(|| std::io::Error::other("control history missing"))?,
6451 )?;
6452 control.set_metadata_unchecked_for_test(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, value);
6453 control.transcript_history_metadata_validation =
6454 TranscriptHistoryMetadataValidation::RequiresValidation;
6455 control.push(message);
6456
6457 let subject_state = subject
6458 .transcript_history_state()?
6459 .ok_or_else(|| std::io::Error::other("subject history missing"))?;
6460 let control_state = control
6461 .transcript_history_state()?
6462 .ok_or_else(|| std::io::Error::other("control history missing"))?;
6463 assert_eq!(
6464 subject_state.head(),
6465 control_state.head(),
6466 "head at {index}"
6467 );
6468 assert_eq!(
6469 subject_state.commits().collect::<Vec<_>>(),
6470 control_state.commits().collect::<Vec<_>>(),
6471 "commits at {index}"
6472 );
6473 let project = |state: &TranscriptHistoryState| {
6474 let mut bodies = state
6475 .materialize_revision_bodies()
6476 .expect("audited bodies should materialize")
6477 .into_iter()
6478 .map(|body| (body.revision, body.messages))
6479 .collect::<Vec<_>>();
6480 bodies.sort_by(|left, right| left.0.cmp(&right.0));
6481 bodies
6482 };
6483 assert_eq!(
6484 project(&subject_state),
6485 project(&control_state),
6486 "retained bodies at {index}"
6487 );
6488 subject.validate_transcript_history_state()?;
6489 }
6490 Ok(())
6491 }
6492 use super::*;
6493 use crate::realtime_transcript::RealtimeTranscriptRole;
6494 use crate::types::{
6495 AssistantBlock, BlockAssistantMessage, ContentBlock, StopReason, SystemMessage, Usage,
6496 UserMessage,
6497 };
6498 use std::sync::Arc;
6499
6500 fn rewrite_record_at(
6501 state: &TranscriptHistoryState,
6502 edge_index: usize,
6503 ) -> TranscriptRewriteRecord {
6504 let commit = state
6505 .commit(edge_index)
6506 .unwrap_or_else(|| panic!("rewrite occurrence {edge_index} should exist"))
6507 .clone();
6508 let parent_body = state
6509 .materialize_occurrence_parent(edge_index)
6510 .unwrap_or_else(|error| {
6511 panic!("rewrite occurrence {edge_index} parent should materialize: {error}")
6512 });
6513 let revision_body = state
6514 .materialize_occurrence_child(edge_index)
6515 .unwrap_or_else(|error| {
6516 panic!("rewrite occurrence {edge_index} child should materialize: {error}")
6517 });
6518 TranscriptRewriteRecord::new(commit, parent_body, revision_body).unwrap_or_else(|error| {
6519 panic!("rewrite occurrence {edge_index} should validate: {error}")
6520 })
6521 }
6522
6523 fn replace_all_bytes(haystack: &mut Vec<u8>, needle: &[u8], replacement: &[u8]) -> usize {
6524 let mut replaced = 0;
6525 let mut cursor = 0;
6526 while let Some(offset) = haystack[cursor..]
6527 .windows(needle.len())
6528 .position(|candidate| candidate == needle)
6529 {
6530 let start = cursor + offset;
6531 haystack.splice(start..start + needle.len(), replacement.iter().copied());
6532 cursor = start + replacement.len();
6533 replaced += 1;
6534 }
6535 replaced
6536 }
6537
6538 fn released_0810_transcript_messages_digest_with_raw_spelling(
6539 messages: &[Message],
6540 canonical_raw: &str,
6541 released_raw: &str,
6542 ) -> String {
6543 let canonical = canonicalize_released_0810_messages_for_digest(messages);
6544 let mut bytes = serde_json::to_vec(&canonical).expect("released digest form serializes");
6545 replace_all_bytes(
6546 &mut bytes,
6547 canonical_raw.as_bytes(),
6548 released_raw.as_bytes(),
6549 );
6550 format!("sha256:{:x}", Sha256::digest(bytes))
6551 }
6552
6553 fn released_0810_document(
6554 session: &Session,
6555 head: String,
6556 revisions: Vec<TranscriptRevisionBody>,
6557 ) -> serde_json::Value {
6558 let state = session
6559 .transcript_history_state()
6560 .expect("current history should decode")
6561 .expect("current history should exist");
6562 let mut commits = serde_json::to_value(state.commits().cloned().collect::<Vec<_>>())
6563 .expect("commits should serialize");
6564 for commit in commits.as_array_mut().expect("commit vector") {
6565 let fields = commit.as_object_mut().expect("commit object");
6566 fields.remove("rewrite_generation");
6567 let selection = fields
6568 .get_mut("selection")
6569 .and_then(serde_json::Value::as_object_mut)
6570 .expect("selection object");
6571 if matches!(
6572 selection.get("type").and_then(serde_json::Value::as_str),
6573 Some("edit_message_range" | "compaction_message_range")
6574 ) {
6575 let range = selection
6576 .remove("range")
6577 .and_then(|value| value.as_object().cloned())
6578 .expect("typed range object");
6579 *selection = serde_json::Map::from_iter([
6580 (
6581 "type".to_string(),
6582 serde_json::Value::String("message_range".to_string()),
6583 ),
6584 (
6585 "start".to_string(),
6586 range.get("start").cloned().expect("range start"),
6587 ),
6588 (
6589 "end".to_string(),
6590 range.get("end").cloned().expect("range end"),
6591 ),
6592 ]);
6593 }
6594 }
6595
6596 let mut document = serde_json::to_value(session).expect("current session should serialize");
6597 document["version"] = serde_json::json!(2);
6598 let metadata = document["metadata"]
6599 .as_object_mut()
6600 .expect("metadata object");
6601 metadata.remove(SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY);
6602 metadata.insert(
6603 SESSION_TRANSCRIPT_HISTORY_STATE_KEY.to_string(),
6604 serde_json::json!({
6605 "head": head,
6606 "commits": commits,
6607 "revisions": revisions,
6608 "digest_format": TRANSCRIPT_DIGEST_FORMAT_RELEASED_0810,
6609 }),
6610 );
6611 document
6612 }
6613
6614 fn transient_context(text: &str) -> TurnRequestContext {
6615 TurnRequestContext::new(text.to_string()).expect("non-empty transient context")
6616 }
6617 async fn wait_for_transient_boundary_request(handle: &TransientTurnContextStateHandle) {
6618 for _ in 0..1_000 {
6619 let registered = matches!(
6620 &handle.boundary.lock().window,
6621 TransientTurnContextBoundaryWindow::Open {
6622 request: Some(_),
6623 ..
6624 }
6625 );
6626 if registered {
6627 return;
6628 }
6629 tokio::task::yield_now().await;
6630 }
6631 panic!("transient boundary request did not register");
6632 }
6633 #[test]
6634 fn prepared_transient_boundary_authority_is_send() {
6635 fn assert_send<T: Send>() {}
6636 assert_send::<PreparedTransientTurnContextBoundary>();
6637 assert_send::<crate::lifecycle::CoreBoundaryStageOutput>();
6638 }
6639 #[tokio::test]
6640 async fn transient_boundary_runner_first_consumes_no_context() {
6641 let state = TransientTurnContextStateHandle::new();
6642 let run_id = RunId::new();
6643 let _guard = state
6644 .begin_boundary_run(run_id.clone())
6645 .expect("open boundary");
6646 let contexts = state
6647 .take_pending_at_exact_boundary(&run_id)
6648 .await
6649 .expect("consume empty boundary");
6650 assert!(contexts.is_empty());
6651 let error = state
6652 .prepare_active_turn_boundary(&run_id, vec![transient_context("late")])
6653 .await
6654 .expect_err("runner-first boundary is closed");
6655 assert!(error.is_unavailable());
6656 }
6657 #[tokio::test]
6658 async fn transient_boundary_prepare_commit_publishes_exact_order_once() {
6659 let state = TransientTurnContextStateHandle::new();
6660 let run_id = RunId::new();
6661 let _guard = state
6662 .begin_boundary_run(run_id.clone())
6663 .expect("open boundary");
6664 let prepare_state = state.clone();
6665 let prepare_run_id = run_id.clone();
6666 let prepare = tokio::spawn(async move {
6667 prepare_state
6668 .prepare_active_turn_boundary(
6669 &prepare_run_id,
6670 vec![transient_context(" first "), transient_context("second")],
6671 )
6672 .await
6673 });
6674 wait_for_transient_boundary_request(&state).await;
6675 let runner_state = state.clone();
6676 let runner_run_id = run_id.clone();
6677 let runner = tokio::spawn(async move {
6678 runner_state
6679 .take_pending_at_exact_boundary(&runner_run_id)
6680 .await
6681 });
6682 let prepared = prepare
6683 .await
6684 .expect("prepare task")
6685 .expect("parked preparation");
6686 prepared
6687 .into_stage_output(None)
6688 .commit()
6689 .expect("publish transient context");
6690 let contexts = runner.await.expect("runner task").expect("runner consume");
6691 assert_eq!(
6692 contexts
6693 .iter()
6694 .map(TurnRequestContext::as_str)
6695 .collect::<Vec<_>>(),
6696 vec![" first ", "second"]
6697 );
6698 }
6699
6700 #[tokio::test]
6701 async fn transient_boundary_prepare_abort_releases_runner_without_context() {
6702 let state = TransientTurnContextStateHandle::new();
6703 let run_id = RunId::new();
6704 let _guard = state
6705 .begin_boundary_run(run_id.clone())
6706 .expect("open boundary");
6707 let prepare_state = state.clone();
6708 let prepare_run_id = run_id.clone();
6709 let prepare = tokio::spawn(async move {
6710 prepare_state
6711 .prepare_active_turn_boundary(
6712 &prepare_run_id,
6713 vec![transient_context("must not publish")],
6714 )
6715 .await
6716 });
6717 wait_for_transient_boundary_request(&state).await;
6718 let runner_state = state.clone();
6719 let runner_run_id = run_id.clone();
6720 let runner = tokio::spawn(async move {
6721 runner_state
6722 .take_pending_at_exact_boundary(&runner_run_id)
6723 .await
6724 });
6725 let prepared = prepare
6726 .await
6727 .expect("prepare task")
6728 .expect("parked preparation");
6729 prepared
6730 .into_stage_output(None)
6731 .abort()
6732 .expect("abort transient context");
6733 assert!(
6734 runner
6735 .await
6736 .expect("runner task")
6737 .expect("runner released")
6738 .is_empty()
6739 );
6740 }
6741
6742 fn block_assistant_text(message: &BlockAssistantMessage) -> String {
6743 message
6744 .blocks
6745 .iter()
6746 .filter_map(|block| match block {
6747 AssistantBlock::Text { text, .. } => Some(text.as_str()),
6748 _ => None,
6749 })
6750 .collect()
6751 }
6752
6753 fn append_staged_user_image(
6758 session: &mut Session,
6759 event: &RealtimeTranscriptEvent,
6760 ) -> RealtimeTranscriptApplyOutcome {
6761 let RealtimeTranscriptEvent::UserContentFinal {
6762 idempotency_key,
6763 item_id,
6764 previous_item_id,
6765 content_index,
6766 content,
6767 } = event
6768 else {
6769 panic!("test helper requires user content final")
6770 };
6771 let [ContentBlock::Image { media_type, data }] = content.as_slice() else {
6772 panic!("test helper requires exactly one image")
6773 };
6774 let media_type = crate::image_generation::MediaType::canonical_str(media_type);
6775 let blob_id = match data {
6776 crate::types::ImageData::Inline { data } => {
6777 crate::blob::content_blob_id(&media_type, data)
6778 }
6779 crate::types::ImageData::Blob { blob_id } => blob_id.clone(),
6780 };
6781 let pending = crate::PendingRealtimeUserContentBlob {
6782 idempotency_key: idempotency_key.clone(),
6783 item_id: item_id.clone(),
6784 previous_item_id: previous_item_id.clone(),
6785 content_index: *content_index,
6786 blob_id,
6787 media_type,
6788 };
6789 assert_eq!(
6790 session
6791 .stage_pending_realtime_user_content_blob(pending.clone())
6792 .expect("test pending anchor should stage"),
6793 crate::generated::session_document::RealtimeUserContentBlobStageDisposition::StageNew
6794 );
6795 session.append_realtime_transcript_event(pending.canonical_event())
6796 }
6797
6798 #[test]
6799 fn transcript_digest_is_content_addressed() {
6800 let base_time = crate::types::message_timestamp_now();
6801 let stamped = vec![
6802 Message::User(UserMessage::text("turn one".to_string())),
6803 Message::BlockAssistant(BlockAssistantMessage {
6804 blocks: vec![AssistantBlock::Text {
6805 text: "answer one".to_string(),
6806 meta: None,
6807 }],
6808 stop_reason: StopReason::EndTurn,
6809 identity: crate::types::TranscriptMessageIdentity {
6810 interaction_id: None,
6811 run_id: Some(crate::lifecycle::RunId::new()),
6812 objective_id: None,
6813 },
6814 created_at: base_time,
6815 }),
6816 ];
6817 let mut restamped = stamped.clone();
6818 for message in &mut restamped {
6819 match message {
6820 Message::User(user) => {
6821 user.created_at = base_time + chrono::Duration::hours(2);
6822 }
6823 Message::BlockAssistant(assistant) => {
6824 assistant.identity = crate::types::TranscriptMessageIdentity {
6825 interaction_id: None,
6826 run_id: Some(crate::lifecycle::RunId::new()),
6827 objective_id: None,
6828 };
6829 assistant.created_at = base_time + chrono::Duration::hours(2);
6830 }
6831 _ => {}
6832 }
6833 }
6834 assert_eq!(
6835 transcript_messages_digest(&stamped).expect("digest"),
6836 transcript_messages_digest(&restamped).expect("digest"),
6837 "bookkeeping variance must not fork the transcript revision"
6838 );
6839
6840 let mut content_changed = stamped.clone();
6841 if let Message::User(user) = &mut content_changed[0] {
6842 user.content = vec![ContentBlock::Text {
6843 text: "a different turn".to_string(),
6844 }];
6845 }
6846 assert_ne!(
6847 transcript_messages_digest(&stamped).expect("digest"),
6848 transcript_messages_digest(&content_changed).expect("digest"),
6849 "content changes must fork the transcript revision"
6850 );
6851 }
6852
6853 #[test]
6854 fn public_generic_rewrite_api_rejects_typed_compaction_semantic() {
6855 let mut session = Session::new();
6856 session.push(Message::User(UserMessage::text("old context")));
6857 let error = session
6858 .commit_transcript_rewrite(
6859 TranscriptRewriteSelection::typed_compaction_for_test(0, 1),
6860 vec![Message::User(UserMessage::compaction_summary("summary"))],
6861 TranscriptRewriteReason::new("anything"),
6862 None,
6863 None,
6864 )
6865 .unwrap_err();
6866 assert!(matches!(
6867 error,
6868 TranscriptEditError::InvalidTranscriptShape(_)
6869 ));
6870 assert_eq!(session.messages().len(), 1);
6871 }
6872
6873 #[test]
6874 fn compaction_witness_authorizes_only_the_exact_validated_rebuild() {
6875 let mut session = Session::new();
6876 session.push(Message::User(UserMessage::text("old context one")));
6877 session.push(Message::User(UserMessage::text("old context two")));
6878 let validated = vec![Message::User(UserMessage::compaction_summary(
6879 "validated summary",
6880 ))];
6881 let authority = crate::agent::compact::ValidatedCompactionRewrite::for_test(
6882 session.messages(),
6883 &validated,
6884 )
6885 .unwrap();
6886 let error = session
6887 .replace_messages_for_compaction_internal(
6888 vec![Message::User(UserMessage::compaction_summary(
6889 "substituted summary",
6890 ))],
6891 &authority,
6892 )
6893 .unwrap_err();
6894 assert!(matches!(
6895 error,
6896 TranscriptEditError::InvalidTranscriptShape(_)
6897 ));
6898 assert_eq!(session.messages().len(), 2);
6899 }
6900
6901 #[test]
6908 fn partial_span_rewrite_records_span_digests_not_whole_document_digests() {
6909 let mut session = Session::new();
6910 session.push(Message::User(UserMessage::text("m-0")));
6911 session.push(Message::User(UserMessage::text("m-1")));
6912 session.push(Message::User(UserMessage::text("m-2")));
6913 let original = session.messages().to_vec();
6914 let whole_before = session.transcript_content_digest().unwrap();
6915 let replacement = vec![Message::User(UserMessage::text("m-1-rewritten"))];
6916 let commit = session
6917 .commit_transcript_rewrite(
6918 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6919 replacement.clone(),
6920 TranscriptRewriteReason::new("unit-test"),
6921 Some("unit-test".to_string()),
6922 None,
6923 )
6924 .unwrap();
6925 assert_eq!(
6926 commit.original_span_digest,
6927 transcript_messages_digest(&original[1..2]).unwrap(),
6928 "partial-span original digest must cover exactly the selected span"
6929 );
6930 assert_ne!(commit.original_span_digest, whole_before);
6931 assert_eq!(
6932 commit.replacement_digest,
6933 transcript_messages_digest(&replacement).unwrap(),
6934 "partial-span replacement digest must cover exactly the replacement"
6935 );
6936 assert_ne!(commit.replacement_digest, commit.revision);
6937 let state = session.transcript_history_state().unwrap().unwrap();
6941 validate_transcript_history_state(&state).unwrap();
6942 }
6943
6944 #[test]
6949 fn synthetic_notice_refresh_inside_audited_prefix_fails_before_mutation() {
6950 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
6951
6952 let mut session = Session::new();
6953 session.push(Message::User(UserMessage::text("u-0")));
6954 session.push(Message::User(UserMessage::text("u-1")));
6955 session
6957 .replace_synthetic_notices(
6958 SystemNoticeKind::McpPending,
6959 vec![Message::SystemNotice(SystemNoticeMessage::new(
6960 SystemNoticeKind::McpPending,
6961 "pending v1",
6962 ))],
6963 )
6964 .expect("initial notice install");
6965 session
6968 .commit_transcript_rewrite(
6969 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
6970 vec![Message::User(UserMessage::text("u-0-rewritten"))],
6971 TranscriptRewriteReason::new("unit-test"),
6972 Some("unit-test".to_string()),
6973 None,
6974 )
6975 .expect("first rewrite commits");
6976 session.push(Message::User(UserMessage::text("u-2")));
6979 let before = session.messages().to_vec();
6980 let error = session
6981 .replace_synthetic_notices(
6982 SystemNoticeKind::McpPending,
6983 vec![Message::SystemNotice(SystemNoticeMessage::new(
6984 SystemNoticeKind::McpPending,
6985 "pending v2",
6986 ))],
6987 )
6988 .expect_err("refresh must not rewrite an audited prefix");
6989 assert!(
6990 matches!(error, TranscriptEditError::InvalidTranscriptShape(_)),
6991 "expected the atomic audited-prefix refusal, got: {error:?}"
6992 );
6993 assert_eq!(session.messages(), before.as_slice());
6994
6995 let bytes = serde_json::to_vec(&session).expect("session serializes");
6997 let decoded: Session = serde_json::from_slice(&bytes)
6998 .expect("the failed rewrite must leave a graph every cold reader accepts");
6999 assert_eq!(decoded.messages().len(), session.messages().len());
7000 }
7001
7002 #[test]
7010 fn current_envelope_rejects_non_append_live_rows_at_ingress() {
7011 let mut session = Session::new();
7012 session.append_system_message("original system");
7013 session.push(Message::User(UserMessage::text("m-1")));
7014 session
7015 .commit_transcript_rewrite(
7016 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
7017 vec![Message::User(UserMessage::text("m-1-rewritten"))],
7018 TranscriptRewriteReason::new("unit-test"),
7019 Some("unit-test".to_string()),
7020 None,
7021 )
7022 .expect("seed rewrite commits");
7023 session.push(Message::User(UserMessage::text("m-2")));
7024
7025 let mut document = serde_json::to_value(&session).expect("session serializes");
7029 let divergent_message =
7030 serde_json::to_value(Message::System(SystemMessage::new("replacement system")))
7031 .expect("message serializes");
7032 let messages = document
7033 .get_mut("messages")
7034 .and_then(serde_json::Value::as_array_mut)
7035 .expect("messages array");
7036 messages[0] = divergent_message;
7037 let error = serde_json::from_value::<Session>(document)
7038 .expect_err("a current non-append live tail must fail closed at ingress");
7039 assert!(
7040 error
7041 .to_string()
7042 .contains("live transcript does not preserve the graph-proved audited endpoint"),
7043 "unexpected error: {error}"
7044 );
7045 }
7046
7047 #[test]
7048 fn current_rewrite_refuses_non_append_parent_divergence() {
7049 let mut session = Session::new();
7050 session.push(Message::User(UserMessage::text("original row")));
7051 session.push(Message::User(UserMessage::text("question")));
7052 session
7053 .commit_transcript_rewrite(
7054 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
7055 vec![Message::User(UserMessage::text("edited question"))],
7056 TranscriptRewriteReason::new("unit-test"),
7057 Some("unit-test".to_string()),
7058 None,
7059 )
7060 .expect("seed audited endpoint");
7061 let generation_before = session
7062 .transcript_rewrite_generation()
7063 .expect("rewrite generation");
7064
7065 let mut divergent = session.messages().to_vec();
7066 divergent[0] = Message::User(UserMessage::text("replacement row"));
7067 session.messages.replace(divergent);
7068 let current_prefix =
7069 crate::SessionMessageRowPrefixAccumulator::from_messages(session.messages())
7070 .expect("current row prefix");
7071 assert!(session.install_exact_message_row_prefix(current_prefix));
7072
7073 let error = session
7074 .commit_transcript_rewrite(
7075 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
7076 vec![Message::User(UserMessage::text("edited again"))],
7077 TranscriptRewriteReason::new("unit-test"),
7078 Some("unit-test".to_string()),
7079 None,
7080 )
7081 .expect_err("current writer must not infer a non-append parent splice");
7082 assert!(
7083 matches!(error, TranscriptEditError::HistoryStateMalformed(ref message)
7084 if message.contains("not an exact audited append")),
7085 "unexpected error: {error}"
7086 );
7087 assert_eq!(
7088 session
7089 .transcript_rewrite_generation()
7090 .expect("rewrite generation"),
7091 generation_before,
7092 "failed current write must not append a graph edge"
7093 );
7094 }
7095
7096 #[test]
7097 fn semantic_marker_prevents_new_generic_compaction_forgery_and_heals_prior_data() {
7098 let mut session = Session::new();
7099 session.push(Message::User(UserMessage::text("old context one")));
7100 session.push(Message::User(UserMessage::text("old context two")));
7101 session
7102 .commit_transcript_rewrite(
7103 TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
7104 vec![Message::User(UserMessage::compaction_summary("summary"))],
7105 TranscriptRewriteReason::new("compaction"),
7106 None,
7107 None,
7108 )
7109 .unwrap();
7110 let session: Session =
7111 serde_json::from_value(serde_json::to_value(&session).unwrap()).unwrap();
7112 let history = session.transcript_history_state().unwrap().unwrap();
7113 let current_commit = history.commit(0).expect("current rewrite commit");
7114 assert_eq!(
7115 current_commit.selection.semantic(),
7116 TranscriptRewriteSemantic::Edit,
7117 "new generic rewrites retain an explicit typed edit marker after roundtrip"
7118 );
7119 assert_eq!(current_commit.reason.kind, "compaction");
7120
7121 let mut legacy_value =
7122 serde_json::to_value(rewrite_record_at(&history, 0)).expect("record wire");
7123 let legacy = legacy_value.as_object_mut().expect("record object");
7124 legacy.remove("digest_format");
7125 legacy.get_mut("commit").expect("legacy commit")["selection"] = serde_json::json!({
7126 "type": "message_range",
7127 "start": 0,
7128 "end": 2,
7129 });
7130 let legacy: TranscriptRewriteRecord =
7131 serde_json::from_value(legacy_value).expect("legacy record should heal");
7132 assert_eq!(
7133 legacy.commit.selection.semantic(),
7134 TranscriptRewriteSemantic::Compaction,
7135 "marker-absent prior data derives compaction from typed transcript evidence"
7136 );
7137
7138 let mut ordinary = Session::new();
7139 ordinary.push(Message::User(UserMessage::text("ordinary old one")));
7140 ordinary.push(Message::User(UserMessage::text("ordinary old two")));
7141 ordinary
7142 .commit_transcript_rewrite(
7143 TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
7144 vec![Message::User(UserMessage::text("ordinary replacement"))],
7145 TranscriptRewriteReason::new("compaction"),
7146 None,
7147 None,
7148 )
7149 .unwrap();
7150 let history = ordinary.transcript_history_state().unwrap().unwrap();
7151 assert_eq!(
7152 history
7153 .commit(0)
7154 .expect("ordinary rewrite commit")
7155 .selection
7156 .semantic(),
7157 TranscriptRewriteSemantic::Edit,
7158 "free-form reason must not upgrade an ordinary edit"
7159 );
7160 }
7161
7162 #[test]
7165 fn snapshot_compaction_returns_the_proof_of_the_installed_graph() {
7166 let mut session = Session::new();
7167 session.push(Message::User(UserMessage::text("seam proof before")));
7168 session
7169 .commit_transcript_rewrite(
7170 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7171 vec![Message::User(UserMessage::text("seam proof after"))],
7172 TranscriptRewriteReason::new("edit"),
7173 None,
7174 None,
7175 )
7176 .unwrap();
7177 let document = serde_json::to_value(&session).unwrap();
7178 let mut metadata = serde_json::Map::new();
7179 metadata.insert(
7180 SESSION_TRANSCRIPT_HISTORY_STATE_KEY.to_string(),
7181 document["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY].clone(),
7182 );
7183 let graph_wire = metadata[SESSION_TRANSCRIPT_HISTORY_STATE_KEY].clone();
7184
7185 let sealed = compact_transcript_history_metadata_for_snapshot(&mut metadata)
7186 .expect("valid graph compacts")
7187 .expect("graph value present");
7188 assert_eq!(
7189 serde_json::to_value(sealed.as_ref()).unwrap(),
7190 graph_wire,
7191 "the returned proof must cover exactly the consumed graph value"
7192 );
7193 assert!(
7194 !metadata.contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY),
7195 "the transient wire graph must not remain beside the typed authority"
7196 );
7197
7198 let mut empty = serde_json::Map::new();
7200 assert!(
7201 compact_transcript_history_metadata_for_snapshot(&mut empty)
7202 .expect("empty metadata compacts")
7203 .is_none()
7204 );
7205 }
7206
7207 #[test]
7212 fn decode_seeds_shared_transcript_graph_with_the_proven_parse() {
7213 let mut session = Session::new();
7214 session.push(Message::User(UserMessage::text("seed shared parse")));
7215 session
7216 .commit_transcript_rewrite(
7217 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7218 vec![Message::User(UserMessage::text("seed shared parse two"))],
7219 TranscriptRewriteReason::new("edit"),
7220 None,
7221 None,
7222 )
7223 .unwrap();
7224 let document = serde_json::to_value(&session).unwrap();
7225 let serialized_graph = document["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY].clone();
7226
7227 let decoded: Session = serde_json::from_value(document).unwrap();
7228 let seeded = decoded
7229 .history_caches
7230 .shared_state
7231 .get()
7232 .expect("decode must seed the shared graph parse");
7233 assert_eq!(
7234 serde_json::to_value(&*seeded).unwrap(),
7235 serialized_graph,
7236 "the seeded graph must be the value the wire carried"
7237 );
7238 assert!(
7239 !decoded
7240 .metadata
7241 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY),
7242 "the typed graph cache is the singular in-memory authority"
7243 );
7244 let sealed = decoded
7246 .validated_transcript_history_state()
7247 .expect("validated read")
7248 .expect("graph present");
7249 assert!(
7250 std::sync::Arc::ptr_eq(&seeded, &sealed.shared()),
7251 "validated_transcript_history_state must serve the decode-seeded parse"
7252 );
7253
7254 let bare: Session =
7256 serde_json::from_value(serde_json::to_value(Session::new()).unwrap()).unwrap();
7257 assert!(bare.history_caches.shared_state.get().is_none());
7258 }
7259
7260 #[test]
7266 fn sealed_transcript_history_refuses_unverified_corrupt_graph() {
7267 let mut source = Session::new();
7268 source.push(Message::User(UserMessage::text("hello".to_string())));
7269 source
7270 .commit_transcript_rewrite(
7271 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7272 vec![Message::User(UserMessage::text("hello again".to_string()))],
7273 TranscriptRewriteReason::new("unit-test"),
7274 Some("unit-test".to_string()),
7275 None,
7276 )
7277 .expect("consistent rewrite should commit");
7278 let mut source_document = serde_json::to_value(&source).expect("source serializes");
7279 source_document["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["anchor"]["messages"]
7280 [0] = serde_json::to_value(Message::User(UserMessage::text("tampered".to_string())))
7281 .expect("tampered message");
7282 assert!(
7283 serde_json::from_value::<Session>(source_document).is_err(),
7284 "decode must not mint a proof for a digest-inconsistent transcript graph"
7285 );
7286 }
7287
7288 #[test]
7293 fn sealed_transcript_history_verifies_and_seals_consistent_graph() {
7294 let mut source = Session::new();
7295 source.push(Message::User(UserMessage::text("hello".to_string())));
7296 let commit = source
7297 .commit_transcript_rewrite(
7298 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7299 vec![Message::User(UserMessage::text("hello again".to_string()))],
7300 TranscriptRewriteReason::new("unit-test"),
7301 Some("unit-test".to_string()),
7302 None,
7303 )
7304 .expect("consistent rewrite should commit");
7305 let source_document = serde_json::to_value(&source).expect("source serializes");
7306 let session: Session =
7307 serde_json::from_value(source_document).expect("consistent graph decodes");
7308 let sealed = session
7309 .validated_transcript_history_state()
7310 .expect("a consistent graph must seal")
7311 .expect("history metadata is present");
7312 assert_eq!(sealed.state().head(), commit.revision);
7313 }
7314
7315 #[test]
7319 fn replace_synthetic_notices_leaves_only_replacements_of_kind() {
7320 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
7321
7322 let mut session = Session::new();
7323 session.push(Message::User(UserMessage::text("hello".to_string())));
7324 session.push(Message::SystemNotice(SystemNoticeMessage::new(
7325 SystemNoticeKind::McpPending,
7326 "stale one",
7327 )));
7328 session.push(Message::SystemNotice(SystemNoticeMessage::new(
7329 SystemNoticeKind::McpPending,
7330 "stale two",
7331 )));
7332 session.push(Message::SystemNotice(SystemNoticeMessage::new(
7334 SystemNoticeKind::BackgroundJob,
7335 "other-kind",
7336 )));
7337
7338 session
7339 .replace_synthetic_notices(
7340 SystemNoticeKind::McpPending,
7341 vec![Message::SystemNotice(SystemNoticeMessage::new(
7342 SystemNoticeKind::McpPending,
7343 "fresh",
7344 ))],
7345 )
7346 .expect("notice refresh succeeds");
7347
7348 let mcp_pending: Vec<&SystemNoticeMessage> = session
7349 .messages()
7350 .iter()
7351 .filter_map(|message| match message {
7352 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending => {
7353 Some(notice)
7354 }
7355 _ => None,
7356 })
7357 .collect();
7358 assert_eq!(mcp_pending.len(), 1, "exactly one notice of the kind");
7359 assert_eq!(mcp_pending[0].body.as_deref(), Some("fresh"));
7360 assert!(
7361 session.messages().iter().any(|message| matches!(
7362 message,
7363 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::BackgroundJob
7364 )),
7365 "other-kind notices are untouched"
7366 );
7367
7368 session
7370 .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
7371 .expect("pure strip succeeds");
7372 assert!(
7373 !session.messages().iter().any(|message| matches!(
7374 message,
7375 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending
7376 )),
7377 "empty replacement clears the kind"
7378 );
7379 }
7380
7381 #[test]
7382 fn ordinary_appends_after_rewrite_leave_audited_graph_untouched() {
7383 let mut session = Session::new();
7384 for message in 0..133 {
7385 session.push(Message::User(UserMessage::text(format!(
7386 "seed message {message}"
7387 ))));
7388 }
7389 let parent = session.transcript_revision().expect("parent revision");
7390 let commit = session
7391 .commit_transcript_rewrite(
7392 TranscriptRewriteSelection::MessageRange {
7393 start: 132,
7394 end: 133,
7395 },
7396 vec![Message::User(UserMessage::text("edited question"))],
7397 TranscriptRewriteReason::new("unit-test-edit"),
7398 Some("unit-test".to_string()),
7399 Some(parent),
7400 )
7401 .expect("rewrite should commit");
7402 let graph_before = session
7403 .validated_transcript_history_state()
7404 .expect("history validation")
7405 .expect("rewrite graph");
7406
7407 for turn in 0..762 {
7408 session.push(Message::User(UserMessage::text(format!("turn {turn}"))));
7409 }
7410
7411 let graph_after = session
7412 .validated_transcript_history_state()
7413 .expect("history validation")
7414 .expect("rewrite graph");
7415 assert!(
7416 graph_before.shares_exact_state_with(&graph_after),
7417 "ordinary appends must preserve the exact audited graph authority"
7418 );
7419 let state = session
7420 .transcript_history_state()
7421 .expect("history state should decode")
7422 .expect("rewrite should create history state");
7423 assert_eq!(session.messages().len(), 895);
7424 assert_eq!(state.commit_count(), 1, "ordinary appends are not rewrites");
7425 let retained_bodies = state
7426 .materialize_revision_bodies()
7427 .expect("audited bodies should materialize");
7428 assert_eq!(
7429 retained_bodies.len(),
7430 2,
7431 "one real rewrite retains only its two audited endpoints"
7432 );
7433 assert_eq!(state.head(), commit.revision);
7434 assert_ne!(
7435 session.transcript_revision().expect("live revision"),
7436 state.head(),
7437 "the live append tail is Session authority, not a mechanical graph head"
7438 );
7439 let retained_message_entries = retained_bodies
7440 .iter()
7441 .map(|body| body.messages.len())
7442 .sum::<usize>();
7443 assert!(retained_message_entries <= 2 * session.messages().len());
7444
7445 let live_bytes = serde_json::to_vec(session.messages())
7446 .expect("live transcript should serialize")
7447 .len();
7448 let snapshot_bytes = serde_json::to_vec(&session)
7449 .expect("session snapshot should serialize")
7450 .len();
7451 assert!(
7452 snapshot_bytes <= live_bytes.saturating_mul(5).saturating_add(64 * 1024),
7453 "snapshot must remain linear in the live transcript: {snapshot_bytes} bytes for {live_bytes} live bytes"
7454 );
7455 }
7456
7457 #[test]
7458 fn repeated_synthetic_notice_refreshes_do_not_mint_rewrite_commits() {
7459 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
7460
7461 let mut session = Session::new();
7462 session.push(Message::User(UserMessage::text("before".to_string())));
7463 session
7464 .commit_transcript_rewrite(
7465 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7466 vec![Message::User(UserMessage::text("after".to_string()))],
7467 TranscriptRewriteReason::new("unit-test-edit"),
7468 Some("unit-test".to_string()),
7469 None,
7470 )
7471 .expect("seed rewrite");
7472 let graph_before = session
7473 .validated_transcript_history_state()
7474 .expect("history validation")
7475 .expect("audited graph");
7476
7477 for refresh in 0..64 {
7478 session
7479 .replace_synthetic_notices(
7480 SystemNoticeKind::McpPending,
7481 vec![Message::SystemNotice(SystemNoticeMessage::new(
7482 SystemNoticeKind::McpPending,
7483 format!("refresh {refresh}"),
7484 ))],
7485 )
7486 .expect("mechanical refresh");
7487 }
7488 let graph_after = session
7489 .validated_transcript_history_state()
7490 .expect("history validation")
7491 .expect("audited graph");
7492 assert!(
7493 graph_before.shares_exact_state_with(&graph_after),
7494 "tail-only synthetic refreshes must preserve the exact audited graph authority"
7495 );
7496
7497 let state = session
7498 .transcript_history_state()
7499 .expect("history state")
7500 .expect("seed rewrite history");
7501 assert_eq!(state.commit_count(), 1);
7502 assert_eq!(session.transcript_rewrite_generation().unwrap(), 1);
7503 assert_eq!(
7504 state
7505 .materialize_revision_bodies()
7506 .expect("audited bodies should materialize")
7507 .len(),
7508 2,
7509 "mechanical refreshes do not mint retained live-head bodies"
7510 );
7511 }
7512
7513 #[test]
7514 fn snapshot_compaction_does_not_launder_corrupt_old_body() {
7515 let mut session = Session::new();
7516 session.push(Message::User(UserMessage::text("seed".to_string())));
7517 session
7518 .commit_transcript_rewrite(
7519 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7520 vec![Message::User(UserMessage::text("rewritten".to_string()))],
7521 TranscriptRewriteReason::new("unit-test-edit"),
7522 Some("unit-test".to_string()),
7523 None,
7524 )
7525 .expect("seed rewrite");
7526 let state = session
7527 .transcript_history_state()
7528 .expect("state")
7529 .expect("history");
7530 let mut state = serde_json::to_value(&state).expect("current history value");
7531 state["anchor"]["messages"][0] =
7532 serde_json::to_value(Message::User(UserMessage::text("tampered".to_string())))
7533 .expect("tampered message");
7534 session.set_metadata_unchecked_for_test(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, state);
7535
7536 assert!(
7537 serde_json::to_vec(&session).is_err(),
7538 "serialization must fail before pruning a corrupt old body"
7539 );
7540 }
7541
7542 #[test]
7547 fn released_0_8_10_mechanical_history_is_compacted_at_import_boundary() {
7548 let mut session = Session::new();
7549 session.push(Message::User(UserMessage::text("seed".to_string())));
7550 session
7551 .commit_transcript_rewrite(
7552 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7553 vec![Message::User(UserMessage::text("rewritten".to_string()))],
7554 TranscriptRewriteReason::new("unit-test-edit"),
7555 Some("unit-test".to_string()),
7556 None,
7557 )
7558 .expect("seed rewrite");
7559 let state = session
7560 .transcript_history_state()
7561 .expect("state")
7562 .expect("history");
7563 let mut revisions = state
7564 .materialize_revision_bodies()
7565 .expect("released audit bodies should materialize");
7566 let mut parent = state.head().to_string();
7567 for index in 0..8 {
7568 session.push(Message::User(UserMessage::text(format!(
7569 "0.8.10 ordinary append {index}"
7570 ))));
7571 let revision = transcript_messages_digest(session.messages()).expect("revision digest");
7572 revisions.push(TranscriptRevisionBody {
7573 revision: revision.clone(),
7574 parent_revision: Some(parent),
7575 messages: session.messages().to_vec(),
7576 created_at: SystemTime::now(),
7577 });
7578 parent = revision;
7579 }
7580 let released = released_0810_document(&session, parent, revisions);
7581 let released = serde_json::to_vec(&released).expect("released document bytes");
7582 let imported =
7583 import_released_0810_session(&released).expect("released document should import");
7584 assert_eq!(
7585 imported.receipt().evidence(),
7586 Released0810ImportEvidence::StoreAuthorizationRequired
7587 );
7588 let compact = imported
7589 .session()
7590 .transcript_history_state()
7591 .expect("imported history")
7592 .expect("imported graph");
7593
7594 assert_eq!(
7595 compact
7596 .materialize_revision_bodies()
7597 .expect("compact bodies should materialize")
7598 .len(),
7599 2,
7600 "import boundary should retain only the two audited endpoints"
7601 );
7602 assert_eq!(
7603 compact.head(),
7604 compact.last_commit().expect("rewrite commit").revision
7605 );
7606 validate_transcript_history_state(&compact).expect("compacted history remains valid");
7607 }
7608
7609 #[test]
7610 fn released_0_8_10_lost_raw_json_spelling_is_authorized_then_rebound_once() {
7611 let opaque = r#"{"z":1,"a":{"y":2,"x":3}}"#;
7612 let canonical_opaque = r#"{"a":{"x":3,"y":2},"z":1}"#;
7613 let mut session = Session::new();
7614 session.append_system_message("system".to_string());
7615 session.push(Message::User(UserMessage::text("question".to_string())));
7616 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
7617 vec![AssistantBlock::ToolUse {
7618 id: "tool-1".to_string(),
7619 name: "opaque".to_string(),
7620 args: serde_json::value::RawValue::from_string(opaque.to_string())
7621 .expect("valid tool args"),
7622 meta: None,
7623 }],
7624 StopReason::ToolUse,
7625 )));
7626 session.push(Message::tool_results(vec![
7627 crate::types::ToolResult::with_blocks(
7628 "tool-1".to_string(),
7629 vec![ContentBlock::Structured {
7630 data: serde_json::value::RawValue::from_string(opaque.to_string())
7631 .expect("valid structured result"),
7632 }],
7633 false,
7634 ),
7635 ]));
7636 session.push(Message::User(UserMessage::text("tail".to_string())));
7637 session
7638 .commit_transcript_rewrite(
7639 TranscriptRewriteSelection::MessageRange { start: 2, end: 3 },
7640 vec![Message::BlockAssistant(BlockAssistantMessage::new(
7641 vec![AssistantBlock::ToolUse {
7642 id: "tool-1".to_string(),
7643 name: "opaque-revised".to_string(),
7644 args: serde_json::value::RawValue::from_string(opaque.to_string())
7645 .expect("valid revised tool args"),
7646 meta: None,
7647 }],
7648 StopReason::ToolUse,
7649 ))],
7650 TranscriptRewriteReason::new("released-rich-content"),
7651 Some("unit-test".to_string()),
7652 None,
7653 )
7654 .expect("seed current rewrite");
7655
7656 let state = session
7657 .transcript_history_state()
7658 .expect("current state")
7659 .expect("current graph");
7660 let revisions = state
7661 .materialize_revision_bodies()
7662 .expect("released bodies materialize");
7663 let released_ids = revisions
7664 .iter()
7665 .map(|body| {
7666 (
7667 body.revision.clone(),
7668 released_0810_transcript_messages_digest_with_raw_spelling(
7669 &body.messages,
7670 canonical_opaque,
7671 opaque,
7672 ),
7673 )
7674 })
7675 .collect::<std::collections::BTreeMap<_, _>>();
7676 assert!(
7677 released_ids
7678 .iter()
7679 .any(|(current, released)| current != released),
7680 "fixture must reproduce a real pre-buffer format-2 identity mismatch"
7681 );
7682 let released_span_ids = (0..state.commit_count())
7683 .map(|index| {
7684 let record = rewrite_record_at(&state, index);
7685 let (start, end) = record.commit.selection.bounds();
7686 let removed = end - start;
7687 let retained = record.commit.messages_before - removed;
7688 let replacement_len = record.commit.messages_after - retained;
7689 (
7690 released_0810_transcript_messages_digest_with_raw_spelling(
7691 &record.parent_body.messages[start..end],
7692 canonical_opaque,
7693 opaque,
7694 ),
7695 released_0810_transcript_messages_digest_with_raw_spelling(
7696 &record.revision_body.messages[start..start + replacement_len],
7697 canonical_opaque,
7698 opaque,
7699 ),
7700 )
7701 })
7702 .collect::<Vec<_>>();
7703 for (index, (released_original, released_replacement)) in
7704 released_span_ids.iter().enumerate()
7705 {
7706 let record = rewrite_record_at(&state, index);
7707 assert_ne!(
7708 released_original, &record.commit.original_span_digest,
7709 "fixture must reproduce a real pre-buffer format-2 original-span mismatch"
7710 );
7711 assert_ne!(
7712 released_replacement, &record.commit.replacement_digest,
7713 "fixture must reproduce a real pre-buffer format-2 replacement-span mismatch"
7714 );
7715 }
7716 let mut released = released_0810_document(&session, state.head().to_string(), revisions);
7717 let history = released["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]
7718 .as_object_mut()
7719 .expect("released history object");
7720 let revisions = history["revisions"]
7721 .as_array_mut()
7722 .expect("released revision vector");
7723 for body in revisions {
7724 body["revision"] = serde_json::Value::String(
7725 released_ids
7726 .get(body["revision"].as_str().expect("body revision"))
7727 .expect("body remap")
7728 .clone(),
7729 );
7730 if let Some(parent) = body["parent_revision"].as_str() {
7731 body["parent_revision"] = serde_json::Value::String(
7732 released_ids.get(parent).expect("parent remap").clone(),
7733 );
7734 }
7735 }
7736 history["head"] = serde_json::Value::String(
7737 released_ids
7738 .get(history["head"].as_str().expect("released head"))
7739 .expect("head remap")
7740 .clone(),
7741 );
7742 for (index, commit) in history["commits"]
7743 .as_array_mut()
7744 .expect("released commits")
7745 .iter_mut()
7746 .enumerate()
7747 {
7748 commit["parent_revision"] = serde_json::Value::String(
7749 released_ids
7750 .get(commit["parent_revision"].as_str().expect("commit parent"))
7751 .expect("commit parent remap")
7752 .clone(),
7753 );
7754 commit["revision"] = serde_json::Value::String(
7755 released_ids
7756 .get(commit["revision"].as_str().expect("commit revision"))
7757 .expect("commit revision remap")
7758 .clone(),
7759 );
7760 commit["original_span_digest"] =
7761 serde_json::Value::String(released_span_ids[index].0.clone());
7762 commit["replacement_digest"] =
7763 serde_json::Value::String(released_span_ids[index].1.clone());
7764 }
7765
7766 let mut inconsistent = released.clone();
7767 inconsistent["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["commits"][0]["messages_before"] =
7768 serde_json::json!(999);
7769 let inconsistent = serde_json::to_vec(&inconsistent).expect("inconsistent released bytes");
7770 assert!(
7771 import_released_0810_session(&inconsistent).is_err(),
7772 "source authorization must not launder contradictory commit/body topology"
7773 );
7774
7775 let released = serde_json::to_vec(&released).expect("released bytes");
7776 let imported = import_released_0810_session(&released)
7777 .expect("store-authorized 0.8.10 lost-spelling graph imports");
7778 assert_eq!(
7779 imported.receipt().evidence(),
7780 Released0810ImportEvidence::StoreAuthorizationRequired
7781 );
7782 let imported_state = imported
7783 .session()
7784 .transcript_history_state()
7785 .expect("imported state")
7786 .expect("imported graph");
7787 assert_eq!(
7788 imported_state.digest_format(),
7789 transcript_history::graph::TRANSCRIPT_DIGEST_FORMAT_CURRENT
7790 );
7791 for body in imported_state
7792 .materialize_revision_bodies()
7793 .expect("current bodies materialize")
7794 {
7795 assert_eq!(
7796 body.revision,
7797 transcript_messages_digest(&body.messages).expect("current body digest"),
7798 "every released format-2 label must be replaced by current identity"
7799 );
7800 }
7801 let current = serde_json::to_vec(imported.session()).expect("current session serializes");
7802 let restored: Session =
7803 serde_json::from_slice(¤t).expect("current session round-trips");
7804 assert_eq!(
7805 restored.transcript_revision().expect("restored revision"),
7806 imported
7807 .session()
7808 .transcript_revision()
7809 .expect("imported revision")
7810 );
7811 }
7812
7813 #[test]
7814 fn released_0_8_10_collapsed_raw_json_rewrite_is_squashed_and_tail_rebased() {
7815 let canonical_raw = r#"{"a":2,"z":1}"#;
7816 let released_raw = r#"{"z":1,"a":2}"#;
7817 let tool_message = |name: &str, raw: &str| {
7818 Message::BlockAssistant(BlockAssistantMessage::new(
7819 vec![AssistantBlock::ToolUse {
7820 id: "tool-1".to_string(),
7821 name: name.to_string(),
7822 args: serde_json::value::RawValue::from_string(raw.to_string())
7823 .expect("valid tool args"),
7824 meta: None,
7825 }],
7826 StopReason::ToolUse,
7827 ))
7828 };
7829
7830 let parent_messages = vec![tool_message("opaque", released_raw)];
7831 let collapsed_messages = vec![tool_message("opaque", canonical_raw)];
7832 let final_messages = vec![tool_message("opaque-revised", released_raw)];
7833 let old_parent = released_0810_transcript_messages_digest_with_raw_spelling(
7834 &parent_messages,
7835 canonical_raw,
7836 released_raw,
7837 );
7838 let old_collapsed = released_0810_transcript_messages_digest_with_raw_spelling(
7839 &collapsed_messages,
7840 canonical_raw,
7841 canonical_raw,
7842 );
7843 let old_final = released_0810_transcript_messages_digest_with_raw_spelling(
7844 &final_messages,
7845 canonical_raw,
7846 released_raw,
7847 );
7848 assert_ne!(old_parent, old_collapsed);
7849 assert_eq!(
7850 transcript_messages_digest(&parent_messages).expect("current parent digest"),
7851 transcript_messages_digest(&collapsed_messages).expect("current collapsed digest"),
7852 "only released RawValue spelling may distinguish the squashed endpoints"
7853 );
7854
7855 let body = |revision: String, parent_revision: Option<String>, messages: Vec<Message>| {
7856 TranscriptRevisionBody {
7857 revision,
7858 parent_revision,
7859 messages,
7860 created_at: SystemTime::UNIX_EPOCH,
7861 }
7862 };
7863 let parent_body = body(old_parent.clone(), None, parent_messages.clone());
7864 let collapsed_body = body(
7865 old_collapsed.clone(),
7866 Some(old_parent.clone()),
7867 collapsed_messages.clone(),
7868 );
7869 let final_body = body(
7870 old_final.clone(),
7871 Some(old_collapsed.clone()),
7872 final_messages.clone(),
7873 );
7874 let commit = |parent_revision: String,
7875 revision: String,
7876 original_span_digest: String,
7877 replacement_digest: String| TranscriptRewriteCommit {
7878 rewrite_generation: 0,
7879 parent_revision,
7880 revision,
7881 selection: TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7882 original_span_digest,
7883 replacement_digest,
7884 messages_before: 1,
7885 messages_after: 1,
7886 reason: TranscriptRewriteReason::new("released-rich-content"),
7887 actor: Some("unit-test".to_string()),
7888 committed_at: SystemTime::UNIX_EPOCH,
7889 };
7890 let collapsed_commit = commit(
7891 old_parent,
7892 old_collapsed.clone(),
7893 released_0810_transcript_messages_digest_with_raw_spelling(
7894 &parent_messages,
7895 canonical_raw,
7896 released_raw,
7897 ),
7898 released_0810_transcript_messages_digest_with_raw_spelling(
7899 &collapsed_messages,
7900 canonical_raw,
7901 canonical_raw,
7902 ),
7903 );
7904 let final_commit = commit(
7905 old_collapsed.clone(),
7906 old_final.clone(),
7907 released_0810_transcript_messages_digest_with_raw_spelling(
7908 &collapsed_messages,
7909 canonical_raw,
7910 canonical_raw,
7911 ),
7912 released_0810_transcript_messages_digest_with_raw_spelling(
7913 &final_messages,
7914 canonical_raw,
7915 released_raw,
7916 ),
7917 );
7918
7919 let mut session = Session::new();
7920 session.push(final_messages[0].clone());
7921 let mut released = serde_json::to_value(&session).expect("released session serializes");
7922 released["version"] = serde_json::json!(2);
7923 released["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY] = serde_json::json!({
7924 "head": old_final,
7925 "commits": [collapsed_commit, final_commit],
7926 "revisions": [parent_body, collapsed_body, final_body],
7927 "digest_format": TRANSCRIPT_DIGEST_FORMAT_RELEASED_0810,
7928 });
7929
7930 for invalid_format in [None, Some(1), Some(3)] {
7931 let mut invalid = released.clone();
7932 match invalid_format {
7933 Some(format) => {
7934 invalid["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["digest_format"] =
7935 serde_json::json!(format);
7936 }
7937 None => {
7938 invalid["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]
7939 .as_object_mut()
7940 .expect("history object")
7941 .remove("digest_format");
7942 }
7943 }
7944 assert!(
7945 import_released_0810_session(
7946 &serde_json::to_vec(&invalid).expect("invalid released bytes")
7947 )
7948 .is_err(),
7949 "released history digest format {invalid_format:?} must fail closed"
7950 );
7951 }
7952
7953 let mut retired_stamp = released.clone();
7954 retired_stamp["metadata"]["session_checkpoint_stamp_v1"] =
7955 serde_json::json!("untrusted-retired-metadata");
7956 let retired_stamp_import = import_released_0810_session(
7957 &serde_json::to_vec(&retired_stamp).expect("released fixture with retired stamp"),
7958 )
7959 .expect("retired checkpoint metadata does not claim physical authority");
7960 assert_eq!(
7961 retired_stamp_import.receipt().evidence(),
7962 Released0810ImportEvidence::StoreAuthorizationRequired
7963 );
7964 assert!(
7965 !retired_stamp_import
7966 .session()
7967 .metadata()
7968 .contains_key("session_checkpoint_stamp_v1")
7969 );
7970
7971 let mut all_collapsed = released.clone();
7972 all_collapsed["messages"] =
7973 serde_json::to_value(&collapsed_messages).expect("collapsed live messages");
7974 all_collapsed["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["head"] =
7975 serde_json::Value::String(old_collapsed);
7976 all_collapsed["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["commits"]
7977 .as_array_mut()
7978 .expect("released commits")
7979 .truncate(1);
7980 all_collapsed["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["revisions"]
7981 .as_array_mut()
7982 .expect("released bodies")
7983 .truncate(2);
7984 let all_collapsed = import_released_0810_session(
7985 &serde_json::to_vec(&all_collapsed).expect("all-collapsed fixture"),
7986 )
7987 .expect("fully collapsed released graph imports");
7988 assert!(
7989 all_collapsed
7990 .session()
7991 .transcript_history_state()
7992 .expect("current history query")
7993 .is_none(),
7994 "a graph containing only semantic no-ops disappears at migration"
7995 );
7996
7997 let imported = import_released_0810_session(
7998 &serde_json::to_vec(&released).expect("released collapse fixture"),
7999 )
8000 .expect("source-proven collapsed rewrite imports");
8001 let state = imported
8002 .session()
8003 .transcript_history_state()
8004 .expect("current graph decodes")
8005 .expect("retained tail keeps one graph");
8006 assert_eq!(state.commit_count(), 1);
8007 assert_eq!(
8008 state.commit(0).expect("retained tail").rewrite_generation,
8009 1,
8010 "collapsed occurrences do not consume current generations"
8011 );
8012 assert_eq!(
8013 state
8014 .materialize_revision_bodies()
8015 .expect("current bodies materialize")
8016 .len(),
8017 2,
8018 "collapsed body identity is deduplicated"
8019 );
8020 assert_eq!(
8021 state.commit(0).expect("retained tail").parent_revision,
8022 transcript_messages_digest(&parent_messages).expect("current parent digest")
8023 );
8024 assert_eq!(
8025 state.head(),
8026 transcript_messages_digest(&final_messages).expect("current final digest")
8027 );
8028 }
8029
8030 #[test]
8031 fn transcript_history_rejects_stale_branch_after_digest_recurrence() {
8032 let mut restored = Session::new();
8033 restored.push(Message::User(UserMessage::text("A".to_string())));
8034 restored
8035 .commit_transcript_rewrite(
8036 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8037 vec![Message::User(UserMessage::text("B".to_string()))],
8038 TranscriptRewriteReason::new("to-b"),
8039 Some("unit-test".to_string()),
8040 None,
8041 )
8042 .expect("A to B");
8043 let mut stale_branch = restored.clone();
8044 restored
8045 .commit_transcript_rewrite(
8046 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8047 vec![Message::User(UserMessage::text("A".to_string()))],
8048 TranscriptRewriteReason::new("restore-a"),
8049 Some("unit-test".to_string()),
8050 None,
8051 )
8052 .expect("B back to A");
8053 stale_branch
8054 .commit_transcript_rewrite(
8055 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8056 vec![Message::User(UserMessage::text("C".to_string()))],
8057 TranscriptRewriteReason::new("stale-b-to-c"),
8058 Some("unit-test".to_string()),
8059 None,
8060 )
8061 .expect("stale B to C is locally valid");
8062
8063 let stale_state = stale_branch
8064 .transcript_history_state()
8065 .expect("stale state")
8066 .expect("stale history");
8067 let restored_state = restored
8068 .transcript_history_state()
8069 .expect("restored state")
8070 .expect("restored history");
8071 let mut records = (0..restored_state.commit_count())
8072 .map(|index| rewrite_record_at(&restored_state, index))
8073 .collect::<Vec<_>>();
8074 let mut stale_record = rewrite_record_at(&stale_state, 1);
8075 stale_record.commit.rewrite_generation = 3;
8076 records.push(stale_record);
8077
8078 assert!(
8079 TranscriptHistoryState::from_rewrite_records(records).is_err(),
8080 "an old B<-A body edge cannot authorize stale B->C after B->A restored A"
8081 );
8082 }
8083
8084 #[test]
8085 fn zero_generation_0_8_10_cycle_normalizes_from_proved_vector_order() {
8086 let mut session = Session::new();
8087 session.push(Message::User(UserMessage::text("A".to_string())));
8088 session
8089 .commit_transcript_rewrite(
8090 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8091 vec![Message::User(UserMessage::text("B".to_string()))],
8092 TranscriptRewriteReason::new("to-b"),
8093 Some("unit-test".to_string()),
8094 None,
8095 )
8096 .expect("A to B");
8097 session
8098 .commit_transcript_rewrite(
8099 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8100 vec![Message::User(UserMessage::text("A".to_string()))],
8101 TranscriptRewriteReason::new("restore-a"),
8102 Some("unit-test".to_string()),
8103 None,
8104 )
8105 .expect("B back to A");
8106
8107 let current = session
8108 .transcript_history_state()
8109 .expect("current history decodes")
8110 .expect("current history exists");
8111 let released = released_0810_document(
8112 &session,
8113 current.head().to_string(),
8114 current
8115 .materialize_revision_bodies()
8116 .expect("released bodies should materialize"),
8117 );
8118 let released = serde_json::to_vec(&released).expect("released document bytes");
8119 let imported =
8120 import_released_0810_session(&released).expect("0.8.10 cyclic graph remains supported");
8121 assert_eq!(
8122 imported.receipt().evidence(),
8123 Released0810ImportEvidence::StoreAuthorizationRequired
8124 );
8125 let state = imported
8126 .session()
8127 .transcript_history_state()
8128 .expect("history decodes")
8129 .expect("history exists");
8130 assert_eq!(
8131 state
8132 .commits()
8133 .map(|commit| commit.rewrite_generation)
8134 .collect::<Vec<_>>(),
8135 vec![1, 2],
8136 "content recurrence must not rotate or refuse the proved commit-vector order"
8137 );
8138 validate_transcript_history_state(&state).expect("normalized cycle remains valid");
8139 }
8140
8141 #[test]
8142 fn transcript_history_rejects_cyclic_edge_base() {
8143 let mut session = Session::new();
8144 session.push(Message::User(UserMessage::text("P".to_string())));
8145 session
8146 .commit_transcript_rewrite(
8147 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8148 vec![Message::User(UserMessage::text("Q".to_string()))],
8149 TranscriptRewriteReason::new("valid"),
8150 Some("unit-test".to_string()),
8151 None,
8152 )
8153 .expect("valid seed rewrite");
8154 let state = session
8155 .transcript_history_state()
8156 .expect("state")
8157 .expect("history");
8158 let mut state = serde_json::to_value(&state).expect("current history value");
8159 let child_revision = state["edges"][0]["commit"]["revision"]
8160 .as_str()
8161 .expect("edge child revision")
8162 .to_string();
8163 state["edges"][0]["base_revision"] = serde_json::Value::String(child_revision);
8164 session.set_metadata_unchecked_for_test(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, state);
8165
8166 assert!(
8167 serde_json::to_vec(&session).is_err(),
8168 "a cyclic compact-edge base must fail instead of looping"
8169 );
8170 }
8171
8172 #[test]
8173 fn live_append_can_recur_to_an_audited_digest_without_moving_audited_head() {
8174 let a = Message::User(UserMessage::text("A".to_string()));
8175 let b = Message::User(UserMessage::text("B".to_string()));
8176 let mut session = Session::new();
8177 session.push(Message::User(UserMessage::text("X".to_string())));
8178 let first = session
8179 .commit_transcript_rewrite(
8180 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8181 vec![a.clone(), b.clone()],
8182 TranscriptRewriteReason::new("to-a-b"),
8183 Some("unit-test".to_string()),
8184 None,
8185 )
8186 .expect("X to [A,B]");
8187 let h_parent = session
8188 .transcript_revision_body(&first.revision)
8189 .expect("H body")
8190 .expect("H retained")
8191 .parent_revision;
8192 let second = session
8193 .commit_transcript_rewrite(
8194 TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
8195 vec![a],
8196 TranscriptRewriteReason::new("to-a"),
8197 Some("unit-test".to_string()),
8198 None,
8199 )
8200 .expect("[A,B] to [A]");
8201
8202 session.push(b);
8203
8204 let state = session
8205 .transcript_history_state()
8206 .expect("state")
8207 .expect("history");
8208 assert_eq!(
8209 state.head(),
8210 second.revision,
8211 "graph head remains the latest audited endpoint"
8212 );
8213 assert_eq!(session.transcript_revision().unwrap(), first.revision);
8214 let recurred_body = state
8215 .materialize_revision(&first.revision)
8216 .expect("recurred H body");
8217 assert_eq!(
8218 recurred_body.parent_revision, h_parent,
8219 "reusing an audited digest must not rewrite its occurrence metadata"
8220 );
8221 validate_transcript_history_state(&state).expect("audited graph remains valid");
8222 }
8223
8224 #[test]
8228 fn replace_synthetic_notices_rejects_mismatched_kind_without_mutation() {
8229 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
8230
8231 let mut session = Session::new();
8232 session.push(Message::SystemNotice(SystemNoticeMessage::new(
8233 SystemNoticeKind::McpPending,
8234 "stale",
8235 )));
8236 let before = session.messages().to_vec();
8237
8238 let err = session
8239 .replace_synthetic_notices(
8240 SystemNoticeKind::McpPending,
8241 vec![Message::User(UserMessage::text("not a notice".to_string()))],
8242 )
8243 .expect_err("mismatched replacement must fail typed");
8244 assert!(
8245 matches!(err, TranscriptEditError::InvalidTranscriptShape(_)),
8246 "expected InvalidTranscriptShape, got {err:?}"
8247 );
8248 assert_eq!(
8249 session.messages(),
8250 before.as_slice(),
8251 "fault must leave the transcript unchanged (no partial strip)"
8252 );
8253 }
8254
8255 #[test]
8256 fn replace_synthetic_notices_rejects_malformed_history_atomically() {
8257 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
8258
8259 let mut session = Session::new();
8260 session.push(Message::User(UserMessage::text("before".to_string())));
8261 session
8262 .commit_transcript_rewrite(
8263 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8264 vec![Message::User(UserMessage::text("after".to_string()))],
8265 TranscriptRewriteReason::new("unit-test-edit"),
8266 Some("unit-test".to_string()),
8267 None,
8268 )
8269 .expect("seed rewrite");
8270 session.push(Message::SystemNotice(SystemNoticeMessage::new(
8271 SystemNoticeKind::McpPending,
8272 "stale",
8273 )));
8274 let state = session
8275 .transcript_history_state()
8276 .expect("state")
8277 .expect("history");
8278 let mut state = serde_json::to_value(&state).expect("current history value");
8279 state["anchor"]["messages"][0] =
8280 serde_json::to_value(Message::User(UserMessage::text("tampered".to_string())))
8281 .expect("tampered message");
8282 session.set_metadata_unchecked_for_test(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, state);
8283 let before_messages = session.messages.clone();
8284 let before_metadata = session.metadata.clone();
8285 let before_updated_at = session.updated_at;
8286
8287 assert!(
8288 session
8289 .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
8290 .is_err()
8291 );
8292 assert_eq!(session.messages(), before_messages.as_slice());
8293 assert_eq!(session.metadata, before_metadata);
8294 assert_eq!(session.updated_at, before_updated_at);
8295 }
8296
8297 #[test]
8298 fn replace_synthetic_notices_rejects_durable_notice_kinds() {
8299 use crate::types::SystemNoticeKind;
8300
8301 let mut session = Session::new();
8302 let before = session.messages().to_vec();
8303 assert!(
8304 session
8305 .replace_synthetic_notices(SystemNoticeKind::Comms, Vec::new())
8306 .is_err()
8307 );
8308 assert_eq!(session.messages(), before);
8309 }
8310
8311 #[test]
8312 fn replace_synthetic_notices_preserves_persisted_mcp_pending_notice() {
8313 use crate::types::{SystemNoticeBlock, SystemNoticeKind, SystemNoticeMessage};
8314
8315 let mut session = Session::new();
8316 session.push(Message::SystemNotice(SystemNoticeMessage::with_block(
8317 SystemNoticeKind::McpPending,
8318 Some("persisted pending fact".to_string()),
8319 SystemNoticeBlock::Mcp {
8320 server_id: Some("server".to_string()),
8321 operation: None,
8322 phase: None,
8323 persisted: true,
8324 detail: None,
8325 pending_sources: Vec::new(),
8326 },
8327 )));
8328 let before = session.messages().to_vec();
8329
8330 session
8331 .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
8332 .expect("synthetic refresh must coexist with a durable notice of the same kind");
8333 assert_eq!(session.messages(), before);
8334 }
8335
8336 #[test]
8337 fn replace_synthetic_notices_replaces_projection_beside_persisted_mcp_fact() {
8338 use crate::types::{SystemNoticeBlock, SystemNoticeKind, SystemNoticeMessage};
8339
8340 let durable = Message::SystemNotice(SystemNoticeMessage::with_block(
8341 SystemNoticeKind::McpPending,
8342 Some("persisted pending fact".to_string()),
8343 SystemNoticeBlock::Mcp {
8344 server_id: Some("server".to_string()),
8345 operation: None,
8346 phase: None,
8347 persisted: true,
8348 detail: None,
8349 pending_sources: Vec::new(),
8350 },
8351 ));
8352 let stale = Message::SystemNotice(SystemNoticeMessage::new(
8353 SystemNoticeKind::McpPending,
8354 "stale synthetic projection",
8355 ));
8356 let fresh = Message::SystemNotice(SystemNoticeMessage::new(
8357 SystemNoticeKind::McpPending,
8358 "fresh synthetic projection",
8359 ));
8360 let mut session = Session::new();
8361 session.push(durable.clone());
8362 session.push(stale);
8363
8364 session
8365 .replace_synthetic_notices(SystemNoticeKind::McpPending, vec![fresh.clone()])
8366 .expect("synthetic refresh beside durable fact");
8367
8368 assert_eq!(session.messages(), &[durable, fresh]);
8369 }
8370
8371 #[test]
8372 fn transcript_rewrite_preserves_full_assistant_block_trace() {
8373 let mut session = Session::new();
8374 session.push(Message::User(UserMessage::text(
8375 "run the trace".to_string(),
8376 )));
8377 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
8378 vec![AssistantBlock::Text {
8379 text: "original assistant trace".to_string(),
8380 meta: None,
8381 }],
8382 StopReason::EndTurn,
8383 )));
8384
8385 let parent_revision = session.transcript_revision().expect("parent revision");
8386 let replacement = vec![
8387 Message::BlockAssistant(BlockAssistantMessage::new(
8388 vec![
8389 AssistantBlock::Text {
8390 text: "compacted assistant trace".to_string(),
8391 meta: None,
8392 },
8393 AssistantBlock::ToolUse {
8394 id: "toolu_trace".to_string(),
8395 name: "trace_probe".to_string(),
8396 args: serde_json::value::RawValue::from_string(
8397 r#"{"path":"N-3"}"#.to_string(),
8398 )
8399 .expect("valid tool args"),
8400 meta: None,
8401 },
8402 ],
8403 StopReason::ToolUse,
8404 )),
8405 Message::tool_results(vec![ToolResult::new(
8406 "toolu_trace".to_string(),
8407 "trace complete".to_string(),
8408 false,
8409 )]),
8410 ];
8411
8412 let commit = session
8413 .commit_transcript_rewrite(
8414 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8415 replacement,
8416 TranscriptRewriteReason::new("compaction"),
8417 Some("unit-test".to_string()),
8418 Some(parent_revision.clone()),
8419 )
8420 .expect("rewrite should commit");
8421
8422 assert_eq!(commit.parent_revision, parent_revision);
8423 let current = session
8424 .transcript_revision_messages(&commit.revision)
8425 .expect("history state should decode")
8426 .expect("current revision should be retained");
8427 let Message::BlockAssistant(assistant) = ¤t[1] else {
8428 panic!("replacement should remain a block assistant message");
8429 };
8430 assert!(assistant.blocks.iter().any(|block| matches!(
8431 block,
8432 AssistantBlock::ToolUse { name, args, .. }
8433 if name == "trace_probe" && args.get().contains("\"N-3\"")
8434 )));
8435
8436 let parent = session
8437 .transcript_revision_messages(&parent_revision)
8438 .expect("history state should decode")
8439 .expect("parent revision should remain retained");
8440 assert!(matches!(
8441 &parent[1],
8442 Message::BlockAssistant(assistant)
8443 if block_assistant_text(assistant).contains("original assistant trace")
8444 ));
8445 }
8446
8447 #[test]
8448 fn transcript_rewrite_rejects_trailing_block_assistant_tool_call() {
8449 let mut session = Session::new();
8450 session.push(Message::User(UserMessage::text("question".to_string())));
8451 session.push(Message::BlockAssistant(BlockAssistantMessage {
8452 blocks: vec![AssistantBlock::Text {
8453 text: "plain answer".to_string(),
8454 meta: None,
8455 }],
8456 stop_reason: StopReason::EndTurn,
8457 identity: crate::types::TranscriptMessageIdentity::default(),
8458 created_at: crate::types::message_timestamp_now(),
8459 }));
8460 let parent_revision = session.transcript_revision().expect("parent revision");
8461
8462 let err = session
8463 .commit_transcript_rewrite(
8464 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8465 vec![Message::BlockAssistant(BlockAssistantMessage::new(
8466 vec![AssistantBlock::ToolUse {
8467 id: "toolu_1".to_string(),
8468 name: "lookup".to_string(),
8469 args: serde_json::value::RawValue::from_string("{}".to_string())
8470 .expect("valid args"),
8471 meta: None,
8472 }],
8473 StopReason::ToolUse,
8474 ))],
8475 TranscriptRewriteReason::new("compaction"),
8476 Some("unit-test".to_string()),
8477 Some(parent_revision),
8478 )
8479 .expect_err("rewrite should reject trailing unresolved block-assistant tool call");
8480 assert!(matches!(
8481 err,
8482 TranscriptEditError::InvalidTranscriptShape(_)
8483 ));
8484 }
8485
8486 #[test]
8487 fn transcript_rewrite_rejects_no_op_self_edge() {
8488 let mut session = Session::new();
8489 session.push(Message::User(UserMessage::text(
8490 "keep this exact transcript".to_string(),
8491 )));
8492 session.push(Message::BlockAssistant(BlockAssistantMessage {
8493 blocks: vec![AssistantBlock::Text {
8494 text: "unchanged".to_string(),
8495 meta: None,
8496 }],
8497 stop_reason: StopReason::EndTurn,
8498 identity: crate::types::TranscriptMessageIdentity::default(),
8499 created_at: crate::types::message_timestamp_now(),
8500 }));
8501
8502 let parent_revision = session.transcript_revision().expect("parent revision");
8503 let err = session
8504 .commit_transcript_rewrite(
8505 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8506 vec![session.messages()[1].clone()],
8507 TranscriptRewriteReason::new("retry"),
8508 Some("unit-test".to_string()),
8509 Some(parent_revision.clone()),
8510 )
8511 .expect_err("same-content rewrite should not emit a self-edge commit");
8512
8513 assert!(matches!(
8514 err,
8515 TranscriptEditError::NoOpRewrite { revision } if revision == parent_revision
8516 ));
8517 assert!(
8518 session
8519 .transcript_history_state()
8520 .expect("history state should decode")
8521 .is_none()
8522 );
8523 }
8524
8525 #[test]
8526 fn transcript_rewrite_run_boundary_guard_accepts_rewrite_then_append() {
8527 let mut original = Session::new();
8528 original.push(Message::User(UserMessage::text("question".to_string())));
8529 original.push(Message::BlockAssistant(BlockAssistantMessage {
8530 blocks: vec![AssistantBlock::Text {
8531 text: "verbose answer".to_string(),
8532 meta: None,
8533 }],
8534 stop_reason: StopReason::EndTurn,
8535 identity: crate::types::TranscriptMessageIdentity::default(),
8536 created_at: crate::types::message_timestamp_now(),
8537 }));
8538
8539 let parent_revision = original.transcript_revision().expect("parent revision");
8540 let mut incoming = original.clone();
8541 incoming
8542 .commit_transcript_rewrite(
8543 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8544 vec![Message::BlockAssistant(BlockAssistantMessage {
8545 blocks: vec![AssistantBlock::Text {
8546 text: "compact answer".to_string(),
8547 meta: None,
8548 }],
8549 stop_reason: StopReason::EndTurn,
8550 identity: crate::types::TranscriptMessageIdentity::default(),
8551 created_at: crate::types::message_timestamp_now(),
8552 })],
8553 TranscriptRewriteReason::new("compaction"),
8554 Some("unit-test".to_string()),
8555 Some(parent_revision),
8556 )
8557 .expect("rewrite should commit");
8558 incoming.push(Message::User(UserMessage::text("follow-up".to_string())));
8559 incoming.push(Message::BlockAssistant(BlockAssistantMessage {
8560 blocks: vec![AssistantBlock::Text {
8561 text: "follow-up answer".to_string(),
8562 meta: None,
8563 }],
8564 stop_reason: StopReason::EndTurn,
8565 identity: crate::types::TranscriptMessageIdentity::default(),
8566 created_at: crate::types::message_timestamp_now(),
8567 }));
8568
8569 crate::session_store::run_boundary_snapshot_save_guard(&incoming, Some(&original))
8570 .expect("rewrite plus appended turn should be a valid run-boundary commit");
8571 }
8572
8573 #[test]
8574 fn transcript_rewrite_rejects_orphaned_tool_results() {
8575 let mut session = Session::new();
8576 session.push(Message::User(UserMessage::text("use a tool".to_string())));
8577 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
8578 vec![AssistantBlock::ToolUse {
8579 id: "toolu_1".to_string(),
8580 name: "lookup".to_string(),
8581 args: serde_json::value::RawValue::from_string("{}".to_string())
8582 .expect("valid args"),
8583 meta: None,
8584 }],
8585 StopReason::ToolUse,
8586 )));
8587 session.push(Message::tool_results(vec![ToolResult::new(
8588 "toolu_1".to_string(),
8589 "done".to_string(),
8590 false,
8591 )]));
8592 let parent_revision = session.transcript_revision().expect("parent revision");
8593
8594 let err = session
8595 .commit_transcript_rewrite(
8596 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8597 vec![Message::BlockAssistant(BlockAssistantMessage {
8598 blocks: vec![AssistantBlock::Text {
8599 text: "no tool after all".to_string(),
8600 meta: None,
8601 }],
8602 stop_reason: StopReason::EndTurn,
8603 identity: crate::types::TranscriptMessageIdentity::default(),
8604 created_at: crate::types::message_timestamp_now(),
8605 })],
8606 TranscriptRewriteReason::new("compaction"),
8607 Some("unit-test".to_string()),
8608 Some(parent_revision),
8609 )
8610 .expect_err("rewrite should reject stranded tool results");
8611 assert!(matches!(
8612 err,
8613 TranscriptEditError::InvalidTranscriptShape(_)
8614 ));
8615 }
8616
8617 #[test]
8618 fn transcript_rewrite_rejects_trailing_assistant_tool_call() {
8619 let mut session = Session::new();
8620 session.push(Message::User(UserMessage::text("question".to_string())));
8621 session.push(Message::BlockAssistant(BlockAssistantMessage {
8622 blocks: vec![AssistantBlock::Text {
8623 text: "plain answer".to_string(),
8624 meta: None,
8625 }],
8626 stop_reason: StopReason::EndTurn,
8627 identity: crate::types::TranscriptMessageIdentity::default(),
8628 created_at: crate::types::message_timestamp_now(),
8629 }));
8630 let parent_revision = session.transcript_revision().expect("parent revision");
8631
8632 let err = session
8633 .commit_transcript_rewrite(
8634 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8635 vec![Message::BlockAssistant(BlockAssistantMessage {
8636 blocks: vec![AssistantBlock::ToolUse {
8637 id: "toolu_1".to_string(),
8638 name: "lookup".to_string(),
8639 args: serde_json::value::RawValue::from_string("{}".to_string())
8640 .expect("valid args"),
8641 meta: None,
8642 }],
8643 stop_reason: StopReason::ToolUse,
8644 identity: crate::types::TranscriptMessageIdentity::default(),
8645 created_at: crate::types::message_timestamp_now(),
8646 })],
8647 TranscriptRewriteReason::new("compaction"),
8648 Some("unit-test".to_string()),
8649 Some(parent_revision),
8650 )
8651 .expect_err("rewrite should reject trailing unresolved tool call");
8652 assert!(matches!(
8653 err,
8654 TranscriptEditError::InvalidTranscriptShape(_)
8655 ));
8656 }
8657
8658 #[test]
8659 fn transcript_rewrite_rejects_duplicate_tool_results() {
8660 let mut session = Session::new();
8661 session.push(Message::User(UserMessage::text("use a tool".to_string())));
8662 session.push(Message::BlockAssistant(BlockAssistantMessage {
8663 blocks: vec![AssistantBlock::Text {
8664 text: "plain answer".to_string(),
8665 meta: None,
8666 }],
8667 stop_reason: StopReason::EndTurn,
8668 identity: crate::types::TranscriptMessageIdentity::default(),
8669 created_at: crate::types::message_timestamp_now(),
8670 }));
8671 let parent_revision = session.transcript_revision().expect("parent revision");
8672
8673 let err = session
8674 .commit_transcript_rewrite(
8675 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8676 vec![
8677 Message::BlockAssistant(BlockAssistantMessage::new(
8678 vec![AssistantBlock::ToolUse {
8679 id: "toolu_1".to_string(),
8680 name: "lookup".to_string(),
8681 args: serde_json::value::RawValue::from_string("{}".to_string())
8682 .expect("valid args"),
8683 meta: None,
8684 }],
8685 StopReason::ToolUse,
8686 )),
8687 Message::tool_results(vec![
8688 ToolResult::new("toolu_1".to_string(), "one".to_string(), false),
8689 ToolResult::new("toolu_1".to_string(), "two".to_string(), false),
8690 ]),
8691 ],
8692 TranscriptRewriteReason::new("compaction"),
8693 Some("unit-test".to_string()),
8694 Some(parent_revision),
8695 )
8696 .expect_err("rewrite should reject duplicate tool results");
8697 assert!(matches!(
8698 err,
8699 TranscriptEditError::InvalidTranscriptShape(_)
8700 ));
8701 }
8702
8703 #[test]
8704 fn transcript_rewrite_record_rejects_prefix_or_suffix_tampering() {
8705 let mut session = Session::new();
8706 session.push(Message::System(SystemMessage::new("keep prefix")));
8707 session.push(Message::BlockAssistant(BlockAssistantMessage {
8708 blocks: vec![AssistantBlock::Text {
8709 text: "verbose answer".to_string(),
8710 meta: None,
8711 }],
8712 stop_reason: StopReason::EndTurn,
8713 identity: crate::types::TranscriptMessageIdentity::default(),
8714 created_at: crate::types::message_timestamp_now(),
8715 }));
8716 session.push(Message::User(UserMessage::text("keep suffix".to_string())));
8717
8718 let parent_revision = session.transcript_revision().expect("parent revision");
8719 let commit = session
8720 .commit_transcript_rewrite(
8721 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8722 vec![Message::BlockAssistant(BlockAssistantMessage {
8723 blocks: vec![AssistantBlock::Text {
8724 text: "compact answer".to_string(),
8725 meta: None,
8726 }],
8727 stop_reason: StopReason::EndTurn,
8728 identity: crate::types::TranscriptMessageIdentity::default(),
8729 created_at: crate::types::message_timestamp_now(),
8730 })],
8731 TranscriptRewriteReason::new("compaction"),
8732 Some("unit-test".to_string()),
8733 Some(parent_revision),
8734 )
8735 .expect("rewrite should commit");
8736 let state = session
8737 .transcript_history_state()
8738 .expect("history state should decode")
8739 .expect("history state should exist");
8740 let record = rewrite_record_at(&state, 0);
8741 let parent_body = record.parent_body;
8742 let revision_body = record.revision_body;
8743
8744 let mut forged_body = revision_body;
8745 forged_body.messages[0] = Message::System(SystemMessage::new("tampered prefix"));
8746 forged_body.revision =
8747 transcript_messages_digest(&forged_body.messages).expect("forged digest");
8748 let mut forged_commit = commit;
8749 forged_commit.revision = forged_body.revision.clone();
8750 let err = TranscriptRewriteRecord::new(forged_commit, parent_body, forged_body)
8751 .expect_err("record validation must reject changes outside selected span");
8752 assert!(
8753 err.to_string().contains("before the selected span"),
8754 "unexpected error: {err}"
8755 );
8756 }
8757
8758 #[test]
8759 fn transcript_rewrite_replay_allows_normal_turn_revisions_between_rewrites() {
8760 let mut session = Session::new();
8761 session.push(Message::User(UserMessage::text("first".to_string())));
8762 session.push(Message::BlockAssistant(BlockAssistantMessage {
8763 blocks: vec![AssistantBlock::Text {
8764 text: "verbose first answer".to_string(),
8765 meta: None,
8766 }],
8767 stop_reason: StopReason::EndTurn,
8768 identity: crate::types::TranscriptMessageIdentity::default(),
8769 created_at: crate::types::message_timestamp_now(),
8770 }));
8771
8772 let first_parent = session.transcript_revision().expect("first parent");
8773 let first_commit = session
8774 .commit_transcript_rewrite(
8775 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8776 vec![Message::BlockAssistant(BlockAssistantMessage {
8777 blocks: vec![AssistantBlock::Text {
8778 text: "compact first answer".to_string(),
8779 meta: None,
8780 }],
8781 stop_reason: StopReason::EndTurn,
8782 identity: crate::types::TranscriptMessageIdentity::default(),
8783 created_at: crate::types::message_timestamp_now(),
8784 })],
8785 TranscriptRewriteReason::new("compaction"),
8786 Some("unit-test".to_string()),
8787 Some(first_parent),
8788 )
8789 .expect("first rewrite");
8790
8791 session.push(Message::User(UserMessage::text("normal turn".to_string())));
8792 session.push(Message::BlockAssistant(BlockAssistantMessage {
8793 blocks: vec![AssistantBlock::Text {
8794 text: "verbose second answer".to_string(),
8795 meta: None,
8796 }],
8797 stop_reason: StopReason::EndTurn,
8798 identity: crate::types::TranscriptMessageIdentity::default(),
8799 created_at: crate::types::message_timestamp_now(),
8800 }));
8801 let bridge_parent = session
8802 .transcript_revision()
8803 .expect("normal turn should advance transcript head");
8804 assert_ne!(bridge_parent, first_commit.revision);
8805 validate_transcript_history_state(
8806 &session
8807 .transcript_history_state()
8808 .expect("history state should decode")
8809 .expect("history state should exist"),
8810 )
8811 .expect("normal turn head may legitimately differ from last rewrite commit");
8812
8813 let second_commit = session
8814 .commit_transcript_rewrite(
8815 TranscriptRewriteSelection::MessageRange { start: 3, end: 4 },
8816 vec![Message::BlockAssistant(BlockAssistantMessage {
8817 blocks: vec![AssistantBlock::Text {
8818 text: "compact second answer".to_string(),
8819 meta: None,
8820 }],
8821 stop_reason: StopReason::EndTurn,
8822 identity: crate::types::TranscriptMessageIdentity::default(),
8823 created_at: crate::types::message_timestamp_now(),
8824 })],
8825 TranscriptRewriteReason::new("compaction"),
8826 Some("unit-test".to_string()),
8827 Some(bridge_parent.clone()),
8828 )
8829 .expect("second rewrite");
8830
8831 let state = session
8832 .transcript_history_state()
8833 .expect("history state should decode")
8834 .expect("history state should exist");
8835 let records =
8836 (0..state.commit_count()).map(|edge_index| rewrite_record_at(&state, edge_index));
8837
8838 let replayed = TranscriptHistoryState::from_rewrite_records(records)
8839 .expect("rewrite replay should accept normal-turn bridge revisions")
8840 .expect("rewrite records should exist");
8841 assert_eq!(replayed.head(), second_commit.revision);
8842 assert!(replayed.contains_revision(&bridge_parent));
8843 }
8844
8845 #[test]
8846 fn transcript_rewrite_replay_rejects_branched_rewrite_records() {
8847 let mut base = Session::new();
8848 base.push(Message::User(UserMessage::text("question".to_string())));
8849 base.push(Message::BlockAssistant(BlockAssistantMessage {
8850 blocks: vec![AssistantBlock::Text {
8851 text: "verbose answer".to_string(),
8852 meta: None,
8853 }],
8854 stop_reason: StopReason::EndTurn,
8855 identity: crate::types::TranscriptMessageIdentity::default(),
8856 created_at: crate::types::message_timestamp_now(),
8857 }));
8858 let parent = base.transcript_revision().expect("parent revision");
8859
8860 let mut first = base.clone();
8861 first
8862 .commit_transcript_rewrite(
8863 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8864 vec![Message::BlockAssistant(BlockAssistantMessage {
8865 blocks: vec![AssistantBlock::Text {
8866 text: "first compact answer".to_string(),
8867 meta: None,
8868 }],
8869 stop_reason: StopReason::EndTurn,
8870 identity: crate::types::TranscriptMessageIdentity::default(),
8871 created_at: crate::types::message_timestamp_now(),
8872 })],
8873 TranscriptRewriteReason::new("compaction"),
8874 Some("unit-test".to_string()),
8875 Some(parent.clone()),
8876 )
8877 .expect("first rewrite");
8878 let first_state = first
8879 .transcript_history_state()
8880 .expect("first state decodes")
8881 .expect("first state exists");
8882
8883 let mut second = base;
8884 second
8885 .commit_transcript_rewrite(
8886 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8887 vec![Message::BlockAssistant(BlockAssistantMessage {
8888 blocks: vec![AssistantBlock::Text {
8889 text: "second compact answer".to_string(),
8890 meta: None,
8891 }],
8892 stop_reason: StopReason::EndTurn,
8893 identity: crate::types::TranscriptMessageIdentity::default(),
8894 created_at: crate::types::message_timestamp_now(),
8895 })],
8896 TranscriptRewriteReason::new("compaction"),
8897 Some("unit-test".to_string()),
8898 Some(parent),
8899 )
8900 .expect("second rewrite");
8901 let second_state = second
8902 .transcript_history_state()
8903 .expect("second state decodes")
8904 .expect("second state exists");
8905
8906 let err = TranscriptHistoryState::from_rewrite_records(vec![
8907 rewrite_record_at(&first_state, 0),
8908 rewrite_record_at(&second_state, 0),
8909 ])
8910 .expect_err("branched rewrite records must not replay as a linear source history");
8911 assert!(
8912 err.to_string()
8913 .contains("not expected contiguous generation"),
8914 "unexpected error: {err}"
8915 );
8916 }
8917
8918 #[test]
8919 fn internal_message_rewrites_refresh_transcript_history_head() {
8920 let mut session = Session::new();
8921 session.push(Message::User(UserMessage::text("question".to_string())));
8922 session.push(Message::BlockAssistant(BlockAssistantMessage {
8923 blocks: vec![AssistantBlock::Text {
8924 text: "verbose answer".to_string(),
8925 meta: None,
8926 }],
8927 stop_reason: StopReason::EndTurn,
8928 identity: crate::types::TranscriptMessageIdentity::default(),
8929 created_at: crate::types::message_timestamp_now(),
8930 }));
8931
8932 let parent = session.transcript_revision().expect("parent revision");
8933 session
8934 .commit_transcript_rewrite(
8935 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8936 vec![Message::BlockAssistant(BlockAssistantMessage {
8937 blocks: vec![AssistantBlock::Text {
8938 text: "compact answer".to_string(),
8939 meta: None,
8940 }],
8941 stop_reason: StopReason::EndTurn,
8942 identity: crate::types::TranscriptMessageIdentity::default(),
8943 created_at: crate::types::message_timestamp_now(),
8944 })],
8945 TranscriptRewriteReason::new("compaction"),
8946 Some("unit-test".to_string()),
8947 Some(parent),
8948 )
8949 .expect("rewrite should commit");
8950
8951 session.push(Message::User(UserMessage::text(
8952 "notice-bearing turn".to_string(),
8953 )));
8954 let retained = session
8955 .messages()
8956 .iter()
8957 .filter(|message| {
8958 !matches!(
8959 message,
8960 Message::User(user)
8961 if user.content.iter().any(|block| matches!(
8962 block,
8963 ContentBlock::Text { text } if text.contains("notice-bearing")
8964 ))
8965 )
8966 })
8967 .cloned()
8968 .collect();
8969 session
8970 .replace_messages_internal(
8971 retained,
8972 TranscriptRewriteReason::new("synthetic_notice_cleanup"),
8973 )
8974 .expect("retain should commit internal rewrite");
8975 let retained_digest =
8976 transcript_messages_digest(session.messages()).expect("retained digest");
8977 assert_eq!(
8978 session.transcript_revision().expect("retained head"),
8979 retained_digest
8980 );
8981
8982 session
8983 .replace_messages_internal(
8984 vec![
8985 Message::User(UserMessage::text("compacted question".to_string())),
8986 Message::BlockAssistant(BlockAssistantMessage {
8987 blocks: vec![AssistantBlock::Text {
8988 text: "compacted answer".to_string(),
8989 meta: None,
8990 }],
8991 stop_reason: StopReason::EndTurn,
8992 identity: crate::types::TranscriptMessageIdentity::default(),
8993 created_at: crate::types::message_timestamp_now(),
8994 }),
8995 ],
8996 TranscriptRewriteReason::new("compaction"),
8997 )
8998 .expect("replace should commit internal rewrite");
8999 let replaced_digest =
9000 transcript_messages_digest(session.messages()).expect("replaced digest");
9001 assert_eq!(
9002 session.transcript_revision().expect("replaced head"),
9003 replaced_digest
9004 );
9005 let state = session
9006 .transcript_history_state()
9007 .expect("history state should decode")
9008 .expect("history state should exist");
9009 assert!(state.contains_revision(&replaced_digest));
9010 validate_transcript_history_state(&state).expect("history state remains valid");
9011 }
9012
9013 #[test]
9014 fn append_system_message_preserves_exact_prefix_without_rewriting_history() {
9015 let mut session = Session::new();
9016 session.push(Message::User(UserMessage::text("question".to_string())));
9017 session.push(Message::BlockAssistant(BlockAssistantMessage {
9018 blocks: vec![AssistantBlock::Text {
9019 text: "verbose answer".to_string(),
9020 meta: None,
9021 }],
9022 stop_reason: StopReason::EndTurn,
9023 identity: crate::types::TranscriptMessageIdentity::default(),
9024 created_at: crate::types::message_timestamp_now(),
9025 }));
9026
9027 let parent = session.transcript_revision().expect("parent revision");
9028 let rewrite = session
9029 .commit_transcript_rewrite(
9030 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9031 vec![Message::BlockAssistant(BlockAssistantMessage {
9032 blocks: vec![AssistantBlock::Text {
9033 text: "compact answer".to_string(),
9034 meta: None,
9035 }],
9036 stop_reason: StopReason::EndTurn,
9037 identity: crate::types::TranscriptMessageIdentity::default(),
9038 created_at: crate::types::message_timestamp_now(),
9039 })],
9040 TranscriptRewriteReason::new("compaction"),
9041 Some("unit-test".to_string()),
9042 Some(parent),
9043 )
9044 .expect("rewrite should commit");
9045 let graph_before = session
9046 .validated_transcript_history_state()
9047 .expect("history validation")
9048 .expect("audited graph");
9049 let messages_before = session.messages().to_vec();
9050
9051 session.append_system_message("durable system prompt".to_string());
9052
9053 assert_eq!(
9054 &session.messages()[..messages_before.len()],
9055 messages_before.as_slice(),
9056 "setting a System prompt must preserve every existing message as an exact prefix"
9057 );
9058 assert!(matches!(
9059 session.messages().last(),
9060 Some(Message::System(system)) if system.content == "durable system prompt"
9061 ));
9062 let head = session
9063 .transcript_revision()
9064 .expect("live system prompt digest");
9065 assert_ne!(head, rewrite.revision);
9066 assert_eq!(
9067 head,
9068 transcript_messages_digest(session.messages()).expect("current digest")
9069 );
9070 let graph_after = session
9071 .validated_transcript_history_state()
9072 .expect("history validation")
9073 .expect("audited graph");
9074 assert!(
9075 graph_before.shares_exact_state_with(&graph_after),
9076 "mechanical prompt mutation must preserve the exact audited graph authority"
9077 );
9078 assert!(
9079 session
9080 .transcript_revision_messages(&head)
9081 .expect("history state should decode")
9082 .is_none(),
9083 "live message digests are not retained graph revisions"
9084 );
9085 let state = session
9086 .transcript_history_state()
9087 .expect("history state should decode")
9088 .expect("history state should exist");
9089 assert_eq!(state.head(), rewrite.revision);
9090 validate_transcript_history_state(&state).expect("audited graph remains valid");
9091 }
9092
9093 #[test]
9094 fn apply_transcript_history_state_uses_latest_commit_time_for_restored_head() {
9095 let mut session = Session::new();
9096 session.push(Message::User(UserMessage::text("question".to_string())));
9097 session.push(Message::BlockAssistant(BlockAssistantMessage {
9098 blocks: vec![AssistantBlock::Text {
9099 text: "verbose answer".to_string(),
9100 meta: None,
9101 }],
9102 stop_reason: StopReason::EndTurn,
9103 identity: crate::types::TranscriptMessageIdentity::default(),
9104 created_at: crate::types::message_timestamp_now(),
9105 }));
9106 let original_messages = session.messages().to_vec();
9107 let parent = session.transcript_revision().expect("parent revision");
9108 let compact = session
9109 .commit_transcript_rewrite(
9110 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9111 vec![Message::BlockAssistant(BlockAssistantMessage {
9112 blocks: vec![AssistantBlock::Text {
9113 text: "compact answer".to_string(),
9114 meta: None,
9115 }],
9116 stop_reason: StopReason::EndTurn,
9117 identity: crate::types::TranscriptMessageIdentity::default(),
9118 created_at: crate::types::message_timestamp_now(),
9119 })],
9120 TranscriptRewriteReason::new("compaction"),
9121 Some("unit-test".to_string()),
9122 Some(parent.clone()),
9123 )
9124 .expect("rewrite should commit");
9125
9126 let restore = session
9127 .commit_transcript_rewrite(
9128 TranscriptRewriteSelection::MessageRange {
9129 start: 0,
9130 end: session.messages().len(),
9131 },
9132 original_messages.clone(),
9133 TranscriptRewriteReason::new("restore"),
9134 Some("unit-test".to_string()),
9135 Some(compact.revision),
9136 )
9137 .expect("restore should commit");
9138 assert_eq!(restore.revision, parent);
9139
9140 let state = session
9141 .transcript_history_state()
9142 .expect("history state should decode")
9143 .expect("history state should exist");
9144 let restored_body_created_at = state
9145 .materialize_revision(&restore.revision)
9146 .expect("restored body should be retained")
9147 .created_at;
9148 assert_eq!(
9149 restored_body_created_at, restore.committed_at,
9150 "restoring a repeated revision selects its latest occurrence timestamp"
9151 );
9152
9153 let mut replayed = Session::new();
9154 replayed
9155 .apply_transcript_history_state(state)
9156 .expect("replay should materialize restored head");
9157 assert_eq!(
9158 serde_json::to_value(replayed.messages()).expect("replayed serializes"),
9159 serde_json::to_value(&original_messages).expect("original serializes")
9160 );
9161 assert_eq!(replayed.updated_at(), restore.committed_at);
9162 }
9163
9164 #[test]
9165 fn validated_bridge_parent_materialization_preserves_its_selected_head() {
9166 let mut session = Session::new();
9167 session.push(Message::User(UserMessage::text("question".to_string())));
9168 session.push(Message::BlockAssistant(BlockAssistantMessage {
9169 blocks: vec![AssistantBlock::Text {
9170 text: "verbose answer".to_string(),
9171 meta: None,
9172 }],
9173 stop_reason: StopReason::EndTurn,
9174 identity: crate::types::TranscriptMessageIdentity::default(),
9175 created_at: crate::types::message_timestamp_now(),
9176 }));
9177
9178 let first_parent = session.transcript_revision().expect("first parent");
9179 let _first = session
9180 .commit_transcript_rewrite(
9181 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9182 vec![Message::BlockAssistant(BlockAssistantMessage {
9183 blocks: vec![AssistantBlock::Text {
9184 text: "compact answer".to_string(),
9185 meta: None,
9186 }],
9187 stop_reason: StopReason::EndTurn,
9188 identity: crate::types::TranscriptMessageIdentity::default(),
9189 created_at: crate::types::message_timestamp_now(),
9190 })],
9191 TranscriptRewriteReason::new("compaction"),
9192 Some("unit-test".to_string()),
9193 Some(first_parent),
9194 )
9195 .expect("first rewrite should commit");
9196
9197 session.push(Message::User(UserMessage::text("follow up".to_string())));
9198 session.push(Message::BlockAssistant(BlockAssistantMessage {
9199 blocks: vec![AssistantBlock::Text {
9200 text: "verbose follow-up".to_string(),
9201 meta: None,
9202 }],
9203 stop_reason: StopReason::EndTurn,
9204 identity: crate::types::TranscriptMessageIdentity::default(),
9205 created_at: crate::types::message_timestamp_now(),
9206 }));
9207 let bridge_messages = session.messages().to_vec();
9208 let bridge_revision = session.transcript_revision().expect("bridge revision");
9209
9210 let second = session
9211 .commit_transcript_rewrite(
9212 TranscriptRewriteSelection::MessageRange { start: 3, end: 4 },
9213 vec![Message::BlockAssistant(BlockAssistantMessage {
9214 blocks: vec![AssistantBlock::Text {
9215 text: "compact follow-up".to_string(),
9216 meta: None,
9217 }],
9218 stop_reason: StopReason::EndTurn,
9219 identity: crate::types::TranscriptMessageIdentity::default(),
9220 created_at: crate::types::message_timestamp_now(),
9221 })],
9222 TranscriptRewriteReason::new("compaction"),
9223 Some("unit-test".to_string()),
9224 Some(bridge_revision.clone()),
9225 )
9226 .expect("second rewrite should commit");
9227 assert_ne!(second.revision, bridge_revision);
9228
9229 let full = session
9230 .transcript_history_state()
9231 .expect("history state should decode")
9232 .expect("history state should exist");
9233 assert_eq!(full.head(), second.revision);
9234 let bridge_body = ValidatedTranscriptHistory::seal_owned(full)
9235 .expect("full graph should seal")
9236 .materialize_rewrite_parent(&second)
9237 .expect("the exact rewrite occurrence must materialize its bridge parent");
9238 assert_eq!(
9239 bridge_body.revision, bridge_revision,
9240 "explicit parent materialization must preserve the selected bridge revision"
9241 );
9242 assert_eq!(
9243 serde_json::to_value(&bridge_body.messages).expect("projection serializes"),
9244 serde_json::to_value(&bridge_messages).expect("bridge serializes")
9245 );
9246 }
9247
9248 #[test]
9249 fn exact_rewrite_occurrence_projection_orders_digest_recurrence() {
9250 let message_a = Message::User(UserMessage::text("A".to_string()));
9251 let message_b = Message::User(UserMessage::text("B".to_string()));
9252 let mut session = Session::new();
9253 session.push(message_a.clone());
9254 let revision_a = session.transcript_revision().expect("A revision");
9255
9256 let first_b = session
9257 .commit_transcript_rewrite(
9258 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9259 vec![message_b.clone()],
9260 TranscriptRewriteReason::new("A-to-B"),
9261 Some("unit-test".to_string()),
9262 Some(revision_a.clone()),
9263 )
9264 .expect("first B occurrence should commit");
9265 let back_to_a = session
9266 .commit_transcript_rewrite(
9267 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9268 vec![message_a.clone()],
9269 TranscriptRewriteReason::new("B-to-A"),
9270 Some("unit-test".to_string()),
9271 Some(first_b.revision.clone()),
9272 )
9273 .expect("second A occurrence should commit");
9274 let second_b = session
9275 .commit_transcript_rewrite(
9276 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9277 vec![message_b.clone()],
9278 TranscriptRewriteReason::new("A-to-B-again"),
9279 Some("unit-test".to_string()),
9280 Some(back_to_a.revision.clone()),
9281 )
9282 .expect("second B occurrence should commit");
9283
9284 assert_eq!(back_to_a.revision, revision_a);
9285 assert_eq!(second_b.revision, first_b.revision);
9286 let graph = session
9287 .transcript_history_state()
9288 .expect("history state should decode")
9289 .expect("history state should exist");
9290 let sealed =
9291 ValidatedTranscriptHistory::seal_owned(graph).expect("recurrence graph should seal");
9292
9293 for (generation, commit, parent_message, revision_message) in [
9294 (1_u64, &first_b, &message_a, &message_b),
9295 (2_u64, &back_to_a, &message_b, &message_a),
9296 (3_u64, &second_b, &message_a, &message_b),
9297 ] {
9298 assert_eq!(commit.rewrite_generation, generation);
9299
9300 let before = sealed
9301 .materialize_rewrite_parent(commit)
9302 .expect("exact parent occurrence should materialize");
9303 assert_eq!(before.messages, std::slice::from_ref(parent_message));
9304
9305 let mut after = Session::new();
9306 after
9307 .apply_validated_transcript_history_state(
9308 sealed
9309 .project_at_rewrite_commit(commit)
9310 .expect("exact rewrite occurrence should project"),
9311 )
9312 .expect("exact rewrite occurrence should materialize");
9313 assert_eq!(after.messages(), std::slice::from_ref(revision_message));
9314 let after_graph = after
9315 .transcript_history_state()
9316 .expect("rewrite graph should decode")
9317 .expect("rewrite graph should exist");
9318 assert_eq!(
9319 after_graph.commit_count(),
9320 usize::try_from(generation).expect("test generation fits usize")
9321 );
9322 assert_eq!(
9323 after_graph.last_commit(),
9324 Some(commit),
9325 "the projection must end at this occurrence, not a later equal digest"
9326 );
9327 }
9328
9329 let latest_b = sealed
9330 .project_at_revision(&first_b.revision)
9331 .expect("content lookup should remain available");
9332 assert_eq!(
9333 latest_b.commit_count(),
9334 3,
9335 "digest-only lookup intentionally selects the latest matching occurrence"
9336 );
9337 }
9338
9339 #[test]
9340 fn test_session_new() {
9341 let session = Session::new();
9342 assert_eq!(session.version(), SESSION_VERSION);
9343 assert!(session.messages().is_empty());
9344 assert!(session.created_at() <= session.updated_at());
9345 }
9346
9347 #[test]
9348 fn llm_identity_model_override_switches_to_catalog_provider() {
9349 let registry = crate::ModelRegistry::from_config(
9350 &crate::Config::default(),
9351 *crate::model_profile::test_catalog::TEST_CATALOG,
9352 )
9353 .unwrap();
9354 let current = SessionLlmIdentity {
9355 model: "test-anthropic-default".to_string(),
9356 provider: Provider::Anthropic,
9357 self_hosted_server_id: None,
9358 provider_params: None,
9359 auth_binding: Some(crate::AuthBindingRef {
9360 realm: crate::RealmId::parse("tenant_a").unwrap(),
9361 binding: crate::BindingId::parse("anthropic_default").unwrap(),
9362 profile: None,
9363 origin: crate::BindingOrigin::Configured,
9364 }),
9365 };
9366
9367 let resolved = resolve_session_llm_identity_override(
9368 ¤t,
9369 ®istry,
9370 SessionLlmIdentityOverride {
9371 model: Some("test-openai-default"),
9372 provider: None,
9373 self_hosted_server_id: None,
9374 provider_params: None,
9375 auth_binding: None,
9376 },
9377 )
9378 .unwrap();
9379
9380 assert_eq!(resolved.model, "test-openai-default");
9381 assert_eq!(resolved.provider, Provider::OpenAI);
9382 assert!(
9383 resolved.auth_binding.is_none(),
9384 "provider switches must not inherit a binding from the previous provider"
9385 );
9386 }
9387
9388 #[test]
9389 fn llm_identity_model_override_keeps_uncatalogued_model_on_current_provider() {
9390 let registry = crate::ModelRegistry::from_config(
9391 &crate::Config::default(),
9392 *crate::model_profile::test_catalog::TEST_CATALOG,
9393 )
9394 .unwrap();
9395 let current = SessionLlmIdentity {
9396 model: "custom-model".to_string(),
9397 provider: Provider::Anthropic,
9398 self_hosted_server_id: None,
9399 provider_params: None,
9400 auth_binding: None,
9401 };
9402
9403 let resolved = resolve_session_llm_identity_override(
9404 ¤t,
9405 ®istry,
9406 SessionLlmIdentityOverride {
9407 model: Some("uncatalogued-custom-model"),
9408 provider: None,
9409 self_hosted_server_id: None,
9410 provider_params: None,
9411 auth_binding: None,
9412 },
9413 )
9414 .unwrap();
9415
9416 assert_eq!(resolved.model, "uncatalogued-custom-model");
9417 assert_eq!(resolved.provider, Provider::Anthropic);
9418 }
9419
9420 fn self_hosted_registry_with_shared_remote_model() -> crate::ModelRegistry {
9421 use crate::config::{
9422 SelfHostedApiStyle, SelfHostedModelConfig, SelfHostedServerConfig, SelfHostedTransport,
9423 };
9424 use crate::model_profile::catalog::ModelTier;
9425
9426 let mut config = crate::Config::default();
9427 for server_id in ["local-a", "local-b"] {
9428 config.self_hosted.servers.insert(
9429 server_id.to_string(),
9430 SelfHostedServerConfig {
9431 transport: SelfHostedTransport::OpenAiCompatible,
9432 base_url: format!("http://{server_id}.test"),
9433 api_style: SelfHostedApiStyle::Responses,
9434 },
9435 );
9436 config.self_hosted.models.insert(
9437 format!("shared-local-{server_id}"),
9438 SelfHostedModelConfig {
9439 server: server_id.to_string(),
9440 remote_model: "shared-local-model".to_string(),
9441 display_name: "Shared local model".to_string(),
9442 family: "shared-local".to_string(),
9443 tier: ModelTier::Supported,
9444 ..Default::default()
9445 },
9446 );
9447 }
9448 config.self_hosted.default_model = Some("shared-local-local-a".to_string());
9449 crate::ModelRegistry::from_config(
9450 &config,
9451 *crate::model_profile::test_catalog::TEST_CATALOG,
9452 )
9453 .expect("shared local registry")
9454 }
9455
9456 #[test]
9457 fn llm_identity_override_preserves_exact_self_hosted_server_route() {
9458 let registry = self_hosted_registry_with_shared_remote_model();
9459 let current = SessionLlmIdentity {
9460 model: "shared-local-local-a".to_string(),
9461 provider: Provider::SelfHosted,
9462 self_hosted_server_id: Some("local-a".to_string()),
9463 provider_params: None,
9464 auth_binding: None,
9465 };
9466
9467 let resolved = resolve_session_llm_identity_override(
9468 ¤t,
9469 ®istry,
9470 SessionLlmIdentityOverride {
9471 model: Some("shared-local-local-b"),
9472 provider: Some(Provider::SelfHosted),
9473 self_hosted_server_id: Some("local-b"),
9474 provider_params: None,
9475 auth_binding: None,
9476 },
9477 )
9478 .expect("exact configured local route should resolve");
9479
9480 assert_eq!(resolved.model, "shared-local-local-b");
9481 assert_eq!(resolved.provider, Provider::SelfHosted);
9482 assert_eq!(resolved.self_hosted_server_id.as_deref(), Some("local-b"));
9483 }
9484
9485 #[test]
9486 fn llm_identity_override_rejects_self_hosted_server_model_mismatch() {
9487 let registry = self_hosted_registry_with_shared_remote_model();
9488 let current = SessionLlmIdentity {
9489 model: "shared-local-local-a".to_string(),
9490 provider: Provider::SelfHosted,
9491 self_hosted_server_id: Some("local-a".to_string()),
9492 provider_params: None,
9493 auth_binding: None,
9494 };
9495
9496 let error = resolve_session_llm_identity_override(
9497 ¤t,
9498 ®istry,
9499 SessionLlmIdentityOverride {
9500 model: Some("shared-local-local-b"),
9501 provider: Some(Provider::SelfHosted),
9502 self_hosted_server_id: Some("local-a"),
9503 provider_params: None,
9504 auth_binding: None,
9505 },
9506 )
9507 .expect_err("server id must match the requested model alias route");
9508
9509 assert!(matches!(
9510 error,
9511 SessionLlmIdentityOverrideError::SelfHostedServerMismatch {
9512 requested,
9513 configured,
9514 ..
9515 } if requested == "local-a" && configured == "local-b"
9516 ));
9517 }
9518
9519 #[test]
9520 fn realtime_transcript_append_is_idempotent_by_provider_item_and_delta_id() {
9521 let mut session = Session::new();
9522
9523 let user = RealtimeTranscriptEvent::UserTranscriptFinal {
9524 item_id: "item_user".to_string(),
9525 previous_item_id: None,
9526 content_index: 0,
9527 text: "hello".to_string(),
9528 };
9529 assert!(
9530 !session
9531 .append_realtime_transcript_event(user.clone())
9532 .is_inert()
9533 );
9534 assert!(session.append_realtime_transcript_event(user).is_inert());
9535
9536 let delta = RealtimeTranscriptEvent::AssistantTextDelta {
9537 response_id: "resp_assistant".to_string(),
9538 delta_id: "evt_delta_1".to_string(),
9539 item_id: "item_assistant".to_string(),
9540 previous_item_id: Some("item_user".to_string()),
9541 content_index: 0,
9542 delta: "hi".to_string(),
9543 };
9544 assert!(
9545 session
9546 .append_realtime_transcript_event(delta.clone())
9547 .is_inert()
9548 );
9549 assert!(session.append_realtime_transcript_event(delta).is_inert());
9550
9551 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
9552 response_id: "resp_assistant".to_string(),
9553 stop_reason: StopReason::EndTurn,
9554 usage: Usage::default(),
9555 };
9556 assert!(
9557 !session
9558 .append_realtime_transcript_event(terminal.clone())
9559 .is_inert()
9560 );
9561 assert!(
9562 session
9563 .append_realtime_transcript_event(terminal)
9564 .is_inert()
9565 );
9566
9567 assert_eq!(session.messages().len(), 2);
9568 assert!(matches!(
9569 &session.messages()[0],
9570 Message::User(user) if user.text_content() == "hello"
9571 ));
9572 assert!(matches!(
9573 &session.messages()[1],
9574 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "hi"
9575 ));
9576 }
9577
9578 #[test]
9579 fn realtime_legacy_inline_activation_is_failure_atomic_and_preserves_whole_blob() {
9580 let mut malformed = Session::new();
9581 malformed.set_metadata_unchecked_for_test(
9582 SESSION_REALTIME_TRANSCRIPT_STATE_KEY,
9583 serde_json::json!("not-a-realtime-state"),
9584 );
9585 let pristine_prefix = malformed
9586 .realtime_component_event_prefix()
9587 .expect("pristine realtime prefix");
9588 assert!(matches!(
9589 malformed.activate_realtime_component_sidecar(),
9590 Err(RealtimeTranscriptSidecarError::Serialization(_))
9591 ));
9592 assert_eq!(
9593 malformed
9594 .metadata()
9595 .get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
9596 Some(&serde_json::json!("not-a-realtime-state")),
9597 "failed activation must leave the exact legacy value in place"
9598 );
9599 assert_eq!(
9600 malformed
9601 .realtime_component_event_prefix()
9602 .expect("unchanged realtime prefix"),
9603 pristine_prefix,
9604 "failed activation must not advance component authority"
9605 );
9606
9607 let mut session = Session::new();
9608 let state = SessionRealtimeTranscriptState::default();
9609 let inline = serde_json::to_value(&state).expect("inline projection");
9610 session
9611 .set_metadata_unchecked_for_test(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, inline.clone());
9612 session
9613 .activate_realtime_component_sidecar()
9614 .expect("supported inline activation");
9615 assert!(
9616 !session
9617 .metadata()
9618 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
9619 "successful activation removes raw shadow authority"
9620 );
9621 let suffix = session
9622 .prepare_realtime_component_event_suffix()
9623 .expect("prepare activation suffix")
9624 .expect("SnapshotV1 suffix");
9625 assert_eq!(suffix.events().len(), 1);
9626 assert!(matches!(
9627 suffix.events()[0]
9628 .decode_payload::<crate::RealtimeTranscriptSidecarRecord>(
9629 crate::REALTIME_TRANSCRIPT_SIDECAR_EVENT_SCHEMA_V1
9630 )
9631 .expect("decode activation record"),
9632 crate::RealtimeTranscriptSidecarRecord::SnapshotV1 { .. }
9633 ));
9634
9635 let whole_blob =
9636 serde_json::to_value(&session).expect("WholeBlob projection after activation");
9637 assert_eq!(
9638 whole_blob
9639 .get("metadata")
9640 .and_then(serde_json::Value::as_object)
9641 .and_then(|metadata| metadata.get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY)),
9642 Some(&inline),
9643 "activation changes storage authority, not the WholeBlob projection"
9644 );
9645 }
9646
9647 #[test]
9648 fn realtime_user_image_materializes_once_and_unblocks_causal_assistant() {
9649 let mut session = Session::new();
9650 let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
9651 let image = RealtimeTranscriptEvent::UserContentFinal {
9652 idempotency_key: "image-request-1".to_string(),
9653 item_id: "item_image".to_string(),
9654 previous_item_id: None,
9655 content_index: 0,
9656 content: vec![ContentBlock::Image {
9657 media_type: "image/png".to_string(),
9658 data: crate::types::ImageData::Inline {
9659 data: image_data.clone(),
9660 },
9661 }],
9662 };
9663
9664 assert!(
9665 !append_staged_user_image(&mut session, &image).is_inert(),
9666 "first image final must materialize canonical user content"
9667 );
9668 let replay = session
9669 .preflight_realtime_user_content_event(&image)
9670 .expect("exact retry should preflight as committed");
9671 assert!(matches!(
9672 replay,
9673 crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(_)
9674 ));
9675
9676 assert!(
9677 !session
9678 .metadata()
9679 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
9680 "ordinary operation must keep the full realtime projection out of raw metadata"
9681 );
9682 let whole_blob =
9683 serde_json::to_value(&session).expect("WholeBlob projection should serialize");
9684 let staged_state = whole_blob
9685 .get("metadata")
9686 .and_then(serde_json::Value::as_object)
9687 .and_then(|metadata| metadata.get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY))
9688 .expect("WholeBlob compatibility projection must include realtime state");
9689 assert!(
9690 !staged_state.to_string().contains(&image_data),
9691 "materialized image bytes must not remain duplicated in transcript metadata"
9692 );
9693
9694 assert!(
9695 session
9696 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
9697 response_id: "resp_image".to_string(),
9698 delta_id: "delta_image".to_string(),
9699 item_id: "item_assistant".to_string(),
9700 previous_item_id: Some("item_image".to_string()),
9701 content_index: 0,
9702 delta: "I see red.".to_string(),
9703 })
9704 .is_inert()
9705 );
9706 assert!(
9707 !session
9708 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
9709 response_id: "resp_image".to_string(),
9710 stop_reason: StopReason::EndTurn,
9711 usage: Usage::default(),
9712 },)
9713 .is_inert(),
9714 "materialized image predecessor must unblock the assistant response"
9715 );
9716
9717 assert_eq!(session.messages().len(), 2);
9718 assert!(matches!(
9719 &session.messages()[0],
9720 Message::User(user)
9721 if matches!(
9722 user.content.as_slice(),
9723 [ContentBlock::Image {
9724 media_type,
9725 data: crate::types::ImageData::Blob { blob_id },
9726 }] if media_type == "image/png"
9727 && blob_id == &crate::blob::content_blob_id("image/png", &image_data)
9728 )
9729 ));
9730 assert!(matches!(
9731 &session.messages()[1],
9732 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "I see red."
9733 ));
9734 }
9735
9736 #[test]
9737 fn realtime_user_image_identity_is_durable_canonical_and_conflict_safe() {
9738 let mut session = Session::new();
9739 let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
9740 let initial = RealtimeTranscriptEvent::UserContentFinal {
9741 idempotency_key: "stable-image-key".to_string(),
9742 item_id: "canonical-image-item".to_string(),
9743 previous_item_id: None,
9744 content_index: 0,
9745 content: vec![ContentBlock::Image {
9746 media_type: " image/PNG; charset=binary ".to_string(),
9747 data: crate::types::ImageData::Inline { data: data.clone() },
9748 }],
9749 };
9750 let committed = append_staged_user_image(&mut session, &initial);
9751 let Some(crate::RealtimeUserContentApplyOutcome::Committed(identity)) =
9752 committed.user_content
9753 else {
9754 panic!("first image must commit its durable identity");
9755 };
9756 assert_eq!(identity.item_id, "canonical-image-item");
9757 assert_eq!(identity.media_type, "image/png");
9758
9759 let encoded = serde_json::to_string(&session).expect("session should serialize");
9760 let restored: Session =
9761 serde_json::from_str(&encoded).expect("committed identity should restore");
9762
9763 let replay_event = RealtimeTranscriptEvent::UserContentFinal {
9764 idempotency_key: "stable-image-key".to_string(),
9765 item_id: "ignored-retry-item".to_string(),
9766 previous_item_id: None,
9767 content_index: 0,
9768 content: vec![ContentBlock::Image {
9769 media_type: "image/png".to_string(),
9770 data: crate::types::ImageData::Inline { data: data.clone() },
9771 }],
9772 };
9773 let replay = restored
9774 .preflight_realtime_user_content_event(&replay_event)
9775 .expect("exact retry should preflight");
9776 assert!(matches!(
9777 replay,
9778 crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(
9779 crate::RealtimeUserContentIdentity { ref item_id, .. }
9780 ) if item_id == "canonical-image-item"
9781 ));
9782
9783 let conflict = restored
9784 .preflight_realtime_user_content_event(&RealtimeTranscriptEvent::UserContentFinal {
9785 idempotency_key: "stable-image-key".to_string(),
9786 item_id: "conflicting-item".to_string(),
9787 previous_item_id: None,
9788 content_index: 0,
9789 content: vec![ContentBlock::Image {
9790 media_type: "image/png".to_string(),
9791 data: crate::types::ImageData::Inline {
9792 data: "different-payload".to_string(),
9793 },
9794 }],
9795 })
9796 .expect("conflicting retry should preflight");
9797 assert!(matches!(
9798 conflict,
9799 crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. }
9800 ));
9801
9802 let item_collision = restored
9803 .preflight_realtime_user_content_event(&RealtimeTranscriptEvent::UserContentFinal {
9804 idempotency_key: "another-key".to_string(),
9805 item_id: "canonical-image-item".to_string(),
9806 previous_item_id: None,
9807 content_index: 0,
9808 content: vec![ContentBlock::Image {
9809 media_type: "image/png".to_string(),
9810 data: crate::types::ImageData::Inline { data },
9811 }],
9812 })
9813 .expect("item collision should preflight");
9814 assert!(matches!(
9815 item_collision,
9816 crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. }
9817 ));
9818 assert_eq!(restored.messages().len(), 1);
9819 serde_json::to_string(&restored).expect("rejections must not corrupt durable state");
9820 }
9821
9822 #[test]
9823 fn realtime_user_image_reducer_never_receipts_without_pending_blob_proof() {
9824 for data in [
9825 crate::types::ImageData::Inline {
9826 data: "iVBORw0KGgo=".to_string(),
9827 },
9828 crate::types::ImageData::Blob {
9829 blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgo="),
9830 },
9831 ] {
9832 let mut session = Session::new();
9833 let outcome = session.append_realtime_transcript_event(
9834 RealtimeTranscriptEvent::UserContentFinal {
9835 idempotency_key: "unstaged-image-key".to_string(),
9836 item_id: "unstaged-image-item".to_string(),
9837 previous_item_id: None,
9838 content_index: 0,
9839 content: vec![ContentBlock::Image {
9840 media_type: "image/png".to_string(),
9841 data,
9842 }],
9843 },
9844 );
9845 assert!(matches!(
9846 outcome.user_content,
9847 Some(crate::RealtimeUserContentApplyOutcome::RejectedInvalidIdentity { .. })
9848 ));
9849 assert!(session.messages().is_empty());
9850 assert!(session.realtime_user_content_identities().is_empty());
9851 }
9852 }
9853
9854 #[test]
9855 fn realtime_user_image_pending_slot_is_generated_bounded_and_recovery_typed() {
9856 use crate::generated::session_document::{
9857 RealtimeUserContentBlobRecoveryDisposition, RealtimeUserContentBlobStageDisposition,
9858 };
9859 let mut session = Session::new();
9860 let pending = crate::PendingRealtimeUserContentBlob {
9861 idempotency_key: "pending-key-a".to_string(),
9862 item_id: "pending-item-a".to_string(),
9863 previous_item_id: None,
9864 content_index: 0,
9865 blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgo="),
9866 media_type: "image/png".to_string(),
9867 };
9868 let different = crate::PendingRealtimeUserContentBlob {
9869 idempotency_key: "pending-key-b".to_string(),
9870 item_id: "pending-item-b".to_string(),
9871 previous_item_id: None,
9872 content_index: 0,
9873 blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgoB"),
9874 media_type: "image/png".to_string(),
9875 };
9876 assert_eq!(
9877 session
9878 .stage_pending_realtime_user_content_blob(pending.clone())
9879 .expect("empty slot stages"),
9880 RealtimeUserContentBlobStageDisposition::StageNew
9881 );
9882 assert_eq!(
9883 session
9884 .stage_pending_realtime_user_content_blob(pending.clone())
9885 .expect("exact stage retry is idempotent"),
9886 RealtimeUserContentBlobStageDisposition::ReuseExact
9887 );
9888 assert_eq!(
9889 session
9890 .stage_pending_realtime_user_content_blob(different.clone())
9891 .expect("occupied decision is typed"),
9892 RealtimeUserContentBlobStageDisposition::RejectOccupied
9893 );
9894 assert_eq!(
9895 session.pending_realtime_user_content_blob(),
9896 Some(pending.clone())
9897 );
9898 assert_eq!(
9899 session
9900 .resolve_pending_realtime_user_content_blob_recovery(Some(&pending), false)
9901 .expect("exact recovery decision"),
9902 RealtimeUserContentBlobRecoveryDisposition::RetryExact
9903 );
9904 assert_eq!(
9905 session
9906 .resolve_pending_realtime_user_content_blob_recovery(Some(&different), true)
9907 .expect("verified older recovery decision"),
9908 RealtimeUserContentBlobRecoveryDisposition::CommitVerifiedBeforeCurrent
9909 );
9910 assert_eq!(
9911 session
9912 .resolve_pending_realtime_user_content_blob_recovery(Some(&different), false)
9913 .expect("invalid older recovery decision"),
9914 RealtimeUserContentBlobRecoveryDisposition::ClearInvalidBeforeCurrent
9915 );
9916 session
9917 .clear_invalid_pending_realtime_user_content_blob(Some(&different))
9918 .expect("generated clear-invalid disposition authorizes clear");
9919 assert!(session.pending_realtime_user_content_blob().is_none());
9920 }
9921
9922 #[test]
9923 fn transcript_rewrite_tombstones_removed_image_key_and_accepts_new_key() {
9924 let mut session = Session::new();
9925 let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
9926 let original = RealtimeTranscriptEvent::UserContentFinal {
9927 idempotency_key: "removed-image-key".to_string(),
9928 item_id: "removed-image-item".to_string(),
9929 previous_item_id: None,
9930 content_index: 0,
9931 content: vec![ContentBlock::Image {
9932 media_type: "image/png".to_string(),
9933 data: crate::types::ImageData::Inline { data: data.clone() },
9934 }],
9935 };
9936 assert!(matches!(
9937 append_staged_user_image(&mut session, &original).user_content,
9938 Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
9939 ));
9940
9941 let parent = session.transcript_revision().expect("parent revision");
9942 session
9943 .commit_transcript_rewrite(
9944 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9945 vec![Message::User(UserMessage::text("image removed"))],
9946 TranscriptRewriteReason::new("remove-image"),
9947 None,
9948 Some(parent),
9949 )
9950 .expect("rewrite should tombstone removed image identity");
9951
9952 assert!(session.realtime_user_content_identities().is_empty());
9953 assert_eq!(
9954 session.realtime_user_content_tombstones(),
9955 vec![crate::RealtimeUserContentTombstone {
9956 idempotency_key: "removed-image-key".to_string(),
9957 }]
9958 );
9959 assert!(matches!(
9960 session.preflight_realtime_user_content_event(&original),
9961 Some(crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. })
9962 ));
9963 assert!(matches!(
9964 session
9965 .append_realtime_transcript_event(original)
9966 .user_content,
9967 Some(crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. })
9968 ));
9969 assert_eq!(
9970 session.messages().len(),
9971 1,
9972 "stale retry emits no receipt content"
9973 );
9974
9975 let new_image = RealtimeTranscriptEvent::UserContentFinal {
9976 idempotency_key: "new-image-key".to_string(),
9977 item_id: "new-image-item".to_string(),
9978 previous_item_id: None,
9979 content_index: 0,
9980 content: vec![ContentBlock::Image {
9981 media_type: "image/png".to_string(),
9982 data: crate::types::ImageData::Inline { data },
9983 }],
9984 };
9985 assert!(matches!(
9986 append_staged_user_image(&mut session, &new_image).user_content,
9987 Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
9988 ));
9989 assert_eq!(session.messages().len(), 2);
9990
9991 let restored: Session = serde_json::from_str(
9992 &serde_json::to_string(&session).expect("serialize rewritten session"),
9993 )
9994 .expect("cold restore rewritten session");
9995 assert_eq!(restored.realtime_user_content_identities().len(), 1);
9996 assert_eq!(restored.realtime_user_content_tombstones().len(), 1);
9997 }
9998
9999 #[test]
10000 fn transcript_rewrite_retains_only_canonical_image_occurrence_for_exact_replay() {
10001 let mut session = Session::new();
10002 let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
10003 let original = RealtimeTranscriptEvent::UserContentFinal {
10004 idempotency_key: "retained-image-key".to_string(),
10005 item_id: "retained-image-item".to_string(),
10006 previous_item_id: None,
10007 content_index: 0,
10008 content: vec![ContentBlock::Image {
10009 media_type: "image/png".to_string(),
10010 data: crate::types::ImageData::Inline { data },
10011 }],
10012 };
10013 assert!(matches!(
10014 append_staged_user_image(&mut session, &original).user_content,
10015 Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
10016 ));
10017 let retained_message = session.messages()[0].clone();
10018 let parent = session.transcript_revision().expect("parent revision");
10019 session
10020 .commit_transcript_rewrite(
10021 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
10022 vec![
10023 retained_message,
10024 Message::User(UserMessage::text("new canonical neighbor")),
10025 ],
10026 TranscriptRewriteReason::new("retain-image"),
10027 None,
10028 Some(parent),
10029 )
10030 .expect("rewrite retaining exact inline image should reconcile");
10031
10032 assert!(session.realtime_user_content_tombstones().is_empty());
10033 let replay = session
10034 .preflight_realtime_user_content_event(&original)
10035 .expect("retained image should preflight as exact replay");
10036 assert!(matches!(
10037 replay,
10038 crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(_)
10039 ));
10040 assert_eq!(session.messages().len(), 2);
10041 }
10042
10043 #[test]
10044 fn transcript_rewrite_rejects_atomically_while_image_blob_anchor_is_pending() {
10045 let mut session = Session::new();
10046 session.push(Message::User(UserMessage::text("before rewrite")));
10047 let pending = crate::PendingRealtimeUserContentBlob {
10048 idempotency_key: "pending-rewrite-key".to_string(),
10049 item_id: "pending-rewrite-item".to_string(),
10050 previous_item_id: None,
10051 content_index: 0,
10052 blob_id: crate::blob::content_blob_id("image/png", "pending-bytes"),
10053 media_type: "image/png".to_string(),
10054 };
10055 session
10056 .stage_pending_realtime_user_content_blob(pending.clone())
10057 .expect("stage durable pending anchor");
10058 let parent = session.transcript_revision().expect("parent revision");
10059 let error = session
10060 .commit_transcript_rewrite(
10061 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
10062 vec![Message::User(UserMessage::text("after rewrite"))],
10063 TranscriptRewriteReason::new("blocked-pending-image"),
10064 None,
10065 Some(parent),
10066 )
10067 .expect_err("rewrite must not cross an unresolved image anchor");
10068 assert!(
10069 error
10070 .to_string()
10071 .contains("history_rewrite_pending_user_content_blob")
10072 );
10073 assert!(matches!(
10074 &session.messages()[0],
10075 Message::User(user) if user.text_content() == "before rewrite"
10076 ));
10077 assert_eq!(session.pending_realtime_user_content_blob(), Some(pending));
10078 }
10079
10080 #[test]
10081 fn realtime_user_image_rejects_noncanonical_blob_and_multiblock_shape() {
10082 let mut session = Session::new();
10083 for (key, content) in [
10084 (
10085 "invalid-blob",
10086 vec![ContentBlock::Image {
10087 media_type: "image/png".to_string(),
10088 data: crate::types::ImageData::Blob {
10089 blob_id: crate::BlobId::new("sha256:not-a-digest"),
10090 },
10091 }],
10092 ),
10093 (
10094 "multi-block",
10095 vec![
10096 ContentBlock::Image {
10097 media_type: "image/png".to_string(),
10098 data: crate::types::ImageData::Inline {
10099 data: "payload".to_string(),
10100 },
10101 },
10102 ContentBlock::Text {
10103 text: "smuggled".to_string(),
10104 },
10105 ],
10106 ),
10107 ] {
10108 let outcome = session.append_realtime_transcript_event(
10109 RealtimeTranscriptEvent::UserContentFinal {
10110 idempotency_key: key.to_string(),
10111 item_id: format!("item-{key}"),
10112 previous_item_id: None,
10113 content_index: 0,
10114 content,
10115 },
10116 );
10117 assert!(matches!(
10118 outcome.user_content,
10119 Some(crate::RealtimeUserContentApplyOutcome::RejectedInvalidIdentity { .. })
10120 ));
10121 }
10122 assert!(session.messages().is_empty());
10123 let encoded = serde_json::to_string(&session).expect("session should serialize");
10124 serde_json::from_str::<Session>(&encoded).expect("rejections must leave restorable state");
10125 }
10126
10127 #[test]
10128 fn realtime_restore_rejects_malformed_causal_graphs_and_accepts_waiting_dag() {
10129 fn restore(
10130 items: serde_json::Value,
10131 first_seen_order: Vec<&str>,
10132 ) -> Result<
10133 crate::realtime_transcript_revision::SessionRealtimeTranscriptState,
10134 crate::realtime_transcript_revision::RealtimeTranscriptShellError,
10135 > {
10136 let state = serde_json::from_value(serde_json::json!({
10137 "items": items,
10138 "first_seen_order": first_seen_order,
10139 }))
10140 .expect("test state shape should deserialize");
10141 crate::realtime_transcript_revision::restore_realtime_transcript_state(state)
10142 }
10143
10144 assert!(
10145 restore(
10146 serde_json::json!({
10147 "child": { "role": "user", "previous_item_id": "missing" }
10148 }),
10149 vec!["child"],
10150 )
10151 .is_ok(),
10152 "an unmaterialized out-of-order item must survive cold restore until its predecessor arrives"
10153 );
10154 assert!(
10155 restore(
10156 serde_json::json!({
10157 "child": {
10158 "role": "user",
10159 "previous_item_id": "missing",
10160 "ready": true,
10161 "materialized": true
10162 }
10163 }),
10164 vec!["child"],
10165 )
10166 .is_err(),
10167 "a materialized item cannot reference a missing predecessor"
10168 );
10169 assert!(
10170 restore(
10171 serde_json::json!({
10172 "self": { "role": "user", "previous_item_id": "self" }
10173 }),
10174 vec!["self"],
10175 )
10176 .is_err(),
10177 "self edge must fail cold restore"
10178 );
10179 assert!(
10180 restore(
10181 serde_json::json!({
10182 "a": { "role": "user", "previous_item_id": "b" },
10183 "b": { "role": "user", "previous_item_id": "a" }
10184 }),
10185 vec!["a", "b"],
10186 )
10187 .is_err(),
10188 "cycle must fail cold restore"
10189 );
10190 assert!(
10191 restore(
10192 serde_json::json!({
10193 "root": { "role": "user" },
10194 "materialized_child": {
10195 "role": "user",
10196 "previous_item_id": "root",
10197 "ready": true,
10198 "materialized": true
10199 }
10200 }),
10201 vec!["root", "materialized_child"],
10202 )
10203 .is_err(),
10204 "materialized child cannot have unmaterialized ancestry"
10205 );
10206 assert!(
10207 restore(
10208 serde_json::json!({
10209 "root": { "role": "user" },
10210 "waiting_child": { "role": "user", "previous_item_id": "root" }
10211 }),
10212 vec!["waiting_child", "root"],
10213 )
10214 .is_ok(),
10215 "valid acyclic waiting graph should restore even when first-seen order is child-first"
10216 );
10217 }
10218
10219 #[test]
10220 fn realtime_restore_handles_long_waiting_chain_with_bounded_graph_walk() {
10221 const ITEM_COUNT: usize = 4_096;
10222 let mut items = serde_json::Map::new();
10223 let mut order = Vec::with_capacity(ITEM_COUNT);
10224 for index in 0..ITEM_COUNT {
10225 let item_id = format!("item-{index:04}");
10226 let value = if index == 0 {
10227 serde_json::json!({ "role": "user" })
10228 } else {
10229 serde_json::json!({
10230 "role": "user",
10231 "previous_item_id": format!("item-{:04}", index - 1),
10232 })
10233 };
10234 order.push(item_id.clone());
10235 items.insert(item_id, value);
10236 }
10237 let state = serde_json::from_value(serde_json::json!({
10238 "items": items,
10239 "first_seen_order": order,
10240 }))
10241 .expect("long-chain fixture should deserialize");
10242 crate::realtime_transcript_revision::restore_realtime_transcript_state(state)
10243 .expect("long valid waiting DAG should restore in one bounded graph walk");
10244 }
10245
10246 #[test]
10251 fn realtime_transcript_final_text_overrides_partial_delta_and_promotes_to_spoken_lane() {
10252 let mut session = Session::new();
10253
10254 assert!(
10257 session
10258 .append_realtime_transcript_event(
10259 RealtimeTranscriptEvent::AssistantTranscriptDelta {
10260 response_id: "resp_a".to_string(),
10261 delta_id: "evt_1".to_string(),
10262 item_id: "item_a".to_string(),
10263 previous_item_id: None,
10264 content_index: 0,
10265 delta: "incom".to_string(),
10266 }
10267 )
10268 .is_inert()
10269 );
10270
10271 assert!(
10273 session
10274 .append_realtime_transcript_event(
10275 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
10276 response_id: "resp_a".to_string(),
10277 item_id: "item_a".to_string(),
10278 content_index: 0,
10279 text: "complete answer".to_string(),
10280 }
10281 )
10282 .is_inert()
10283 );
10284
10285 let outcome = session.append_realtime_transcript_event(
10287 RealtimeTranscriptEvent::AssistantTurnCompleted {
10288 response_id: "resp_a".to_string(),
10289 stop_reason: StopReason::EndTurn,
10290 usage: Usage::default(),
10291 },
10292 );
10293 assert!(!outcome.is_inert());
10294
10295 assert_eq!(session.messages().len(), 1);
10298 match &session.messages()[0] {
10299 Message::BlockAssistant(assistant) => {
10300 let mut found_transcript = false;
10301 for block in &assistant.blocks {
10302 if let AssistantBlock::Transcript { text, .. } = block {
10303 assert_eq!(text, "complete answer");
10304 found_transcript = true;
10305 }
10306 }
10307 assert!(
10308 found_transcript,
10309 "AssistantTranscriptFinalText must promote to the Spoken lane and \
10310 materialize as AssistantBlock::Transcript"
10311 );
10312 }
10313 other => unreachable!("expected BlockAssistant, got {other:?}"),
10314 }
10315 }
10316
10317 #[test]
10320 fn realtime_transcript_final_text_creates_item_when_no_delta_staged() {
10321 let mut session = Session::new();
10322
10323 assert!(
10324 session
10325 .append_realtime_transcript_event(
10326 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
10327 response_id: "resp_a".to_string(),
10328 item_id: "item_a".to_string(),
10329 content_index: 0,
10330 text: "spoken-final-only".to_string(),
10331 }
10332 )
10333 .is_inert()
10334 );
10335
10336 let outcome = session.append_realtime_transcript_event(
10337 RealtimeTranscriptEvent::AssistantTurnCompleted {
10338 response_id: "resp_a".to_string(),
10339 stop_reason: StopReason::EndTurn,
10340 usage: Usage::default(),
10341 },
10342 );
10343 assert!(!outcome.is_inert());
10344
10345 assert_eq!(session.messages().len(), 1);
10346 match &session.messages()[0] {
10347 Message::BlockAssistant(assistant) => {
10348 let has_transcript = assistant.blocks.iter().any(|b| {
10349 matches!(b, AssistantBlock::Transcript { text, .. } if text == "spoken-final-only")
10350 });
10351 assert!(
10352 has_transcript,
10353 "final-only provider path must materialize as Transcript on the Spoken lane"
10354 );
10355 }
10356 other => unreachable!("expected BlockAssistant, got {other:?}"),
10357 }
10358 }
10359
10360 #[test]
10361 fn realtime_transcript_append_orders_causally_equivalent_out_of_order_items() {
10362 let mut session = Session::new();
10363
10364 assert!(
10365 session
10366 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10367 response_id: "resp_assistant".to_string(),
10368 delta_id: "evt_delta_1".to_string(),
10369 item_id: "item_assistant".to_string(),
10370 previous_item_id: Some("item_user".to_string()),
10371 content_index: 0,
10372 delta: "answer".to_string(),
10373 })
10374 .is_inert()
10375 );
10376 assert!(
10377 session
10378 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10379 response_id: "resp_assistant".to_string(),
10380 stop_reason: StopReason::EndTurn,
10381 usage: Usage::default(),
10382 })
10383 .is_inert()
10384 );
10385
10386 let outcome = session.append_realtime_transcript_event(
10387 RealtimeTranscriptEvent::UserTranscriptFinal {
10388 item_id: "item_user".to_string(),
10389 previous_item_id: None,
10390 content_index: 0,
10391 text: "question".to_string(),
10392 },
10393 );
10394
10395 assert_eq!(outcome.materialized_messages.len(), 2);
10396 assert_eq!(session.messages().len(), 2);
10397 assert!(matches!(
10398 &session.messages()[0],
10399 Message::User(user) if user.text_content() == "question"
10400 ));
10401 assert!(matches!(
10402 &session.messages()[1],
10403 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer"
10404 ));
10405 }
10406
10407 #[test]
10408 fn realtime_transcript_replay_of_seen_provider_items_is_inert() {
10409 let mut session = Session::new();
10410 let events = vec![
10411 RealtimeTranscriptEvent::UserTranscriptFinal {
10412 item_id: "item_user".to_string(),
10413 previous_item_id: None,
10414 content_index: 0,
10415 text: "hello".to_string(),
10416 },
10417 RealtimeTranscriptEvent::AssistantTextDelta {
10418 response_id: "resp_assistant".to_string(),
10419 delta_id: "evt_delta_1".to_string(),
10420 item_id: "item_assistant".to_string(),
10421 previous_item_id: Some("item_user".to_string()),
10422 content_index: 0,
10423 delta: "world".to_string(),
10424 },
10425 RealtimeTranscriptEvent::AssistantTurnCompleted {
10426 response_id: "resp_assistant".to_string(),
10427 stop_reason: StopReason::EndTurn,
10428 usage: Usage::default(),
10429 },
10430 ];
10431
10432 for event in events.iter().cloned() {
10433 let _ = session.append_realtime_transcript_event(event);
10434 }
10435 let first_messages = serde_json::to_value(session.messages()).unwrap();
10436
10437 for event in events {
10438 assert!(session.append_realtime_transcript_event(event).is_inert());
10439 }
10440
10441 assert_eq!(
10442 serde_json::to_value(session.messages()).unwrap(),
10443 first_messages
10444 );
10445 }
10446
10447 #[test]
10448 fn realtime_transcript_user_final_replay_cannot_erase_existing_segment() {
10449 let mut session = Session::new();
10450
10451 let user = RealtimeTranscriptEvent::UserTranscriptFinal {
10452 item_id: "item_user".to_string(),
10453 previous_item_id: None,
10454 content_index: 0,
10455 text: "remember amber lantern".to_string(),
10456 };
10457 assert!(
10458 !session
10459 .append_realtime_transcript_event(user.clone())
10460 .is_inert()
10461 );
10462 let first_messages = serde_json::to_value(session.messages()).unwrap();
10463
10464 assert!(
10465 session
10466 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
10467 item_id: "item_user".to_string(),
10468 previous_item_id: None,
10469 content_index: 0,
10470 text: String::new(),
10471 })
10472 .is_inert()
10473 );
10474 assert!(session.append_realtime_transcript_event(user).is_inert());
10475 assert_eq!(
10476 serde_json::to_value(session.messages()).unwrap(),
10477 first_messages
10478 );
10479 }
10480
10481 #[test]
10482 fn realtime_transcript_empty_user_final_can_be_filled_by_later_nonempty_replay() {
10483 let mut session = Session::new();
10484
10485 assert!(
10486 session
10487 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
10488 item_id: "item_user".to_string(),
10489 previous_item_id: None,
10490 content_index: 0,
10491 text: String::new(),
10492 })
10493 .is_inert()
10494 );
10495 assert!(session.messages().is_empty());
10496
10497 let outcome = session.append_realtime_transcript_event(
10498 RealtimeTranscriptEvent::UserTranscriptFinal {
10499 item_id: "item_user".to_string(),
10500 previous_item_id: None,
10501 content_index: 0,
10502 text: "remember amber lantern".to_string(),
10503 },
10504 );
10505 assert_eq!(outcome.materialized_messages.len(), 1);
10506 assert_eq!(session.messages().len(), 1);
10507 assert!(matches!(
10508 &session.messages()[0],
10509 Message::User(user) if user.text_content() == "remember amber lantern"
10510 ));
10511 }
10512
10513 #[test]
10514 fn realtime_transcript_skipped_provider_items_preserve_causal_order_without_content() {
10515 let mut session = Session::new();
10516
10517 let assistant_delta = RealtimeTranscriptEvent::AssistantTextDelta {
10518 response_id: "resp_assistant".to_string(),
10519 delta_id: "evt_delta_1".to_string(),
10520 item_id: "item_assistant".to_string(),
10521 previous_item_id: Some("item_tool".to_string()),
10522 content_index: 0,
10523 delta: "done".to_string(),
10524 };
10525 assert!(
10526 session
10527 .append_realtime_transcript_event(assistant_delta.clone())
10528 .is_inert()
10529 );
10530 let assistant_complete = RealtimeTranscriptEvent::AssistantTurnCompleted {
10531 response_id: "resp_assistant".to_string(),
10532 stop_reason: StopReason::EndTurn,
10533 usage: Usage::default(),
10534 };
10535 assert!(
10536 session
10537 .append_realtime_transcript_event(assistant_complete.clone())
10538 .is_inert()
10539 );
10540
10541 let skipped = RealtimeTranscriptEvent::ItemSkipped {
10542 item_id: "item_tool".to_string(),
10543 previous_item_id: Some("item_user".to_string()),
10544 };
10545 assert!(
10546 session
10547 .append_realtime_transcript_event(skipped.clone())
10548 .is_inert(),
10549 "a skipped provider item must not append transcript content"
10550 );
10551 assert!(session.messages().is_empty());
10552
10553 let outcome = session.append_realtime_transcript_event(
10554 RealtimeTranscriptEvent::UserTranscriptFinal {
10555 item_id: "item_user".to_string(),
10556 previous_item_id: None,
10557 content_index: 0,
10558 text: "please use the tool".to_string(),
10559 },
10560 );
10561 assert_eq!(outcome.materialized_messages.len(), 2);
10562 assert_eq!(session.messages().len(), 2);
10563 assert!(matches!(
10564 &session.messages()[0],
10565 Message::User(user) if user.text_content() == "please use the tool"
10566 ));
10567 assert!(matches!(
10568 &session.messages()[1],
10569 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "done"
10570 ));
10571
10572 let first_messages = serde_json::to_value(session.messages()).unwrap();
10573 assert!(session.append_realtime_transcript_event(skipped).is_inert());
10574 assert!(
10575 session
10576 .append_realtime_transcript_event(assistant_delta)
10577 .is_inert()
10578 );
10579 assert!(
10580 session
10581 .append_realtime_transcript_event(assistant_complete)
10582 .is_inert()
10583 );
10584 assert_eq!(
10585 serde_json::to_value(session.messages()).unwrap(),
10586 first_messages
10587 );
10588 }
10589
10590 #[test]
10591 fn realtime_transcript_interrupted_assistant_item_unblocks_later_provider_items() {
10592 let mut session = Session::new();
10599
10600 let _ = session.append_realtime_transcript_event(
10601 RealtimeTranscriptEvent::UserTranscriptFinal {
10602 item_id: "item_repeat".to_string(),
10603 previous_item_id: None,
10604 content_index: 0,
10605 text: "repeat until stop".to_string(),
10606 },
10607 );
10608 assert!(
10609 session
10610 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10611 response_id: "resp_loop".to_string(),
10612 delta_id: "evt_loop_1".to_string(),
10613 item_id: "item_loop".to_string(),
10614 previous_item_id: Some("item_repeat".to_string()),
10615 content_index: 0,
10616 delta: "Looping now".to_string(),
10617 })
10618 .is_inert()
10619 );
10620 assert!(
10621 session
10622 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
10623 item_id: "item_stop".to_string(),
10624 previous_item_id: Some("item_loop".to_string()),
10625 content_index: 0,
10626 text: "Stop.".to_string(),
10627 })
10628 .is_inert(),
10629 "the stop turn waits until the interrupted assistant provider item is resolved"
10630 );
10631
10632 let outcome = session.append_realtime_transcript_event(
10633 RealtimeTranscriptEvent::AssistantTurnInterrupted {
10634 response_id: "resp_loop".to_string(),
10635 },
10636 );
10637
10638 assert_eq!(outcome.materialized_messages.len(), 2);
10641 assert_eq!(session.messages().len(), 3);
10643 assert!(matches!(
10644 &session.messages()[0],
10645 Message::User(user) if user.text_content() == "repeat until stop"
10646 ));
10647 match &session.messages()[1] {
10648 Message::BlockAssistant(assistant) => {
10649 let text = block_assistant_text(assistant);
10650 assert_eq!(text, "Looping now");
10651 }
10652 other => unreachable!(
10653 "Display lane assistant item must be retained on Interrupted, got {other:?}"
10654 ),
10655 }
10656 assert!(matches!(
10657 &session.messages()[2],
10658 Message::User(user) if user.text_content() == "Stop."
10659 ));
10660 }
10661
10662 #[test]
10663 fn realtime_transcript_late_interrupted_assistant_delta_stays_noncanonical() {
10664 let mut session = Session::new();
10665
10666 let _ = session.append_realtime_transcript_event(
10667 RealtimeTranscriptEvent::UserTranscriptFinal {
10668 item_id: "item_repeat".to_string(),
10669 previous_item_id: None,
10670 content_index: 0,
10671 text: "repeat until stop".to_string(),
10672 },
10673 );
10674 assert!(
10675 session
10676 .append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
10677 item_id: "item_loop".to_string(),
10678 previous_item_id: Some("item_repeat".to_string()),
10679 role: RealtimeTranscriptRole::Assistant,
10680 response_id: None,
10681 })
10682 .is_inert(),
10683 "provider can observe an assistant item before the adapter learns its response id"
10684 );
10685 assert!(
10686 session
10687 .append_realtime_transcript_event(
10688 RealtimeTranscriptEvent::AssistantTurnInterrupted {
10689 response_id: "resp_loop".to_string(),
10690 }
10691 )
10692 .is_inert(),
10693 "an interruption can arrive before delayed transcript deltas for the response"
10694 );
10695 assert!(
10696 session
10697 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
10698 item_id: "item_stop".to_string(),
10699 previous_item_id: Some("item_loop".to_string()),
10700 content_index: 0,
10701 text: "Stop.".to_string(),
10702 })
10703 .is_inert(),
10704 "the stop turn waits for the provider's interrupted assistant item anchor"
10705 );
10706
10707 let late_delta_outcome =
10708 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10709 response_id: "resp_loop".to_string(),
10710 delta_id: "evt_loop_late".to_string(),
10711 item_id: "item_loop".to_string(),
10712 previous_item_id: Some("item_repeat".to_string()),
10713 content_index: 0,
10714 delta: "Looping now".to_string(),
10715 });
10716 assert_eq!(late_delta_outcome.materialized_messages.len(), 1);
10717 assert!(matches!(
10718 &session.messages()[1],
10719 Message::User(user) if user.text_content() == "Stop."
10720 ));
10721 assert!(
10722 session
10723 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10724 response_id: "resp_loop".to_string(),
10725 stop_reason: StopReason::EndTurn,
10726 usage: Usage::default(),
10727 })
10728 .is_inert(),
10729 "late completion for an interrupted response must not resurrect its deltas"
10730 );
10731 assert!(
10732 session
10733 .messages()
10734 .iter()
10735 .filter_map(|message| match message {
10736 Message::BlockAssistant(assistant) => Some(block_assistant_text(assistant)),
10737 _ => None,
10738 })
10739 .all(|text| !text.contains("Looping now")),
10740 "late interrupted assistant text must remain non-canonical"
10741 );
10742 }
10743
10744 #[test]
10745 fn realtime_transcript_completion_only_finalizes_matching_response() {
10746 let mut session = Session::new();
10747
10748 let _ = session.append_realtime_transcript_event(
10749 RealtimeTranscriptEvent::UserTranscriptFinal {
10750 item_id: "item_user".to_string(),
10751 previous_item_id: None,
10752 content_index: 0,
10753 text: "question".to_string(),
10754 },
10755 );
10756 assert!(
10757 session
10758 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10759 response_id: "resp_a".to_string(),
10760 delta_id: "evt_a".to_string(),
10761 item_id: "item_a".to_string(),
10762 previous_item_id: Some("item_user".to_string()),
10763 content_index: 0,
10764 delta: "answer a".to_string(),
10765 })
10766 .is_inert()
10767 );
10768
10769 assert!(
10770 session
10771 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10772 response_id: "resp_b".to_string(),
10773 stop_reason: StopReason::EndTurn,
10774 usage: Usage::default(),
10775 })
10776 .is_inert(),
10777 "a completion for another response must not finalize buffered assistant text"
10778 );
10779 assert_eq!(session.messages().len(), 1);
10780
10781 let outcome = session.append_realtime_transcript_event(
10782 RealtimeTranscriptEvent::AssistantTurnCompleted {
10783 response_id: "resp_a".to_string(),
10784 stop_reason: StopReason::EndTurn,
10785 usage: Usage::default(),
10786 },
10787 );
10788 assert_eq!(outcome.materialized_messages.len(), 1);
10789 assert_eq!(session.messages().len(), 2);
10790 assert!(matches!(
10791 &session.messages()[1],
10792 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer a"
10793 ));
10794 }
10795
10796 #[test]
10797 fn realtime_transcript_completion_before_later_delta_is_response_scoped() {
10798 let mut session = Session::new();
10799
10800 let _ = session.append_realtime_transcript_event(
10801 RealtimeTranscriptEvent::UserTranscriptFinal {
10802 item_id: "item_user".to_string(),
10803 previous_item_id: None,
10804 content_index: 0,
10805 text: "question".to_string(),
10806 },
10807 );
10808 assert!(
10809 session
10810 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10811 response_id: "resp_a".to_string(),
10812 stop_reason: StopReason::EndTurn,
10813 usage: Usage::default(),
10814 })
10815 .is_inert()
10816 );
10817 assert!(
10818 session
10819 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10820 response_id: "resp_b".to_string(),
10821 delta_id: "evt_b".to_string(),
10822 item_id: "item_b".to_string(),
10823 previous_item_id: Some("item_user".to_string()),
10824 content_index: 0,
10825 delta: "wrong response".to_string(),
10826 })
10827 .is_inert(),
10828 "a later delta for another response must not be finalized by resp_a's pending completion"
10829 );
10830
10831 let outcome =
10832 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10833 response_id: "resp_a".to_string(),
10834 delta_id: "evt_a".to_string(),
10835 item_id: "item_a".to_string(),
10836 previous_item_id: Some("item_user".to_string()),
10837 content_index: 0,
10838 delta: "right response".to_string(),
10839 });
10840
10841 assert_eq!(outcome.materialized_messages.len(), 1);
10842 assert_eq!(session.messages().len(), 2);
10843 assert!(matches!(
10844 &session.messages()[1],
10845 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "right response"
10846 ));
10847 }
10848
10849 #[test]
10850 fn realtime_transcript_late_duplicate_completion_cannot_finalize_unrelated_response() {
10851 let mut session = Session::new();
10852
10853 let _ = session.append_realtime_transcript_event(
10854 RealtimeTranscriptEvent::UserTranscriptFinal {
10855 item_id: "item_user".to_string(),
10856 previous_item_id: None,
10857 content_index: 0,
10858 text: "question".to_string(),
10859 },
10860 );
10861 let _ =
10862 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10863 response_id: "resp_a".to_string(),
10864 delta_id: "evt_a".to_string(),
10865 item_id: "item_a".to_string(),
10866 previous_item_id: Some("item_user".to_string()),
10867 content_index: 0,
10868 delta: "first".to_string(),
10869 });
10870 let _ = session.append_realtime_transcript_event(
10871 RealtimeTranscriptEvent::AssistantTurnCompleted {
10872 response_id: "resp_a".to_string(),
10873 stop_reason: StopReason::EndTurn,
10874 usage: Usage::default(),
10875 },
10876 );
10877 assert_eq!(session.messages().len(), 2);
10878
10879 assert!(
10880 session
10881 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10882 response_id: "resp_b".to_string(),
10883 delta_id: "evt_b".to_string(),
10884 item_id: "item_b".to_string(),
10885 previous_item_id: Some("item_a".to_string()),
10886 content_index: 0,
10887 delta: "second".to_string(),
10888 })
10889 .is_inert()
10890 );
10891 assert!(
10892 session
10893 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10894 response_id: "resp_a".to_string(),
10895 stop_reason: StopReason::EndTurn,
10896 usage: Usage::default(),
10897 })
10898 .is_inert(),
10899 "a duplicate late terminal for resp_a must not finalize resp_b"
10900 );
10901 assert_eq!(session.messages().len(), 2);
10902
10903 let outcome = session.append_realtime_transcript_event(
10904 RealtimeTranscriptEvent::AssistantTurnCompleted {
10905 response_id: "resp_b".to_string(),
10906 stop_reason: StopReason::EndTurn,
10907 usage: Usage::default(),
10908 },
10909 );
10910 assert_eq!(outcome.materialized_messages.len(), 1);
10911 assert_eq!(session.messages().len(), 3);
10912 }
10913
10914 #[test]
10915 fn realtime_transcript_interruption_discards_only_matching_response() {
10916 let mut session = Session::new();
10922
10923 let _ = session.append_realtime_transcript_event(
10924 RealtimeTranscriptEvent::UserTranscriptFinal {
10925 item_id: "item_user".to_string(),
10926 previous_item_id: None,
10927 content_index: 0,
10928 text: "question".to_string(),
10929 },
10930 );
10931 let _ =
10932 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10933 response_id: "resp_a".to_string(),
10934 delta_id: "evt_a".to_string(),
10935 item_id: "item_a".to_string(),
10936 previous_item_id: Some("item_user".to_string()),
10937 content_index: 0,
10938 delta: "interrupted display".to_string(),
10939 });
10940 let _ =
10941 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10942 response_id: "resp_b".to_string(),
10943 delta_id: "evt_b".to_string(),
10944 item_id: "item_b".to_string(),
10945 previous_item_id: Some("item_user".to_string()),
10946 content_index: 0,
10947 delta: "keep me".to_string(),
10948 });
10949
10950 let interrupt_outcome = session.append_realtime_transcript_event(
10953 RealtimeTranscriptEvent::AssistantTurnInterrupted {
10954 response_id: "resp_a".to_string(),
10955 },
10956 );
10957 assert_eq!(
10958 interrupt_outcome.materialized_messages.len(),
10959 1,
10960 "resp_a's Display item commits on Interrupted"
10961 );
10962
10963 let outcome = session.append_realtime_transcript_event(
10964 RealtimeTranscriptEvent::AssistantTurnCompleted {
10965 response_id: "resp_b".to_string(),
10966 stop_reason: StopReason::EndTurn,
10967 usage: Usage::default(),
10968 },
10969 );
10970 assert_eq!(
10971 outcome.materialized_messages.len(),
10972 1,
10973 "resp_b commits on its TurnCompleted, untouched by resp_a's Interrupted"
10974 );
10975
10976 assert_eq!(session.messages().len(), 3);
10978 assert!(matches!(
10979 &session.messages()[1],
10980 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "interrupted display"
10981 ));
10982 assert!(matches!(
10983 &session.messages()[2],
10984 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "keep me"
10985 ));
10986 }
10987
10988 #[test]
10991 fn test_fork_shares_arc_no_clone() {
10992 let mut session = Session::new();
10993 for i in 0..100 {
10994 session.push(Message::User(UserMessage::text(format!("Message {i}"))));
10995 }
10996
10997 let forked = session.fork();
10999
11000 assert!(Arc::ptr_eq(session.messages.arc(), forked.messages.arc()));
11002 assert_eq!(forked.messages().len(), 100);
11003 }
11004
11005 #[test]
11006 fn test_fork_at_shares_arc_prefix() {
11007 let mut session = Session::new();
11008 for i in 0..100 {
11009 session.push(Message::User(UserMessage::text(format!("Message {i}"))));
11010 }
11011
11012 let forked = session.fork_at(50);
11014 assert_eq!(forked.messages().len(), 50);
11015
11016 assert_eq!(session.messages().len(), 100);
11018 }
11019
11020 #[test]
11021 fn test_fork_at_resets_transcript_history_state_for_branch_identity() {
11022 let mut session = Session::new();
11023 session.push(Message::User(UserMessage::text(
11024 "summarize this".to_string(),
11025 )));
11026 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
11027 vec![AssistantBlock::Text {
11028 text: "long assistant trace".to_string(),
11029 meta: None,
11030 }],
11031 StopReason::EndTurn,
11032 )));
11033 let parent_revision = session.transcript_revision().expect("parent revision");
11034 session
11035 .commit_transcript_rewrite(
11036 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
11037 vec![Message::BlockAssistant(BlockAssistantMessage::new(
11038 vec![AssistantBlock::Text {
11039 text: "compact trace".to_string(),
11040 meta: None,
11041 }],
11042 StopReason::EndTurn,
11043 ))],
11044 TranscriptRewriteReason::new("compaction"),
11045 Some("test".to_string()),
11046 Some(parent_revision),
11047 )
11048 .expect("rewrite should commit");
11049
11050 let source_head = session.transcript_revision().expect("source head");
11051 let mut forked = session.fork_at(1);
11052 assert_ne!(forked.id(), session.id());
11053 assert!(
11054 !forked
11055 .metadata()
11056 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
11057 );
11058 assert_eq!(
11059 forked.transcript_revision().expect("fork head"),
11060 transcript_messages_digest(forked.messages()).expect("fork digest")
11061 );
11062 assert!(
11063 forked
11064 .transcript_revision_messages(&source_head)
11065 .expect("fork history lookup")
11066 .is_none()
11067 );
11068
11069 let fork_parent = forked.transcript_revision().expect("fork parent");
11070 let commit = forked
11071 .commit_transcript_rewrite(
11072 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
11073 vec![Message::User(UserMessage::text(
11074 "branch prompt".to_string(),
11075 ))],
11076 TranscriptRewriteReason::new("branch_edit"),
11077 Some("test".to_string()),
11078 Some(fork_parent.clone()),
11079 )
11080 .expect("fork rewrite should use fork-local parent");
11081 assert_eq!(commit.parent_revision, fork_parent);
11082 }
11083
11084 #[test]
11085 fn test_push_cow_behavior() {
11086 let mut session = Session::new();
11087 session.push(Message::User(UserMessage::text("First".to_string())));
11088
11089 let forked = session.fork();
11091 assert!(Arc::ptr_eq(session.messages.arc(), forked.messages.arc()));
11092
11093 session.push(Message::User(UserMessage::text("Second".to_string())));
11095
11096 assert!(!Arc::ptr_eq(session.messages.arc(), forked.messages.arc()));
11098 assert_eq!(session.messages().len(), 2);
11099 assert_eq!(forked.messages().len(), 1);
11100 }
11101
11102 #[test]
11105 fn test_push_batch_single_timestamp() {
11106 let mut session = Session::new();
11107 let initial_updated = session.updated_at();
11108
11109 session.push_batch(vec![
11111 Message::User(UserMessage::text("First".to_string())),
11112 Message::User(UserMessage::text("Second".to_string())),
11113 Message::User(UserMessage::text("Third".to_string())),
11114 ]);
11115
11116 assert_eq!(session.messages().len(), 3);
11117 assert!(session.updated_at() >= initial_updated);
11119 }
11120
11121 #[test]
11122 fn test_touch_updates_timestamp() {
11123 let mut session = Session::new();
11124 let initial = session.updated_at();
11125
11126 std::thread::sleep(std::time::Duration::from_millis(10));
11127
11128 session.touch();
11130
11131 assert!(session.updated_at() > initial);
11132 }
11133
11134 #[test]
11135 fn test_session_push() {
11136 let mut session = Session::new();
11137 let initial_updated = session.updated_at();
11138
11139 std::thread::sleep(std::time::Duration::from_millis(10));
11141
11142 session.push(Message::User(UserMessage::text("Hello".to_string())));
11143
11144 assert_eq!(session.messages().len(), 1);
11145 assert!(session.updated_at() > initial_updated);
11146 }
11147
11148 #[test]
11149 fn test_session_fork() {
11150 let mut session = Session::new();
11151 session.push(Message::System(SystemMessage::new("System prompt")));
11152 session.push(Message::User(UserMessage::text("Hello".to_string())));
11153 session.push(Message::BlockAssistant(BlockAssistantMessage {
11154 blocks: vec![AssistantBlock::Text {
11155 text: "Hi!".to_string(),
11156 meta: None,
11157 }],
11158 stop_reason: StopReason::EndTurn,
11159 identity: crate::types::TranscriptMessageIdentity::default(),
11160 created_at: crate::types::message_timestamp_now(),
11161 }));
11162
11163 let forked = session.fork_at(2);
11165 assert_eq!(forked.messages().len(), 2);
11166 assert_ne!(forked.id(), session.id());
11167
11168 let full_fork = session.fork();
11170 assert_eq!(full_fork.messages().len(), 3);
11171 }
11172
11173 #[test]
11174 fn test_session_forks_drop_generated_authority_metadata() {
11175 let mut session = Session::new();
11176 session.push(Message::User(UserMessage::text("original")));
11177 session.set_metadata("ordinary", serde_json::json!("keep"));
11178 session
11179 .set_build_state(SessionBuildState::default())
11180 .expect("build state should serialize");
11181 session
11182 .set_deferred_turn_state(SessionDeferredTurnState::default())
11183 .expect("deferred-turn state should serialize");
11184 session
11185 .set_tool_visibility_state(
11186 AuthorizedSessionToolVisibilityState::from_generated_authority(
11187 SessionToolVisibilityState::default(),
11188 ),
11189 )
11190 .expect("visibility state should serialize");
11191 let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
11192 item_id: "rt-item".to_string(),
11193 previous_item_id: None,
11194 role: RealtimeTranscriptRole::User,
11195 response_id: None,
11196 });
11197 session.metadata.insert(
11198 crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY.to_string(),
11199 serde_json::json!([{"sealed_projection": "must-not-fork"}]),
11200 );
11201 assert!(
11202 !session
11203 .metadata()
11204 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
11205 "typed realtime authority must not leak into the raw metadata map"
11206 );
11207 assert_eq!(
11208 session
11209 .realtime_component_event_prefix()
11210 .expect("realtime component prefix")
11211 .event_count(),
11212 1,
11213 "test setup should park one typed realtime event"
11214 );
11215
11216 let forked_at = session.fork_at(1);
11217 let full_fork = session.fork();
11218 let replaced = session
11219 .fork_replacing(
11220 0,
11221 TranscriptReplacement::Message {
11222 message: Message::User(UserMessage::text("replacement")),
11223 },
11224 )
11225 .expect("replacement fork should succeed");
11226
11227 for forked in [&forked_at, &full_fork, &replaced] {
11228 assert_eq!(forked.metadata().get("ordinary").unwrap(), "keep");
11229 assert!(
11230 !forked.metadata().contains_key(SESSION_BUILD_STATE_KEY),
11231 "forked sessions must not raw-copy durable build-state authority"
11232 );
11233 assert!(
11234 !forked
11235 .metadata()
11236 .contains_key(SESSION_DEFERRED_TURN_STATE_KEY),
11237 "forked sessions must not raw-copy deferred-turn authority state"
11238 );
11239 assert!(
11240 !forked
11241 .metadata()
11242 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY),
11243 "forked sessions must not raw-copy tool-visibility authority state"
11244 );
11245 assert!(
11246 !forked
11247 .metadata()
11248 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
11249 "forked sessions must not raw-copy realtime transcript authority state"
11250 );
11251 assert_eq!(
11252 forked
11253 .realtime_component_event_prefix()
11254 .expect("fork realtime component prefix")
11255 .event_count(),
11256 0,
11257 "forked sessions must start a new empty realtime component lineage"
11258 );
11259 assert!(
11260 !forked
11261 .metadata()
11262 .contains_key(crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY),
11263 "forked sessions must not raw-copy compaction outbox authority"
11264 );
11265 }
11266 }
11267
11268 #[test]
11269 fn test_session_metadata() {
11270 let mut session = Session::new();
11271 session.set_metadata("key", serde_json::json!("value"));
11272
11273 assert_eq!(session.metadata().get("key").unwrap(), "value");
11274 }
11275
11276 #[test]
11277 fn identical_metadata_projection_is_wire_idempotent() {
11278 let mut session = Session::new();
11279 session.set_metadata("key", serde_json::json!({ "value": 1 }));
11280 let updated_at = session.updated_at;
11281 let bytes = session
11282 .to_persisted_bytes()
11283 .expect("session bytes before identical projection");
11284
11285 session.set_metadata("key", serde_json::json!({ "value": 1 }));
11286 session.remove_metadata("already_absent");
11287
11288 assert_eq!(
11289 session.updated_at, updated_at,
11290 "an identical durable projection must not manufacture a content mutation"
11291 );
11292 assert_eq!(
11293 session
11294 .to_persisted_bytes()
11295 .expect("session bytes after identical projection"),
11296 bytes,
11297 "an identical durable projection must not rotate current Session bytes"
11298 );
11299 }
11300
11301 #[test]
11302 fn session_metadata_realm_id_is_back_read_compatible_string() {
11303 let metadata = SessionMetadata {
11306 schema_version: SESSION_METADATA_SCHEMA_VERSION,
11307 model: "test-model".to_string(),
11308 max_tokens: 1024,
11309 structured_output_retries: 2,
11310 provider: Provider::Other,
11311 self_hosted_server_id: None,
11312 provider_params: None,
11313 tooling: SessionTooling::default(),
11314 keep_alive: false,
11315 comms_name: None,
11316 peer_meta: None,
11317 realm_id: Some(crate::RealmId::parse("env_default").unwrap()),
11318 instance_id: None,
11319 backend: None,
11320 config_generation: None,
11321 auth_binding: None,
11322 mob_member_binding: None,
11323 };
11324 let value = serde_json::to_value(&metadata).unwrap();
11325 assert_eq!(
11326 value.get("realm_id"),
11327 Some(&serde_json::json!("env_default")),
11328 "typed realm_id must serialize as a bare slug string"
11329 );
11330
11331 let legacy = serde_json::json!({
11334 "schema_version": SESSION_METADATA_SCHEMA_VERSION,
11335 "model": "test-model",
11336 "max_tokens": 1024,
11337 "structured_output_retries": 2,
11338 "provider": "other",
11339 "tooling": SessionTooling::default(),
11340 "keep_alive": false,
11341 "comms_name": null,
11342 "realm_id": "legacy_realm",
11343 });
11344 let restored: SessionMetadata = serde_json::from_value(legacy).unwrap();
11345 assert_eq!(
11346 restored.realm_id.as_ref().map(crate::RealmId::as_str),
11347 Some("legacy_realm")
11348 );
11349 }
11350
11351 #[test]
11356 fn session_tooling_tool_access_policy_round_trip_and_absent_default() {
11357 let legacy = serde_json::json!({});
11359 let restored: SessionTooling = serde_json::from_value(legacy).unwrap();
11360 assert_eq!(restored.tool_access_policy, None);
11361
11362 let value = serde_json::to_value(SessionTooling::default()).unwrap();
11364 assert!(
11365 value.get("tool_access_policy").is_none(),
11366 "None policy must not serialize"
11367 );
11368
11369 let tooling = SessionTooling {
11371 tool_access_policy: Some(crate::ops::ToolAccessPolicy::AllowList(
11372 ["read_file", "send_message"].into_iter().collect(),
11373 )),
11374 ..SessionTooling::default()
11375 };
11376 let value = serde_json::to_value(&tooling).unwrap();
11377 let restored: SessionTooling = serde_json::from_value(value).unwrap();
11378 assert_eq!(restored.tool_access_policy, tooling.tool_access_policy);
11379 }
11380
11381 #[test]
11382 fn lifecycle_terminal_typed_round_trip() {
11383 let mut session = Session::new();
11384 assert_eq!(session.lifecycle_terminal(), None);
11385
11386 session
11387 .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
11388 .expect("typed terminal write should serialize");
11389 assert_eq!(
11390 session.lifecycle_terminal(),
11391 Some(SessionLifecycleTerminal::Archived)
11392 );
11393 assert!(
11394 session
11395 .lifecycle_terminal()
11396 .is_some_and(SessionLifecycleTerminal::is_archived)
11397 );
11398 assert_eq!(
11400 session
11401 .metadata()
11402 .get(SESSION_LIFECYCLE_TERMINAL_KEY)
11403 .unwrap(),
11404 &serde_json::json!("archived")
11405 );
11406 }
11407
11408 #[test]
11409 fn recovered_head_adoption_keeps_archived_absorbing_from_either_copy() {
11410 let mut archived_recovery = Session::new();
11411 archived_recovery
11412 .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
11413 .expect("archive terminal serializes");
11414 let mut active_head = archived_recovery.clone();
11415 active_head
11416 .set_lifecycle_terminal(SessionLifecycleTerminal::Active)
11417 .expect("active terminal serializes");
11418 archived_recovery
11419 .adopt_recovered_head_state(&active_head)
11420 .expect("generated lifecycle merge resolves");
11421 assert_eq!(
11422 archived_recovery.lifecycle_terminal(),
11423 Some(SessionLifecycleTerminal::Archived),
11424 "a newer Active projection must not resurrect an Archived recovery base"
11425 );
11426
11427 let mut active_recovery = Session::new();
11428 active_recovery
11429 .set_lifecycle_terminal(SessionLifecycleTerminal::Active)
11430 .expect("active terminal serializes");
11431 let mut archived_head = active_recovery.clone();
11432 archived_head
11433 .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
11434 .expect("archive terminal serializes");
11435 active_recovery
11436 .adopt_recovered_head_state(&archived_head)
11437 .expect("generated lifecycle merge resolves");
11438 assert_eq!(
11439 active_recovery.lifecycle_terminal(),
11440 Some(SessionLifecycleTerminal::Archived),
11441 "an Archived durable head must remain terminal after recovery adoption"
11442 );
11443 }
11444
11445 #[test]
11446 fn lifecycle_terminal_key_rejects_raw_mutation() {
11447 let mut session = Session::new();
11448 assert!(
11449 session
11450 .try_set_metadata(
11451 SESSION_LIFECYCLE_TERMINAL_KEY,
11452 serde_json::json!("archived")
11453 )
11454 .is_err(),
11455 "the typed lifecycle-terminal key is reserved for session authority"
11456 );
11457 }
11458
11459 #[test]
11460 fn test_session_metadata_backfill_preserves_timestamp() {
11461 let mut session = Session::new();
11462 let initial_updated = session.updated_at();
11463
11464 std::thread::sleep(std::time::Duration::from_millis(10));
11465
11466 assert!(session.backfill_metadata_if_absent("key", serde_json::json!("value")));
11467 assert_eq!(session.metadata().get("key").unwrap(), "value");
11468 assert_eq!(session.updated_at(), initial_updated);
11469 assert!(!session.backfill_metadata_if_absent("key", serde_json::json!("other")));
11470 assert_eq!(session.metadata().get("key").unwrap(), "value");
11471 assert_eq!(session.updated_at(), initial_updated);
11472 }
11473
11474 #[test]
11475 fn test_reserved_generated_authority_metadata_rejects_raw_mutation() {
11476 let mut session = Session::new();
11477
11478 assert!(
11479 session
11480 .try_set_metadata(SESSION_METADATA_KEY, serde_json::json!({}))
11481 .is_err()
11482 );
11483 assert!(
11484 session
11485 .try_set_metadata(SESSION_BUILD_STATE_KEY, serde_json::json!({}))
11486 .is_err()
11487 );
11488 assert!(
11489 session
11490 .try_set_metadata(
11491 SESSION_TRANSCRIPT_REWRITE_PREFIX_AUTHORITY_KEY,
11492 serde_json::json!({
11493 "occurrence_count": 0,
11494 "digest": "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
11495 })
11496 )
11497 .is_err(),
11498 "raw metadata must not forge rewrite-prefix authority"
11499 );
11500 let compaction_intents_key = crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY;
11501 let sealed_compaction_intents =
11502 serde_json::json!([{"sealed_projection": "typed-owner-only"}]);
11503 session.metadata.insert(
11504 compaction_intents_key.to_string(),
11505 sealed_compaction_intents.clone(),
11506 );
11507 assert!(
11508 session
11509 .try_set_metadata(compaction_intents_key, serde_json::json!([]))
11510 .is_err(),
11511 "raw metadata must not overwrite compaction outbox authority"
11512 );
11513 session.remove_metadata(compaction_intents_key);
11514 assert_eq!(
11515 session.metadata().get(compaction_intents_key),
11516 Some(&sealed_compaction_intents),
11517 "raw metadata removal must not erase compaction outbox authority"
11518 );
11519 let mut absent = Session::new();
11520 assert!(
11521 !absent.backfill_metadata_if_absent(
11522 compaction_intents_key,
11523 serde_json::json!([{"forged_projection": true}])
11524 ),
11525 "compatibility backfill must not fabricate compaction outbox authority"
11526 );
11527 assert!(!absent.metadata().contains_key(compaction_intents_key));
11528 session
11529 .set_session_metadata(SessionMetadata {
11530 schema_version: SESSION_METADATA_SCHEMA_VERSION,
11531 model: "test-model".to_string(),
11532 max_tokens: 1024,
11533 structured_output_retries: 2,
11534 provider: Provider::Other,
11535 self_hosted_server_id: None,
11536 provider_params: None,
11537 tooling: SessionTooling::default(),
11538 keep_alive: false,
11539 comms_name: None,
11540 peer_meta: None,
11541 realm_id: None,
11542 instance_id: None,
11543 backend: None,
11544 config_generation: None,
11545 auth_binding: None,
11546 mob_member_binding: None,
11547 })
11548 .expect("typed metadata setter should route through generated authority");
11549 session
11550 .set_build_state(SessionBuildState::default())
11551 .expect("typed build-state setter should route through generated authority");
11552 session.remove_metadata(SESSION_METADATA_KEY);
11553 session.remove_metadata(SESSION_BUILD_STATE_KEY);
11554 assert!(
11555 session.metadata().contains_key(SESSION_METADATA_KEY),
11556 "raw removal must not delete generated-authority session metadata"
11557 );
11558 assert!(
11559 session.metadata().contains_key(SESSION_BUILD_STATE_KEY),
11560 "raw removal must not delete generated-authority build state"
11561 );
11562 session.set_metadata(SESSION_DEFERRED_TURN_STATE_KEY, serde_json::json!({}));
11563 assert!(
11564 !session
11565 .metadata()
11566 .contains_key(SESSION_DEFERRED_TURN_STATE_KEY)
11567 );
11568 session.metadata.insert(
11569 SESSION_METADATA_KEY.to_string(),
11570 serde_json::json!("not-metadata"),
11571 );
11572 assert!(
11573 session.try_session_metadata().is_err(),
11574 "malformed session metadata must not decode as absent/default"
11575 );
11576
11577 session.metadata.insert(
11578 SESSION_BUILD_STATE_KEY.to_string(),
11579 serde_json::json!("not-build-state"),
11580 );
11581 assert!(
11582 session.try_build_state().is_err(),
11583 "malformed build state must not decode as absent/default"
11584 );
11585
11586 assert!(
11587 session
11588 .try_set_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY, serde_json::json!({}))
11589 .is_err()
11590 );
11591 session
11592 .set_tool_visibility_state(
11593 AuthorizedSessionToolVisibilityState::from_generated_authority(
11594 SessionToolVisibilityState::default(),
11595 ),
11596 )
11597 .expect("typed visibility setter should route through typed authority handoff");
11598 session.remove_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY);
11599 assert!(
11600 session
11601 .metadata()
11602 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
11603 );
11604 session.clear_tool_visibility_state();
11605 assert!(
11606 !session
11607 .metadata()
11608 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
11609 );
11610 assert!(
11611 session
11612 .try_set_metadata(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, serde_json::json!({}))
11613 .is_err()
11614 );
11615 let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
11616 item_id: "rt-item".to_string(),
11617 previous_item_id: None,
11618 role: RealtimeTranscriptRole::User,
11619 response_id: None,
11620 });
11621 assert!(
11622 !session
11623 .metadata()
11624 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
11625 "typed realtime transcript append must not recreate raw shadow authority"
11626 );
11627 assert_eq!(
11628 session
11629 .realtime_component_event_prefix()
11630 .expect("typed realtime prefix")
11631 .event_count(),
11632 1,
11633 "typed append must advance the authenticated component prefix"
11634 );
11635 session.metadata.insert(
11636 SESSION_REALTIME_TRANSCRIPT_STATE_KEY.to_string(),
11637 serde_json::json!("not-a-state"),
11638 );
11639 let whole_blob =
11640 serde_json::to_value(&session).expect("typed projection must override a raw shadow");
11641 let projected = whole_blob
11642 .get("metadata")
11643 .and_then(serde_json::Value::as_object)
11644 .and_then(|metadata| metadata.get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY))
11645 .expect("WholeBlob projection");
11646 assert!(
11647 serde_json::from_value::<SessionRealtimeTranscriptState>(projected.clone()).is_ok(),
11648 "WholeBlob encoding must derive from typed authority, never a raw metadata shadow"
11649 );
11650 }
11651
11652 #[test]
11653 fn test_session_mob_tool_authority_context_persists_projection_without_authority_seal() {
11654 let mut session = Session::new();
11655 session
11656 .set_build_state(SessionBuildState::default())
11657 .expect("session build state should serialize");
11658 let authority = MobToolAuthorityContext::generated_for_test(
11659 crate::service::OpaquePrincipalToken::new("opaque-principal"),
11660 false,
11661 false,
11662 false,
11663 std::collections::BTreeSet::from(["mob-a".to_string()]),
11664 std::collections::BTreeMap::new(),
11665 None,
11666 Some("audit-1".to_string()),
11667 );
11668
11669 session
11670 .set_mob_tool_authority_context(Some(authority))
11671 .expect("authority should serialize");
11672 assert!(session.mob_tool_authority_context().is_none());
11673 let stored = session
11674 .build_state()
11675 .and_then(|state| state.mob_tool_authority_context)
11676 .expect("stored projection should deserialize");
11677 assert!(!stored.is_generated_authority_context());
11678 assert!(!stored.can_manage_mob("mob-a"));
11679
11680 session
11681 .set_mob_tool_authority_context(None)
11682 .expect("authority should clear");
11683 assert!(session.mob_tool_authority_context().is_none());
11684 }
11685
11686 #[test]
11687 fn test_session_build_state_rejects_forged_mob_authority_projection() {
11688 let mut session = Session::new();
11689 let authority = MobToolAuthorityContext::generated_for_test(
11690 crate::service::OpaquePrincipalToken::new("opaque-principal"),
11691 false,
11692 false,
11693 false,
11694 std::collections::BTreeSet::from(["mob-a".to_string()]),
11695 std::collections::BTreeMap::new(),
11696 None,
11697 Some("audit-1".to_string()),
11698 );
11699 let forged_projection: MobToolAuthorityContext =
11700 serde_json::from_value(serde_json::to_value(authority).expect("serialize authority"))
11701 .expect("deserialize projection");
11702 assert!(!forged_projection.is_generated_authority_context());
11703
11704 let err = session
11705 .set_build_state(SessionBuildState {
11706 mob_tool_authority_context: Some(forged_projection),
11707 ..Default::default()
11708 })
11709 .expect_err("forged build state must be rejected by generated authority");
11710 assert!(
11714 err.to_string()
11715 .contains("generated session document authority rejected"),
11716 "unexpected error: {err}"
11717 );
11718 }
11719
11720 #[test]
11721 fn test_session_tool_visibility_state_roundtrip() {
11722 let mut session = Session::new();
11723 let state = SessionToolVisibilityState {
11724 inherited_base_filter: ToolFilter::Allow(["visible".to_string()].into_iter().collect()),
11725 active_filter: ToolFilter::Allow(
11726 ["visible".to_string(), "missing".to_string()]
11727 .into_iter()
11728 .collect(),
11729 ),
11730 staged_filter: ToolFilter::Allow(
11731 ["visible".to_string(), "missing".to_string()]
11732 .into_iter()
11733 .collect(),
11734 ),
11735 active_revision: 1,
11736 staged_revision: 2,
11737 ..Default::default()
11738 };
11739
11740 session
11741 .set_tool_visibility_state(
11742 AuthorizedSessionToolVisibilityState::from_generated_authority(state.clone()),
11743 )
11744 .expect("tool visibility state should serialize");
11745 assert_eq!(session.tool_visibility_state().unwrap(), Some(state));
11746 }
11747
11748 #[test]
11749 fn test_session_tool_visibility_state_malformed_returns_error() {
11750 let mut session = Session::new();
11751 session.metadata.insert(
11752 SESSION_TOOL_VISIBILITY_STATE_KEY.to_string(),
11753 serde_json::json!({
11754 "active_filter": {
11755 "unexpected_filter_kind": ["secret"]
11756 }
11757 }),
11758 );
11759
11760 assert!(
11761 session.tool_visibility_state().is_err(),
11762 "malformed canonical visibility metadata must not decode as absent/default"
11763 );
11764 }
11765
11766 #[test]
11767 fn test_session_serialization() {
11768 let mut session = Session::new();
11769 session.push(Message::User(UserMessage::text("Test".to_string())));
11770
11771 let json = serde_json::to_string(&session).unwrap();
11772 let parsed: Session = serde_json::from_str(&json).unwrap();
11773
11774 assert_eq!(parsed.id(), session.id());
11775 assert_eq!(parsed.messages().len(), 1);
11776 assert_eq!(parsed.version(), SESSION_VERSION);
11777 }
11778
11779 #[test]
11780 fn test_session_meta_from_session() {
11781 let mut session = Session::new();
11782 session.push(Message::User(UserMessage::text("Hello".to_string())));
11783 session.push(Message::BlockAssistant(BlockAssistantMessage {
11784 blocks: vec![AssistantBlock::Text {
11785 text: "Hi!".to_string(),
11786 meta: None,
11787 }],
11788 stop_reason: StopReason::EndTurn,
11789 identity: crate::types::TranscriptMessageIdentity::default(),
11790 created_at: crate::types::message_timestamp_now(),
11791 }));
11792 session.record_usage(Usage {
11793 input_tokens: 10,
11794 output_tokens: 5,
11795 cache_creation_tokens: None,
11796 cache_read_tokens: None,
11797 });
11798
11799 let meta = SessionMeta::from(&session);
11800 assert_eq!(meta.id, *session.id());
11801 assert_eq!(meta.message_count, 2);
11802 assert_eq!(meta.total_tokens, 15);
11803 }
11804
11805 #[test]
11806 fn deferred_tool_result_redelivery_is_idempotent_per_exact_payload() {
11807 let mut state = SessionDeferredTurnState::default();
11808 let results = vec![
11809 ToolResult::new("callback-a".to_string(), "a".to_string(), false),
11810 ToolResult::new("callback-b".to_string(), "b".to_string(), false),
11811 ];
11812 assert_eq!(
11813 state.stage_tool_results(results.clone(), SystemTime::UNIX_EPOCH),
11814 2
11815 );
11816 let before = serde_json::to_value(&state).expect("serialize staged state");
11817
11818 assert_eq!(
11819 state.stage_tool_results(
11820 results,
11821 SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
11822 ),
11823 0,
11824 "identical redelivery must coalesce without restaging"
11825 );
11826 assert_eq!(
11827 serde_json::to_value(&state).expect("serialize redelivered state"),
11828 before,
11829 "duplicate ingress must be a byte-identical no-op"
11830 );
11831 }
11832
11833 #[test]
11834 fn deferred_tool_result_conflict_and_wrong_id_fail_without_poison_after_replay() {
11835 let mut state = SessionDeferredTurnState::default();
11836 state
11837 .try_stage_tool_results(
11838 vec![ToolResult::new(
11839 "callback-a".to_string(),
11840 "approved".to_string(),
11841 false,
11842 )],
11843 SystemTime::UNIX_EPOCH,
11844 )
11845 .expect("first callback payload should stage");
11846 let mut replayed: SessionDeferredTurnState = serde_json::from_value(
11847 serde_json::to_value(&state).expect("serialize deferred callback state"),
11848 )
11849 .expect("restore deferred callback state");
11850 let before = serde_json::to_value(&replayed).expect("serialize replayed state");
11851
11852 assert!(matches!(
11853 replayed.try_stage_tool_results(
11854 vec![ToolResult::new(
11855 "callback-a".to_string(),
11856 "denied".to_string(),
11857 false,
11858 )],
11859 SystemTime::UNIX_EPOCH,
11860 ),
11861 Err(DeferredToolResultsIngressError::ConflictingRedelivery(id))
11862 if id == "callback-a"
11863 ));
11864 assert_eq!(serde_json::to_value(&replayed).unwrap(), before);
11865
11866 assert!(matches!(
11867 replayed.try_stage_tool_results(
11868 vec![ToolResult::new(
11869 "callback-b".to_string(),
11870 "wrong".to_string(),
11871 false,
11872 )],
11873 SystemTime::UNIX_EPOCH,
11874 ),
11875 Err(DeferredToolResultsIngressError::WrongToolUseId(id))
11876 if id == "callback-b"
11877 ));
11878 assert_eq!(
11879 serde_json::to_value(&replayed).unwrap(),
11880 before,
11881 "typed ingress refusals must leave the valid pending continuation intact"
11882 );
11883 }
11884
11885 #[test]
11886 fn persisted_round_trip_preserves_multiple_systems_anywhere_exactly() {
11887 let mut session = Session::new();
11888 session.append_system_message("first");
11889 session.push(Message::User(UserMessage::text("hello")));
11890 session.append_system_message(" second ");
11891 session.append_system_message("");
11892 session.append_system_message(" second ");
11893 let expected = session.messages().to_vec();
11894 let bytes = serde_json::to_vec(&session).expect("serialize session");
11895 let resumed: Session = serde_json::from_slice(&bytes).expect("deserialize session");
11896 assert_eq!(resumed.messages(), expected.as_slice());
11897 assert_eq!(resumed.messages_for_model_boundary(), expected);
11898 }
11899 #[test]
11900 fn system_control_idempotency_is_explicit_and_does_not_coalesce_keyless_rows() {
11901 let mut session = Session::new();
11902 let timestamp = crate::types::message_timestamp_now();
11903 let first = session
11904 .append_system_message_idempotent(
11905 " exact ",
11906 Some("host".to_string()),
11907 Some("key".to_string()),
11908 timestamp,
11909 )
11910 .expect("first append");
11911 assert_eq!(first, crate::service::AppendSystemContextStatus::Applied);
11912 let duplicate = session
11913 .append_system_message_idempotent(
11914 " exact ",
11915 Some("host".to_string()),
11916 Some("key".to_string()),
11917 timestamp,
11918 )
11919 .expect("exact retry");
11920 assert_eq!(
11921 duplicate,
11922 crate::service::AppendSystemContextStatus::Duplicate
11923 );
11924 session
11925 .append_system_message_idempotent("", None, None, timestamp)
11926 .expect("empty keyless System");
11927 session
11928 .append_system_message_idempotent("", None, None, timestamp)
11929 .expect("duplicate keyless System");
11930 assert_eq!(
11931 session
11932 .messages()
11933 .iter()
11934 .filter(|message| matches!(message, Message::System(system) if system.content.is_empty()))
11935 .count(),
11936 2
11937 );
11938 assert!(matches!(
11939 session.append_system_message_idempotent(
11940 "different",
11941 Some("host".to_string()),
11942 Some("key".to_string()),
11943 timestamp,
11944 ),
11945 Err(SystemMessageAppendError::Conflict { .. })
11946 ));
11947 }
11948 #[test]
11949 fn realtime_transcript_assistant_transcript_delta_materializes_transcript_block() {
11950 let mut session = Session::new();
11951
11952 let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
11953 response_id: "resp_spoken".to_string(),
11954 delta_id: "evt_delta_spoken_1".to_string(),
11955 item_id: "item_spoken".to_string(),
11956 previous_item_id: None,
11957 content_index: 0,
11958 delta: "I said hi".to_string(),
11959 };
11960 assert!(
11961 session.append_realtime_transcript_event(delta).is_inert(),
11962 "delta alone is inert until turn-completed flushes"
11963 );
11964
11965 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
11966 response_id: "resp_spoken".to_string(),
11967 stop_reason: StopReason::EndTurn,
11968 usage: Usage::default(),
11969 };
11970 let outcome = session.append_realtime_transcript_event(terminal);
11971 assert_eq!(outcome.materialized_messages.len(), 1);
11972
11973 let messages = session.messages();
11975 assert_eq!(messages.len(), 1);
11976 match &messages[0] {
11977 Message::BlockAssistant(assistant) => {
11978 assert_eq!(assistant.blocks.len(), 1);
11979 match &assistant.blocks[0] {
11980 AssistantBlock::Transcript { text, source, .. } => {
11981 assert_eq!(text, "I said hi");
11982 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
11983 }
11984 other => unreachable!(
11985 "AssistantTranscriptDelta must materialize as AssistantBlock::Transcript, got {other:?}"
11986 ),
11987 }
11988 }
11989 other => unreachable!("expected BlockAssistant message, got {other:?}"),
11990 }
11991 }
11992
11993 #[test]
11994 fn round4_cc4_in_flight_response_ids_lists_distinct_unmaterialized_responses() {
11995 let mut session = Session::new();
12001
12002 for (i, response_id) in [
12006 ("resp_a", "resp_a"),
12007 ("resp_a_extra", "resp_a"),
12008 ("resp_b", "resp_b"),
12009 ("resp_c", "resp_c"),
12010 ]
12011 .iter()
12012 .enumerate()
12013 {
12014 let event = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12015 response_id: response_id.1.to_string(),
12016 delta_id: format!("delta_{i}"),
12017 item_id: response_id.0.to_string(),
12018 previous_item_id: None,
12019 content_index: 0,
12020 delta: "x".to_string(),
12021 };
12022 let _ = session.append_realtime_transcript_event(event);
12023 }
12024
12025 let _ = session.append_realtime_transcript_event(
12027 RealtimeTranscriptEvent::AssistantTurnInterrupted {
12028 response_id: "resp_c".to_string(),
12029 },
12030 );
12031
12032 let _ = session.append_realtime_transcript_event(
12035 RealtimeTranscriptEvent::UserTranscriptFinal {
12036 item_id: "u_item".to_string(),
12037 previous_item_id: None,
12038 content_index: 0,
12039 text: "hi".to_string(),
12040 },
12041 );
12042
12043 let in_flight = session.in_flight_realtime_assistant_response_ids();
12044 assert!(in_flight.contains(&"resp_a".to_string()), "{in_flight:?}");
12045 assert!(in_flight.contains(&"resp_b".to_string()), "{in_flight:?}");
12046 assert!(
12047 !in_flight.contains(&"resp_c".to_string()),
12048 "discarded response must not appear in in_flight: {in_flight:?}"
12049 );
12050 assert_eq!(
12052 in_flight.iter().filter(|r| *r == "resp_a").count(),
12053 1,
12054 "distinct response_ids only: {in_flight:?}"
12055 );
12056 }
12057
12058 #[test]
12059 fn round4_cc2_assistant_turn_completed_after_transcript_deltas_materializes_transcript() {
12060 let mut session = Session::new();
12067
12068 let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12069 response_id: "resp_cc2".to_string(),
12070 delta_id: "delta_cc2_1".to_string(),
12071 item_id: "item_cc2".to_string(),
12072 previous_item_id: None,
12073 content_index: 0,
12074 delta: "hello world".to_string(),
12075 };
12076 assert!(session.append_realtime_transcript_event(delta).is_inert());
12077
12078 assert_eq!(
12080 session.in_flight_realtime_assistant_response_ids(),
12081 vec!["resp_cc2".to_string()]
12082 );
12083
12084 let outcome = session.append_realtime_transcript_event(
12085 RealtimeTranscriptEvent::AssistantTurnCompleted {
12086 response_id: "resp_cc2".to_string(),
12087 stop_reason: StopReason::EndTurn,
12088 usage: Usage::default(),
12089 },
12090 );
12091 assert_eq!(outcome.materialized_messages.len(), 1);
12092
12093 assert!(
12095 session
12096 .in_flight_realtime_assistant_response_ids()
12097 .is_empty(),
12098 "materialized items must not appear in in_flight_realtime_assistant_response_ids"
12099 );
12100
12101 let messages = session.messages();
12102 let assistant = messages.iter().find_map(|m| match m {
12103 Message::BlockAssistant(a) => Some(a),
12104 _ => None,
12105 });
12106 let assistant = assistant.expect("assistant block message expected");
12107 assert_eq!(assistant.blocks.len(), 1);
12108 assert!(matches!(
12109 &assistant.blocks[0],
12110 AssistantBlock::Transcript {
12111 source: crate::types::TranscriptSource::Spoken,
12112 ..
12113 }
12114 ));
12115 }
12116
12117 #[test]
12118 fn realtime_transcript_assistant_text_delta_still_materializes_text_block() {
12119 let mut session = Session::new();
12123
12124 let delta = RealtimeTranscriptEvent::AssistantTextDelta {
12125 response_id: "resp_display".to_string(),
12126 delta_id: "evt_delta_display_1".to_string(),
12127 item_id: "item_display".to_string(),
12128 previous_item_id: None,
12129 content_index: 0,
12130 delta: "I wrote".to_string(),
12131 };
12132 let _ = session.append_realtime_transcript_event(delta);
12133
12134 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
12135 response_id: "resp_display".to_string(),
12136 stop_reason: StopReason::EndTurn,
12137 usage: Usage::default(),
12138 };
12139 let outcome = session.append_realtime_transcript_event(terminal);
12140 assert_eq!(outcome.materialized_messages.len(), 1);
12141
12142 let messages = session.messages();
12143 match &messages[0] {
12144 Message::BlockAssistant(assistant) => match &assistant.blocks[0] {
12145 AssistantBlock::Text { text, .. } => assert_eq!(text, "I wrote"),
12146 other => unreachable!(
12147 "AssistantTextDelta must keep materializing AssistantBlock::Text, got {other:?}"
12148 ),
12149 },
12150 other => unreachable!("expected BlockAssistant message, got {other:?}"),
12151 }
12152 }
12153
12154 #[test]
12155 fn round4_cc7_mixed_response_persists_text_and_transcript_in_order() {
12156 let mut session = Session::new();
12174
12175 let display_a = RealtimeTranscriptEvent::AssistantTextDelta {
12177 response_id: "resp_mixed_1".to_string(),
12178 delta_id: "delta_disp_1".to_string(),
12179 item_id: "item_display".to_string(),
12180 previous_item_id: None,
12181 content_index: 0,
12182 delta: "Here's the report:".to_string(),
12183 };
12184 assert!(
12185 session
12186 .append_realtime_transcript_event(display_a)
12187 .is_inert()
12188 );
12189
12190 let display_b = RealtimeTranscriptEvent::AssistantTextDelta {
12191 response_id: "resp_mixed_1".to_string(),
12192 delta_id: "delta_disp_2".to_string(),
12193 item_id: "item_display".to_string(),
12194 previous_item_id: None,
12195 content_index: 0,
12196 delta: " (still writing)".to_string(),
12197 };
12198 assert!(
12199 session
12200 .append_realtime_transcript_event(display_b)
12201 .is_inert()
12202 );
12203
12204 let spoken_a = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12209 response_id: "resp_mixed_1".to_string(),
12210 delta_id: "delta_spoken_1".to_string(),
12211 item_id: "item_spoken".to_string(),
12212 previous_item_id: Some("item_display".to_string()),
12213 content_index: 0,
12214 delta: "I'm reading the report aloud:".to_string(),
12215 };
12216 assert!(
12217 session
12218 .append_realtime_transcript_event(spoken_a)
12219 .is_inert()
12220 );
12221
12222 let spoken_b = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12223 response_id: "resp_mixed_1".to_string(),
12224 delta_id: "delta_spoken_2".to_string(),
12225 item_id: "item_spoken".to_string(),
12226 previous_item_id: Some("item_display".to_string()),
12227 content_index: 0,
12228 delta: " sentence two.".to_string(),
12229 };
12230 assert!(
12231 session
12232 .append_realtime_transcript_event(spoken_b)
12233 .is_inert()
12234 );
12235
12236 let outcome = session.append_realtime_transcript_event(
12239 RealtimeTranscriptEvent::AssistantTurnCompleted {
12240 response_id: "resp_mixed_1".to_string(),
12241 stop_reason: StopReason::EndTurn,
12242 usage: Usage {
12243 input_tokens: 11,
12244 output_tokens: 22,
12245 cache_creation_tokens: None,
12246 cache_read_tokens: None,
12247 },
12248 },
12249 );
12250 assert_eq!(outcome.materialized_messages.len(), 2);
12252
12253 let messages = session.messages();
12256 let assistants: Vec<&BlockAssistantMessage> = messages
12257 .iter()
12258 .filter_map(|m| match m {
12259 Message::BlockAssistant(a) => Some(a),
12260 _ => None,
12261 })
12262 .collect();
12263 assert_eq!(
12264 assistants.len(),
12265 1,
12266 "mixed display+spoken response under one response_id must produce exactly ONE BlockAssistant message, got: {assistants:?}"
12267 );
12268 let assistant = assistants[0];
12269 assert_eq!(
12270 assistant.blocks.len(),
12271 2,
12272 "mixed response message must carry both blocks: {:?}",
12273 assistant.blocks
12274 );
12275
12276 match &assistant.blocks[0] {
12278 AssistantBlock::Text { text, .. } => {
12279 assert_eq!(text, "Here's the report: (still writing)");
12280 }
12281 other => unreachable!(
12282 "first block must be AssistantBlock::Text (display lane), got {other:?}"
12283 ),
12284 }
12285 match &assistant.blocks[1] {
12287 AssistantBlock::Transcript { text, source, .. } => {
12288 assert_eq!(text, "I'm reading the report aloud: sentence two.");
12289 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
12290 }
12291 other => unreachable!(
12292 "second block must be AssistantBlock::Transcript {{ source: Spoken }}, got {other:?}"
12293 ),
12294 }
12295
12296 assert_eq!(session.usage.input_tokens, 11);
12298 assert_eq!(session.usage.output_tokens, 22);
12299 }
12300
12301 #[test]
12302 fn round5_r55_mixed_response_barge_in_preserves_display_drops_spoken() {
12303 let mut session = Session::new();
12319
12320 let display = RealtimeTranscriptEvent::AssistantTextDelta {
12321 response_id: "resp_mixed_2".to_string(),
12322 delta_id: "delta_disp_1".to_string(),
12323 item_id: "item_display_2".to_string(),
12324 previous_item_id: None,
12325 content_index: 0,
12326 delta: "Working on the report...".to_string(),
12327 };
12328 let _ = session.append_realtime_transcript_event(display);
12329
12330 let spoken = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12331 response_id: "resp_mixed_2".to_string(),
12332 delta_id: "delta_spoken_1".to_string(),
12333 item_id: "item_spoken_2".to_string(),
12334 previous_item_id: Some("item_display_2".to_string()),
12335 content_index: 0,
12336 delta: "I'm reading the report".to_string(),
12337 };
12338 let _ = session.append_realtime_transcript_event(spoken);
12339
12340 let outcome = session.append_realtime_transcript_event(
12344 RealtimeTranscriptEvent::AssistantTurnInterrupted {
12345 response_id: "resp_mixed_2".to_string(),
12346 },
12347 );
12348 assert_eq!(
12349 outcome.materialized_messages.len(),
12350 1,
12351 "Display lane item must materialize on Interrupted: {outcome:?}"
12352 );
12353
12354 let late_completion = session.append_realtime_transcript_event(
12358 RealtimeTranscriptEvent::AssistantTurnCompleted {
12359 response_id: "resp_mixed_2".to_string(),
12360 stop_reason: StopReason::Cancelled,
12361 usage: Usage::default(),
12362 },
12363 );
12364 assert_eq!(
12365 late_completion.materialized_messages.len(),
12366 0,
12367 "post-barge-in TurnCompleted must not resurrect anything"
12368 );
12369
12370 let messages = session.messages();
12373 let assistants: Vec<&BlockAssistantMessage> = messages
12374 .iter()
12375 .filter_map(|m| match m {
12376 Message::BlockAssistant(a) => Some(a),
12377 _ => None,
12378 })
12379 .collect();
12380 assert_eq!(
12381 assistants.len(),
12382 1,
12383 "barge-in must commit exactly one BlockAssistant containing the Display lane: {assistants:?}"
12384 );
12385 let assistant = assistants[0];
12386 assert_eq!(assistant.blocks.len(), 1, "blocks: {:?}", assistant.blocks);
12387 match &assistant.blocks[0] {
12388 AssistantBlock::Text { text, .. } => {
12389 assert_eq!(text, "Working on the report...");
12390 }
12391 other => {
12392 unreachable!("Display lane must materialize as AssistantBlock::Text, got {other:?}")
12393 }
12394 }
12395 assert!(
12397 !assistant
12398 .blocks
12399 .iter()
12400 .any(|b| matches!(b, AssistantBlock::Transcript { .. })),
12401 "Spoken lane must be dropped on barge-in"
12402 );
12403
12404 assert!(
12407 !session
12408 .in_flight_realtime_assistant_response_ids()
12409 .contains(&"resp_mixed_2".to_string()),
12410 "barged-in response must not appear in in_flight_realtime_assistant_response_ids"
12411 );
12412 }
12413
12414 #[test]
12415 fn round5_r55_barge_in_preserves_display_lane_drops_spoken() {
12416 let mut session = Session::new();
12420
12421 let _ =
12422 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12423 response_id: "resp_a".to_string(),
12424 delta_id: "delta_d_1".to_string(),
12425 item_id: "item_display".to_string(),
12426 previous_item_id: None,
12427 content_index: 0,
12428 delta: "display-text".to_string(),
12429 });
12430 let _ = session.append_realtime_transcript_event(
12431 RealtimeTranscriptEvent::AssistantTranscriptDelta {
12432 response_id: "resp_a".to_string(),
12433 delta_id: "delta_s_1".to_string(),
12434 item_id: "item_spoken".to_string(),
12435 previous_item_id: None,
12436 content_index: 0,
12437 delta: "spoken-transcript".to_string(),
12438 },
12439 );
12440
12441 let outcome = session.append_realtime_transcript_event(
12442 RealtimeTranscriptEvent::AssistantTurnInterrupted {
12443 response_id: "resp_a".to_string(),
12444 },
12445 );
12446 assert_eq!(outcome.materialized_messages.len(), 1);
12448
12449 let messages = session.messages();
12450 let assistants: Vec<&BlockAssistantMessage> = messages
12451 .iter()
12452 .filter_map(|m| match m {
12453 Message::BlockAssistant(a) => Some(a),
12454 _ => None,
12455 })
12456 .collect();
12457 assert_eq!(assistants.len(), 1);
12458 assert_eq!(assistants[0].blocks.len(), 1);
12460 match &assistants[0].blocks[0] {
12461 AssistantBlock::Text { text, .. } => assert_eq!(text, "display-text"),
12462 other => unreachable!("expected Text, got {other:?}"),
12463 }
12464 }
12465
12466 #[test]
12467 fn round5_r55_barge_in_finalizes_retained_display_into_committed_block() {
12468 let mut session = Session::new();
12473
12474 let _ =
12475 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12476 response_id: "resp_a".to_string(),
12477 delta_id: "delta_d_1".to_string(),
12478 item_id: "item_display".to_string(),
12479 previous_item_id: None,
12480 content_index: 0,
12481 delta: "committed-display-text".to_string(),
12482 });
12483
12484 assert!(session.messages().is_empty());
12486
12487 let outcome = session.append_realtime_transcript_event(
12488 RealtimeTranscriptEvent::AssistantTurnInterrupted {
12489 response_id: "resp_a".to_string(),
12490 },
12491 );
12492 assert_eq!(
12493 outcome.materialized_messages.len(),
12494 1,
12495 "Interrupted must finalize retained Display lane immediately"
12496 );
12497
12498 let messages = session.messages();
12500 assert_eq!(messages.len(), 1);
12501 match &messages[0] {
12502 Message::BlockAssistant(assistant) => {
12503 assert_eq!(assistant.blocks.len(), 1);
12504 match &assistant.blocks[0] {
12505 AssistantBlock::Text { text, .. } => {
12506 assert_eq!(text, "committed-display-text");
12507 }
12508 other => unreachable!("expected Text, got {other:?}"),
12509 }
12510 }
12511 other => unreachable!("expected BlockAssistant, got {other:?}"),
12512 }
12513 }
12514
12515 #[test]
12516 fn round5_r56_truncation_promotes_default_lane_item_to_spoken() {
12517 let mut session = Session::new();
12524
12525 let _ = session.append_realtime_transcript_event(
12526 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
12527 response_id: "resp_a".to_string(),
12528 item_id: "item_a".to_string(),
12529 content_index: 0,
12530 text: "what was actually heard".to_string(),
12531 },
12532 );
12533
12534 let outcome = session.append_realtime_transcript_event(
12535 RealtimeTranscriptEvent::AssistantTurnCompleted {
12536 response_id: "resp_a".to_string(),
12537 stop_reason: StopReason::EndTurn,
12538 usage: Usage::default(),
12539 },
12540 );
12541 assert_eq!(outcome.materialized_messages.len(), 1);
12542
12543 assert_eq!(session.messages().len(), 1);
12544 match &session.messages()[0] {
12545 Message::BlockAssistant(assistant) => {
12546 assert_eq!(assistant.blocks.len(), 1);
12547 match &assistant.blocks[0] {
12548 AssistantBlock::Transcript { text, source, .. } => {
12549 assert_eq!(text, "what was actually heard");
12550 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
12551 }
12552 other => unreachable!(
12553 "truncation-only path must materialize as AssistantBlock::Transcript, got {other:?}"
12554 ),
12555 }
12556 }
12557 other => unreachable!("expected BlockAssistant, got {other:?}"),
12558 }
12559 }
12560
12561 #[test]
12562 fn round5_r56_truncation_after_display_delta_is_no_op_keeping_display_content() {
12563 let mut session = Session::new();
12571
12572 let _ =
12573 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12574 response_id: "resp_a".to_string(),
12575 delta_id: "delta_d_1".to_string(),
12576 item_id: "item_a".to_string(),
12577 previous_item_id: None,
12578 content_index: 0,
12579 delta: "display-text-from-delta".to_string(),
12580 });
12581
12582 let _ = session.append_realtime_transcript_event(
12583 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
12584 response_id: "resp_a".to_string(),
12585 item_id: "item_a".to_string(),
12586 content_index: 0,
12587 text: "spoken-truncation-text".to_string(),
12588 },
12589 );
12590
12591 let _ = session.append_realtime_transcript_event(
12592 RealtimeTranscriptEvent::AssistantTurnCompleted {
12593 response_id: "resp_a".to_string(),
12594 stop_reason: StopReason::EndTurn,
12595 usage: Usage::default(),
12596 },
12597 );
12598
12599 assert_eq!(session.messages().len(), 1);
12602 match &session.messages()[0] {
12603 Message::BlockAssistant(assistant) => {
12604 assert_eq!(assistant.blocks.len(), 1);
12605 match &assistant.blocks[0] {
12606 AssistantBlock::Text { text, .. } => {
12607 assert_eq!(text, "display-text-from-delta");
12608 }
12609 other => unreachable!(
12610 "Display content must survive misrouted truncation, got {other:?}"
12611 ),
12612 }
12613 }
12614 other => unreachable!("expected BlockAssistant, got {other:?}"),
12615 }
12616 }
12617
12618 #[test]
12626 fn round5_r56_sibling_display_delta_skipped_on_spoken_item() {
12627 let mut session = Session::new();
12628
12629 let _ = session.append_realtime_transcript_event(
12631 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
12632 response_id: "resp_a".to_string(),
12633 item_id: "item_a".to_string(),
12634 content_index: 0,
12635 text: "what was actually heard".to_string(),
12636 },
12637 );
12638
12639 let _ =
12642 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12643 response_id: "resp_a".to_string(),
12644 delta_id: "delta_d_1".to_string(),
12645 item_id: "item_a".to_string(),
12646 previous_item_id: None,
12647 content_index: 0,
12648 delta: "should-not-appear".to_string(),
12649 });
12650
12651 let _ = session.append_realtime_transcript_event(
12652 RealtimeTranscriptEvent::AssistantTurnCompleted {
12653 response_id: "resp_a".to_string(),
12654 stop_reason: StopReason::EndTurn,
12655 usage: Usage::default(),
12656 },
12657 );
12658
12659 assert_eq!(session.messages().len(), 1);
12662 match &session.messages()[0] {
12663 Message::BlockAssistant(assistant) => {
12664 assert_eq!(assistant.blocks.len(), 1);
12665 match &assistant.blocks[0] {
12666 AssistantBlock::Transcript { text, source, .. } => {
12667 assert_eq!(text, "what was actually heard");
12668 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
12669 }
12670 other => unreachable!(
12671 "Spoken-locked item must materialize as Transcript, got {other:?}"
12672 ),
12673 }
12674 }
12675 other => unreachable!("expected BlockAssistant, got {other:?}"),
12676 }
12677 }
12678
12679 #[test]
12686 fn round5_r56_sibling_spoken_delta_skipped_on_display_item() {
12687 let mut session = Session::new();
12688
12689 let _ =
12691 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12692 response_id: "resp_a".to_string(),
12693 delta_id: "delta_d_1".to_string(),
12694 item_id: "item_a".to_string(),
12695 previous_item_id: None,
12696 content_index: 0,
12697 delta: "display-locked-text".to_string(),
12698 });
12699
12700 let _ = session.append_realtime_transcript_event(
12703 RealtimeTranscriptEvent::AssistantTranscriptDelta {
12704 response_id: "resp_a".to_string(),
12705 delta_id: "delta_s_1".to_string(),
12706 item_id: "item_a".to_string(),
12707 previous_item_id: None,
12708 content_index: 0,
12709 delta: "should-not-appear".to_string(),
12710 },
12711 );
12712
12713 let _ = session.append_realtime_transcript_event(
12714 RealtimeTranscriptEvent::AssistantTurnCompleted {
12715 response_id: "resp_a".to_string(),
12716 stop_reason: StopReason::EndTurn,
12717 usage: Usage::default(),
12718 },
12719 );
12720
12721 assert_eq!(session.messages().len(), 1);
12723 match &session.messages()[0] {
12724 Message::BlockAssistant(assistant) => {
12725 assert_eq!(assistant.blocks.len(), 1);
12726 match &assistant.blocks[0] {
12727 AssistantBlock::Text { text, .. } => {
12728 assert_eq!(text, "display-locked-text");
12729 }
12730 other => {
12731 unreachable!("Display-locked item must materialize as Text, got {other:?}")
12732 }
12733 }
12734 }
12735 other => unreachable!("expected BlockAssistant, got {other:?}"),
12736 }
12737 }
12738
12739 #[test]
12747 fn round5_r57_late_final_text_after_turn_completed_warns_and_skips() {
12748 let mut session = Session::new();
12749
12750 let _ = session.append_realtime_transcript_event(
12752 RealtimeTranscriptEvent::AssistantTranscriptDelta {
12753 response_id: "resp_a".to_string(),
12754 delta_id: "delta_s_1".to_string(),
12755 item_id: "item_a".to_string(),
12756 previous_item_id: None,
12757 content_index: 0,
12758 delta: "delta-accumulated".to_string(),
12759 },
12760 );
12761
12762 let commit_outcome = session.append_realtime_transcript_event(
12764 RealtimeTranscriptEvent::AssistantTurnCompleted {
12765 response_id: "resp_a".to_string(),
12766 stop_reason: StopReason::EndTurn,
12767 usage: Usage::default(),
12768 },
12769 );
12770 assert_eq!(commit_outcome.materialized_messages.len(), 1);
12771
12772 let late_outcome = session.append_realtime_transcript_event(
12776 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
12777 response_id: "resp_a".to_string(),
12778 item_id: "item_a".to_string(),
12779 content_index: 0,
12780 text: "authoritative-final-that-must-not-land".to_string(),
12781 },
12782 );
12783 assert!(
12784 late_outcome.is_inert(),
12785 "late FinalText after materialization must produce inert outcome"
12786 );
12787
12788 assert_eq!(session.messages().len(), 1);
12791 match &session.messages()[0] {
12792 Message::BlockAssistant(assistant) => {
12793 assert_eq!(assistant.blocks.len(), 1);
12794 match &assistant.blocks[0] {
12795 AssistantBlock::Transcript { text, .. } => {
12796 assert_eq!(
12797 text, "delta-accumulated",
12798 "canonical message must preserve delta-accumulated text; \
12799 append-only history forbids late FinalText repair"
12800 );
12801 }
12802 other => unreachable!("expected Transcript, got {other:?}"),
12803 }
12804 }
12805 other => unreachable!("expected BlockAssistant, got {other:?}"),
12806 }
12807 }
12808
12809 fn metadata_seam_session_metadata() -> SessionMetadata {
12810 SessionMetadata {
12811 schema_version: SESSION_METADATA_SCHEMA_VERSION,
12812 model: "test-model".to_string(),
12813 max_tokens: 1024,
12814 structured_output_retries: 2,
12815 provider: Provider::Anthropic,
12816 self_hosted_server_id: None,
12817 provider_params: None,
12818 tooling: SessionTooling::default(),
12819 keep_alive: false,
12820 comms_name: Some("team/reviewer/alice".to_string()),
12821 peer_meta: None,
12822 realm_id: None,
12823 instance_id: None,
12824 backend: None,
12825 config_generation: None,
12826 auth_binding: None,
12827 mob_member_binding: Some(crate::MobMemberBinding {
12828 mob_id: "team".to_string(),
12829 role: "reviewer".to_string(),
12830 member: "alice".to_string(),
12831 }),
12832 }
12833 }
12834
12835 #[test]
12840 fn session_metadata_document_lockstep_with_full_envelope() {
12841 let mut session = Session::new();
12842 session.push(Message::User(UserMessage::text("hello".to_string())));
12843 session
12844 .set_session_metadata(metadata_seam_session_metadata())
12845 .expect("session metadata should persist");
12846 session
12847 .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
12848 .expect("lifecycle terminal should persist");
12849
12850 let bytes = serde_json::to_vec(&session).expect("session should serialize");
12851 let document = session_metadata_document_from_slice(&bytes)
12852 .expect("partial decode must accept the canonical envelope");
12853
12854 assert_eq!(document.session_id(), session.id());
12855 assert_eq!(
12856 document.session_metadata_value(),
12857 session.metadata().get(SESSION_METADATA_KEY),
12858 "partial decode must project the identical raw session-metadata value"
12859 );
12860 assert_eq!(
12861 document.lifecycle_terminal_value(),
12862 session.metadata().get(SESSION_LIFECYCLE_TERMINAL_KEY),
12863 "partial decode must project the identical raw lifecycle-terminal value"
12864 );
12865
12866 let view = document
12867 .try_into_view()
12868 .expect("typed view must decode from the partial document");
12869 let full_view =
12870 PersistedSessionMetadataView::try_from_session(&session).expect("full-session view");
12871 assert_eq!(view.session_id, full_view.session_id);
12872 assert_eq!(
12873 view.session_metadata.as_ref().map(|m| m.model.clone()),
12874 full_view.session_metadata.as_ref().map(|m| m.model.clone())
12875 );
12876 assert_eq!(
12877 view.mob_member_binding(),
12878 full_view.mob_member_binding(),
12879 "typed binding must be identical across the two decode paths"
12880 );
12881 assert_eq!(
12882 view.lifecycle_terminal,
12883 Some(SessionLifecycleTerminal::Archived)
12884 );
12885 assert_eq!(
12886 full_view.lifecycle_terminal,
12887 Some(SessionLifecycleTerminal::Archived)
12888 );
12889 }
12890
12891 #[test]
12894 fn session_metadata_document_fails_closed_on_envelope_version() {
12895 let session = Session::new();
12896 let mut value = serde_json::to_value(&session).expect("session should serialize");
12897 value["version"] = serde_json::json!(SESSION_VERSION + 999);
12898 let bytes = serde_json::to_vec(&value).expect("mangled envelope should serialize");
12899
12900 session_metadata_document_from_slice(&bytes)
12901 .expect_err("an unsupported envelope version must fail the partial decode closed");
12902 }
12903
12904 #[test]
12905 fn current_session_deserializer_rejects_released_envelope_version() {
12906 let session = Session::new();
12907 let mut value = serde_json::to_value(&session).expect("session should serialize");
12908 value["version"] = serde_json::json!(2);
12909 let bytes = serde_json::to_vec(&value).expect("released envelope should serialize");
12910
12911 Session::from_persisted_bytes(&bytes)
12912 .expect_err("ordinary Session decode must accept only current envelope v3");
12913 }
12914
12915 #[test]
12918 fn persisted_session_metadata_view_fails_closed_on_corrupt_values() {
12919 let session_id = SessionId::new();
12920
12921 let mut corrupt_metadata = serde_json::Map::new();
12922 corrupt_metadata.insert(SESSION_METADATA_KEY.to_string(), serde_json::json!(42));
12923 PersistedSessionMetadataView::try_from_metadata_map(session_id.clone(), &corrupt_metadata)
12924 .expect_err("corrupt session_metadata must fail the view decode closed");
12925
12926 let mut corrupt_terminal = serde_json::Map::new();
12927 corrupt_terminal.insert(
12928 SESSION_LIFECYCLE_TERMINAL_KEY.to_string(),
12929 serde_json::json!("definitely-not-a-terminal"),
12930 );
12931 PersistedSessionMetadataView::try_from_metadata_map(session_id, &corrupt_terminal)
12932 .expect_err("corrupt lifecycle terminal must fail the view decode closed");
12933 }
12934
12935 #[test]
12937 fn persisted_session_metadata_view_reads_absent_facts_as_none() {
12938 let view = PersistedSessionMetadataView::try_from_metadata_map(
12939 SessionId::new(),
12940 &serde_json::Map::new(),
12941 )
12942 .expect("empty metadata map must decode");
12943 assert!(view.session_metadata.is_none());
12944 assert!(view.lifecycle_terminal.is_none());
12945 assert!(view.mob_member_binding().is_none());
12946 }
12947}