1use crate::Provider;
13use crate::generated::{session_document, session_persistence_version_authority};
14use crate::lifecycle::run_primitive::TurnMetadataOverride;
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::service::{AppendSystemContextRequest, MobToolAuthorityContext};
23use crate::session_durable_config_authority;
24use crate::time_compat::SystemTime;
25#[cfg(target_arch = "wasm32")]
26use crate::tokio;
27use crate::tool_scope::ToolFilter;
28use crate::types::{
29 AssistantBlock, BlockAssistantMessage, ContentBlock, ContentInput, Message, SessionId,
30 StopReason, ToolDef, ToolName, ToolProvenance, ToolResult, Usage, UserMessage,
31};
32use serde::{Deserialize, Deserializer, Serialize, Serializer};
33use sha2::{Digest, Sha256};
34use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
35use std::sync::{Arc, Mutex, OnceLock};
36
37mod digest_accumulator;
38
39use digest_accumulator::TranscriptMessages;
40
41pub use crate::generated::session_persistence_version_authority::SESSION_VERSION;
48
49pub use crate::generated::session_persistence_version_authority::SESSION_METADATA_SCHEMA_VERSION;
54
55pub fn session_version() -> u32 {
57 session_persistence_version_authority::session_envelope_version()
58}
59
60pub fn session_metadata_schema_version() -> u32 {
62 session_persistence_version_authority::session_metadata_schema_version()
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(tag = "type", rename_all = "snake_case")]
71pub enum TranscriptReplacement {
72 Message { message: Message },
74 UserContentBlock {
76 block_index: usize,
77 block: ContentBlock,
78 },
79 AssistantBlock {
81 block_index: usize,
82 block: AssistantBlock,
83 },
84 ToolResultContentBlock {
86 result_index: usize,
87 block_index: usize,
88 block: ContentBlock,
89 },
90}
91
92pub const SESSION_TRANSCRIPT_HISTORY_STATE_KEY: &str = "session_transcript_history_state_v1";
94
95pub const SESSION_TRANSCRIPT_HISTORY_CHECKPOINT_DIGEST_KEY: &str =
103 "session_transcript_history_checkpoint_digest_v1";
104
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
108#[serde(tag = "type", rename_all = "snake_case")]
109pub enum TranscriptRewriteSelection {
110 MessageRange { start: usize, end: usize },
114 EditMessageRange { range: TranscriptEditRewriteRange },
116 CompactionMessageRange { range: CompactionRewriteRange },
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
127pub struct TranscriptEditRewriteRange {
128 start: usize,
129 end: usize,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
134#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
135pub struct CompactionRewriteRange {
136 start: usize,
137 end: usize,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum TranscriptRewriteSemantic {
143 Edit,
145 Compaction,
147}
148
149impl TranscriptRewriteSelection {
150 pub fn bounds(&self) -> (usize, usize) {
153 match self {
154 Self::MessageRange { start, end } => (*start, *end),
155 Self::EditMessageRange { range } => (range.start, range.end),
156 Self::CompactionMessageRange { range } => (range.start, range.end),
157 }
158 }
159
160 pub fn semantic(&self) -> TranscriptRewriteSemantic {
161 match self {
162 Self::MessageRange { .. } | Self::EditMessageRange { .. } => {
163 TranscriptRewriteSemantic::Edit
164 }
165 Self::CompactionMessageRange { .. } => TranscriptRewriteSemantic::Compaction,
166 }
167 }
168
169 fn into_current_edit_semantic(self) -> Self {
170 match self {
171 Self::MessageRange { start, end } => Self::EditMessageRange {
172 range: TranscriptEditRewriteRange { start, end },
173 },
174 current => current,
175 }
176 }
177
178 fn is_legacy_untyped(&self) -> bool {
179 matches!(self, Self::MessageRange { .. })
180 }
181
182 fn validated_compaction(
183 start: usize,
184 end: usize,
185 _authority: &crate::agent::compact::ValidatedCompactionRewrite,
186 ) -> Self {
187 Self::CompactionMessageRange {
188 range: CompactionRewriteRange { start, end },
189 }
190 }
191
192 fn migrated_legacy_compaction(start: usize, end: usize) -> Self {
193 Self::CompactionMessageRange {
194 range: CompactionRewriteRange { start, end },
195 }
196 }
197
198 #[cfg(test)]
199 pub(crate) fn typed_compaction_for_test(start: usize, end: usize) -> Self {
200 Self::CompactionMessageRange {
201 range: CompactionRewriteRange { start, end },
202 }
203 }
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
212#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
213#[serde(rename_all = "snake_case")]
214pub struct TranscriptRewriteReason {
215 pub kind: String,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub note: Option<String>,
218}
219
220impl TranscriptRewriteReason {
221 pub fn new(kind: impl Into<String>) -> Self {
222 Self {
223 kind: kind.into(),
224 note: None,
225 }
226 }
227}
228
229pub const RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON: &str = "resume-system-prompt-refresh";
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub enum ResumedSystemPromptReconciliation {
236 PreservedContinuation,
241 RewrittenBase,
245 NoChange,
248}
249
250impl std::fmt::Display for TranscriptRewriteReason {
251 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255 match &self.note {
256 Some(note) => write!(f, "{}: {note}", self.kind),
257 None => f.write_str(&self.kind),
258 }
259 }
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
264#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
265#[serde(rename_all = "snake_case")]
266pub struct TranscriptRewriteCommit {
267 pub parent_revision: String,
268 pub revision: String,
269 pub selection: TranscriptRewriteSelection,
270 pub original_span_digest: String,
271 pub replacement_digest: String,
272 pub messages_before: usize,
273 pub messages_after: usize,
274 pub reason: TranscriptRewriteReason,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub actor: Option<String>,
277 #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
278 pub committed_at: SystemTime,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
283#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
284#[serde(rename_all = "snake_case")]
285pub struct TranscriptRevisionBody {
286 pub revision: String,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub parent_revision: Option<String>,
289 #[cfg_attr(feature = "schema", schemars(with = "Vec<serde_json::Value>"))]
290 pub messages: Vec<Message>,
291 #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
292 pub created_at: SystemTime,
293}
294
295#[cfg(feature = "schema")]
296#[allow(dead_code)]
297#[derive(schemars::JsonSchema)]
298#[schemars(rename = "SystemTime")]
299struct SchemaSystemTime {
300 secs_since_epoch: u64,
301 nanos_since_epoch: u32,
302}
303
304#[derive(Debug, Clone, Serialize)]
306#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
307#[serde(rename_all = "snake_case")]
308pub struct TranscriptRewriteRecord {
309 pub commit: TranscriptRewriteCommit,
310 pub parent_body: TranscriptRevisionBody,
311 pub revision_body: TranscriptRevisionBody,
312}
313
314impl<'de> Deserialize<'de> for TranscriptRewriteRecord {
315 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
316 where
317 D: Deserializer<'de>,
318 {
319 #[derive(Deserialize)]
320 #[serde(rename_all = "snake_case")]
321 struct Wire {
322 commit: TranscriptRewriteCommit,
323 parent_body: TranscriptRevisionBody,
324 revision_body: TranscriptRevisionBody,
325 }
326 let wire = Wire::deserialize(deserializer)?;
327 let mut revisions = vec![wire.parent_body, wire.revision_body];
328 let mut commits = vec![wire.commit];
329 heal_legacy_revision_strings(&mut revisions, &mut commits, None)
330 .map_err(serde::de::Error::custom)?;
331 heal_legacy_compaction_rewrite_semantics(&mut commits, &revisions);
332 let mut revisions = revisions.into_iter();
333 let parent_body = revisions
334 .next()
335 .ok_or_else(|| serde::de::Error::custom("rewrite record lost its parent body"))?;
336 let revision_body = revisions
337 .next()
338 .ok_or_else(|| serde::de::Error::custom("rewrite record lost its revision body"))?;
339 let commit = commits
340 .into_iter()
341 .next()
342 .ok_or_else(|| serde::de::Error::custom("rewrite record lost its commit"))?;
343 Ok(Self {
344 commit,
345 parent_body,
346 revision_body,
347 })
348 }
349}
350
351impl TranscriptRewriteRecord {
352 pub fn new(
353 commit: TranscriptRewriteCommit,
354 parent_body: TranscriptRevisionBody,
355 revision_body: TranscriptRevisionBody,
356 ) -> Result<Self, TranscriptEditError> {
357 validate_transcript_rewrite_record(&commit, &parent_body, &revision_body)?;
358 Ok(Self {
359 commit,
360 parent_body,
361 revision_body,
362 })
363 }
364}
365
366#[derive(Debug, Clone, Serialize)]
368#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
369#[serde(rename_all = "snake_case")]
370pub struct TranscriptHistoryState {
371 pub head: String,
372 #[serde(default, skip_serializing_if = "Vec::is_empty")]
373 pub commits: Vec<TranscriptRewriteCommit>,
374 #[serde(default, skip_serializing_if = "Vec::is_empty")]
375 pub revisions: Vec<TranscriptRevisionBody>,
376 #[serde(default, skip_serializing_if = "digest_format_is_unknown")]
383 pub digest_format: u32,
384}
385
386fn digest_format_is_unknown(format: &u32) -> bool {
387 *format == 0
388}
389
390pub(crate) const TRANSCRIPT_DIGEST_FORMAT_CURRENT: u32 = 2;
392
393const TRANSCRIPT_GRAPH_FACT_HEAL_PROBE_CURRENT: u8 = 1;
396const TRANSCRIPT_GRAPH_FACT_VALIDATED: u8 = 2;
399
400fn transcript_graph_shape_key(
415 fact: u8,
416 head: &str,
417 commits: &[TranscriptRewriteCommit],
418 revisions: &[TranscriptRevisionBody],
419) -> Option<String> {
420 let mut hasher = Sha256::new();
421 hasher.update([fact]);
422 hasher.update((head.len() as u64).to_le_bytes());
423 hasher.update(head.as_bytes());
424 hasher.update((revisions.len() as u64).to_le_bytes());
425 for body in revisions {
426 hasher.update((body.revision.len() as u64).to_le_bytes());
427 hasher.update(body.revision.as_bytes());
428 match body.parent_revision.as_deref() {
429 Some(parent) => {
430 hasher.update([1]);
431 hasher.update((parent.len() as u64).to_le_bytes());
432 hasher.update(parent.as_bytes());
433 }
434 None => hasher.update([0]),
435 }
436 hasher.update((body.messages.len() as u64).to_le_bytes());
437 }
438 hasher.update((commits.len() as u64).to_le_bytes());
439 for commit in commits {
440 let bytes = serde_json::to_vec(commit).ok()?;
441 hasher.update((bytes.len() as u64).to_le_bytes());
442 hasher.update(&bytes);
443 }
444 let digest = hasher.finalize();
445 let mut out = String::with_capacity(2 + digest.len() * 2);
446 out.push(char::from(b'0' + fact));
447 out.push(':');
448 const HEX: &[u8; 16] = b"0123456789abcdef";
449 for byte in digest {
450 out.push(HEX[(byte >> 4) as usize] as char);
451 out.push(HEX[(byte & 0x0f) as usize] as char);
452 }
453 Some(out)
454}
455
456struct BoundedTranscriptGraphDecodeMemo {
471 capacity: usize,
472 entries: HashSet<String>,
473 order: VecDeque<String>,
474}
475
476impl BoundedTranscriptGraphDecodeMemo {
477 fn new(capacity: usize) -> Self {
478 Self {
479 capacity,
480 entries: HashSet::new(),
481 order: VecDeque::new(),
482 }
483 }
484
485 fn contains(&self, key: &str) -> bool {
486 self.entries.contains(key)
487 }
488
489 fn record(&mut self, key: String) {
490 if self.entries.contains(&key) {
491 return;
492 }
493 while self.entries.len() >= self.capacity {
494 let Some(evicted) = self.order.pop_front() else {
495 break;
496 };
497 self.entries.remove(&evicted);
498 }
499 self.order.push_back(key.clone());
500 self.entries.insert(key);
501 }
502}
503
504const TRANSCRIPT_GRAPH_DECODE_MEMO_CAPACITY: usize = 4096;
505
506static TRANSCRIPT_GRAPH_DECODE_MEMO: OnceLock<Mutex<BoundedTranscriptGraphDecodeMemo>> =
507 OnceLock::new();
508
509fn transcript_graph_decode_memo() -> &'static Mutex<BoundedTranscriptGraphDecodeMemo> {
510 TRANSCRIPT_GRAPH_DECODE_MEMO.get_or_init(|| {
511 Mutex::new(BoundedTranscriptGraphDecodeMemo::new(
512 TRANSCRIPT_GRAPH_DECODE_MEMO_CAPACITY,
513 ))
514 })
515}
516
517fn transcript_graph_fact_is_memoized(key: &str) -> bool {
530 if std::env::var_os("MEERKAT_DISABLE_GRAPH_DECODE_MEMO").is_some() {
531 return false;
532 }
533 transcript_graph_decode_memo()
534 .lock()
535 .map(|memo| memo.contains(key))
536 .unwrap_or(false)
537}
538
539fn record_transcript_graph_fact(key: String) {
541 if let Ok(mut memo) = transcript_graph_decode_memo().lock() {
542 memo.record(key);
543 }
544}
545
546#[derive(Debug, Clone, Copy, PartialEq, Eq)]
549enum TranscriptGraphValidationMode {
550 FullVerify,
554 DecodeMemoized,
559}
560
561impl<'de> Deserialize<'de> for TranscriptHistoryState {
562 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
563 where
564 D: Deserializer<'de>,
565 {
566 #[derive(Deserialize)]
567 #[serde(rename_all = "snake_case")]
568 struct Wire {
569 head: String,
570 #[serde(default)]
571 commits: Vec<TranscriptRewriteCommit>,
572 #[serde(default)]
573 revisions: Vec<TranscriptRevisionBody>,
574 #[serde(default)]
575 digest_format: u32,
576 }
577 let wire = Wire::deserialize(deserializer)?;
578 let mut state = TranscriptHistoryState {
579 head: wire.head,
580 commits: wire.commits,
581 revisions: wire.revisions,
582 digest_format: wire.digest_format,
583 };
584 if state.revisions.len() > 1
590 && state
591 .revisions
592 .iter()
593 .skip(1)
594 .all(|body| body.parent_revision.is_none())
595 {
596 for index in 1..state.revisions.len() {
597 let parent = state.revisions[index - 1].revision.clone();
598 state.revisions[index].parent_revision = Some(parent);
599 }
600 }
601 let head_is_current = state.digest_format >= TRANSCRIPT_DIGEST_FORMAT_CURRENT
608 || match state
609 .revisions
610 .iter()
611 .find(|body| body.revision == state.head)
612 {
613 Some(head_body) => {
614 let probe_key = transcript_graph_shape_key(
615 TRANSCRIPT_GRAPH_FACT_HEAL_PROBE_CURRENT,
616 &state.head,
617 &state.commits,
618 &state.revisions,
619 );
620 if probe_key
621 .as_deref()
622 .is_some_and(transcript_graph_fact_is_memoized)
623 {
624 true
625 } else {
626 let current = transcript_messages_digest(&head_body.messages)
627 .map_err(serde::de::Error::custom)?
628 == state.head;
629 if current && let Some(key) = probe_key {
633 record_transcript_graph_fact(key);
634 }
635 current
636 }
637 }
638 None => true,
639 };
640 state.digest_format = TRANSCRIPT_DIGEST_FORMAT_CURRENT;
641 if !head_is_current {
642 let TranscriptHistoryState {
643 head,
644 commits,
645 digest_format: _,
646 revisions,
647 } = &mut state;
648 heal_legacy_revision_strings(revisions, commits, Some(head))
649 .map_err(serde::de::Error::custom)?;
650 }
651 heal_legacy_compaction_rewrite_semantics(&mut state.commits, &state.revisions);
652 Ok(state)
653 }
654}
655
656impl TranscriptHistoryState {
657 fn compact_mechanical_revision_bodies(&mut self) -> Result<(), TranscriptEditError> {
667 self.compact_mechanical_revision_bodies_for(TranscriptGraphValidationMode::FullVerify)
668 }
669
670 fn compact_mechanical_revision_bodies_for(
681 &mut self,
682 mode: TranscriptGraphValidationMode,
683 ) -> Result<(), TranscriptEditError> {
684 let validated_key = match mode {
685 TranscriptGraphValidationMode::FullVerify => None,
686 TranscriptGraphValidationMode::DecodeMemoized => transcript_graph_shape_key(
687 TRANSCRIPT_GRAPH_FACT_VALIDATED,
688 &self.head,
689 &self.commits,
690 &self.revisions,
691 ),
692 };
693 let already_proved = validated_key
694 .as_deref()
695 .is_some_and(transcript_graph_fact_is_memoized);
696 if !already_proved {
697 validate_transcript_history_state(self)?;
698 if let Some(key) = validated_key {
699 record_transcript_graph_fact(key);
700 }
701 }
702 self.prune_mechanical_revision_bodies();
703 Ok(())
704 }
705
706 fn prune_mechanical_revision_bodies(&mut self) {
716 let mut retained = BTreeSet::from([self.head.clone()]);
717 for commit in &self.commits {
718 retained.insert(commit.parent_revision.clone());
719 retained.insert(commit.revision.clone());
720 }
721
722 let head_is_audited_endpoint = self
723 .commits
724 .iter()
725 .any(|commit| commit.parent_revision == self.head || commit.revision == self.head);
726 if !head_is_audited_endpoint
727 && let Some(last_commit) = self
728 .commits
729 .last()
730 .filter(|commit| commit.revision != self.head)
731 && let Some(head_body) = self
732 .revisions
733 .iter_mut()
734 .find(|body| body.revision == self.head)
735 {
736 head_body.parent_revision = Some(last_commit.revision.clone());
737 }
738
739 let mut seen = BTreeSet::new();
740 self.revisions
741 .retain(|body| retained.contains(&body.revision) && seen.insert(body.revision.clone()));
742
743 }
751}
752
753#[derive(Debug, Clone, Copy, PartialEq, Eq)]
758enum TranscriptMutationShape {
759 Appended,
762 Rewritten,
764}
765
766fn heal_legacy_revision_strings(
775 revisions: &mut [TranscriptRevisionBody],
776 commits: &mut [TranscriptRewriteCommit],
777 head: Option<&mut String>,
778) -> Result<(), serde_json::Error> {
779 let mut remap: BTreeMap<String, String> = BTreeMap::new();
780 for body in revisions.iter() {
781 let content = transcript_messages_digest(&body.messages)?;
782 if body.revision == content {
783 continue;
784 }
785 if body.revision == legacy_transcript_messages_digest(&body.messages)? {
786 remap.insert(body.revision.clone(), content);
787 }
788 }
789 if remap.is_empty() {
790 return Ok(());
791 }
792 for body in revisions.iter_mut() {
793 if let Some(current) = remap.get(&body.revision) {
794 body.revision = current.clone();
795 }
796 if let Some(parent) = body.parent_revision.as_ref()
797 && let Some(current) = remap.get(parent)
798 {
799 body.parent_revision = Some(current.clone());
800 }
801 }
802 for commit in commits.iter_mut() {
803 if let Some(current) = remap.get(&commit.parent_revision) {
804 commit.parent_revision = current.clone();
805 }
806 if let Some(current) = remap.get(&commit.revision) {
807 commit.revision = current.clone();
808 }
809 heal_legacy_commit_span_digests(commit, revisions)?;
810 }
811 if let Some(head) = head
812 && let Some(current) = remap.get(head.as_str())
813 {
814 *head = current.clone();
815 }
816 Ok(())
817}
818
819fn heal_legacy_commit_span_digests(
825 commit: &mut TranscriptRewriteCommit,
826 revisions: &[TranscriptRevisionBody],
827) -> Result<(), serde_json::Error> {
828 let Some(parent_body) = revisions
829 .iter()
830 .find(|body| body.revision == commit.parent_revision)
831 else {
832 return Ok(());
833 };
834 let Some(revision_body) = revisions
835 .iter()
836 .find(|body| body.revision == commit.revision)
837 else {
838 return Ok(());
839 };
840 let (start, end) = commit.selection.bounds();
841 if start > end || end > parent_body.messages.len() {
842 return Ok(());
843 }
844 let removed_len = end - start;
845 let Some(retained_len) = commit.messages_before.checked_sub(removed_len) else {
846 return Ok(());
847 };
848 let Some(replacement_len) = commit.messages_after.checked_sub(retained_len) else {
849 return Ok(());
850 };
851 let Some(replacement_end) = start.checked_add(replacement_len) else {
852 return Ok(());
853 };
854 if replacement_end > revision_body.messages.len() {
855 return Ok(());
856 }
857 let original_span = &parent_body.messages[start..end];
858 if commit.original_span_digest == legacy_transcript_messages_digest(original_span)? {
859 commit.original_span_digest = transcript_messages_digest(original_span)?;
860 }
861 let replacement_span = &revision_body.messages[start..replacement_end];
862 if commit.replacement_digest == legacy_transcript_messages_digest(replacement_span)? {
863 commit.replacement_digest = transcript_messages_digest(replacement_span)?;
864 }
865 Ok(())
866}
867
868fn heal_legacy_compaction_rewrite_semantics(
877 commits: &mut [TranscriptRewriteCommit],
878 revisions: &[TranscriptRevisionBody],
879) {
880 for commit in commits {
881 if !commit.selection.is_legacy_untyped() {
882 continue;
883 }
884 let (start, end) = commit.selection.bounds();
885 if start != 0
886 || end != commit.messages_before
887 || commit.messages_after >= commit.messages_before
888 {
889 continue;
890 }
891 let Some(parent) = revisions
892 .iter()
893 .find(|body| body.revision == commit.parent_revision)
894 else {
895 continue;
896 };
897 let Some(revision) = revisions
898 .iter()
899 .find(|body| body.revision == commit.revision)
900 else {
901 continue;
902 };
903 if parent.messages.len() != commit.messages_before
904 || revision.messages.len() != commit.messages_after
905 {
906 continue;
907 }
908 let summary_count = revision
909 .messages
910 .iter()
911 .filter(|message| {
912 matches!(message, Message::User(user) if user.transcript_role.is_compaction_summary())
913 })
914 .count();
915 if summary_count == 1 {
916 commit.selection = TranscriptRewriteSelection::migrated_legacy_compaction(start, end);
917 }
918 }
919}
920
921impl TranscriptHistoryState {
922 pub fn from_rewrite_records<I>(records: I) -> Result<Option<Self>, TranscriptEditError>
924 where
925 I: IntoIterator<Item = TranscriptRewriteRecord>,
926 {
927 let mut state: Option<Self> = None;
928 for record in records {
929 validate_transcript_rewrite_record(
930 &record.commit,
931 &record.parent_body,
932 &record.revision_body,
933 )?;
934 let state = state.get_or_insert_with(|| Self {
935 head: record.commit.parent_revision.clone(),
936 commits: Vec::new(),
937 revisions: Vec::new(),
938 digest_format: TRANSCRIPT_DIGEST_FORMAT_CURRENT,
939 });
940 if record.commit.parent_revision != state.head {
941 if revision_body_extends_head(&record.parent_body, &state.revisions, &state.head)? {
942 state.head = record.commit.parent_revision.clone();
943 } else {
944 return Err(TranscriptEditError::HistoryStateMalformed(format!(
945 "rewrite record parent {} does not extend transcript head {}",
946 record.commit.parent_revision, state.head
947 )));
948 }
949 }
950 if !state
951 .revisions
952 .iter()
953 .any(|body| body.revision == record.parent_body.revision)
954 {
955 state.revisions.push(record.parent_body);
956 }
957 if !state
958 .revisions
959 .iter()
960 .any(|body| body.revision == record.revision_body.revision)
961 {
962 state.revisions.push(record.revision_body);
963 }
964 state.head = record.commit.revision.clone();
965 state.commits.push(record.commit);
966 }
967 Ok(state)
968 }
969}
970
971#[derive(Debug, Clone, thiserror::Error)]
973pub enum TranscriptEditError {
974 #[error("message index {message_index} out of bounds for {message_count} messages")]
975 MessageIndexOutOfBounds {
976 message_index: usize,
977 message_count: usize,
978 },
979 #[error("{block_kind} index {block_index} out of bounds for {block_count} blocks")]
980 BlockIndexOutOfBounds {
981 block_kind: &'static str,
982 block_index: usize,
983 block_count: usize,
984 },
985 #[error("replacement expected {expected} at message index {message_index}, found {actual}")]
986 MessageRoleMismatch {
987 message_index: usize,
988 expected: &'static str,
989 actual: &'static str,
990 },
991 #[error("invalid transcript rewrite range {start}..{end} for {message_count} messages")]
992 InvalidRewriteRange {
993 start: usize,
994 end: usize,
995 message_count: usize,
996 },
997 #[error("transcript rewrite does not change transcript revision {revision}")]
998 NoOpRewrite { revision: String },
999 #[error("transcript rewrite parent revision mismatch: expected {expected}, actual {actual}")]
1000 RevisionConflict { expected: String, actual: String },
1001 #[error("transcript history state is malformed: {0}")]
1002 HistoryStateMalformed(String),
1003 #[error("invalid transcript shape after rewrite: {0}")]
1004 InvalidTranscriptShape(String),
1005}
1006
1007fn message_role_name(message: &Message) -> &'static str {
1008 match message {
1009 Message::System(_) => "system",
1010 Message::SystemNotice(_) => "system_notice",
1011 Message::User(_) => "user",
1012 Message::BlockAssistant(_) => "block_assistant",
1013 Message::ToolResults { .. } => "tool_results",
1014 }
1015}
1016
1017fn assistant_tool_use_ids(message: &Message) -> Vec<&str> {
1018 match message {
1019 Message::BlockAssistant(assistant) => assistant
1020 .blocks
1021 .iter()
1022 .filter_map(|block| match block {
1023 AssistantBlock::ToolUse { id, .. } => Some(id.as_str()),
1024 _ => None,
1025 })
1026 .collect(),
1027 _ => Vec::new(),
1028 }
1029}
1030
1031fn validate_transcript_tool_result_shape(messages: &[Message]) -> Result<(), TranscriptEditError> {
1032 for (index, message) in messages.iter().enumerate() {
1033 if let Message::ToolResults { results, .. } = message {
1034 let Some(previous) = index
1035 .checked_sub(1)
1036 .and_then(|previous| messages.get(previous))
1037 else {
1038 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
1039 "tool_results at message {index} has no preceding assistant tool-use message"
1040 )));
1041 };
1042 let expected = assistant_tool_use_ids(previous);
1043 if expected.is_empty() {
1044 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
1045 "tool_results at message {index} follows {}, not an assistant tool-use message",
1046 message_role_name(previous)
1047 )));
1048 }
1049 let actual = results
1050 .iter()
1051 .map(|result| result.tool_use_id.as_str())
1052 .collect::<Vec<_>>();
1053 let actual_set = actual.iter().copied().collect::<BTreeSet<_>>();
1054 let expected_set = expected.iter().copied().collect::<BTreeSet<_>>();
1055 if actual.len() != actual_set.len() {
1056 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
1057 "tool_results at message {index} contains duplicate tool ids"
1058 )));
1059 }
1060 if expected.len() != expected_set.len() {
1061 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
1062 "assistant tool-use message before tool_results at message {index} contains duplicate tool ids"
1063 )));
1064 }
1065 if actual_set != expected_set {
1066 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
1067 "tool_results at message {index} resolve tool ids {actual_set:?}, expected {expected_set:?}"
1068 )));
1069 }
1070 }
1071
1072 let tool_use_ids = assistant_tool_use_ids(message);
1073 if tool_use_ids.is_empty() {
1074 continue;
1075 }
1076 let Some(next) = messages.get(index + 1) else {
1077 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
1078 "assistant tool-use message {index} has no following tool_results"
1079 )));
1080 };
1081 if !matches!(next, Message::ToolResults { .. }) {
1082 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
1083 "assistant tool-use message {index} is followed by {}, not tool_results",
1084 message_role_name(next)
1085 )));
1086 }
1087 }
1088 Ok(())
1089}
1090
1091fn canonicalize_digest_image_blocks(blocks: &mut [crate::types::ContentBlock]) {
1092 for block in blocks.iter_mut() {
1093 if let crate::types::ContentBlock::Image {
1094 media_type,
1095 data: crate::types::ImageData::Inline { data },
1096 } = block
1097 {
1098 let blob_id = crate::blob::content_blob_id(media_type, data);
1101 *block = crate::types::ContentBlock::Image {
1102 media_type: media_type.clone(),
1103 data: crate::types::ImageData::Blob { blob_id },
1104 };
1105 }
1106 }
1107}
1108
1109fn canonicalize_message_images_for_digest(messages: &[Message]) -> Vec<Message> {
1118 let mut canonical = messages.to_vec();
1119 for message in &mut canonical {
1120 canonicalize_message_images_for_digest_in_place(message);
1121 }
1122 canonical
1123}
1124
1125fn canonicalize_message_images_for_digest_in_place(message: &mut Message) {
1126 match message {
1127 Message::User(user) => canonicalize_digest_image_blocks(&mut user.content),
1128 Message::ToolResults { results, .. } => {
1129 for result in results.iter_mut() {
1130 canonicalize_digest_image_blocks(&mut result.content);
1131 }
1132 }
1133 Message::SystemNotice(notice) => {
1134 for block in &mut notice.blocks {
1135 match block {
1136 crate::types::SystemNoticeBlock::Comms { content, .. }
1137 | crate::types::SystemNoticeBlock::ExternalEvent { content, .. } => {
1138 canonicalize_digest_image_blocks(content);
1139 }
1140 _ => {}
1141 }
1142 }
1143 }
1144 _ => {}
1145 }
1146}
1147
1148pub(crate) fn canonicalize_checkpoint_history_value(
1154 value: &serde_json::Value,
1155) -> Result<serde_json::Value, serde_json::Error> {
1156 let state: TranscriptHistoryState = serde_json::from_value(value.clone())?;
1157 let mut revisions = state
1158 .revisions
1159 .into_iter()
1160 .map(|body| {
1161 serde_json::json!({
1162 "revision": body.revision,
1163 "messages": canonicalize_messages_for_digest(&body.messages),
1164 })
1165 })
1166 .collect::<Vec<_>>();
1167 revisions.sort_by(|left, right| {
1168 left.get("revision")
1169 .and_then(serde_json::Value::as_str)
1170 .cmp(&right.get("revision").and_then(serde_json::Value::as_str))
1171 });
1172 Ok(serde_json::json!({
1173 "head": state.head,
1174 "commits": state.commits,
1175 "revisions": revisions,
1176 }))
1177}
1178
1179#[derive(Debug, Default)]
1190pub(crate) struct HistoryWitnessAssemblyCache {
1191 inner: std::sync::Mutex<HistoryWitnessAssemblyCacheInner>,
1192}
1193
1194#[derive(Debug, Default, Clone)]
1195struct HistoryWitnessAssemblyCacheInner {
1196 commits: Option<(usize, String, std::sync::Arc<[u8]>)>,
1198 bodies: std::collections::HashMap<String, std::sync::Arc<[u8]>>,
1200}
1201
1202impl Clone for HistoryWitnessAssemblyCache {
1203 fn clone(&self) -> Self {
1204 Self {
1205 inner: std::sync::Mutex::new(self.locked().clone()),
1206 }
1207 }
1208}
1209
1210impl HistoryWitnessAssemblyCache {
1211 fn locked(&self) -> std::sync::MutexGuard<'_, HistoryWitnessAssemblyCacheInner> {
1212 self.inner
1213 .lock()
1214 .unwrap_or_else(std::sync::PoisonError::into_inner)
1215 }
1216}
1217
1218#[derive(Debug, Default)]
1226pub(crate) struct SharedTranscriptHistoryState {
1227 inner: std::sync::Mutex<Option<std::sync::Arc<TranscriptHistoryState>>>,
1228}
1229
1230impl Clone for SharedTranscriptHistoryState {
1231 fn clone(&self) -> Self {
1232 Self {
1233 inner: std::sync::Mutex::new(self.locked().clone()),
1234 }
1235 }
1236}
1237
1238impl SharedTranscriptHistoryState {
1239 fn locked(&self) -> std::sync::MutexGuard<'_, Option<std::sync::Arc<TranscriptHistoryState>>> {
1240 self.inner
1241 .lock()
1242 .unwrap_or_else(std::sync::PoisonError::into_inner)
1243 }
1244
1245 fn clear(&self) {
1246 *self.locked() = None;
1247 }
1248
1249 fn set(&self, state: std::sync::Arc<TranscriptHistoryState>) {
1250 *self.locked() = Some(state);
1251 }
1252
1253 fn get(&self) -> Option<std::sync::Arc<TranscriptHistoryState>> {
1254 self.locked().clone()
1255 }
1256}
1257
1258fn canonical_history_body_chunk(body_value: &serde_json::Value) -> Option<Vec<u8>> {
1263 let body: TranscriptRevisionBody = serde_json::from_value(body_value.clone()).ok()?;
1264 let canonical = serde_json::json!({
1265 "revision": body.revision,
1266 "messages": canonicalize_messages_for_digest(&body.messages),
1267 });
1268 let mut bytes = Vec::new();
1269 crate::checkpoint::write_canonical_json(&canonical, &mut bytes).ok()?;
1270 Some(bytes)
1271}
1272
1273fn canonicalize_checkpoint_deferred_turn_value(
1274 value: &serde_json::Value,
1275) -> Result<serde_json::Value, serde_json::Error> {
1276 let mut state: SessionDeferredTurnState = serde_json::from_value(value.clone())?;
1277 if let Some(prompt) = state.pending_initial_prompt_mut_for_blob_rewrite()
1278 && let crate::types::ContentInput::Blocks(blocks) = &mut prompt.prompt
1279 {
1280 canonicalize_digest_image_blocks(blocks);
1281 }
1282 for pending in state.pending_tool_results_mut_for_blob_rewrite() {
1283 for result in &mut pending.results {
1284 canonicalize_digest_image_blocks(&mut result.content);
1285 }
1286 }
1287 serde_json::to_value(state)
1288}
1289
1290fn digest_timestamp_sentinel() -> crate::types::MessageTimestamp {
1294 chrono::DateTime::<chrono::Utc>::UNIX_EPOCH
1295}
1296
1297fn canonicalize_messages_for_digest(messages: &[Message]) -> Vec<Message> {
1315 let mut canonical = canonicalize_message_images_for_digest(messages);
1316 for message in &mut canonical {
1317 erase_message_construction_bookkeeping(message);
1318 }
1319 canonical
1320}
1321
1322fn erase_message_construction_bookkeeping(message: &mut Message) {
1323 match message {
1324 Message::System(system) => {
1325 system.created_at = digest_timestamp_sentinel();
1326 }
1327 Message::SystemNotice(notice) => {
1328 notice.created_at = digest_timestamp_sentinel();
1329 }
1330 Message::User(user) => {
1331 user.identity = crate::types::TranscriptMessageIdentity::default();
1332 user.created_at = digest_timestamp_sentinel();
1333 }
1334 Message::BlockAssistant(assistant) => {
1335 assistant.identity = crate::types::TranscriptMessageIdentity::default();
1336 assistant.created_at = digest_timestamp_sentinel();
1337 }
1338 Message::ToolResults { created_at, .. } => {
1339 *created_at = digest_timestamp_sentinel();
1340 }
1341 }
1342}
1343
1344pub(crate) fn canonicalize_message_for_digest(message: &Message) -> Message {
1353 let mut canonical = message.clone();
1354 canonicalize_message_images_for_digest_in_place(&mut canonical);
1355 erase_message_construction_bookkeeping(&mut canonical);
1356 canonical
1357}
1358
1359pub fn transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
1360 sha256_json_digest(&canonicalize_messages_for_digest(messages))
1361}
1362
1363pub(crate) fn transcript_messages_digest_uncounted(
1371 messages: &[Message],
1372) -> Result<String, serde_json::Error> {
1373 let canonical = canonicalize_messages_for_digest(messages);
1374 let bytes = serde_json::to_vec(&canonical)?;
1375 Ok(format!("sha256:{:x}", Sha256::digest(bytes)))
1376}
1377
1378fn legacy_transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
1387 sha256_json_digest(&canonicalize_message_images_for_digest(messages))
1388}
1389
1390fn validate_transcript_rewrite_record(
1391 commit: &TranscriptRewriteCommit,
1392 parent_body: &TranscriptRevisionBody,
1393 revision_body: &TranscriptRevisionBody,
1394) -> Result<(), TranscriptEditError> {
1395 if parent_body.revision != commit.parent_revision {
1396 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1397 "parent body revision {} does not match commit parent {}",
1398 parent_body.revision, commit.parent_revision
1399 )));
1400 }
1401 if revision_body.revision != commit.revision {
1402 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1403 "revision body {} does not match commit revision {}",
1404 revision_body.revision, commit.revision
1405 )));
1406 }
1407 if commit.parent_revision == commit.revision {
1408 return Err(TranscriptEditError::NoOpRewrite {
1409 revision: commit.revision.clone(),
1410 });
1411 }
1412 let parent_digest = transcript_messages_digest(&parent_body.messages)
1413 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1414 if parent_digest != commit.parent_revision {
1415 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1416 "parent body digest {parent_digest} does not match commit parent {}",
1417 commit.parent_revision
1418 )));
1419 }
1420 let revision_digest = transcript_messages_digest(&revision_body.messages)
1421 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1422 if revision_digest != commit.revision {
1423 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1424 "revision body digest {revision_digest} does not match commit revision {}",
1425 commit.revision
1426 )));
1427 }
1428 let (start, end) = commit.selection.bounds();
1429 if start > end || end > parent_body.messages.len() {
1430 return Err(TranscriptEditError::InvalidRewriteRange {
1431 start,
1432 end,
1433 message_count: parent_body.messages.len(),
1434 });
1435 }
1436 if commit.messages_before != parent_body.messages.len()
1437 || commit.messages_after != revision_body.messages.len()
1438 {
1439 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1440 "commit message counts {} -> {} do not match revision bodies {} -> {}",
1441 commit.messages_before,
1442 commit.messages_after,
1443 parent_body.messages.len(),
1444 revision_body.messages.len()
1445 )));
1446 }
1447 let original_span_digest = transcript_messages_digest(&parent_body.messages[start..end])
1448 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1449 if original_span_digest != commit.original_span_digest {
1450 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1451 "original span digest {original_span_digest} does not match commit digest {}",
1452 commit.original_span_digest
1453 )));
1454 }
1455 let removed_len = end - start;
1456 let retained_len = commit
1457 .messages_before
1458 .checked_sub(removed_len)
1459 .ok_or_else(|| {
1460 TranscriptEditError::HistoryStateMalformed(
1461 "commit removed more messages than it recorded before rewrite".to_string(),
1462 )
1463 })?;
1464 let replacement_len = commit
1465 .messages_after
1466 .checked_sub(retained_len)
1467 .ok_or_else(|| {
1468 TranscriptEditError::HistoryStateMalformed(
1469 "commit message counts cannot describe a replacement span".to_string(),
1470 )
1471 })?;
1472 let replacement_end = start.checked_add(replacement_len).ok_or_else(|| {
1473 TranscriptEditError::HistoryStateMalformed("replacement span end overflowed".to_string())
1474 })?;
1475 if replacement_end > revision_body.messages.len() {
1476 return Err(TranscriptEditError::InvalidRewriteRange {
1477 start,
1478 end: replacement_end,
1479 message_count: revision_body.messages.len(),
1480 });
1481 }
1482 if commit.selection.semantic() == TranscriptRewriteSemantic::Compaction {
1483 let summary_count = revision_body.messages[start..replacement_end]
1484 .iter()
1485 .filter(|message| {
1486 matches!(message, Message::User(user) if user.transcript_role.is_compaction_summary())
1487 })
1488 .count();
1489 if start != 0
1490 || end != commit.messages_before
1491 || commit.messages_after >= commit.messages_before
1492 || summary_count != 1
1493 {
1494 return Err(TranscriptEditError::HistoryStateMalformed(
1495 "typed compaction rewrite must shrink the full transcript and carry exactly one CompactionSummary"
1496 .to_string(),
1497 ));
1498 }
1499 }
1500 let parent_prefix_digest = transcript_messages_digest(&parent_body.messages[..start])
1501 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1502 let revision_prefix_digest = transcript_messages_digest(&revision_body.messages[..start])
1503 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1504 if parent_prefix_digest != revision_prefix_digest {
1505 return Err(TranscriptEditError::HistoryStateMalformed(
1506 "rewrite revision changed messages before the selected span".to_string(),
1507 ));
1508 }
1509 let parent_suffix_digest = transcript_messages_digest(&parent_body.messages[end..])
1510 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1511 let revision_suffix_digest =
1512 transcript_messages_digest(&revision_body.messages[replacement_end..])
1513 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1514 if parent_suffix_digest != revision_suffix_digest {
1515 return Err(TranscriptEditError::HistoryStateMalformed(
1516 "rewrite revision changed messages after the selected span".to_string(),
1517 ));
1518 }
1519 let replacement_digest =
1520 transcript_messages_digest(&revision_body.messages[start..replacement_end])
1521 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1522 if replacement_digest != commit.replacement_digest {
1523 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1524 "replacement span digest {replacement_digest} does not match commit digest {}",
1525 commit.replacement_digest
1526 )));
1527 }
1528 Ok(())
1529}
1530
1531pub(crate) fn validate_transcript_history_state(
1532 state: &TranscriptHistoryState,
1533) -> Result<(), TranscriptEditError> {
1534 if state
1535 .revisions
1536 .iter()
1537 .all(|body| body.revision != state.head)
1538 {
1539 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1540 "missing transcript head body {}",
1541 state.head
1542 )));
1543 }
1544 for body in &state.revisions {
1545 let digest = transcript_messages_digest(&body.messages)
1546 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1547 if digest != body.revision {
1548 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1549 "transcript revision body {} has digest {digest}",
1550 body.revision
1551 )));
1552 }
1553 }
1554 for commit in &state.commits {
1555 let parent_body = state
1556 .revisions
1557 .iter()
1558 .find(|body| body.revision == commit.parent_revision)
1559 .ok_or_else(|| {
1560 TranscriptEditError::HistoryStateMalformed(format!(
1561 "missing parent transcript body {}",
1562 commit.parent_revision
1563 ))
1564 })?;
1565 let revision_body = state
1566 .revisions
1567 .iter()
1568 .find(|body| body.revision == commit.revision)
1569 .ok_or_else(|| {
1570 TranscriptEditError::HistoryStateMalformed(format!(
1571 "missing transcript revision body {}",
1572 commit.revision
1573 ))
1574 })?;
1575 validate_transcript_rewrite_record(commit, parent_body, revision_body)?;
1576 }
1577 let Some(first_commit) = state.commits.first() else {
1578 return Ok(());
1579 };
1580 let mut expected_head = first_commit.parent_revision.clone();
1581 for commit in &state.commits {
1582 let parent_body = state
1583 .revisions
1584 .iter()
1585 .find(|body| body.revision == commit.parent_revision)
1586 .ok_or_else(|| {
1587 TranscriptEditError::HistoryStateMalformed(format!(
1588 "missing parent transcript body {}",
1589 commit.parent_revision
1590 ))
1591 })?;
1592 if commit.parent_revision != expected_head
1593 && !revision_body_extends_head(parent_body, &state.revisions, &expected_head)?
1594 {
1595 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1596 "rewrite commit parent {} does not extend transcript head {}",
1597 commit.parent_revision, expected_head
1598 )));
1599 }
1600 expected_head = commit.revision.clone();
1601 }
1602 let head_is_audited_endpoint = state
1603 .commits
1604 .iter()
1605 .any(|commit| commit.parent_revision == state.head || commit.revision == state.head);
1606 let head_extends_latest_commit = if head_is_audited_endpoint {
1607 let Some(head_body) = state
1608 .revisions
1609 .iter()
1610 .find(|body| body.revision == state.head)
1611 else {
1612 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1613 "missing transcript head body {}",
1614 state.head
1615 )));
1616 };
1617 revision_body_extends_head(head_body, &state.revisions, &expected_head)?
1618 } else {
1619 let mut cursor = state.head.as_str();
1620 let mut visited = BTreeSet::new();
1621 while cursor != expected_head {
1622 if !visited.insert(cursor.to_string()) {
1623 break;
1624 }
1625 let Some(head_body) = state.revisions.iter().find(|body| body.revision == cursor)
1626 else {
1627 break;
1628 };
1629 let Some(parent) = head_body.parent_revision.as_deref() else {
1630 break;
1631 };
1632 cursor = parent;
1633 }
1634 cursor == expected_head
1635 };
1636 if !head_extends_latest_commit {
1637 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1638 "transcript head {} does not extend the rewrite chain",
1639 state.head
1640 )));
1641 }
1642 Ok(())
1643}
1644
1645fn revision_body_extends_head(
1646 candidate: &TranscriptRevisionBody,
1647 revisions: &[TranscriptRevisionBody],
1648 head: &str,
1649) -> Result<bool, TranscriptEditError> {
1650 let Some(head_body) = revisions.iter().find(|body| body.revision == head) else {
1651 return Ok(false);
1652 };
1653 if candidate.revision == head {
1654 return Ok(true);
1655 }
1656 if candidate.messages.len() < head_body.messages.len() {
1657 return Ok(false);
1658 }
1659 let prefix_digest = transcript_messages_digest(&candidate.messages[..head_body.messages.len()])
1660 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1661 if prefix_digest == head {
1662 return Ok(true);
1663 }
1664
1665 let (Some(Message::System(_)), Some(Message::System(_))) =
1671 (candidate.messages.first(), head_body.messages.first())
1672 else {
1673 return Ok(false);
1674 };
1675 let head_tail_len = head_body.messages.len().saturating_sub(1);
1676 if head_tail_len == 0 {
1677 return Ok(true);
1678 }
1679 let candidate_tail = &candidate.messages[1..];
1680 if candidate_tail.len() < head_tail_len {
1681 return Ok(false);
1682 }
1683 let head_tail_digest = transcript_messages_digest(&head_body.messages[1..])
1684 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1685 let candidate_tail_prefix_digest = transcript_messages_digest(&candidate_tail[..head_tail_len])
1686 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1687 Ok(candidate_tail_prefix_digest == head_tail_digest)
1688}
1689
1690use digest_accumulator::take_verification_sample as digest_accumulator_take_verification_sample;
1691
1692fn sha256_json_digest<T: Serialize + ?Sized>(value: &T) -> Result<String, serde_json::Error> {
1693 crate::checkpoint::record_content_digest_computation();
1694 let bytes = serde_json::to_vec(value)?;
1695 crate::checkpoint::record_content_digest_bytes(bytes.len() as u64);
1696 let digest = Sha256::digest(bytes);
1697 let mut out = String::with_capacity(digest.len() * 2);
1698 const HEX: &[u8; 16] = b"0123456789abcdef";
1699 for byte in digest {
1700 out.push(HEX[(byte >> 4) as usize] as char);
1701 out.push(HEX[(byte & 0x0f) as usize] as char);
1702 }
1703 Ok(format!("sha256:{out}"))
1704}
1705
1706#[derive(Debug, Default, Clone)]
1721pub(crate) struct SessionHistoryCaches {
1722 witness: std::sync::OnceLock<String>,
1724 assembly: HistoryWitnessAssemblyCache,
1726 shared_state: SharedTranscriptHistoryState,
1728}
1729
1730#[derive(Debug, Clone)]
1731pub struct Session {
1732 version: u32,
1735 id: SessionId,
1737 pub(crate) messages: TranscriptMessages,
1746 created_at: SystemTime,
1748 updated_at: SystemTime,
1750 metadata: serde_json::Map<String, serde_json::Value>,
1752 history_caches: Box<SessionHistoryCaches>,
1762 transcript_history_metadata_validation: TranscriptHistoryMetadataValidation,
1776 usage: Usage,
1778}
1779
1780#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1781enum TranscriptHistoryMetadataValidation {
1782 Validated,
1783 RequiresValidation,
1784}
1785
1786#[derive(Deserialize)]
1788#[serde(rename_all = "snake_case")]
1789struct SessionSerde {
1790 version: u32,
1791 id: SessionId,
1792 messages: Vec<Message>,
1793 created_at: SystemTime,
1794 updated_at: SystemTime,
1795 #[serde(default)]
1796 metadata: serde_json::Map<String, serde_json::Value>,
1797 #[serde(default)]
1798 usage: Usage,
1799}
1800
1801#[derive(Serialize)]
1805#[serde(rename_all = "snake_case")]
1806struct SessionSerdeRef<'a> {
1807 version: u32,
1808 id: &'a SessionId,
1809 messages: &'a [Message],
1810 created_at: &'a SystemTime,
1811 updated_at: &'a SystemTime,
1812 metadata: &'a serde_json::Map<String, serde_json::Value>,
1813 usage: &'a Usage,
1814}
1815
1816impl Serialize for Session {
1817 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1818 where
1819 S: Serializer,
1820 {
1821 let compacted_metadata = if self.transcript_history_metadata_validation
1822 == TranscriptHistoryMetadataValidation::RequiresValidation
1823 {
1824 let mut metadata = self.metadata.clone();
1825 compact_transcript_history_metadata_for_snapshot(
1826 &mut metadata,
1827 TranscriptGraphValidationMode::FullVerify,
1828 )
1829 .map_err(<S::Error as serde::ser::Error>::custom)?;
1830 Some(metadata)
1831 } else {
1832 None
1833 };
1834 let metadata = compacted_metadata.as_ref().unwrap_or(&self.metadata);
1835 let serde_repr = SessionSerdeRef {
1836 version: self.version,
1837 id: &self.id,
1838 messages: self.messages(),
1839 created_at: &self.created_at,
1840 updated_at: &self.updated_at,
1841 metadata,
1842 usage: &self.usage,
1843 };
1844 serde_repr.serialize(serializer)
1845 }
1846}
1847
1848fn compact_transcript_history_metadata_for_snapshot(
1849 metadata: &mut serde_json::Map<String, serde_json::Value>,
1850 mode: TranscriptGraphValidationMode,
1851) -> Result<(), String> {
1852 let Some(value) = metadata.remove(SESSION_TRANSCRIPT_HISTORY_STATE_KEY) else {
1853 return Ok(());
1854 };
1855 let mut state: TranscriptHistoryState =
1856 serde_json::from_value(value).map_err(|error| error.to_string())?;
1857 state
1858 .compact_mechanical_revision_bodies_for(mode)
1859 .map_err(|error| error.to_string())?;
1860 metadata.insert(
1861 SESSION_TRANSCRIPT_HISTORY_STATE_KEY.to_string(),
1862 serde_json::to_value(state).map_err(|error| error.to_string())?,
1863 );
1864 Ok(())
1865}
1866
1867impl<'de> Deserialize<'de> for Session {
1868 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1869 where
1870 D: Deserializer<'de>,
1871 {
1872 let serde_repr = SessionSerde::deserialize(deserializer)?;
1873 let version = session_persistence_version_authority::restore_session_envelope_version(
1874 serde_repr.version,
1875 )
1876 .map_err(<D::Error as serde::de::Error>::custom)?;
1877 let mut metadata = serde_repr.metadata;
1878 compact_transcript_history_metadata_for_snapshot(
1882 &mut metadata,
1883 TranscriptGraphValidationMode::DecodeMemoized,
1884 )
1885 .map_err(<D::Error as serde::de::Error>::custom)?;
1886 Ok(Session {
1887 version,
1888 id: serde_repr.id,
1889 messages: TranscriptMessages::from_vec(serde_repr.messages),
1890 created_at: serde_repr.created_at,
1891 updated_at: serde_repr.updated_at,
1892 metadata,
1893 history_caches: Box::default(),
1894 transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
1895 usage: serde_repr.usage,
1896 })
1897 }
1898}
1899
1900#[derive(Deserialize)]
1909#[serde(rename_all = "snake_case")]
1910struct SessionMetadataDocumentSerde {
1911 version: u32,
1912 id: SessionId,
1913 #[serde(default)]
1914 metadata: serde_json::Map<String, serde_json::Value>,
1915}
1916
1917#[derive(Debug, Clone)]
1926pub struct SessionMetadataDocument {
1927 session_id: SessionId,
1928 metadata: serde_json::Map<String, serde_json::Value>,
1929}
1930
1931impl SessionMetadataDocument {
1932 pub fn session_id(&self) -> &SessionId {
1934 &self.session_id
1935 }
1936
1937 pub fn session_metadata_value(&self) -> Option<&serde_json::Value> {
1940 self.metadata.get(SESSION_METADATA_KEY)
1941 }
1942
1943 pub fn lifecycle_terminal_value(&self) -> Option<&serde_json::Value> {
1946 self.metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY)
1947 }
1948
1949 pub fn try_checkpoint_metadata_state(
1955 &self,
1956 ) -> Result<
1957 crate::checkpoint::SessionCheckpointMetadataState,
1958 crate::checkpoint::SessionCheckpointError,
1959 > {
1960 crate::checkpoint::session_checkpoint_metadata_state(&self.session_id, &self.metadata)
1961 }
1962
1963 pub fn try_into_view(self) -> Result<PersistedSessionMetadataView, serde_json::Error> {
1966 PersistedSessionMetadataView::try_from_metadata_map(self.session_id, &self.metadata)
1967 }
1968}
1969
1970pub fn session_metadata_document_from_slice(
1977 bytes: &[u8],
1978) -> Result<SessionMetadataDocument, serde_json::Error> {
1979 let serde_repr: SessionMetadataDocumentSerde = serde_json::from_slice(bytes)?;
1980 session_persistence_version_authority::restore_session_envelope_version(serde_repr.version)
1981 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1982 Ok(SessionMetadataDocument {
1983 session_id: serde_repr.id,
1984 metadata: serde_repr.metadata,
1985 })
1986}
1987
1988impl Session {
1989 pub(crate) fn from_head_parts(
1997 version: u32,
1998 id: SessionId,
1999 messages: Vec<Message>,
2000 created_at: SystemTime,
2001 updated_at: SystemTime,
2002 metadata: serde_json::Map<String, serde_json::Value>,
2003 usage: Usage,
2004 ) -> Result<Self, String> {
2005 let version =
2006 session_persistence_version_authority::restore_session_envelope_version(version)
2007 .map_err(|err| err.to_string())?;
2008 Ok(Self {
2009 version,
2010 id,
2011 messages: TranscriptMessages::from_vec(messages),
2012 created_at,
2013 updated_at,
2014 transcript_history_metadata_validation: if metadata
2015 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
2016 {
2017 TranscriptHistoryMetadataValidation::RequiresValidation
2018 } else {
2019 TranscriptHistoryMetadataValidation::Validated
2020 },
2021 metadata,
2022 history_caches: Box::default(),
2023 usage,
2024 })
2025 }
2026
2027 pub(crate) fn checkpoint_digest_document(
2030 &self,
2031 ) -> Result<serde_json::Value, serde_json::Error> {
2032 let messages = canonicalize_messages_for_digest(self.messages());
2033 let mut metadata = self.metadata.clone();
2034 if self.transcript_history_metadata_validation
2035 == TranscriptHistoryMetadataValidation::RequiresValidation
2036 {
2037 compact_transcript_history_metadata_for_snapshot(
2038 &mut metadata,
2039 TranscriptGraphValidationMode::FullVerify,
2040 )
2041 .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
2042 }
2043 if let Some(history) = metadata.get_mut(SESSION_TRANSCRIPT_HISTORY_STATE_KEY) {
2044 *history = canonicalize_checkpoint_history_value(history)?;
2045 }
2046 if let Some(deferred) = metadata.get_mut(SESSION_DEFERRED_TURN_STATE_KEY) {
2047 *deferred = canonicalize_checkpoint_deferred_turn_value(deferred)?;
2048 }
2049 serde_json::to_value(SessionSerdeRef {
2050 version: self.version,
2051 id: &self.id,
2052 messages: &messages,
2053 created_at: &self.created_at,
2054 updated_at: &self.updated_at,
2055 metadata: &metadata,
2056 usage: &self.usage,
2057 })
2058 }
2059}
2060
2061pub const SESSION_SYSTEM_CONTEXT_STATE_KEY: &str = "session_system_context_state";
2063
2064pub const SESSION_DEFERRED_TURN_STATE_KEY: &str = "session_deferred_turn_state";
2066
2067pub(crate) const SESSION_PENDING_CALLBACK_BATCH_KEY: &str = "session_pending_callback_batch_v1";
2071
2072pub const SESSION_BUILD_STATE_KEY: &str = "session_build_state";
2074
2075pub const SESSION_TOOL_VISIBILITY_STATE_KEY: &str = "session_tool_visibility_state_v1";
2077
2078pub const SESSION_LIFECYCLE_TERMINAL_KEY: &str = "session_lifecycle_terminal";
2080
2081pub const SESSION_CHECKPOINT_STAMP_KEY: &str = "session_checkpoint_stamp_v1";
2083
2084pub const SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY: &str =
2091 "session_runtime_checkpoint_provenance_v1";
2092
2093pub const VIEW_IMAGE_TOOL_NAME: &str = "view_image";
2095
2096pub const SYSTEM_CONTEXT_SEPARATOR: &str = "\n\n---\n\n";
2098
2099#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2100#[error("metadata key `{key}` is reserved for session authority")]
2101pub struct ReservedSessionMetadataKey {
2102 key: String,
2103}
2104
2105impl ReservedSessionMetadataKey {
2106 fn new(key: &str) -> Self {
2107 Self {
2108 key: key.to_string(),
2109 }
2110 }
2111}
2112
2113fn is_session_authority_metadata_key(key: &str) -> bool {
2114 crate::surface_metadata::ReservedMetadataKey::is_session_authority(key)
2117}
2118
2119#[allow(clippy::panic)]
2120fn fail_closed_generated_restore(authority: &'static str, err: serde_json::Error) -> ! {
2121 tracing::error!(
2122 authority,
2123 error = %err,
2124 "generated authority rejected durable restore"
2125 );
2126 panic!("generated {authority} authority rejected durable restore: {err}");
2127}
2128
2129#[derive(Clone)]
2135pub struct SystemContextStateHandle {
2136 inner: Arc<std::sync::Mutex<SessionSystemContextState>>,
2137 boundary: Arc<SystemContextBoundaryCoordinator>,
2138}
2139
2140struct SystemContextBoundaryCoordinator {
2141 incarnation_id: uuid::Uuid,
2142 lifecycle: std::sync::Mutex<SystemContextBoundaryLifecycle>,
2143 notify: tokio::sync::Notify,
2144}
2145
2146struct SystemContextBoundaryLifecycle {
2147 actor_live: bool,
2148 next_generation: u64,
2149 next_request_id: u64,
2150 window: SystemContextBoundaryWindow,
2151}
2152
2153enum SystemContextBoundaryWindow {
2154 Closed,
2155 Open {
2156 run_id: RunId,
2157 generation: u64,
2158 request: Option<RegisteredSystemContextBoundaryRequest>,
2159 },
2160 Parked {
2161 run_id: RunId,
2162 generation: u64,
2163 request_id: u64,
2164 candidate_state: SessionSystemContextState,
2165 },
2166 Resolved {
2167 run_id: RunId,
2168 generation: u64,
2169 request_id: u64,
2170 resolution: SystemContextBoundaryResolution,
2171 },
2172 Consuming {
2177 run_id: RunId,
2178 generation: u64,
2179 request_id: Option<u64>,
2180 },
2181}
2182
2183struct RegisteredSystemContextBoundaryRequest {
2184 request_id: u64,
2185 appends: Vec<(AppendSystemContextRequest, SystemTime)>,
2186}
2187
2188#[derive(Clone)]
2189enum SystemContextBoundaryResolution {
2190 Committed,
2191 Aborted,
2192 Failed(CoreBoundaryStageError),
2193}
2194
2195impl Default for SystemContextBoundaryCoordinator {
2196 fn default() -> Self {
2197 Self {
2198 incarnation_id: uuid::Uuid::new_v4(),
2199 lifecycle: std::sync::Mutex::new(SystemContextBoundaryLifecycle {
2200 actor_live: true,
2201 next_generation: 0,
2202 next_request_id: 0,
2203 window: SystemContextBoundaryWindow::Closed,
2204 }),
2205 notify: tokio::sync::Notify::new(),
2206 }
2207 }
2208}
2209
2210impl SystemContextBoundaryCoordinator {
2211 fn lock(&self) -> std::sync::MutexGuard<'_, SystemContextBoundaryLifecycle> {
2212 self.lifecycle.lock().unwrap_or_else(|poisoned| {
2213 tracing::warn!(
2214 "system-context boundary coordinator lock poisoned; retaining exact authority"
2215 );
2216 poisoned.into_inner()
2217 })
2218 }
2219
2220 fn abort_request(&self, request_id: u64) -> Result<(), CoreBoundaryStageError> {
2221 let mut lifecycle = self.lock();
2222 let parked_owner = match &lifecycle.window {
2223 SystemContextBoundaryWindow::Parked {
2224 run_id,
2225 generation,
2226 request_id: current_request_id,
2227 ..
2228 } if *current_request_id == request_id => Some((run_id.clone(), *generation)),
2229 _ => None,
2230 };
2231 if let Some((run_id, generation)) = parked_owner {
2232 lifecycle.window = SystemContextBoundaryWindow::Resolved {
2233 run_id,
2234 generation,
2235 request_id,
2236 resolution: SystemContextBoundaryResolution::Aborted,
2237 };
2238 drop(lifecycle);
2239 self.notify.notify_waiters();
2240 return Ok(());
2241 }
2242 match &mut lifecycle.window {
2243 SystemContextBoundaryWindow::Open { request, .. }
2244 if request
2245 .as_ref()
2246 .is_some_and(|request| request.request_id == request_id) =>
2247 {
2248 *request = None;
2249 }
2250 SystemContextBoundaryWindow::Resolved {
2251 request_id: current_request_id,
2252 ..
2253 } if *current_request_id == request_id => return Ok(()),
2254 _ => {
2255 return Err(CoreBoundaryStageError::stale(format!(
2256 "boundary request {request_id} no longer owns its actor window"
2257 )));
2258 }
2259 }
2260 drop(lifecycle);
2261 self.notify.notify_waiters();
2262 Ok(())
2263 }
2264
2265 fn close_run(&self, run_id: &RunId) {
2266 let mut lifecycle = self.lock();
2267 let owns_window = match &lifecycle.window {
2268 SystemContextBoundaryWindow::Open {
2269 run_id: current, ..
2270 }
2271 | SystemContextBoundaryWindow::Parked {
2272 run_id: current, ..
2273 }
2274 | SystemContextBoundaryWindow::Resolved {
2275 run_id: current, ..
2276 }
2277 | SystemContextBoundaryWindow::Consuming {
2278 run_id: current, ..
2279 } => current == run_id,
2280 SystemContextBoundaryWindow::Closed => false,
2281 };
2282 if owns_window {
2283 lifecycle.window = SystemContextBoundaryWindow::Closed;
2284 drop(lifecycle);
2285 self.notify.notify_waiters();
2286 }
2287 }
2288
2289 fn revoke_actor(&self) {
2290 let mut lifecycle = self.lock();
2291 lifecycle.actor_live = false;
2292 lifecycle.window = SystemContextBoundaryWindow::Closed;
2293 drop(lifecycle);
2294 self.notify.notify_waiters();
2295 }
2296}
2297
2298#[must_use]
2302pub(crate) struct SystemContextBoundaryRunGuard {
2303 boundary: Arc<SystemContextBoundaryCoordinator>,
2304 run_id: RunId,
2305}
2306
2307impl Drop for SystemContextBoundaryRunGuard {
2308 fn drop(&mut self) {
2309 self.boundary.close_run(&self.run_id);
2310 }
2311}
2312
2313struct PendingSystemContextBoundaryPreparation {
2314 boundary: Arc<SystemContextBoundaryCoordinator>,
2315 request_id: u64,
2316 armed: bool,
2317}
2318
2319impl Drop for PendingSystemContextBoundaryPreparation {
2320 fn drop(&mut self) {
2321 if self.armed {
2322 let _ = self.boundary.abort_request(self.request_id);
2323 }
2324 }
2325}
2326
2327#[must_use = "model-boundary context must be consumed or dropped before opening another boundary"]
2335pub(crate) struct ModelBoundarySystemContext {
2336 state: SystemContextStateHandle,
2337 run_id: RunId,
2338 generation: u64,
2339 request_id: Option<u64>,
2340 appends: Vec<PendingSystemContextAppend>,
2341 armed: bool,
2342}
2343
2344impl ModelBoundarySystemContext {
2345 pub(crate) fn appends(&self) -> &[PendingSystemContextAppend] {
2346 &self.appends
2347 }
2348
2349 pub(crate) fn projected_state_after_consume(&self) -> SessionSystemContextState {
2353 let mut projected = self.state.snapshot();
2354 projected.mark_pending_applied();
2355 projected
2356 }
2357
2358 pub(crate) fn consume(
2359 mut self,
2360 ) -> Result<Vec<PendingSystemContextAppend>, CoreBoundaryStageError> {
2361 self.state.finish_model_boundary_consumption(
2362 &self.run_id,
2363 self.generation,
2364 self.request_id,
2365 true,
2366 )?;
2367 self.armed = false;
2368 Ok(std::mem::take(&mut self.appends))
2369 }
2370}
2371
2372impl Drop for ModelBoundarySystemContext {
2373 fn drop(&mut self) {
2374 if self.armed {
2375 let _ = self.state.finish_model_boundary_consumption(
2376 &self.run_id,
2377 self.generation,
2378 self.request_id,
2379 false,
2380 );
2381 self.armed = false;
2382 }
2383 }
2384}
2385
2386#[must_use = "prepared system context must be committed or aborted"]
2397pub struct PreparedSystemContextBoundary {
2398 state: SystemContextStateHandle,
2399 expected_run_id: RunId,
2400 generation: u64,
2401 request_id: u64,
2402 candidate_state: SessionSystemContextState,
2403 armed: bool,
2404 _not_sync: std::marker::PhantomData<std::cell::Cell<()>>,
2408}
2409
2410impl std::fmt::Debug for PreparedSystemContextBoundary {
2411 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2412 formatter
2413 .debug_struct("PreparedSystemContextBoundary")
2414 .field("actor_incarnation", &self.state.boundary.incarnation_id)
2415 .field("expected_run_id", &self.expected_run_id)
2416 .field("generation", &self.generation)
2417 .field("request_id", &self.request_id)
2418 .finish_non_exhaustive()
2419 }
2420}
2421
2422impl PreparedSystemContextBoundary {
2423 #[must_use]
2424 pub fn expected_run_id(&self) -> &RunId {
2425 &self.expected_run_id
2426 }
2427
2428 #[must_use]
2429 pub fn boundary_generation(&self) -> u64 {
2430 self.generation
2431 }
2432
2433 #[must_use]
2434 pub fn candidate_state(&self) -> &SessionSystemContextState {
2435 &self.candidate_state
2436 }
2437
2438 pub fn into_stage_output(
2442 self,
2443 session_snapshot: Option<Vec<u8>>,
2444 ) -> crate::lifecycle::CoreBoundaryStageOutput {
2445 crate::lifecycle::CoreBoundaryStageOutput::prepared(session_snapshot, Box::new(self))
2446 }
2447
2448 fn resolve(
2449 &mut self,
2450 resolution: SystemContextBoundaryResolution,
2451 ) -> Result<(), CoreBoundaryStageError> {
2452 if !self.armed {
2453 return Err(CoreBoundaryStageError::stale(
2454 "prepared boundary authority was already resolved",
2455 ));
2456 }
2457 let mut lifecycle = self.state.boundary.lock();
2458 if !lifecycle.actor_live {
2459 self.armed = false;
2460 return Err(CoreBoundaryStageError::stale(format!(
2461 "actor incarnation {} was revoked",
2462 self.state.boundary.incarnation_id
2463 )));
2464 }
2465 let matches_exact = matches!(
2466 &lifecycle.window,
2467 SystemContextBoundaryWindow::Parked {
2468 run_id,
2469 generation,
2470 request_id,
2471 ..
2472 } if run_id == &self.expected_run_id
2473 && *generation == self.generation
2474 && *request_id == self.request_id
2475 );
2476 if !matches_exact {
2477 self.armed = false;
2478 return Err(CoreBoundaryStageError::stale(format!(
2479 "actor/run/boundary witness no longer matches request {}",
2480 self.request_id
2481 )));
2482 }
2483 if matches!(&resolution, SystemContextBoundaryResolution::Committed) {
2484 let mut state = self
2485 .state
2486 .inner
2487 .lock()
2488 .unwrap_or_else(std::sync::PoisonError::into_inner);
2489 *state = self.candidate_state.clone();
2490 }
2491 lifecycle.window = SystemContextBoundaryWindow::Resolved {
2492 run_id: self.expected_run_id.clone(),
2493 generation: self.generation,
2494 request_id: self.request_id,
2495 resolution,
2496 };
2497 self.armed = false;
2498 drop(lifecycle);
2499 self.state.boundary.notify.notify_waiters();
2500 Ok(())
2501 }
2502}
2503
2504impl crate::lifecycle::core_executor::CoreBoundaryStageCommitAuthority
2505 for PreparedSystemContextBoundary
2506{
2507 fn commit(&mut self) -> Result<(), CoreBoundaryStageError> {
2508 self.resolve(SystemContextBoundaryResolution::Committed)
2509 }
2510
2511 fn abort(&mut self) -> Result<(), CoreBoundaryStageError> {
2512 self.resolve(SystemContextBoundaryResolution::Aborted)
2513 }
2514}
2515
2516impl Drop for PreparedSystemContextBoundary {
2517 fn drop(&mut self) {
2518 if self.armed {
2519 let _ = self.state.boundary.abort_request(self.request_id);
2520 self.armed = false;
2521 }
2522 }
2523}
2524
2525impl std::fmt::Debug for SystemContextStateHandle {
2526 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2527 f.debug_struct("SystemContextStateHandle")
2528 .field("inner", &"<Arc<Mutex<SessionSystemContextState>>>")
2529 .field("actor_incarnation", &self.boundary.incarnation_id)
2530 .finish()
2531 }
2532}
2533
2534impl SystemContextStateHandle {
2535 fn boundary_reserves_state(lifecycle: &SystemContextBoundaryLifecycle) -> bool {
2536 matches!(
2537 &lifecycle.window,
2538 SystemContextBoundaryWindow::Parked { .. }
2539 | SystemContextBoundaryWindow::Resolved { .. }
2540 | SystemContextBoundaryWindow::Consuming { .. }
2541 )
2542 }
2543
2544 pub fn new(state: SessionSystemContextState) -> Result<Self, serde_json::Error> {
2545 let state = system_context_authority::restore_system_context_state(state)
2546 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
2547 Ok(Self {
2548 inner: Arc::new(std::sync::Mutex::new(state)),
2549 boundary: Arc::new(SystemContextBoundaryCoordinator::default()),
2550 })
2551 }
2552
2553 pub(crate) fn begin_boundary_run(
2556 &self,
2557 run_id: RunId,
2558 ) -> Result<SystemContextBoundaryRunGuard, CoreBoundaryStageError> {
2559 self.open_next_boundary(&run_id)?;
2560 Ok(SystemContextBoundaryRunGuard {
2561 boundary: Arc::clone(&self.boundary),
2562 run_id,
2563 })
2564 }
2565
2566 pub(crate) fn open_next_boundary(&self, run_id: &RunId) -> Result<u64, CoreBoundaryStageError> {
2570 let mut lifecycle = self.boundary.lock();
2571 if !lifecycle.actor_live {
2572 return Err(CoreBoundaryStageError::stale(format!(
2573 "actor incarnation {} was revoked",
2574 self.boundary.incarnation_id
2575 )));
2576 }
2577 match &lifecycle.window {
2578 SystemContextBoundaryWindow::Open {
2579 run_id: current,
2580 generation,
2581 ..
2582 } if current == run_id => return Ok(*generation),
2583 SystemContextBoundaryWindow::Parked { .. }
2584 | SystemContextBoundaryWindow::Resolved { .. }
2585 | SystemContextBoundaryWindow::Consuming { .. } => {
2586 return Err(CoreBoundaryStageError::fault(
2587 "runner attempted to open a new boundary while the prior boundary was unresolved",
2588 ));
2589 }
2590 SystemContextBoundaryWindow::Open {
2591 run_id: current, ..
2592 } => {
2593 return Err(CoreBoundaryStageError::stale(format!(
2594 "run {run_id} cannot replace still-open boundary owned by {current}"
2595 )));
2596 }
2597 SystemContextBoundaryWindow::Closed => {}
2598 }
2599 lifecycle.next_generation = lifecycle
2600 .next_generation
2601 .checked_add(1)
2602 .ok_or_else(|| CoreBoundaryStageError::fault("boundary generation overflow"))?;
2603 let generation = lifecycle.next_generation;
2604 lifecycle.window = SystemContextBoundaryWindow::Open {
2605 run_id: run_id.clone(),
2606 generation,
2607 request: None,
2608 };
2609 drop(lifecycle);
2610 self.boundary.notify.notify_waiters();
2611 Ok(generation)
2612 }
2613
2614 pub async fn prepare_active_turn_boundary(
2619 &self,
2620 expected_run_id: &RunId,
2621 appends: Vec<PendingSystemContextAppend>,
2622 ) -> Result<PreparedSystemContextBoundary, CoreBoundaryStageError> {
2623 if appends.is_empty() {
2624 return Err(CoreBoundaryStageError::fault(
2625 "boundary preparation requires at least one context append",
2626 ));
2627 }
2628 let stage_inputs = appends
2629 .into_iter()
2630 .map(|append| {
2631 (
2632 AppendSystemContextRequest {
2633 content: append.content,
2634 source: append.source,
2635 idempotency_key: append.idempotency_key,
2636 source_kind: append.source_kind,
2637 peer_response_terminal: append.peer_response_terminal,
2638 },
2639 append.accepted_at,
2640 )
2641 })
2642 .collect::<Vec<_>>();
2643
2644 let request_id = {
2645 let mut lifecycle = self.boundary.lock();
2646 if !lifecycle.actor_live {
2647 return Err(CoreBoundaryStageError::stale(format!(
2648 "actor incarnation {} was revoked",
2649 self.boundary.incarnation_id
2650 )));
2651 }
2652 let (run_id, request) = match &mut lifecycle.window {
2653 SystemContextBoundaryWindow::Open {
2654 run_id, request, ..
2655 } => (run_id, request),
2656 SystemContextBoundaryWindow::Closed => {
2657 return Err(CoreBoundaryStageError::unavailable(format!(
2658 "run {expected_run_id} has no open cooperative model boundary"
2659 )));
2660 }
2661 SystemContextBoundaryWindow::Parked { .. }
2662 | SystemContextBoundaryWindow::Resolved { .. }
2663 | SystemContextBoundaryWindow::Consuming { .. } => {
2664 return Err(CoreBoundaryStageError::unavailable(format!(
2665 "the open boundary for run {expected_run_id} was already claimed or consumed"
2666 )));
2667 }
2668 };
2669 if run_id != expected_run_id {
2670 return Err(CoreBoundaryStageError::stale(format!(
2671 "open boundary belongs to run {run_id}, not {expected_run_id}"
2672 )));
2673 }
2674 if request.is_some() {
2675 return Err(CoreBoundaryStageError::unavailable(format!(
2676 "the next boundary for run {expected_run_id} already has a preparation"
2677 )));
2678 }
2679 let state = self
2682 .inner
2683 .lock()
2684 .unwrap_or_else(std::sync::PoisonError::into_inner);
2685 let mut candidate = state.clone();
2686 for (append, accepted_at) in &stage_inputs {
2687 candidate
2688 .stage_active_turn_append(append, *accepted_at)
2689 .map_err(|error| CoreBoundaryStageError::fault(error.to_string()))?;
2690 }
2691 drop(state);
2692 lifecycle.next_request_id = lifecycle
2693 .next_request_id
2694 .checked_add(1)
2695 .ok_or_else(|| CoreBoundaryStageError::fault("boundary request id overflow"))?;
2696 let request_id = lifecycle.next_request_id;
2697 let SystemContextBoundaryWindow::Open { request, .. } = &mut lifecycle.window else {
2698 return Err(CoreBoundaryStageError::fault(
2699 "boundary window changed while registering preparation",
2700 ));
2701 };
2702 *request = Some(RegisteredSystemContextBoundaryRequest {
2703 request_id,
2704 appends: stage_inputs,
2705 });
2706 request_id
2707 };
2708
2709 let mut pending = PendingSystemContextBoundaryPreparation {
2710 boundary: Arc::clone(&self.boundary),
2711 request_id,
2712 armed: true,
2713 };
2714 self.boundary.notify.notify_waiters();
2715
2716 loop {
2717 let notified = self.boundary.notify.notified();
2718 tokio::pin!(notified);
2719 notified.as_mut().enable();
2720 let poll = {
2721 let lifecycle = self.boundary.lock();
2722 if lifecycle.actor_live {
2723 match &lifecycle.window {
2724 SystemContextBoundaryWindow::Parked {
2725 run_id,
2726 generation,
2727 request_id: parked_request_id,
2728 candidate_state,
2729 } if *parked_request_id == request_id => {
2730 Ok(Some(PreparedSystemContextBoundary {
2731 state: self.clone(),
2732 expected_run_id: run_id.clone(),
2733 generation: *generation,
2734 request_id,
2735 candidate_state: candidate_state.clone(),
2736 armed: true,
2737 _not_sync: std::marker::PhantomData,
2738 }))
2739 }
2740 SystemContextBoundaryWindow::Open { request, .. }
2741 if request
2742 .as_ref()
2743 .is_some_and(|request| request.request_id == request_id) =>
2744 {
2745 Ok(None)
2746 }
2747 SystemContextBoundaryWindow::Resolved {
2748 request_id: resolved_request_id,
2749 resolution,
2750 ..
2751 } if *resolved_request_id == request_id => match resolution {
2752 SystemContextBoundaryResolution::Failed(error) => Err(error.clone()),
2753 SystemContextBoundaryResolution::Committed
2754 | SystemContextBoundaryResolution::Aborted => {
2755 Err(CoreBoundaryStageError::stale(format!(
2756 "boundary request {request_id} resolved before its authority was delivered"
2757 )))
2758 }
2759 },
2760 _ => Err(CoreBoundaryStageError::unavailable(format!(
2761 "run {expected_run_id} ended before boundary request {request_id} parked"
2762 ))),
2763 }
2764 } else {
2765 Err(CoreBoundaryStageError::stale(format!(
2766 "actor incarnation {} was revoked while preparing boundary",
2767 self.boundary.incarnation_id
2768 )))
2769 }
2770 };
2771 match poll {
2772 Ok(Some(prepared)) => {
2773 pending.armed = false;
2774 return Ok(prepared);
2775 }
2776 Ok(None) => notified.as_mut().await,
2777 Err(error) => return Err(error),
2778 }
2779 }
2780 }
2781
2782 pub(crate) async fn take_pending_at_exact_boundary(
2788 &self,
2789 run_id: &RunId,
2790 ) -> Result<ModelBoundarySystemContext, CoreBoundaryStageError> {
2791 let parked_request_id;
2792 {
2793 let mut lifecycle = self.boundary.lock();
2794 if !lifecycle.actor_live {
2795 return Err(CoreBoundaryStageError::stale(format!(
2796 "actor incarnation {} was revoked",
2797 self.boundary.incarnation_id
2798 )));
2799 }
2800 let (generation, request) = match &mut lifecycle.window {
2801 SystemContextBoundaryWindow::Open {
2802 run_id: current,
2803 generation,
2804 request,
2805 } if current == run_id => (*generation, request.take()),
2806 SystemContextBoundaryWindow::Open {
2807 run_id: current, ..
2808 } => {
2809 return Err(CoreBoundaryStageError::stale(format!(
2810 "runner {run_id} reached boundary owned by {current}"
2811 )));
2812 }
2813 SystemContextBoundaryWindow::Closed => {
2814 return Err(CoreBoundaryStageError::unavailable(format!(
2815 "run {run_id} reached a boundary with no open generation"
2816 )));
2817 }
2818 SystemContextBoundaryWindow::Parked { .. }
2819 | SystemContextBoundaryWindow::Resolved { .. }
2820 | SystemContextBoundaryWindow::Consuming { .. } => {
2821 return Err(CoreBoundaryStageError::fault(
2822 "runner re-entered an unresolved model boundary",
2823 ));
2824 }
2825 };
2826 if let Some(request) = request {
2827 let RegisteredSystemContextBoundaryRequest {
2828 request_id,
2829 appends,
2830 } = request;
2831 let state = self
2832 .inner
2833 .lock()
2834 .unwrap_or_else(std::sync::PoisonError::into_inner);
2835 let mut candidate_state = state.clone();
2836 let candidate_result = appends.into_iter().try_for_each(|(append, accepted_at)| {
2837 candidate_state
2838 .stage_active_turn_append(&append, accepted_at)
2839 .map(|_| ())
2840 });
2841 if let Err(error) = candidate_result {
2842 drop(state);
2843 let error = CoreBoundaryStageError::fault(error.to_string());
2844 lifecycle.window = SystemContextBoundaryWindow::Resolved {
2845 run_id: run_id.clone(),
2846 generation,
2847 request_id,
2848 resolution: SystemContextBoundaryResolution::Failed(error.clone()),
2849 };
2850 drop(lifecycle);
2851 self.boundary.notify.notify_waiters();
2852 return Err(error);
2853 }
2854 drop(state);
2855 parked_request_id = request_id;
2856 lifecycle.window = SystemContextBoundaryWindow::Parked {
2857 run_id: run_id.clone(),
2858 generation,
2859 request_id,
2860 candidate_state,
2861 };
2862 } else {
2863 let state = self
2864 .inner
2865 .lock()
2866 .unwrap_or_else(std::sync::PoisonError::into_inner);
2867 let pending = state.pending().to_vec();
2868 drop(state);
2869 lifecycle.window = SystemContextBoundaryWindow::Consuming {
2870 run_id: run_id.clone(),
2871 generation,
2872 request_id: None,
2873 };
2874 return Ok(ModelBoundarySystemContext {
2875 state: self.clone(),
2876 run_id: run_id.clone(),
2877 generation,
2878 request_id: None,
2879 appends: pending,
2880 armed: true,
2881 });
2882 }
2883 }
2884 self.boundary.notify.notify_waiters();
2885
2886 let request_id = parked_request_id;
2887 struct RunnerParkGuard {
2888 boundary: Arc<SystemContextBoundaryCoordinator>,
2889 request_id: u64,
2890 armed: bool,
2891 }
2892 impl Drop for RunnerParkGuard {
2893 fn drop(&mut self) {
2894 if self.armed {
2895 let _ = self.boundary.abort_request(self.request_id);
2896 }
2897 }
2898 }
2899 let mut park_guard = RunnerParkGuard {
2900 boundary: Arc::clone(&self.boundary),
2901 request_id,
2902 armed: true,
2903 };
2904
2905 loop {
2906 let notified = self.boundary.notify.notified();
2907 tokio::pin!(notified);
2908 notified.as_mut().enable();
2909 let poll = {
2910 let mut lifecycle = self.boundary.lock();
2911 if lifecycle.actor_live {
2912 match &lifecycle.window {
2913 SystemContextBoundaryWindow::Parked {
2914 request_id: parked_request_id,
2915 ..
2916 } if *parked_request_id == request_id => Ok(None),
2917 SystemContextBoundaryWindow::Resolved {
2918 run_id: resolved_run_id,
2919 generation,
2920 request_id: resolved_request_id,
2921 resolution,
2922 } if resolved_run_id == run_id && *resolved_request_id == request_id => {
2923 let resolution = resolution.clone();
2924 let generation = *generation;
2925 if let SystemContextBoundaryResolution::Failed(error) = resolution {
2926 Err(error)
2927 } else {
2928 let pending = {
2929 let state = self
2930 .inner
2931 .lock()
2932 .unwrap_or_else(std::sync::PoisonError::into_inner);
2933 state.pending().to_vec()
2934 };
2935 lifecycle.window = SystemContextBoundaryWindow::Consuming {
2936 run_id: run_id.clone(),
2937 generation,
2938 request_id: Some(request_id),
2939 };
2940 Ok(Some((
2941 ModelBoundarySystemContext {
2942 state: self.clone(),
2943 run_id: run_id.clone(),
2944 generation,
2945 request_id: Some(request_id),
2946 appends: pending,
2947 armed: true,
2948 },
2949 resolution,
2950 )))
2951 }
2952 }
2953 _ => Err(CoreBoundaryStageError::stale(format!(
2954 "parked boundary request {request_id} lost exact run/generation authority"
2955 ))),
2956 }
2957 } else {
2958 Err(CoreBoundaryStageError::stale(format!(
2959 "actor incarnation {} was revoked while parked",
2960 self.boundary.incarnation_id
2961 )))
2962 }
2963 };
2964 match poll {
2965 Ok(Some((context, resolution))) => {
2966 park_guard.armed = false;
2967 self.boundary.notify.notify_waiters();
2968 if matches!(resolution, SystemContextBoundaryResolution::Aborted) {
2969 tracing::debug!(
2970 actor_incarnation = %self.boundary.incarnation_id,
2971 run_id = %run_id,
2972 request_id,
2973 "exact model-boundary preparation aborted; consuming ordinary pending context only"
2974 );
2975 }
2976 return Ok(context);
2977 }
2978 Ok(None) => notified.as_mut().await,
2979 Err(error) => {
2980 park_guard.armed = false;
2981 return Err(error);
2982 }
2983 }
2984 }
2985 }
2986
2987 fn finish_model_boundary_consumption(
2988 &self,
2989 run_id: &RunId,
2990 generation: u64,
2991 request_id: Option<u64>,
2992 apply: bool,
2993 ) -> Result<(), CoreBoundaryStageError> {
2994 let mut lifecycle = self.boundary.lock();
2995 if !lifecycle.actor_live {
2996 return Err(CoreBoundaryStageError::stale(format!(
2997 "actor incarnation {} was revoked before model-boundary consumption",
2998 self.boundary.incarnation_id
2999 )));
3000 }
3001 let matches_exact = matches!(
3002 &lifecycle.window,
3003 SystemContextBoundaryWindow::Consuming {
3004 run_id: current_run_id,
3005 generation: current_generation,
3006 request_id: current_request_id,
3007 } if current_run_id == run_id
3008 && *current_generation == generation
3009 && *current_request_id == request_id
3010 );
3011 if !matches_exact {
3012 return Err(CoreBoundaryStageError::stale(format!(
3013 "runner model-boundary witness for run {run_id} generation {generation} is no longer current"
3014 )));
3015 }
3016 if apply {
3017 let mut state = self
3018 .inner
3019 .lock()
3020 .unwrap_or_else(std::sync::PoisonError::into_inner);
3021 state.mark_pending_applied();
3022 }
3023 lifecycle.window = SystemContextBoundaryWindow::Closed;
3024 drop(lifecycle);
3025 self.boundary.notify.notify_waiters();
3026 Ok(())
3027 }
3028
3029 pub fn revoke_boundary_actor(&self) {
3033 self.boundary.revoke_actor();
3034 }
3035
3036 pub fn snapshot(&self) -> SessionSystemContextState {
3037 match self.inner.lock() {
3038 Ok(guard) => guard.clone(),
3039 Err(poisoned) => {
3040 tracing::warn!("system-context state lock poisoned while reading snapshot");
3041 poisoned.into_inner().clone()
3042 }
3043 }
3044 }
3045
3046 pub fn replace_from_generated_restore(
3047 &self,
3048 state: SessionSystemContextState,
3049 ) -> Result<(), serde_json::Error> {
3050 let state = system_context_authority::restore_system_context_state(state)
3051 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
3052 let boundary = self.boundary.lock();
3053 if Self::boundary_reserves_state(&boundary) {
3054 return Err(<serde_json::Error as serde::de::Error>::custom(
3055 "system-context state is reserved by an exact parked boundary",
3056 ));
3057 }
3058 match self.inner.lock() {
3059 Ok(mut guard) => {
3060 *guard = state;
3061 }
3062 Err(poisoned) => {
3063 tracing::warn!("system-context state lock poisoned while restoring state");
3064 *poisoned.into_inner() = state;
3065 }
3066 }
3067 Ok(())
3068 }
3069
3070 pub fn replace_from_generated_restore_if_changed(
3071 &self,
3072 state: SessionSystemContextState,
3073 ) -> Result<bool, serde_json::Error> {
3074 let state = system_context_authority::restore_system_context_state(state)
3075 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
3076 let boundary = self.boundary.lock();
3077 if Self::boundary_reserves_state(&boundary) {
3078 return Err(<serde_json::Error as serde::de::Error>::custom(
3079 "system-context state is reserved by an exact parked boundary",
3080 ));
3081 }
3082 let mut guard = match self.inner.lock() {
3083 Ok(guard) => guard,
3084 Err(poisoned) => {
3085 tracing::warn!(
3086 "system-context state lock poisoned while replacing generated-restored state"
3087 );
3088 poisoned.into_inner()
3089 }
3090 };
3091 if *guard == state {
3092 return Ok(false);
3093 }
3094 *guard = state;
3095 Ok(true)
3096 }
3097
3098 pub fn replace_from_generated_restore_if_current(
3099 &self,
3100 current: &SessionSystemContextState,
3101 replacement: SessionSystemContextState,
3102 ) -> Result<bool, serde_json::Error> {
3103 let replacement = system_context_authority::restore_system_context_state(replacement)
3104 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
3105 let boundary = self.boundary.lock();
3106 if Self::boundary_reserves_state(&boundary) {
3107 return Err(<serde_json::Error as serde::de::Error>::custom(
3108 "system-context state is reserved by an exact parked boundary",
3109 ));
3110 }
3111 let mut guard = match self.inner.lock() {
3112 Ok(guard) => guard,
3113 Err(poisoned) => {
3114 tracing::warn!(
3115 "system-context state lock poisoned while conditionally replacing generated-restored state"
3116 );
3117 poisoned.into_inner()
3118 }
3119 };
3120 if *guard != *current {
3121 return Ok(false);
3122 }
3123 *guard = replacement;
3124 Ok(true)
3125 }
3126
3127 pub fn stage_append_with_snapshot(
3128 &self,
3129 req: &AppendSystemContextRequest,
3130 accepted_at: SystemTime,
3131 ) -> Result<
3132 (
3133 crate::service::AppendSystemContextStatus,
3134 SessionSystemContextState,
3135 SessionSystemContextState,
3136 ),
3137 SystemContextStageError,
3138 > {
3139 let boundary = self.boundary.lock();
3140 if Self::boundary_reserves_state(&boundary) {
3141 return Err(SystemContextStageError::InvalidRequest(
3142 "system-context state is reserved by an exact parked boundary".to_string(),
3143 ));
3144 }
3145 let mut guard = match self.inner.lock() {
3146 Ok(guard) => guard,
3147 Err(poisoned) => {
3148 tracing::warn!("system-context state lock poisoned while staging append");
3149 poisoned.into_inner()
3150 }
3151 };
3152 let snapshot = guard.clone();
3153 let status = guard.stage_append(req, accepted_at)?;
3154 let staged = guard.clone();
3155 Ok((status, snapshot, staged))
3156 }
3157
3158 pub fn stage_active_turn_appends_with_snapshot(
3159 &self,
3160 appends: Vec<(AppendSystemContextRequest, SystemTime)>,
3161 ) -> Result<(SessionSystemContextState, SessionSystemContextState), SystemContextStageError>
3162 {
3163 let boundary = self.boundary.lock();
3164 if Self::boundary_reserves_state(&boundary) {
3165 return Err(SystemContextStageError::InvalidRequest(
3166 "system-context state is reserved by an exact parked boundary".to_string(),
3167 ));
3168 }
3169 let mut guard = match self.inner.lock() {
3170 Ok(guard) => guard,
3171 Err(poisoned) => {
3172 tracing::warn!(
3173 "system-context state lock poisoned while staging active-turn appends"
3174 );
3175 poisoned.into_inner()
3176 }
3177 };
3178 let snapshot = guard.clone();
3179 let mut candidate = snapshot.clone();
3180 for (req, accepted_at) in appends {
3181 candidate.stage_active_turn_append(&req, accepted_at)?;
3182 }
3183 *guard = candidate.clone();
3184 let staged = candidate;
3185 Ok((snapshot, staged))
3186 }
3187
3188 pub fn discard_unapplied_active_turn_pending(&self) -> Result<usize, CoreBoundaryStageError> {
3189 let boundary = self.boundary.lock();
3190 if Self::boundary_reserves_state(&boundary) {
3191 return Err(CoreBoundaryStageError::fault(format!(
3192 "cannot discard active-turn system context while exact actor incarnation {} owns a parked or consuming boundary",
3193 self.boundary.incarnation_id
3194 )));
3195 }
3196 let discarded = match self.inner.lock() {
3197 Ok(mut guard) => guard.discard_unapplied_active_turn_pending(),
3198 Err(poisoned) => {
3199 tracing::warn!(
3200 "system-context state lock poisoned while discarding active-turn context"
3201 );
3202 poisoned
3203 .into_inner()
3204 .discard_unapplied_active_turn_pending()
3205 }
3206 };
3207 Ok(discarded.len())
3208 }
3209
3210 pub fn discard_active_turn_pending_by_keys(
3211 &self,
3212 idempotency_keys: &[String],
3213 ) -> Result<Vec<PendingSystemContextAppend>, CoreBoundaryStageError> {
3214 let boundary = self.boundary.lock();
3215 if Self::boundary_reserves_state(&boundary) {
3216 return Err(CoreBoundaryStageError::fault(format!(
3217 "cannot discard keyed active-turn system context while exact actor incarnation {} owns a parked or consuming boundary",
3218 self.boundary.incarnation_id
3219 )));
3220 }
3221 let discarded = match self.inner.lock() {
3222 Ok(mut guard) => guard.discard_active_turn_pending_by_keys(idempotency_keys),
3223 Err(poisoned) => {
3224 tracing::warn!(
3225 "system-context state lock poisoned while discarding active-turn pending appends"
3226 );
3227 poisoned
3228 .into_inner()
3229 .discard_active_turn_pending_by_keys(idempotency_keys)
3230 }
3231 };
3232 Ok(discarded)
3233 }
3234
3235 pub fn stage_active_turn_append(
3236 &self,
3237 req: &AppendSystemContextRequest,
3238 accepted_at: SystemTime,
3239 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
3240 let boundary = self.boundary.lock();
3241 if Self::boundary_reserves_state(&boundary) {
3242 return Err(SystemContextStageError::InvalidRequest(
3243 "system-context state is reserved by an exact parked boundary".to_string(),
3244 ));
3245 }
3246 match self.inner.lock() {
3247 Ok(mut guard) => guard.stage_active_turn_append(req, accepted_at),
3248 Err(poisoned) => {
3249 tracing::warn!(
3250 "system-context state lock poisoned while staging active-turn context"
3251 );
3252 poisoned
3253 .into_inner()
3254 .stage_active_turn_append(req, accepted_at)
3255 }
3256 }
3257 }
3258}
3259
3260#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
3264#[serde(rename_all = "snake_case")]
3265pub struct SessionSystemContextState {
3266 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3267 pub(crate) pending: Vec<PendingSystemContextAppend>,
3268 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3269 pub(crate) applied: Vec<PendingSystemContextAppend>,
3270 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
3271 pub(crate) seen: std::collections::BTreeMap<String, SeenSystemContextKey>,
3272 #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
3275 pub(crate) active_turn_pending_keys: std::collections::BTreeSet<String>,
3276 #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
3282 pub(crate) active_turn_pending_indices: std::collections::BTreeSet<u64>,
3283}
3284
3285#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
3295#[serde(rename_all = "snake_case")]
3296pub enum SystemContextSource {
3297 #[default]
3299 Normal,
3300 RuntimeSteer,
3303}
3304
3305impl From<SystemContextSource> for session_document::SystemContextSource {
3306 fn from(value: SystemContextSource) -> Self {
3307 match value {
3308 SystemContextSource::Normal => Self::Normal,
3309 SystemContextSource::RuntimeSteer => Self::RuntimeSteer,
3310 }
3311 }
3312}
3313
3314impl SystemContextSource {
3315 #[must_use]
3318 pub fn is_normal(&self) -> bool {
3319 matches!(self, Self::Normal)
3320 }
3321
3322 #[must_use]
3324 pub fn is_runtime_steer(&self) -> bool {
3325 matches!(self, Self::RuntimeSteer)
3326 }
3327}
3328
3329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3333#[serde(rename_all = "snake_case")]
3334pub struct PendingSystemContextAppend {
3335 pub content: crate::lifecycle::run_primitive::CoreRenderable,
3343 #[serde(default, skip_serializing_if = "Option::is_none")]
3344 pub source: Option<String>,
3345 #[serde(default, skip_serializing_if = "Option::is_none")]
3346 pub idempotency_key: Option<String>,
3347 #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
3349 pub source_kind: SystemContextSource,
3350 #[serde(default, skip_serializing_if = "Option::is_none")]
3358 pub peer_response_terminal: Option<crate::handles::PeerResponseTerminalFact>,
3359 pub accepted_at: SystemTime,
3360}
3361
3362#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3377#[serde(rename_all = "snake_case")]
3378pub enum SessionLifecycleTerminal {
3379 Active,
3381 Archived,
3383}
3384
3385impl SessionLifecycleTerminal {
3386 #[must_use]
3388 pub fn is_archived(self) -> bool {
3389 matches!(self, Self::Archived)
3390 }
3391}
3392
3393impl From<SessionLifecycleTerminal> for session_document::SessionDocumentLifecycle {
3394 fn from(value: SessionLifecycleTerminal) -> Self {
3395 match value {
3396 SessionLifecycleTerminal::Active => Self::Active,
3397 SessionLifecycleTerminal::Archived => Self::Archived,
3398 }
3399 }
3400}
3401
3402impl From<session_document::SessionDocumentLifecycle> for SessionLifecycleTerminal {
3403 fn from(value: session_document::SessionDocumentLifecycle) -> Self {
3404 match value {
3405 session_document::SessionDocumentLifecycle::Active => Self::Active,
3406 session_document::SessionDocumentLifecycle::Archived => Self::Archived,
3407 }
3408 }
3409}
3410
3411#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
3413#[serde(rename_all = "snake_case")]
3414pub struct SessionDeferredTurnState {
3415 #[serde(default, skip_serializing_if = "DeferredFirstTurnPhase::is_inactive")]
3416 pub(crate) first_turn_phase: DeferredFirstTurnPhase,
3417 #[serde(default, skip_serializing_if = "Option::is_none")]
3418 pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
3419 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3420 pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
3421}
3422
3423#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
3425#[serde(rename_all = "snake_case")]
3426pub enum DeferredFirstTurnPhase {
3427 #[default]
3429 Inactive,
3430 Pending,
3432 Consumed,
3434}
3435
3436impl DeferredFirstTurnPhase {
3437 pub fn is_inactive(&self) -> bool {
3438 matches!(self, Self::Inactive)
3439 }
3440}
3441
3442impl From<DeferredFirstTurnPhase> for session_document::SessionFirstTurnPhase {
3443 fn from(value: DeferredFirstTurnPhase) -> Self {
3444 match value {
3445 DeferredFirstTurnPhase::Inactive => Self::Inactive,
3446 DeferredFirstTurnPhase::Pending => Self::Pending,
3447 DeferredFirstTurnPhase::Consumed => Self::Consumed,
3448 }
3449 }
3450}
3451
3452impl From<session_document::SessionFirstTurnPhase> for DeferredFirstTurnPhase {
3453 fn from(value: session_document::SessionFirstTurnPhase) -> Self {
3454 match value {
3455 session_document::SessionFirstTurnPhase::Inactive => Self::Inactive,
3456 session_document::SessionFirstTurnPhase::Pending => Self::Pending,
3457 session_document::SessionFirstTurnPhase::Consumed => Self::Consumed,
3458 }
3459 }
3460}
3461
3462fn is_default_hook_run_overrides(value: &crate::HookRunOverrides) -> bool {
3463 value == &crate::HookRunOverrides::default()
3464}
3465
3466fn is_default_call_timeout_override(value: &crate::CallTimeoutOverride) -> bool {
3467 value == &crate::CallTimeoutOverride::default()
3468}
3469
3470fn is_tool_filter_all(value: &ToolFilter) -> bool {
3471 matches!(value, ToolFilter::All)
3472}
3473
3474fn is_zero(value: &u64) -> bool {
3475 *value == 0
3476}
3477
3478pub fn capability_base_filter_for_image_tool_results(image_tool_results: bool) -> ToolFilter {
3480 if image_tool_results {
3481 ToolFilter::All
3482 } else {
3483 ToolFilter::Deny([VIEW_IMAGE_TOOL_NAME.to_string()].into_iter().collect())
3484 }
3485}
3486
3487#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
3494#[serde(rename_all = "snake_case")]
3495pub struct ToolVisibilityWitness {
3496 #[serde(default, skip_serializing_if = "Option::is_none")]
3497 pub last_seen_provenance: Option<ToolProvenance>,
3498}
3499
3500impl ToolVisibilityWitness {
3501 pub fn has_identity_witness(&self) -> bool {
3502 self.last_seen_provenance.is_some()
3503 }
3504}
3505
3506#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3512#[serde(rename_all = "snake_case")]
3513pub struct DeferredToolLoadAuthority {
3514 pub name: ToolName,
3515 pub witness: ToolVisibilityWitness,
3516}
3517
3518impl DeferredToolLoadAuthority {
3519 pub fn new(name: impl Into<ToolName>, witness: ToolVisibilityWitness) -> Self {
3520 Self {
3521 name: name.into(),
3522 witness,
3523 }
3524 }
3525
3526 pub fn into_parts(self) -> (ToolName, ToolVisibilityWitness) {
3527 (self.name, self.witness)
3528 }
3529}
3530
3531#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
3534#[serde(rename_all = "snake_case")]
3535pub struct WitnessedToolFilter {
3536 pub filter: ToolFilter,
3537 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3538 pub witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3539}
3540
3541impl WitnessedToolFilter {
3542 pub fn new(filter: ToolFilter, witnesses: BTreeMap<ToolName, ToolVisibilityWitness>) -> Self {
3543 Self { filter, witnesses }
3544 }
3545
3546 pub fn into_parts(self) -> (ToolFilter, BTreeMap<ToolName, ToolVisibilityWitness>) {
3547 (self.filter, self.witnesses)
3548 }
3549}
3550
3551#[derive(Debug, Clone, PartialEq, Eq)]
3558pub struct InheritedToolVisibilityAuthority {
3559 filter: ToolFilter,
3560 witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3561}
3562
3563impl InheritedToolVisibilityAuthority {
3564 pub(crate) fn from_generated_composition_authority(
3565 filter: ToolFilter,
3566 witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3567 ) -> Self {
3568 Self { filter, witnesses }
3569 }
3570
3571 pub fn filter(&self) -> &ToolFilter {
3572 &self.filter
3573 }
3574
3575 pub fn witnesses(&self) -> &BTreeMap<ToolName, ToolVisibilityWitness> {
3576 &self.witnesses
3577 }
3578
3579 pub(crate) fn into_initial_visibility_state(self) -> SessionToolVisibilityState {
3580 SessionToolVisibilityState {
3581 inherited_base_filter: self.filter,
3582 filter_witnesses: self.witnesses,
3583 ..Default::default()
3584 }
3585 }
3586}
3587
3588#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
3590#[serde(rename_all = "snake_case")]
3591pub struct SessionToolVisibilityState {
3592 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
3593 pub capability_base_filter: ToolFilter,
3594 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
3595 pub inherited_base_filter: ToolFilter,
3596 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
3597 pub active_filter: ToolFilter,
3598 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
3599 pub staged_filter: ToolFilter,
3600 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
3601 pub active_requested_deferred_names: BTreeSet<ToolName>,
3602 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
3603 pub staged_requested_deferred_names: BTreeSet<ToolName>,
3604 #[serde(default, skip_serializing_if = "is_zero")]
3605 pub active_revision: u64,
3606 #[serde(default, skip_serializing_if = "is_zero")]
3607 pub staged_revision: u64,
3608 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3609 pub requested_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3610 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3611 pub filter_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3612}
3613
3614impl SessionToolVisibilityState {
3615 #[cfg(test)]
3619 pub(crate) fn projected_boundary_applied(&self) -> Self {
3620 let mut projected = self.clone();
3621 projected.active_filter = self.staged_filter.clone();
3622 projected.active_requested_deferred_names = self.staged_requested_deferred_names.clone();
3623 projected.active_revision = self.staged_revision;
3624 projected
3625 }
3626}
3627
3628#[derive(Debug, Clone, PartialEq, Eq)]
3634pub struct AuthorizedSessionToolVisibilityState {
3635 state: SessionToolVisibilityState,
3636}
3637
3638impl AuthorizedSessionToolVisibilityState {
3639 pub(crate) fn from_generated_authority(state: SessionToolVisibilityState) -> Self {
3640 Self { state }
3641 }
3642
3643 pub fn as_state(&self) -> &SessionToolVisibilityState {
3644 &self.state
3645 }
3646
3647 pub fn into_state(self) -> SessionToolVisibilityState {
3648 self.state
3649 }
3650}
3651
3652#[derive(Debug, Clone, Serialize, Deserialize, Default)]
3655#[serde(rename_all = "snake_case")]
3656pub struct SessionBuildState {
3657 #[serde(
3658 default,
3659 skip_serializing_if = "crate::config::SystemPromptOverride::is_inherit"
3660 )]
3661 pub system_prompt: crate::config::SystemPromptOverride,
3662 #[serde(default, skip_serializing_if = "Option::is_none")]
3663 pub output_schema: Option<crate::OutputSchema>,
3664 #[serde(default, skip_serializing_if = "is_default_hook_run_overrides")]
3665 pub hooks_override: crate::HookRunOverrides,
3666 #[serde(default, skip_serializing_if = "Option::is_none")]
3667 pub budget_limits: Option<crate::BudgetLimits>,
3668 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3669 pub recoverable_tool_defs: Vec<ToolDef>,
3670 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3671 pub silent_comms_intents: Vec<String>,
3672 #[serde(default, skip_serializing_if = "Option::is_none")]
3673 pub max_inline_peer_notifications: Option<i32>,
3674 #[serde(default, skip_serializing_if = "Option::is_none")]
3675 pub app_context: Option<serde_json::Value>,
3676 #[serde(default, skip_serializing_if = "Option::is_none")]
3677 pub additional_instructions: Option<Vec<String>>,
3678 #[serde(default, skip_serializing_if = "Option::is_none")]
3679 pub shell_env: Option<HashMap<String, String>>,
3680 #[serde(default, skip_serializing_if = "Option::is_none")]
3686 pub mob_tool_authority_context: Option<MobToolAuthorityContext>,
3687 #[serde(default, skip_serializing_if = "is_default_call_timeout_override")]
3688 pub call_timeout_override: crate::CallTimeoutOverride,
3689 #[serde(default, skip_serializing_if = "Option::is_none")]
3696 pub assembled_system_prompt: Option<String>,
3697}
3698
3699#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3701#[serde(rename_all = "snake_case")]
3702pub struct PendingDeferredPrompt {
3703 pub prompt: ContentInput,
3704 pub accepted_at: SystemTime,
3705}
3706
3707#[derive(Debug, Clone, Serialize, Deserialize)]
3709#[serde(rename_all = "snake_case")]
3710pub struct PendingToolResultsMessage {
3711 pub results: Vec<ToolResult>,
3712 pub accepted_at: SystemTime,
3713}
3714
3715#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3717pub enum DeferredToolResultsIngressError {
3718 #[error("callback result ingress contains duplicate tool id '{0}'")]
3719 DuplicateToolUseId(String),
3720 #[error("callback result for tool id '{0}' conflicts with its staged payload")]
3721 ConflictingRedelivery(String),
3722 #[error("callback result tool id '{0}' is outside the staged pending set")]
3723 WrongToolUseId(String),
3724}
3725
3726#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3733#[serde(rename_all = "snake_case")]
3734pub(crate) struct PendingCallbackToolBatch {
3735 pub run_id: RunId,
3736 pub tool_use_order: Vec<String>,
3737 pub pending_tool_use_ids: Vec<String>,
3738 pub completed_results: Vec<ToolResult>,
3739 pub session_effects: Vec<crate::ops::SessionEffect>,
3740 pub async_ops: Vec<crate::ops::AsyncOpRef>,
3741}
3742
3743#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3744#[serde(tag = "state", rename_all = "snake_case")]
3745enum CallbackToolBatchState {
3746 Pending {
3747 batch: PendingCallbackToolBatch,
3748 },
3749 Applied {
3750 tool_use_order: Vec<String>,
3751 results: Vec<ToolResult>,
3752 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3753 async_ops: Vec<crate::ops::AsyncOpRef>,
3754 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3755 post_tool_messages: Vec<Message>,
3756 #[serde(default)]
3757 post_tool_messages_applied: bool,
3758 },
3759}
3760
3761pub(crate) enum ResolvedPendingCallbackToolResults {
3762 NoState,
3763 Pending {
3764 batch: PendingCallbackToolBatch,
3765 ordered_results: Vec<ToolResult>,
3766 },
3767 AlreadyApplied {
3768 async_ops: Vec<crate::ops::AsyncOpRef>,
3769 },
3770}
3771
3772#[derive(Debug, Clone, PartialEq, Eq)]
3775#[doc(hidden)]
3776pub enum CallbackResultIngress {
3777 NoPendingBatch,
3780 Pending { pending_tool_use_ids: Vec<String> },
3782 AlreadyApplied,
3784}
3785
3786#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3788pub(crate) enum PendingCallbackBatchError {
3789 #[error("a pending callback batch is already staged")]
3790 AlreadyStaged,
3791 #[error("no pending callback batch is staged")]
3792 Missing,
3793 #[error("pending callback batch is malformed: {0}")]
3794 Malformed(String),
3795 #[error("callback results contain duplicate tool id '{0}'")]
3796 DuplicateResult(String),
3797 #[error("mob authority replacement cannot cross a durable callback staging boundary")]
3798 NonDurableAuthorityEffect,
3799 #[error("callback result ids {actual:?} do not match pending ids {expected:?}")]
3800 ResultSetMismatch {
3801 expected: BTreeSet<String>,
3802 actual: BTreeSet<String>,
3803 },
3804 #[error("callback result redelivery conflicts with the already applied payload")]
3805 ConflictingRedelivery,
3806}
3807
3808fn unique_tool_results(
3809 results: Vec<ToolResult>,
3810) -> Result<BTreeMap<String, ToolResult>, PendingCallbackBatchError> {
3811 let mut by_id = BTreeMap::new();
3812 for result in results {
3813 let id = result.tool_use_id.clone();
3814 if by_id.insert(id.clone(), result).is_some() {
3815 return Err(PendingCallbackBatchError::DuplicateResult(id));
3816 }
3817 }
3818 Ok(by_id)
3819}
3820
3821fn validate_pending_callback_batch(
3822 messages: &[Message],
3823 batch: &PendingCallbackToolBatch,
3824) -> Result<(), PendingCallbackBatchError> {
3825 let Some(assistant) = messages.last() else {
3826 return Err(PendingCallbackBatchError::Malformed(
3827 "staged callback batch has no assistant transcript tail".to_string(),
3828 ));
3829 };
3830 let assistant_order = assistant_tool_use_ids(assistant)
3831 .into_iter()
3832 .map(str::to_string)
3833 .collect::<Vec<_>>();
3834 if assistant_order != batch.tool_use_order {
3835 return Err(PendingCallbackBatchError::Malformed(format!(
3836 "assistant tool ids {assistant_order:?} do not match staged order {:?}",
3837 batch.tool_use_order
3838 )));
3839 }
3840 let assistant_set = assistant_order.iter().cloned().collect::<BTreeSet<_>>();
3841 if assistant_set.len() != assistant_order.len() {
3842 return Err(PendingCallbackBatchError::Malformed(
3843 "assistant tool-use batch contains duplicate ids".to_string(),
3844 ));
3845 }
3846 let pending_set = batch
3847 .pending_tool_use_ids
3848 .iter()
3849 .cloned()
3850 .collect::<BTreeSet<_>>();
3851 if pending_set.len() != batch.pending_tool_use_ids.len() || pending_set.is_empty() {
3852 return Err(PendingCallbackBatchError::Malformed(
3853 "staged callback batch must contain at least one unique pending tool id".to_string(),
3854 ));
3855 }
3856 let completed = unique_tool_results(batch.completed_results.clone())?;
3857 let completed_set = completed.keys().cloned().collect::<BTreeSet<_>>();
3858 if !pending_set.is_disjoint(&completed_set)
3859 || pending_set
3860 .union(&completed_set)
3861 .cloned()
3862 .collect::<BTreeSet<_>>()
3863 != assistant_set
3864 {
3865 return Err(PendingCallbackBatchError::Malformed(format!(
3866 "pending ids {pending_set:?} plus completed ids {completed_set:?} do not partition assistant ids {assistant_set:?}"
3867 )));
3868 }
3869 if batch.session_effects.iter().any(|effect| {
3870 matches!(
3871 effect,
3872 crate::ops::SessionEffect::ReplaceMobToolAuthorityContext { .. }
3873 )
3874 }) {
3875 return Err(PendingCallbackBatchError::NonDurableAuthorityEffect);
3876 }
3877 Ok(())
3878}
3879
3880impl PartialEq for PendingToolResultsMessage {
3881 fn eq(&self, other: &Self) -> bool {
3882 self.accepted_at == other.accepted_at
3883 && serde_json::to_value(&self.results).ok() == serde_json::to_value(&other.results).ok()
3884 }
3885}
3886
3887#[derive(Debug, Clone, Default, PartialEq)]
3889pub struct ConsumedDeferredTurnInputs {
3890 pub(crate) restore_first_turn_pending: bool,
3891 pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
3892 pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
3893}
3894
3895impl ConsumedDeferredTurnInputs {
3896 pub fn is_empty(&self) -> bool {
3897 !self.restore_first_turn_pending
3898 && self.pending_initial_prompt.is_none()
3899 && self.pending_tool_results.is_empty()
3900 }
3901
3902 pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
3903 self.pending_initial_prompt.as_ref()
3904 }
3905
3906 pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
3907 &self.pending_tool_results
3908 }
3909}
3910
3911#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3913#[serde(rename_all = "snake_case")]
3914pub struct SeenSystemContextKey {
3915 pub content: crate::lifecycle::run_primitive::CoreRenderable,
3917 #[serde(default, skip_serializing_if = "Option::is_none")]
3918 pub source: Option<String>,
3919 #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
3922 pub source_kind: SystemContextSource,
3923 pub state: SeenSystemContextState,
3924}
3925
3926#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3928#[serde(rename_all = "snake_case")]
3929pub enum SeenSystemContextState {
3930 Pending,
3931 Applied,
3932}
3933
3934impl SessionSystemContextState {
3935 pub fn pending(&self) -> &[PendingSystemContextAppend] {
3936 &self.pending
3937 }
3938
3939 pub fn applied(&self) -> &[PendingSystemContextAppend] {
3940 &self.applied
3941 }
3942
3943 pub fn seen(&self) -> &BTreeMap<String, SeenSystemContextKey> {
3944 &self.seen
3945 }
3946
3947 pub fn active_turn_pending_keys(&self) -> &BTreeSet<String> {
3948 &self.active_turn_pending_keys
3949 }
3950
3951 pub fn pending_len(&self) -> usize {
3952 self.pending.len()
3953 }
3954
3955 pub fn applied_len(&self) -> usize {
3956 self.applied.len()
3957 }
3958
3959 pub fn active_turn_pending_len(&self) -> usize {
3960 if self.active_turn_pending_indices.is_empty() && !self.active_turn_pending_keys.is_empty()
3961 {
3962 return self
3963 .pending
3964 .iter()
3965 .filter(|append| {
3966 append
3967 .idempotency_key
3968 .as_ref()
3969 .is_some_and(|key| self.active_turn_pending_keys.contains(key))
3970 })
3971 .count();
3972 }
3973 self.active_turn_pending_indices.len()
3974 }
3975
3976 pub fn realtime_projection_appends(&self) -> Vec<PendingSystemContextAppend> {
3977 self.applied
3978 .iter()
3979 .chain(self.pending.iter())
3980 .cloned()
3981 .collect()
3982 }
3983
3984 pub fn stage_append(
3986 &mut self,
3987 req: &AppendSystemContextRequest,
3988 accepted_at: SystemTime,
3989 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
3990 system_context_authority::stage_append(self, req, accepted_at, false)
3991 }
3992
3993 fn stage_append_with_generated_authority(
3994 &mut self,
3995 req: &AppendSystemContextRequest,
3996 accepted_at: SystemTime,
3997 active_turn_scoped: bool,
3998 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
3999 system_context_authority::stage_append(self, req, accepted_at, active_turn_scoped)
4000 }
4001
4002 pub fn stage_active_turn_append(
4009 &mut self,
4010 req: &AppendSystemContextRequest,
4011 accepted_at: SystemTime,
4012 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
4013 self.stage_append_with_generated_authority(req, accepted_at, true)
4014 }
4015
4016 pub fn mark_pending_applied(&mut self) {
4018 system_context_authority::mark_pending_applied(self);
4019 }
4020
4021 pub fn discard_unapplied_active_turn_pending(&mut self) -> Vec<PendingSystemContextAppend> {
4024 system_context_authority::discard_unapplied_active_turn_pending(self)
4025 }
4026
4027 pub fn discard_active_turn_pending_by_keys(
4034 &mut self,
4035 idempotency_keys: &[String],
4036 ) -> Vec<PendingSystemContextAppend> {
4037 system_context_authority::discard_active_turn_pending_by_keys(self, idempotency_keys)
4038 }
4039
4040 pub fn restore_from_snapshot(self) -> Result<Self, SystemContextStageError> {
4044 system_context_authority::restore_system_context_state(self)
4045 }
4046
4047 pub fn record_applied_blocks(
4051 &mut self,
4052 appends: &[PendingSystemContextAppend],
4053 current_system_prompt: &str,
4054 ) -> Vec<PendingSystemContextAppend> {
4055 system_context_authority::record_applied_system_context_blocks(
4056 self,
4057 appends,
4058 current_system_prompt,
4059 )
4060 }
4061}
4062
4063const SESSION_DOCUMENT_FIRST_TURN_KEY: &str = "first_turn";
4068
4069fn usize_to_u64(value: usize) -> u64 {
4070 u64::try_from(value).unwrap_or(u64::MAX)
4071}
4072
4073fn validate_deferred_turn_snapshot(
4081 state: SessionDeferredTurnState,
4082) -> Result<SessionDeferredTurnState, session_document::SessionDocumentError> {
4083 let mut authority = session_document::SessionDocumentMachineAuthority::new();
4084 let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
4085 authority.recover_session_first_turn_phase(
4089 key,
4090 state.first_turn_phase.into(),
4091 state.pending_initial_prompt.is_some(),
4092 usize_to_u64(state.pending_tool_results.len()),
4093 )?;
4094 Ok(state)
4095}
4096
4097impl SessionDeferredTurnState {
4098 pub fn first_turn_phase(&self) -> DeferredFirstTurnPhase {
4099 self.first_turn_phase
4100 }
4101
4102 pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
4103 self.pending_initial_prompt.as_ref()
4104 }
4105
4106 pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
4107 &self.pending_tool_results
4108 }
4109
4110 pub fn pending_tool_results_len(&self) -> usize {
4111 self.pending_tool_results.len()
4112 }
4113
4114 pub(crate) fn pending_initial_prompt_mut_for_blob_rewrite(
4115 &mut self,
4116 ) -> Option<&mut PendingDeferredPrompt> {
4117 self.pending_initial_prompt.as_mut()
4118 }
4119
4120 pub(crate) fn pending_tool_results_mut_for_blob_rewrite(
4121 &mut self,
4122 ) -> &mut [PendingToolResultsMessage] {
4123 &mut self.pending_tool_results
4124 }
4125
4126 fn document_authority(
4136 &self,
4137 ) -> (
4138 session_document::SessionDocumentMachineAuthority,
4139 session_document::SessionDocumentKey,
4140 ) {
4141 let mut authority = session_document::SessionDocumentMachineAuthority::new();
4142 let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
4143 if let Err(err) = authority.recover_session_first_turn_phase(
4144 key.clone(),
4145 self.first_turn_phase.into(),
4146 self.pending_initial_prompt.is_some(),
4147 usize_to_u64(self.pending_tool_results.len()),
4148 ) {
4149 tracing::warn!(
4150 error = %err,
4151 "generated session document authority rejected first-turn recovery"
4152 );
4153 }
4154 (authority, key)
4155 }
4156
4157 fn mirror_first_turn_phase(
4160 &mut self,
4161 effects: &[session_document::SessionDocumentEffect],
4162 ) -> Option<bool> {
4163 for effect in effects {
4164 if let session_document::SessionDocumentEffect::SessionFirstTurnPhaseResolved {
4165 phase,
4166 was_pending,
4167 } = effect
4168 {
4169 self.first_turn_phase = (*phase).into();
4170 return Some(*was_pending);
4171 }
4172 }
4173 None
4174 }
4175
4176 pub fn mark_initial_turn_pending(&mut self) {
4178 let (mut authority, key) = self.document_authority();
4179 match authority.mark_session_initial_turn_pending(key) {
4180 Ok(effects) => {
4181 self.mirror_first_turn_phase(&effects);
4182 }
4183 Err(err) => tracing::warn!(
4184 error = %err,
4185 "generated session document authority rejected pending mark"
4186 ),
4187 }
4188 }
4189
4190 pub fn mark_initial_turn_started(&mut self) -> bool {
4194 let (mut authority, key) = self.document_authority();
4195 match authority.start_session_initial_turn(key) {
4196 Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
4197 Err(err) => {
4198 tracing::warn!(
4199 error = %err,
4200 "generated session document authority rejected first-turn start"
4201 );
4202 false
4203 }
4204 }
4205 }
4206
4207 pub fn restore_initial_turn_pending(&mut self) {
4209 let (mut authority, key) = self.document_authority();
4214 match authority.restore_session_consumed_inputs(
4215 key.clone(),
4216 true,
4217 self.pending_initial_prompt.is_some(),
4218 usize_to_u64(self.pending_tool_results.len()),
4219 ) {
4220 Ok(_) => {
4221 if let Some(phase) = authority.session_first_turn_phase_for(&key) {
4224 self.first_turn_phase = phase.into();
4225 }
4226 }
4227 Err(err) => tracing::warn!(
4228 error = %err,
4229 "generated session document authority rejected pending restore"
4230 ),
4231 }
4232 }
4233
4234 pub fn allows_initial_turn_overrides(&self) -> bool {
4236 let (mut authority, key) = self.document_authority();
4237 match authority.resolve_session_first_turn_overrides_allowed(key) {
4238 Ok(effects) => effects
4239 .iter()
4240 .find_map(|effect| {
4241 match effect {
4242 session_document::SessionDocumentEffect::SessionFirstTurnOverridesResolved {
4243 allowed,
4244 } => Some(*allowed),
4245 _ => None,
4246 }
4247 })
4248 .unwrap_or(false),
4249 Err(err) => {
4250 tracing::warn!(
4251 error = %err,
4252 "generated session document authority rejected override resolution"
4253 );
4254 false
4255 }
4256 }
4257 }
4258
4259 pub fn stage_initial_prompt(&mut self, prompt: ContentInput, accepted_at: SystemTime) {
4261 let prompt_has_content = prompt.has_images() || !prompt.text_content().trim().is_empty();
4262 let (mut authority, key) = self.document_authority();
4263 match authority.stage_session_initial_prompt(key, prompt_has_content) {
4264 Ok(effects) => {
4265 let decision = effects.iter().find_map(|effect| {
4266 match effect {
4267 session_document::SessionDocumentEffect::SessionInitialPromptStageResolved {
4268 decision,
4269 } => Some(*decision),
4270 _ => None,
4271 }
4272 });
4273 match decision {
4274 Some(session_document::SessionInitialPromptStageDecision::Store) => {
4275 self.pending_initial_prompt = Some(PendingDeferredPrompt {
4276 prompt,
4277 accepted_at,
4278 });
4279 }
4280 Some(session_document::SessionInitialPromptStageDecision::Clear) => {
4281 self.pending_initial_prompt = None;
4282 }
4283 None => tracing::warn!(
4284 "generated session document authority returned no prompt-stage decision"
4285 ),
4286 }
4287 }
4288 Err(err) => tracing::warn!(
4289 error = %err,
4290 "generated session document authority rejected initial prompt stage"
4291 ),
4292 }
4293 }
4294
4295 pub fn try_stage_tool_results(
4297 &mut self,
4298 results: Vec<ToolResult>,
4299 accepted_at: SystemTime,
4300 ) -> Result<usize, DeferredToolResultsIngressError> {
4301 let mut incoming_by_id = BTreeMap::new();
4302 for result in &results {
4303 if incoming_by_id
4304 .insert(result.tool_use_id.clone(), result)
4305 .is_some()
4306 {
4307 return Err(DeferredToolResultsIngressError::DuplicateToolUseId(
4308 result.tool_use_id.clone(),
4309 ));
4310 }
4311 }
4312
4313 let mut staged_by_id = BTreeMap::new();
4314 for pending in &self.pending_tool_results {
4315 for result in &pending.results {
4316 match staged_by_id.insert(result.tool_use_id.clone(), result) {
4317 Some(previous) if previous != result => {
4318 return Err(DeferredToolResultsIngressError::ConflictingRedelivery(
4319 result.tool_use_id.clone(),
4320 ));
4321 }
4322 _ => {}
4323 }
4324 }
4325 }
4326 if !staged_by_id.is_empty() {
4327 for (id, incoming) in &incoming_by_id {
4328 match staged_by_id.get(id) {
4329 Some(staged) if *staged == *incoming => {}
4330 Some(_) => {
4331 return Err(DeferredToolResultsIngressError::ConflictingRedelivery(
4332 id.clone(),
4333 ));
4334 }
4335 None => {
4336 return Err(DeferredToolResultsIngressError::WrongToolUseId(id.clone()));
4337 }
4338 }
4339 }
4340 return Ok(0);
4341 }
4342
4343 let (mut authority, key) = self.document_authority();
4344 let accepted = match authority.stage_session_tool_results(key, usize_to_u64(results.len()))
4345 {
4346 Ok(effects) => effects.iter().find_map(|effect| match effect {
4347 session_document::SessionDocumentEffect::SessionToolResultsStageResolved {
4348 accepted_count,
4349 } => Some(*accepted_count),
4350 _ => None,
4351 }),
4352 Err(err) => {
4353 tracing::warn!(
4354 error = %err,
4355 "generated session document authority rejected tool-results stage"
4356 );
4357 return Ok(0);
4358 }
4359 };
4360 let Some(accepted) = accepted else {
4361 tracing::warn!(
4362 "generated session document authority returned no tool-results decision"
4363 );
4364 return Ok(0);
4365 };
4366 if accepted == 0 {
4367 return Ok(0);
4368 }
4369 let accepted = usize::try_from(accepted).unwrap_or(usize::MAX);
4370 self.pending_tool_results.push(PendingToolResultsMessage {
4371 results,
4372 accepted_at,
4373 });
4374 Ok(accepted)
4375 }
4376
4377 pub fn stage_tool_results(
4381 &mut self,
4382 results: Vec<ToolResult>,
4383 accepted_at: SystemTime,
4384 ) -> usize {
4385 match self.try_stage_tool_results(results, accepted_at) {
4386 Ok(accepted) => accepted,
4387 Err(error) => {
4388 tracing::warn!(%error, "deferred callback-result ingress was rejected");
4389 0
4390 }
4391 }
4392 }
4393
4394 pub fn has_pending_tool_results(&self) -> bool {
4396 !self.pending_tool_results.is_empty()
4397 }
4398
4399 pub fn consume_for_started_turn(&mut self) -> ConsumedDeferredTurnInputs {
4401 let (mut authority, key) = self.document_authority();
4402 let was_pending = match authority.consume_session_deferred_inputs(key) {
4403 Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
4404 Err(err) => {
4405 tracing::warn!(
4406 error = %err,
4407 "generated session document authority rejected started-turn consumption"
4408 );
4409 return ConsumedDeferredTurnInputs::default();
4410 }
4411 };
4412 ConsumedDeferredTurnInputs {
4413 restore_first_turn_pending: was_pending,
4414 pending_initial_prompt: self.pending_initial_prompt.take(),
4415 pending_tool_results: std::mem::take(&mut self.pending_tool_results),
4416 }
4417 }
4418
4419 pub fn restore_consumed_turn_inputs(&mut self, consumed: ConsumedDeferredTurnInputs) {
4421 if consumed.is_empty() {
4422 return;
4423 }
4424 let (mut authority, key) = self.document_authority();
4425 let effects = match authority.restore_session_consumed_inputs(
4426 key,
4427 consumed.restore_first_turn_pending,
4428 consumed.pending_initial_prompt.is_some(),
4429 usize_to_u64(consumed.pending_tool_results.len()),
4430 ) {
4431 Ok(effects) => effects,
4432 Err(err) => {
4433 tracing::warn!(
4434 error = %err,
4435 "generated session document authority rejected consumed input restore"
4436 );
4437 return;
4438 }
4439 };
4440 let Some((restore_first_turn_pending, restore_initial_prompt, restore_tool_results)) =
4441 effects.iter().find_map(|effect| match effect {
4442 session_document::SessionDocumentEffect::SessionConsumedInputsRestoreResolved {
4443 restore_first_turn_pending,
4444 restore_initial_prompt,
4445 restore_tool_results,
4446 } => Some((
4447 *restore_first_turn_pending,
4448 *restore_initial_prompt,
4449 *restore_tool_results,
4450 )),
4451 _ => None,
4452 })
4453 else {
4454 tracing::warn!(
4455 "generated session document authority returned no consumed-input restore decision"
4456 );
4457 return;
4458 };
4459 if restore_first_turn_pending {
4460 self.restore_initial_turn_pending();
4461 }
4462 if restore_initial_prompt && self.pending_initial_prompt.is_none() {
4463 self.pending_initial_prompt = consumed.pending_initial_prompt;
4464 }
4465 if restore_tool_results {
4466 let mut restored = consumed.pending_tool_results;
4467 restored.extend(std::mem::take(&mut self.pending_tool_results));
4468 self.pending_tool_results = restored;
4469 }
4470 }
4471}
4472
4473#[derive(Debug, Clone, PartialEq, Eq)]
4475pub enum SystemContextStageError {
4476 InvalidRequest(String),
4477 Conflict {
4478 key: String,
4479 existing_text: String,
4480 existing_source: Option<String>,
4481 },
4482}
4483
4484impl std::fmt::Display for SystemContextStageError {
4485 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4486 match self {
4487 Self::InvalidRequest(message) => {
4488 write!(f, "invalid system-context append request: {message}")
4489 }
4490 Self::Conflict { key, .. } => {
4491 write!(
4492 f,
4493 "system-context append conflict for idempotency key `{key}`"
4494 )
4495 }
4496 }
4497 }
4498}
4499
4500impl std::error::Error for SystemContextStageError {}
4501
4502fn render_system_context_block(append: &PendingSystemContextAppend) -> String {
4510 let mut rendered = String::from(SYSTEM_CONTEXT_RENDER_LABEL);
4511 if let Some(source) = &append.source {
4512 rendered.push_str("\nsource: ");
4513 rendered.push_str(source);
4514 }
4515 rendered.push_str("\n\n");
4516 rendered.push_str(append.content.render_text().trim());
4519 rendered
4520}
4521
4522const SYSTEM_CONTEXT_RENDER_LABEL: &str = "[Runtime System Context]";
4527
4528fn render_system_context_blocks_joined(appends: &[PendingSystemContextAppend]) -> String {
4534 appends
4535 .iter()
4536 .map(render_system_context_block)
4537 .collect::<Vec<_>>()
4538 .join(SYSTEM_CONTEXT_SEPARATOR)
4539}
4540
4541fn compose_system_prompt_with_context_tail(base: &str, tail: &str) -> String {
4546 if tail.is_empty() {
4547 return base.to_string();
4548 }
4549 if base.is_empty() {
4550 return tail
4551 .strip_prefix(SYSTEM_CONTEXT_SEPARATOR)
4552 .unwrap_or(tail)
4553 .to_string();
4554 }
4555 format!("{base}{tail}")
4556}
4557
4558fn persisted_prompt_is_admitted_context_append_continuation(
4569 assembled_base: &str,
4570 persisted_content: &str,
4571 persisted_mutation_kind: crate::types::SystemPromptMutationKind,
4572) -> bool {
4573 let content_identical = persisted_content == assembled_base;
4574 let content_extends = persisted_content.starts_with(assembled_base);
4575 let appended_starts_with_separator = content_extends
4576 && persisted_content[assembled_base.len()..].starts_with(SYSTEM_CONTEXT_SEPARATOR);
4577 let mut authority = session_document::SessionDocumentMachineAuthority::new();
4578 match authority.resolve_system_context_persist_append_admission(
4579 true,
4580 content_identical,
4581 content_extends,
4582 appended_starts_with_separator,
4583 persisted_mutation_kind.is_runtime_context_append(),
4584 ) {
4585 Ok(effects) => effects.into_iter().any(|effect| {
4586 matches!(
4587 effect,
4588 session_document::SessionDocumentEffect::SystemContextPersistAppendAdmissionResolved {
4589 admission: session_document::SystemContextPersistAppendAdmission::Admit,
4590 }
4591 )
4592 }),
4593 Err(error) => {
4594 tracing::warn!(
4595 error = %error,
4596 "session document authority refused resume prompt continuation admission; \
4597 falling back to audited rewrite"
4598 );
4599 false
4600 }
4601 }
4602}
4603
4604mod system_context_authority {
4615 use super::{
4616 AppendSystemContextRequest, BTreeSet, PendingSystemContextAppend, SeenSystemContextKey,
4617 SeenSystemContextState, SessionSystemContextState, SystemContextSource,
4618 SystemContextStageError, SystemTime, render_system_context_block, session_document,
4619 usize_to_u64,
4620 };
4621 use crate::service::AppendSystemContextStatus;
4622
4623 fn document_authority() -> session_document::SessionDocumentMachineAuthority {
4624 session_document::SessionDocumentMachineAuthority::new()
4625 }
4626
4627 fn resolve_append_decision(
4629 trimmed_text_byte_count: u64,
4630 idempotency_key_present: bool,
4631 existing_key_matches: bool,
4632 existing_key_conflicts: bool,
4633 active_turn_scoped: bool,
4634 ) -> Result<session_document::SystemContextAppendDecision, SystemContextStageError> {
4635 let mut authority = document_authority();
4636 let effects = authority
4637 .resolve_system_context_append(
4638 trimmed_text_byte_count,
4639 idempotency_key_present,
4640 existing_key_matches,
4641 existing_key_conflicts,
4642 active_turn_scoped,
4643 )
4644 .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
4645 effects
4646 .into_iter()
4647 .find_map(|effect| match effect {
4648 session_document::SessionDocumentEffect::SystemContextAppendResolved {
4649 decision,
4650 ..
4651 } => Some(decision),
4652 _ => None,
4653 })
4654 .ok_or_else(|| {
4655 SystemContextStageError::InvalidRequest(
4656 "generated session document authority returned no append decision".to_string(),
4657 )
4658 })
4659 }
4660
4661 fn pending_apply_item(source_kind: SystemContextSource) -> Option<(bool, bool, bool)> {
4664 let mut authority = document_authority();
4665 match authority.resolve_system_context_pending_apply_item(source_kind.into()) {
4666 Ok(effects) => effects.into_iter().find_map(|effect| {
4667 match effect {
4668 session_document::SessionDocumentEffect::SystemContextPendingApplyItemResolved {
4669 promote_to_applied,
4670 mark_seen_applied,
4671 remove_seen,
4672 } => Some((promote_to_applied, mark_seen_applied, remove_seen)),
4673 _ => None,
4674 }
4675 }),
4676 Err(err) => {
4677 tracing::warn!(
4678 error = %err,
4679 "generated session document authority rejected system-context apply item"
4680 );
4681 None
4682 }
4683 }
4684 }
4685
4686 fn steer_cleanup_discards(source_kind: SystemContextSource) -> bool {
4689 let mut authority = document_authority();
4690 match authority.resolve_system_context_steer_cleanup_item(source_kind.into()) {
4691 Ok(effects) => effects
4692 .into_iter()
4693 .find_map(|effect| {
4694 match effect {
4695 session_document::SessionDocumentEffect::SystemContextSteerCleanupItemResolved {
4696 discard,
4697 } => Some(discard),
4698 _ => None,
4699 }
4700 })
4701 .unwrap_or(false),
4702 Err(err) => {
4703 tracing::warn!(
4704 error = %err,
4705 "generated session document authority rejected system-context steer cleanup item"
4706 );
4707 false
4708 }
4709 }
4710 }
4711
4712 fn discard_pending_where(
4713 state: &mut SessionSystemContextState,
4714 mut should_discard: impl FnMut(&PendingSystemContextAppend, bool) -> bool,
4715 ) -> Vec<PendingSystemContextAppend> {
4716 reconstruct_legacy_active_turn_indices(state);
4717 let active_indices = std::mem::take(&mut state.active_turn_pending_indices);
4718 let pending = std::mem::take(&mut state.pending);
4719 let mut retained = Vec::with_capacity(pending.len());
4720 let mut retained_active_indices = BTreeSet::new();
4721 let mut retained_active_keys = BTreeSet::new();
4722 let mut discarded = Vec::new();
4723
4724 for (index, append) in pending.into_iter().enumerate() {
4725 let is_active_turn = active_indices.contains(&usize_to_u64(index));
4726 if should_discard(&append, is_active_turn) {
4727 discarded.push(append);
4728 continue;
4729 }
4730 if is_active_turn {
4731 retained_active_indices.insert(usize_to_u64(retained.len()));
4732 if let Some(key) = append.idempotency_key.as_ref() {
4733 retained_active_keys.insert(key.clone());
4734 }
4735 }
4736 retained.push(append);
4737 }
4738
4739 state.pending = retained;
4740 state.active_turn_pending_indices = retained_active_indices;
4741 state.active_turn_pending_keys = retained_active_keys;
4742 discarded
4743 }
4744
4745 fn reconstruct_legacy_active_turn_indices(state: &mut SessionSystemContextState) {
4746 if !state.active_turn_pending_indices.is_empty()
4747 || state.active_turn_pending_keys.is_empty()
4748 {
4749 return;
4750 }
4751 state.active_turn_pending_indices = state
4752 .pending
4753 .iter()
4754 .enumerate()
4755 .filter(|(_index, append)| {
4756 append
4757 .idempotency_key
4758 .as_ref()
4759 .is_some_and(|key| state.active_turn_pending_keys.contains(key))
4760 })
4761 .map(|(index, _append)| usize_to_u64(index))
4762 .collect();
4763 }
4764
4765 pub(super) fn restore_system_context_state(
4766 mut state: SessionSystemContextState,
4767 ) -> Result<SessionSystemContextState, SystemContextStageError> {
4768 reconstruct_legacy_active_turn_indices(&mut state);
4772 let active_indices_are_in_bounds = state
4773 .active_turn_pending_indices
4774 .iter()
4775 .all(|index| usize::try_from(*index).is_ok_and(|index| index < state.pending.len()));
4776 let active_keys_have_indexed_pending = state.active_turn_pending_keys.iter().all(|key| {
4777 state.active_turn_pending_indices.iter().any(|index| {
4778 usize::try_from(*index)
4779 .ok()
4780 .and_then(|index| state.pending.get(index))
4781 .and_then(|append| append.idempotency_key.as_ref())
4782 == Some(key)
4783 })
4784 });
4785 let indexed_pending_keys_are_active =
4786 state.active_turn_pending_indices.iter().all(|index| {
4787 usize::try_from(*index)
4788 .ok()
4789 .and_then(|index| state.pending.get(index))
4790 .is_some_and(|append| {
4791 append
4792 .idempotency_key
4793 .as_ref()
4794 .is_none_or(|key| state.active_turn_pending_keys.contains(key))
4795 })
4796 });
4797 let active_turn_membership_is_consistent = active_indices_are_in_bounds
4798 && active_keys_have_indexed_pending
4799 && indexed_pending_keys_are_active;
4800 let seen_keys_match_known_appends = state.seen.iter().all(|(key, seen)| {
4801 state
4802 .pending
4803 .iter()
4804 .chain(state.applied.iter())
4805 .any(|append| {
4806 append.idempotency_key.as_ref() == Some(key)
4807 && seen.content == append.content
4808 && seen.source.as_deref() == append.source.as_deref()
4809 })
4810 });
4811 let mut authority = document_authority();
4812 authority
4813 .restore_system_context_snapshot(
4814 active_turn_membership_is_consistent,
4815 seen_keys_match_known_appends,
4816 )
4817 .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
4818 Ok(state)
4819 }
4820
4821 pub(super) fn stage_append(
4822 state: &mut SessionSystemContextState,
4823 req: &AppendSystemContextRequest,
4824 accepted_at: SystemTime,
4825 active_turn_scoped: bool,
4826 ) -> Result<AppendSystemContextStatus, SystemContextStageError> {
4827 let rendered_text = req.content.render_text();
4831 let rendered_len = rendered_text.trim().len();
4832 let existing = req
4833 .idempotency_key
4834 .as_ref()
4835 .and_then(|key| state.seen.get(key));
4836 let existing_key_matches = existing.is_some_and(|existing| {
4837 existing.content == req.content && existing.source.as_deref() == req.source.as_deref()
4838 });
4839 let existing_key_conflicts = existing.is_some() && !existing_key_matches;
4840 let decision = resolve_append_decision(
4841 usize_to_u64(rendered_len),
4842 req.idempotency_key.is_some(),
4843 existing_key_matches,
4844 existing_key_conflicts,
4845 active_turn_scoped,
4846 )?;
4847
4848 match decision {
4849 session_document::SystemContextAppendDecision::RejectEmpty => {
4850 return Err(SystemContextStageError::InvalidRequest(
4851 "system context text must not be empty".to_string(),
4852 ));
4853 }
4854 session_document::SystemContextAppendDecision::RejectConflict => {
4855 let Some(key) = req.idempotency_key.as_ref() else {
4856 return Err(SystemContextStageError::InvalidRequest(
4857 "generated system-context authority rejected append without a key"
4858 .to_string(),
4859 ));
4860 };
4861 let Some(existing) = existing else {
4862 return Err(SystemContextStageError::InvalidRequest(
4863 "generated system-context authority rejected append without a conflict"
4864 .to_string(),
4865 ));
4866 };
4867 return Err(SystemContextStageError::Conflict {
4868 key: key.clone(),
4869 existing_text: existing.content.render_text(),
4870 existing_source: existing.source.clone(),
4871 });
4872 }
4873 session_document::SystemContextAppendDecision::Duplicate => {
4874 return Ok(AppendSystemContextStatus::Duplicate);
4875 }
4876 session_document::SystemContextAppendDecision::Staged => {}
4877 }
4878
4879 let append = PendingSystemContextAppend {
4880 content: req.content.clone(),
4881 source: req.source.clone(),
4882 idempotency_key: req.idempotency_key.clone(),
4883 source_kind: req.source_kind,
4884 peer_response_terminal: req.peer_response_terminal.clone(),
4888 accepted_at,
4889 };
4890 if let Some(key) = req.idempotency_key.as_ref() {
4891 state.seen.insert(
4892 key.clone(),
4893 SeenSystemContextKey {
4894 content: append.content.clone(),
4895 source: append.source.clone(),
4896 source_kind: append.source_kind,
4897 state: SeenSystemContextState::Pending,
4898 },
4899 );
4900 }
4901 if active_turn_scoped {
4902 state
4903 .active_turn_pending_indices
4904 .insert(usize_to_u64(state.pending.len()));
4905 if let Some(key) = req.idempotency_key.as_ref() {
4906 state.active_turn_pending_keys.insert(key.clone());
4907 }
4908 }
4909 state.pending.push(append);
4910 Ok(AppendSystemContextStatus::Staged)
4911 }
4912
4913 pub(super) fn mark_pending_applied(state: &mut SessionSystemContextState) {
4914 let pending = std::mem::take(&mut state.pending);
4917 let mut seen_to_remove = Vec::new();
4918 for append in &pending {
4919 let Some((promote_to_applied, mark_seen_applied, remove_seen)) =
4920 pending_apply_item(append.source_kind)
4921 else {
4922 continue;
4923 };
4924 if promote_to_applied && !state.applied.contains(append) {
4925 state.applied.push(append.clone());
4926 }
4927 if let Some(key) = append.idempotency_key.as_ref() {
4928 if remove_seen {
4929 seen_to_remove.push(key.clone());
4930 } else if mark_seen_applied && let Some(seen) = state.seen.get_mut(key) {
4931 seen.state = SeenSystemContextState::Applied;
4932 }
4933 }
4934 }
4935 for key in seen_to_remove {
4936 state.seen.remove(&key);
4937 }
4938 state.active_turn_pending_keys.clear();
4939 state.active_turn_pending_indices.clear();
4940 }
4941
4942 pub(super) fn discard_unapplied_active_turn_pending(
4943 state: &mut SessionSystemContextState,
4944 ) -> Vec<PendingSystemContextAppend> {
4945 reconstruct_legacy_active_turn_indices(state);
4946 if state.active_turn_pending_indices.is_empty() {
4947 return Vec::new();
4948 }
4949 let discarded = discard_pending_where(state, |_append, is_active_turn| is_active_turn);
4950
4951 for append in &discarded {
4952 if let Some(key) = append.idempotency_key.as_ref()
4953 && state
4954 .seen
4955 .get(key)
4956 .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
4957 {
4958 state.seen.remove(key);
4959 }
4960 }
4961
4962 discarded
4963 }
4964
4965 pub(super) fn discard_active_turn_pending_by_keys(
4966 state: &mut SessionSystemContextState,
4967 idempotency_keys: &[String],
4968 ) -> Vec<PendingSystemContextAppend> {
4969 reconstruct_legacy_active_turn_indices(state);
4970 if idempotency_keys.is_empty() || state.active_turn_pending_indices.is_empty() {
4971 return Vec::new();
4972 }
4973 let requested_keys: BTreeSet<&str> = idempotency_keys.iter().map(String::as_str).collect();
4974 let discarded = discard_pending_where(state, |append, is_active_turn| {
4975 is_active_turn
4976 && append
4977 .idempotency_key
4978 .as_ref()
4979 .is_some_and(|key| requested_keys.contains(key.as_str()))
4980 });
4981
4982 for append in &discarded {
4983 let Some(key) = append.idempotency_key.as_ref() else {
4984 continue;
4985 };
4986 if state
4987 .seen
4988 .get(key)
4989 .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
4990 {
4991 state.seen.remove(key);
4992 }
4993 }
4994
4995 discarded
4996 }
4997
4998 pub(super) fn discard_transient_runtime_steer_state(
4999 state: &mut SessionSystemContextState,
5000 ) -> usize {
5001 let mut removed = 0usize;
5002
5003 let before_active = state.active_turn_pending_keys.len();
5004 removed += discard_pending_where(state, |append, _is_active_turn| {
5005 steer_cleanup_discards(append.source_kind)
5006 })
5007 .len();
5008
5009 let before_applied = state.applied.len();
5010 state
5011 .applied
5012 .retain(|append| !steer_cleanup_discards(append.source_kind));
5013 removed += before_applied.saturating_sub(state.applied.len());
5014
5015 let before_seen = state.seen.len();
5016 state
5017 .seen
5018 .retain(|_key, seen| !steer_cleanup_discards(seen.source_kind));
5019 removed += before_seen.saturating_sub(state.seen.len());
5020
5021 removed += before_active.saturating_sub(state.active_turn_pending_keys.len());
5022
5023 removed
5024 }
5025
5026 pub(super) fn remove_runtime_steer_blocks_for_rendered(
5027 system_prompt: &str,
5028 runtime_steer_appends: &[PendingSystemContextAppend],
5029 ) -> (String, usize) {
5030 if runtime_steer_appends.is_empty() {
5031 return (system_prompt.to_string(), 0);
5032 }
5033 let steer_blocks: BTreeSet<String> = runtime_steer_appends
5037 .iter()
5038 .map(render_system_context_block)
5039 .collect();
5040 let parts = system_prompt
5041 .split(super::SYSTEM_CONTEXT_SEPARATOR)
5042 .map(str::to_string)
5043 .collect::<Vec<_>>();
5044 let original_len = parts.len();
5045 let retained = parts
5046 .into_iter()
5047 .filter(|part| !steer_blocks.contains(part))
5048 .collect::<Vec<_>>();
5049 let removed = original_len.saturating_sub(retained.len());
5050 (retained.join(super::SYSTEM_CONTEXT_SEPARATOR), removed)
5051 }
5052
5053 pub(super) fn record_applied_system_context_blocks(
5054 state: &mut SessionSystemContextState,
5055 appends: &[PendingSystemContextAppend],
5056 current_system_prompt: &str,
5057 ) -> Vec<PendingSystemContextAppend> {
5058 let mut new_appends: Vec<PendingSystemContextAppend> = Vec::new();
5059 for append in appends {
5060 if append.content.render_text().trim().is_empty() {
5061 continue;
5062 }
5063 let rendered = render_system_context_block(append);
5064 if let Some(key) = append.idempotency_key.as_ref() {
5065 if let Some(existing) = state.seen.get(key)
5066 && !seen_system_context_matches(existing, append)
5067 {
5068 tracing::warn!(
5069 idempotency_key = %key,
5070 "skipping conflicting runtime system-context append"
5071 );
5072 continue;
5073 }
5074 if let Some(existing) = state
5075 .applied
5076 .iter()
5077 .find(|applied| applied.idempotency_key.as_ref() == Some(key))
5078 && !pending_system_context_matches(existing, append)
5079 {
5080 tracing::warn!(
5081 idempotency_key = %key,
5082 "skipping conflicting runtime system-context append"
5083 );
5084 continue;
5085 }
5086 if let Some(existing) = new_appends
5087 .iter()
5088 .find(|pending| pending.idempotency_key.as_ref() == Some(key))
5089 {
5090 if !pending_system_context_matches(existing, append) {
5091 tracing::warn!(
5092 idempotency_key = %key,
5093 "skipping conflicting runtime system-context append"
5094 );
5095 }
5096 continue;
5097 }
5098 if current_system_prompt.contains(&rendered) {
5099 record_applied_append(state, append);
5100 continue;
5101 }
5102 } else if new_appends.contains(append) || current_system_prompt.contains(&rendered) {
5103 continue;
5104 }
5105 record_applied_append(state, append);
5106 new_appends.push(append.clone());
5107 }
5108 new_appends
5109 }
5110
5111 fn record_applied_append(
5112 state: &mut SessionSystemContextState,
5113 append: &PendingSystemContextAppend,
5114 ) {
5115 if let Some(key) = append.idempotency_key.as_ref() {
5116 state.seen.insert(
5117 key.clone(),
5118 SeenSystemContextKey {
5119 content: append.content.clone(),
5120 source: append.source.clone(),
5121 source_kind: append.source_kind,
5122 state: SeenSystemContextState::Applied,
5123 },
5124 );
5125 if state
5126 .applied
5127 .iter()
5128 .any(|applied| applied.idempotency_key.as_ref() == Some(key))
5129 {
5130 return;
5131 }
5132 } else if state.applied.contains(append) {
5133 return;
5134 }
5135 state.applied.push(append.clone());
5136 }
5137
5138 fn seen_system_context_matches(
5139 seen: &SeenSystemContextKey,
5140 append: &PendingSystemContextAppend,
5141 ) -> bool {
5142 seen.content == append.content && seen.source.as_deref() == append.source.as_deref()
5143 }
5144
5145 fn pending_system_context_matches(
5146 existing: &PendingSystemContextAppend,
5147 append: &PendingSystemContextAppend,
5148 ) -> bool {
5149 existing.content == append.content && existing.source.as_deref() == append.source.as_deref()
5150 }
5151}
5152
5153impl Session {
5154 #[doc(hidden)]
5157 pub fn classify_callback_result_ingress(
5158 &self,
5159 incoming: &[ToolResult],
5160 ) -> Result<CallbackResultIngress, crate::error::AgentError> {
5161 match self
5162 .resolve_pending_callback_tool_results(incoming.to_vec())
5163 .map_err(|error| {
5164 crate::error::AgentError::ConfigError(format!(
5165 "callback result ingress was rejected: {error}"
5166 ))
5167 })? {
5168 ResolvedPendingCallbackToolResults::NoState => {
5169 Ok(CallbackResultIngress::NoPendingBatch)
5170 }
5171 ResolvedPendingCallbackToolResults::AlreadyApplied { .. } => {
5172 Ok(CallbackResultIngress::AlreadyApplied)
5173 }
5174 ResolvedPendingCallbackToolResults::Pending { batch, .. } => {
5175 Ok(CallbackResultIngress::Pending {
5176 pending_tool_use_ids: batch.pending_tool_use_ids,
5177 })
5178 }
5179 }
5180 }
5181
5182 pub fn new() -> Self {
5184 let now = SystemTime::now();
5185 Self {
5186 version: session_version(),
5187 id: SessionId::new(),
5188 messages: TranscriptMessages::default(),
5189 created_at: now,
5190 updated_at: now,
5191 metadata: serde_json::Map::new(),
5192 history_caches: Box::default(),
5193 transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
5194 usage: Usage::default(),
5195 }
5196 }
5197
5198 pub fn with_id(id: SessionId) -> Self {
5200 let mut session = Self::new();
5201 session.id = id;
5202 session
5203 }
5204
5205 pub fn id(&self) -> &SessionId {
5207 &self.id
5208 }
5209
5210 pub fn version(&self) -> u32 {
5212 self.version
5213 }
5214
5215 pub fn messages(&self) -> &[Message] {
5217 &self.messages
5218 }
5219
5220 pub fn transcript_content_digest(&self) -> Result<String, serde_json::Error> {
5230 self.messages.digest()
5231 }
5232
5233 pub fn transcript_prefix_digest(&self, count: usize) -> Result<String, serde_json::Error> {
5239 if count > self.messages.len() {
5240 return Err(<serde_json::Error as serde::ser::Error>::custom(format!(
5245 "transcript prefix digest requested for {count} messages but the transcript has {}",
5246 self.messages.len()
5247 )));
5248 }
5249 if let Some(witness) = self.messages.prefix_digest_witness(count) {
5250 return Ok(witness);
5251 }
5252 transcript_messages_digest(&self.messages[..count])
5253 }
5254
5255 #[doc(hidden)]
5258 #[must_use]
5259 pub fn transcript_mutation_epoch(&self) -> u64 {
5260 self.messages.mutation_epoch()
5261 }
5262
5263 #[allow(dead_code)] pub(crate) fn replace_messages_internal(
5270 &mut self,
5271 messages: Vec<Message>,
5272 reason: TranscriptRewriteReason,
5273 ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError> {
5274 if transcript_messages_digest(self.messages()).ok()
5275 == transcript_messages_digest(&messages).ok()
5276 {
5277 return Ok(None);
5278 }
5279 let commit = self.commit_transcript_rewrite(
5280 TranscriptRewriteSelection::MessageRange {
5281 start: 0,
5282 end: self.messages.len(),
5283 },
5284 messages,
5285 reason,
5286 Some("meerkat-core".to_string()),
5287 None,
5288 )?;
5289 Ok(Some(commit))
5290 }
5291
5292 pub(crate) fn replace_messages_for_compaction_internal(
5295 &mut self,
5296 messages: Vec<Message>,
5297 authority: &crate::agent::compact::ValidatedCompactionRewrite,
5298 ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError> {
5299 if transcript_messages_digest(self.messages()).ok()
5300 == transcript_messages_digest(&messages).ok()
5301 {
5302 return Ok(None);
5303 }
5304 if !authority
5305 .authorizes(self.messages(), &messages)
5306 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?
5307 {
5308 return Err(TranscriptEditError::InvalidTranscriptShape(
5309 "validated compaction witness does not authorize this exact transcript rebuild"
5310 .to_string(),
5311 ));
5312 }
5313 let summary_count = messages
5314 .iter()
5315 .filter(|message| {
5316 matches!(message, Message::User(user) if user.transcript_role.is_compaction_summary())
5317 })
5318 .count();
5319 if messages.len() >= self.messages.len() || summary_count != 1 {
5320 return Err(TranscriptEditError::InvalidTranscriptShape(
5321 "validated compaction rewrite must shrink the transcript and carry exactly one CompactionSummary"
5322 .to_string(),
5323 ));
5324 }
5325 let selection =
5326 TranscriptRewriteSelection::validated_compaction(0, self.messages.len(), authority);
5327 let commit = self.commit_transcript_rewrite_authorized(
5328 selection,
5329 messages,
5330 TranscriptRewriteReason::new("compaction"),
5331 Some("meerkat-core".to_string()),
5332 None,
5333 )?;
5334 Ok(Some(commit))
5335 }
5336
5337 pub fn replace_synthetic_notices(
5349 &mut self,
5350 kind: crate::types::SystemNoticeKind,
5351 replacements: Vec<Message>,
5352 ) -> Result<(), TranscriptEditError> {
5353 if !kind.is_synthetic_refresh_projection() {
5354 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
5355 "system notice kind {kind:?} is durable transcript content, not a synthetic refresh projection"
5356 )));
5357 }
5358 for (index, message) in replacements.iter().enumerate() {
5359 let matches_kind = matches!(
5360 message,
5361 Message::SystemNotice(notice)
5362 if notice.kind == kind && notice.is_synthetic_refresh_projection()
5363 );
5364 if !matches_kind {
5365 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
5366 "replacement {index} for synthetic notice kind {kind:?} is not a system notice of that kind"
5367 )));
5368 }
5369 }
5370
5371 let mut refreshed = self
5372 .messages
5373 .iter()
5374 .filter(|message| {
5375 !matches!(
5376 message,
5377 Message::SystemNotice(notice)
5378 if notice.kind == kind && notice.is_synthetic_refresh_projection()
5379 )
5380 })
5381 .cloned()
5382 .collect::<Vec<_>>();
5383 refreshed.extend(replacements);
5384 let refreshed_digest = transcript_messages_digest(&refreshed)
5385 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
5386 if self.messages.digest().ok().as_deref() == Some(refreshed_digest.as_str()) {
5387 return Ok(());
5388 }
5389
5390 let realtime_state =
5391 self.reconciled_realtime_transcript_metadata_after_rewrite(&refreshed)?;
5392 let updated_at = SystemTime::now();
5393 let history_state = self
5394 .transcript_history_state_after_message_mutation(
5395 &refreshed,
5396 refreshed_digest,
5397 updated_at,
5398 TranscriptMutationShape::Rewritten,
5399 )?
5400 .map(serde_json::to_value)
5401 .transpose()
5402 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
5403
5404 self.messages.replace(refreshed);
5407 self.updated_at = updated_at;
5408 if let Some(value) = realtime_state {
5409 self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
5410 }
5411 if let Some(value) = history_state {
5412 self.set_validated_transcript_history_metadata(value);
5413 }
5414 Ok(())
5415 }
5416
5417 pub fn created_at(&self) -> SystemTime {
5419 self.created_at
5420 }
5421
5422 pub fn updated_at(&self) -> SystemTime {
5424 self.updated_at
5425 }
5426
5427 pub fn push(&mut self, message: Message) {
5431 self.messages.push(message);
5433 self.updated_at = SystemTime::now();
5434 self.refresh_transcript_head_after_message_mutation(TranscriptMutationShape::Appended);
5435 }
5436
5437 pub fn push_batch(&mut self, messages: Vec<Message>) {
5441 if messages.is_empty() {
5442 return;
5443 }
5444 self.messages.extend_batch(messages);
5446 self.updated_at = SystemTime::now();
5447 self.refresh_transcript_head_after_message_mutation(TranscriptMutationShape::Appended);
5448 }
5449
5450 pub async fn externalize_media(
5461 &mut self,
5462 blob_store: &dyn crate::BlobStore,
5463 start: usize,
5464 ) -> Result<(), crate::blob::BlobStoreError> {
5465 let previous_digest = if self
5471 .metadata
5472 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
5473 {
5474 self.messages.digest().ok()
5475 } else {
5476 None
5477 };
5478 let buffer = self.messages.begin_in_place_scan();
5479 let lowest_mutated = match crate::image_content::externalize_messages_from_reporting_lowest(
5480 blob_store, buffer, start,
5481 )
5482 .await
5483 {
5484 Ok(lowest_mutated) => lowest_mutated,
5485 Err(error) => {
5486 self.messages.finish_in_place_scan(Some(start));
5489 return Err(error);
5490 }
5491 };
5492 self.messages.finish_in_place_scan(lowest_mutated);
5493 if lowest_mutated.is_some()
5494 && let Some(previous_digest) = previous_digest
5495 && self.messages.digest().ok().as_ref() != Some(&previous_digest)
5496 {
5497 self.refresh_transcript_head_after_message_mutation(TranscriptMutationShape::Rewritten);
5498 }
5499 Ok(())
5500 }
5501
5502 pub async fn hydrate_realtime_user_images(
5510 &mut self,
5511 blob_store: &dyn crate::BlobStore,
5512 max_decoded_bytes: usize,
5513 ) -> Result<(), crate::image_content::RealtimeUserImageHydrationError> {
5514 self.hydrate_realtime_user_images_with_usage(blob_store, max_decoded_bytes)
5515 .await
5516 .map(|_| ())
5517 }
5518
5519 pub async fn hydrate_realtime_user_images_with_usage(
5522 &mut self,
5523 blob_store: &dyn crate::BlobStore,
5524 max_decoded_bytes: usize,
5525 ) -> Result<usize, crate::image_content::RealtimeUserImageHydrationError> {
5526 let previous_digest = if self
5527 .metadata
5528 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
5529 {
5530 self.messages.digest().ok()
5531 } else {
5532 None
5533 };
5534 let buffer = self.messages.begin_in_place_scan();
5536 let (decoded_total, lowest_mutated) =
5537 match crate::image_content::hydrate_user_images_for_realtime_projection_reporting_lowest(
5538 blob_store,
5539 buffer,
5540 max_decoded_bytes,
5541 )
5542 .await
5543 {
5544 Ok(outcome) => outcome,
5545 Err(error) => {
5546 self.messages.finish_in_place_scan(Some(0));
5547 return Err(error);
5548 }
5549 };
5550 self.messages.finish_in_place_scan(lowest_mutated);
5551 if lowest_mutated.is_some()
5552 && let Some(previous_digest) = previous_digest
5553 && self.messages.digest().ok().as_ref() != Some(&previous_digest)
5554 {
5555 self.refresh_transcript_head_after_message_mutation(TranscriptMutationShape::Rewritten);
5556 }
5557 Ok(decoded_total)
5558 }
5559
5560 pub fn touch(&mut self) {
5564 self.updated_at = SystemTime::now();
5565 }
5566
5567 pub fn last_n(&self, n: usize) -> &[Message] {
5569 let start = self.messages.len().saturating_sub(n);
5570 &self.messages[start..]
5571 }
5572
5573 pub fn total_tokens(&self) -> u64 {
5575 self.usage.total_tokens()
5576 }
5577
5578 pub fn total_usage(&self) -> Usage {
5580 self.usage.clone()
5581 }
5582
5583 pub fn record_usage(&mut self, turn_usage: Usage) {
5585 self.usage.add(&turn_usage);
5586 self.updated_at = SystemTime::now();
5587 }
5588
5589 pub fn append_external_user_content(&mut self, content: ContentInput) {
5591 self.push(Message::User(UserMessage::with_blocks(
5592 content.into_blocks(),
5593 )));
5594 }
5595
5596 pub fn append_external_assistant_blocks(
5598 &mut self,
5599 blocks: Vec<AssistantBlock>,
5600 stop_reason: StopReason,
5601 usage: Usage,
5602 ) {
5603 if !blocks.is_empty() {
5604 self.push(Message::BlockAssistant(BlockAssistantMessage::new(
5605 blocks,
5606 stop_reason,
5607 )));
5608 }
5609 if usage != Usage::default() {
5610 self.record_usage(usage);
5611 }
5612 }
5613
5614 pub fn append_realtime_transcript_event(
5622 &mut self,
5623 event: RealtimeTranscriptEvent,
5624 ) -> RealtimeTranscriptApplyOutcome {
5625 let mut state = self.realtime_transcript_state();
5626 let commit =
5627 realtime_transcript_revision::apply_realtime_transcript_event(&mut state, event)
5628 .unwrap_or_else(|err| {
5629 fail_closed_generated_restore(
5630 "realtime-transcript",
5631 <serde_json::Error as serde::de::Error>::custom(err),
5632 )
5633 });
5634 self.store_realtime_transcript_state(&state);
5635 self.push_batch(commit.messages);
5636 if commit.usage != Usage::default() {
5637 self.record_usage(commit.usage);
5638 }
5639 commit.outcome
5640 }
5641
5642 #[must_use]
5645 pub fn preflight_realtime_user_content_event(
5646 &self,
5647 event: &RealtimeTranscriptEvent,
5648 ) -> Option<crate::RealtimeUserContentApplyOutcome> {
5649 let state = self.realtime_transcript_state();
5650 realtime_transcript_revision::preflight_realtime_user_content_event(&state, event)
5651 .unwrap_or_else(|err| {
5652 fail_closed_generated_restore(
5653 "realtime-user-content-preflight",
5654 <serde_json::Error as serde::de::Error>::custom(err),
5655 )
5656 })
5657 }
5658
5659 #[must_use]
5678 pub fn in_flight_realtime_assistant_response_ids(&self) -> Vec<String> {
5679 let state = self.realtime_transcript_state();
5680 realtime_transcript_revision::in_flight_realtime_assistant_response_ids(&state)
5681 }
5682
5683 #[must_use]
5686 pub fn realtime_user_content_identities(&self) -> Vec<RealtimeUserContentIdentity> {
5687 let state = self.realtime_transcript_state();
5688 realtime_transcript_revision::realtime_user_content_identities(&state)
5689 }
5690
5691 #[must_use]
5694 pub fn pending_realtime_user_content_blob(
5695 &self,
5696 ) -> Option<crate::PendingRealtimeUserContentBlob> {
5697 let state = self.realtime_transcript_state();
5698 realtime_transcript_revision::pending_realtime_user_content_blob(&state)
5699 }
5700
5701 pub fn stage_pending_realtime_user_content_blob(
5704 &mut self,
5705 pending: crate::PendingRealtimeUserContentBlob,
5706 ) -> Result<
5707 crate::generated::session_document::RealtimeUserContentBlobStageDisposition,
5708 realtime_transcript_revision::RealtimeTranscriptShellError,
5709 > {
5710 let mut state = self.realtime_transcript_state();
5711 let disposition = realtime_transcript_revision::stage_pending_realtime_user_content_blob(
5712 &mut state, pending,
5713 )?;
5714 self.store_realtime_transcript_state(&state);
5715 Ok(disposition)
5716 }
5717
5718 pub fn resolve_pending_realtime_user_content_blob_recovery(
5719 &self,
5720 request: Option<&crate::PendingRealtimeUserContentBlob>,
5721 pending_blob_valid: bool,
5722 ) -> Result<
5723 crate::generated::session_document::RealtimeUserContentBlobRecoveryDisposition,
5724 realtime_transcript_revision::RealtimeTranscriptShellError,
5725 > {
5726 let state = self.realtime_transcript_state();
5727 realtime_transcript_revision::resolve_pending_realtime_user_content_blob_recovery(
5728 &state,
5729 request,
5730 pending_blob_valid,
5731 )
5732 }
5733
5734 pub fn clear_invalid_pending_realtime_user_content_blob(
5737 &mut self,
5738 request: Option<&crate::PendingRealtimeUserContentBlob>,
5739 ) -> Result<(), realtime_transcript_revision::RealtimeTranscriptShellError> {
5740 let mut state = self.realtime_transcript_state();
5741 realtime_transcript_revision::clear_invalid_pending_realtime_user_content_blob(
5742 &mut state, request,
5743 )?;
5744 self.store_realtime_transcript_state(&state);
5745 Ok(())
5746 }
5747
5748 #[must_use]
5752 pub fn realtime_user_content_tombstones(
5753 &self,
5754 ) -> Vec<crate::realtime_transcript::RealtimeUserContentTombstone> {
5755 let state = self.realtime_transcript_state();
5756 realtime_transcript_revision::realtime_user_content_tombstones(&state)
5757 }
5758
5759 fn realtime_transcript_state(&self) -> SessionRealtimeTranscriptState {
5760 match self.try_realtime_transcript_state() {
5761 Ok(Some(state)) => state,
5762 Ok(None) => SessionRealtimeTranscriptState::default(),
5763 Err(err) => fail_closed_generated_restore("realtime-transcript", err),
5764 }
5765 }
5766
5767 fn try_realtime_transcript_state(
5768 &self,
5769 ) -> Result<Option<SessionRealtimeTranscriptState>, serde_json::Error> {
5770 self.metadata
5771 .get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY)
5772 .map(|value| {
5773 let state = serde_json::from_value(value.clone())?;
5774 realtime_transcript_revision::restore_realtime_transcript_state(state)
5775 .map_err(<serde_json::Error as serde::de::Error>::custom)
5776 })
5777 .transpose()
5778 }
5779
5780 fn store_realtime_transcript_state(&mut self, state: &SessionRealtimeTranscriptState) {
5781 match serde_json::to_value(state) {
5782 Ok(value) => self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value),
5783 Err(error) => {
5784 tracing::warn!(error = %error, "failed to serialize realtime transcript state");
5785 }
5786 }
5787 }
5788
5789 fn reconciled_realtime_transcript_metadata_after_rewrite(
5790 &self,
5791 messages: &[Message],
5792 ) -> Result<Option<serde_json::Value>, TranscriptEditError> {
5793 let Some(state) = self
5794 .try_realtime_transcript_state()
5795 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
5796 else {
5797 return Ok(None);
5798 };
5799 let state =
5800 realtime_transcript_revision::reconcile_realtime_transcript_state_after_rewrite(
5801 state, messages,
5802 )
5803 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
5804 serde_json::to_value(state)
5805 .map(Some)
5806 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))
5807 }
5808
5809 fn apply_authorized_system_prompt(
5810 &mut self,
5811 prompt: session_durable_config_authority::AuthorizedSystemPrompt,
5812 ) {
5813 use crate::types::SystemMessage;
5814
5815 let mutation_kind = prompt.mutation_kind();
5820 let (prompt, _replacing_existing) = prompt.into_parts();
5821 let message = SystemMessage::with_mutation_kind(prompt, mutation_kind);
5822 let inner = self.messages.mutate_in_place();
5825 if let Some(Message::System(_)) = inner.first() {
5827 inner[0] = Message::System(message);
5828 } else {
5829 inner.insert(0, Message::System(message));
5830 }
5831 self.updated_at = SystemTime::now();
5832 self.refresh_transcript_head_after_message_mutation(TranscriptMutationShape::Rewritten);
5833 }
5834
5835 pub fn set_system_prompt_with_source(
5837 &mut self,
5838 prompt: String,
5839 source: session_durable_config_authority::SessionSystemPromptSource,
5840 ) -> Result<(), session_durable_config_authority::SessionDurableConfigAuthorityError> {
5841 let replacing_existing = matches!(self.messages.first(), Some(Message::System(_)));
5842 let prompt = session_durable_config_authority::authorize_system_prompt_mutation(
5843 prompt,
5844 source,
5845 replacing_existing,
5846 )?;
5847 self.apply_authorized_system_prompt(prompt);
5848 Ok(())
5849 }
5850
5851 pub fn set_system_prompt(&mut self, prompt: String) {
5853 if let Err(err) = self.set_system_prompt_with_source(
5854 prompt,
5855 session_durable_config_authority::SessionSystemPromptSource::DirectMutation,
5856 ) {
5857 tracing::warn!(error = %err, "generated session durable-config authority rejected system prompt mutation");
5858 }
5859 }
5860
5861 pub fn discard_transient_runtime_steer_context(&mut self) -> usize {
5867 let mut removed = 0usize;
5868
5869 let mut state = match self.try_system_context_state() {
5870 Ok(state) => state.unwrap_or_default(),
5871 Err(err) => {
5872 tracing::warn!(
5873 error = %err,
5874 "generated system-context authority rejected runtime steer cleanup state"
5875 );
5876 return removed;
5877 }
5878 };
5879
5880 let runtime_steer_appends = state
5885 .pending
5886 .iter()
5887 .chain(state.applied.iter())
5888 .filter(|append| append.source_kind.is_runtime_steer())
5889 .cloned()
5890 .collect::<Vec<_>>();
5891 if let Some(Message::System(system)) = self.messages.first() {
5892 let (retained_prompt, removed_blocks) =
5893 system_context_authority::remove_runtime_steer_blocks_for_rendered(
5894 &system.content,
5895 &runtime_steer_appends,
5896 );
5897 if removed_blocks > 0 {
5898 removed += removed_blocks;
5899 if let Err(err) = self.set_system_prompt_with_source(
5900 retained_prompt,
5901 session_durable_config_authority::SessionSystemPromptSource::RuntimeSteerCleanup,
5902 ) {
5903 tracing::warn!(
5904 error = %err,
5905 "generated session durable-config authority rejected runtime steer prompt cleanup"
5906 );
5907 }
5908 }
5909 }
5910
5911 removed += system_context_authority::discard_transient_runtime_steer_state(&mut state);
5912
5913 if removed > 0
5914 && let Err(err) = self.set_system_context_state(state)
5915 {
5916 tracing::warn!(
5917 error = %err,
5918 "failed to persist runtime steer context cleanup"
5919 );
5920 }
5921
5922 removed
5923 }
5924
5925 pub fn append_system_context_blocks(&mut self, appends: &[PendingSystemContextAppend]) {
5927 if appends.is_empty() {
5928 return;
5929 }
5930
5931 let current_system_prompt = self
5932 .messages
5933 .first()
5934 .and_then(|message| match message {
5935 Message::System(system) => Some(system.content.as_str()),
5936 _ => None,
5937 })
5938 .unwrap_or_default();
5939 let mut state = match self.try_system_context_state() {
5940 Ok(state) => state.unwrap_or_default(),
5941 Err(err) => {
5942 tracing::warn!(
5943 error = %err,
5944 "generated system-context authority rejected applied context state"
5945 );
5946 return;
5947 }
5948 };
5949 let new_appends = system_context_authority::record_applied_system_context_blocks(
5950 &mut state,
5951 appends,
5952 current_system_prompt,
5953 );
5954 if new_appends.is_empty() {
5955 if let Err(err) = self.set_system_context_state(state) {
5956 tracing::warn!(error = %err, "failed to persist applied system-context state");
5957 }
5958 return;
5959 }
5960
5961 let rendered = render_system_context_blocks_joined(&new_appends);
5962
5963 let next = match self.messages.first() {
5964 Some(Message::System(sys)) if !sys.content.is_empty() => {
5965 format!("{}{}{}", sys.content, SYSTEM_CONTEXT_SEPARATOR, rendered)
5966 }
5967 _ => rendered,
5968 };
5969 if let Err(err) = self.set_system_prompt_with_source(
5970 next,
5971 session_durable_config_authority::SessionSystemPromptSource::RuntimeContextAppend,
5972 ) {
5973 tracing::warn!(
5974 error = %err,
5975 "generated session durable-config authority rejected system-context prompt append"
5976 );
5977 return;
5978 }
5979 if let Err(err) = self.set_system_context_state(state) {
5980 tracing::warn!(error = %err, "failed to persist applied system-context state");
5981 }
5982 }
5983
5984 pub fn reconcile_resumed_system_prompt(
6010 &mut self,
6011 assembled_base: String,
6012 actor: Option<String>,
6013 ) -> Result<ResumedSystemPromptReconciliation, TranscriptEditError> {
6014 let persisted = match self.messages.first() {
6015 Some(Message::System(system)) => Some((system.content.clone(), system.mutation_kind)),
6016 _ => None,
6017 };
6018
6019 let Some((persisted_content, persisted_mutation_kind)) = persisted else {
6020 if assembled_base.is_empty() {
6021 return Ok(ResumedSystemPromptReconciliation::NoChange);
6022 }
6023 self.commit_resume_system_prompt_rewrite(assembled_base, false, actor)?;
6027 return Ok(ResumedSystemPromptReconciliation::RewrittenBase);
6028 };
6029
6030 if persisted_content == assembled_base {
6031 return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
6032 }
6033
6034 if let Some(tail) = self.verified_runtime_context_tail(&persisted_content) {
6043 let expected = compose_system_prompt_with_context_tail(&assembled_base, &tail);
6044 if expected == persisted_content {
6045 return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
6046 }
6047 self.commit_resume_system_prompt_rewrite(expected, true, actor)?;
6048 return Ok(ResumedSystemPromptReconciliation::RewrittenBase);
6049 }
6050
6051 if persisted_prompt_is_admitted_context_append_continuation(
6059 &assembled_base,
6060 &persisted_content,
6061 persisted_mutation_kind,
6062 ) {
6063 return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
6064 }
6065
6066 let dropping_applied_context = persisted_mutation_kind.is_runtime_context_append()
6073 || self
6074 .system_context_state()
6075 .is_some_and(|state| !state.applied.is_empty());
6076 self.commit_resume_system_prompt_rewrite(assembled_base, true, actor)?;
6077 if dropping_applied_context {
6078 tracing::warn!(
6079 session_id = %self.id,
6080 "resume base-prompt refresh dropped an unverifiable runtime system-context tail; \
6081 clearing applied-append records so keyed re-sends can restore the context"
6082 );
6083 self.clear_applied_system_context_records();
6084 }
6085 Ok(ResumedSystemPromptReconciliation::RewrittenBase)
6086 }
6087
6088 fn verified_runtime_context_tail(&self, persisted_content: &str) -> Option<String> {
6098 if let Some(prior_base) = self
6099 .build_state()
6100 .and_then(|state| state.assembled_system_prompt)
6101 {
6102 if persisted_content == prior_base {
6103 return Some(String::new());
6104 }
6105 if let Some(appended) = persisted_content.strip_prefix(prior_base.as_str())
6106 && appended.starts_with(SYSTEM_CONTEXT_SEPARATOR)
6107 {
6108 return Some(appended.to_string());
6109 }
6110 }
6113 let rendered_tail = self
6114 .system_context_state()
6115 .map(|state| render_system_context_blocks_joined(&state.applied))
6116 .unwrap_or_default();
6117 if rendered_tail.is_empty() {
6118 return None;
6119 }
6120 if persisted_content == rendered_tail {
6121 return Some(format!("{SYSTEM_CONTEXT_SEPARATOR}{rendered_tail}"));
6125 }
6126 let with_separator = format!("{SYSTEM_CONTEXT_SEPARATOR}{rendered_tail}");
6127 persisted_content
6128 .ends_with(&with_separator)
6129 .then_some(with_separator)
6130 }
6131
6132 fn commit_resume_system_prompt_rewrite(
6135 &mut self,
6136 content: String,
6137 replacing_existing: bool,
6138 actor: Option<String>,
6139 ) -> Result<(), TranscriptEditError> {
6140 let authorized = session_durable_config_authority::authorize_system_prompt_mutation(
6141 content,
6142 session_durable_config_authority::SessionSystemPromptSource::ExplicitBuild,
6143 replacing_existing,
6144 )
6145 .map_err(|err| {
6146 TranscriptEditError::HistoryStateMalformed(format!(
6147 "generated session durable-config authority rejected resume system prompt refresh: {err}"
6148 ))
6149 })?;
6150 let mutation_kind = authorized.mutation_kind();
6151 let (content, _replacing_existing) = authorized.into_parts();
6152 let replacement = Message::System(crate::types::SystemMessage::with_mutation_kind(
6153 content,
6154 mutation_kind,
6155 ));
6156 let end = usize::from(replacing_existing);
6157 self.commit_transcript_rewrite(
6158 TranscriptRewriteSelection::MessageRange { start: 0, end },
6159 vec![replacement],
6160 TranscriptRewriteReason::new(RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON),
6161 actor,
6162 None,
6163 )?;
6164 Ok(())
6165 }
6166
6167 fn clear_applied_system_context_records(&mut self) {
6171 let mut state = match self.try_system_context_state() {
6172 Ok(Some(state)) => state,
6173 Ok(None) => return,
6174 Err(error) => {
6175 tracing::warn!(
6176 session_id = %self.id,
6177 error = %error,
6178 "failed to read system-context state while clearing orphaned applied records"
6179 );
6180 return;
6181 }
6182 };
6183 if state.applied.is_empty() {
6184 return;
6185 }
6186 let dropped_keys: Vec<String> = state
6187 .applied
6188 .iter()
6189 .filter_map(|append| append.idempotency_key.clone())
6190 .collect();
6191 state.applied.clear();
6192 for key in &dropped_keys {
6193 state.seen.remove(key);
6194 }
6195 if let Err(error) = self.set_system_context_state(state) {
6196 tracing::warn!(
6197 session_id = %self.id,
6198 error = %error,
6199 "failed to persist cleared applied system-context records after resume prompt refresh"
6200 );
6201 }
6202 }
6203
6204 pub fn last_assistant_text(&self) -> Option<String> {
6211 self.messages.iter().rev().find_map(|m| match m {
6212 Message::BlockAssistant(a) => {
6213 let mut buf = String::new();
6214 for block in &a.blocks {
6215 match block {
6216 crate::types::AssistantBlock::Text { text, .. }
6217 | crate::types::AssistantBlock::Transcript { text, .. } => {
6218 buf.push_str(text);
6219 }
6220 _ => {}
6221 }
6222 }
6223 if buf.is_empty() { None } else { Some(buf) }
6224 }
6225 _ => None,
6226 })
6227 }
6228
6229 pub fn tool_call_count(&self) -> usize {
6231 self.messages
6232 .iter()
6233 .filter_map(|m| match m {
6234 Message::BlockAssistant(a) => Some(
6235 a.blocks
6236 .iter()
6237 .filter(|b| matches!(b, crate::types::AssistantBlock::ToolUse { .. }))
6238 .count(),
6239 ),
6240 _ => None,
6241 })
6242 .sum()
6243 }
6244
6245 pub fn metadata(&self) -> &serde_json::Map<String, serde_json::Value> {
6247 &self.metadata
6248 }
6249
6250 pub(crate) fn cached_transcript_history_witness(&self) -> Option<&str> {
6256 self.history_caches.witness.get().map(String::as_str)
6257 }
6258
6259 pub(crate) fn record_transcript_history_witness(&self, witness: &str) {
6261 let _ = self.history_caches.witness.set(witness.to_string());
6262 }
6263
6264 pub(crate) fn assemble_transcript_history_witness(
6282 &self,
6283 history: &serde_json::Value,
6284 ) -> Option<crate::checkpoint::SessionCheckpointDigest> {
6285 use sha2::Digest as _;
6286 if self.transcript_history_metadata_validation
6287 != TranscriptHistoryMetadataValidation::Validated
6288 {
6289 return None;
6290 }
6291 let object = history.as_object()?;
6292 let head = object.get("head")?.as_str()?;
6293 let commits_value = object.get("commits")?;
6294 let commits_array = commits_value.as_array()?;
6295 let revisions = object.get("revisions")?.as_array()?;
6296
6297 let commits_last = match commits_array.last() {
6298 Some(commit) => commit.get("revision")?.as_str()?.to_string(),
6299 None => String::new(),
6300 };
6301 let mut cache = self.history_caches.assembly.locked();
6302 let commits_bytes = match &cache.commits {
6303 Some((count, last, bytes))
6304 if *count == commits_array.len() && *last == commits_last =>
6305 {
6306 std::sync::Arc::clone(bytes)
6307 }
6308 _ => {
6309 let typed: Vec<TranscriptRewriteCommit> =
6310 serde_json::from_value(commits_value.clone()).ok()?;
6311 let value = serde_json::to_value(&typed).ok()?;
6312 let mut bytes = Vec::new();
6313 crate::checkpoint::write_canonical_json(&value, &mut bytes).ok()?;
6314 let bytes: std::sync::Arc<[u8]> = bytes.into();
6315 cache.commits = Some((
6316 commits_array.len(),
6317 commits_last,
6318 std::sync::Arc::clone(&bytes),
6319 ));
6320 bytes
6321 }
6322 };
6323
6324 let mut chunks: Vec<(&str, Option<std::sync::Arc<[u8]>>)> =
6327 Vec::with_capacity(revisions.len());
6328 for body_value in revisions {
6329 let revision = body_value.get("revision")?.as_str()?;
6330 if revision == head {
6331 let body_messages = body_value.get("messages")?.as_array()?;
6332 if body_messages.len() != self.messages.len() {
6333 return None;
6334 }
6335 chunks.push((revision, None));
6336 continue;
6337 }
6338 let bytes = match cache.bodies.get(revision) {
6339 Some(bytes) => std::sync::Arc::clone(bytes),
6340 None => {
6341 let bytes = canonical_history_body_chunk(body_value)?;
6342 let bytes: std::sync::Arc<[u8]> = bytes.into();
6343 cache
6344 .bodies
6345 .insert(revision.to_string(), std::sync::Arc::clone(&bytes));
6346 bytes
6347 }
6348 };
6349 chunks.push((revision, Some(bytes)));
6350 }
6351 if cache.bodies.len() > revisions.len() {
6353 let live = revisions
6354 .iter()
6355 .filter_map(|body| body.get("revision").and_then(serde_json::Value::as_str))
6356 .collect::<std::collections::HashSet<_>>();
6357 cache
6358 .bodies
6359 .retain(|revision, _| live.contains(revision.as_str()));
6360 }
6361 drop(cache);
6362
6363 chunks.sort_by(|left, right| left.0.cmp(right.0));
6366
6367 let mut hasher = Sha256::new();
6368 let mut hashed_bytes = 0u64;
6369 let mut absorb = |hasher: &mut Sha256, bytes: &[u8]| {
6370 hashed_bytes += bytes.len() as u64;
6371 hasher.update(bytes);
6372 };
6373 absorb(&mut hasher, b"{\"commits\":");
6374 absorb(&mut hasher, &commits_bytes);
6375 absorb(&mut hasher, b",\"head\":");
6376 absorb(&mut hasher, serde_json::to_string(head).ok()?.as_bytes());
6377 absorb(&mut hasher, b",\"revisions\":[");
6378 for (index, (revision, bytes)) in chunks.iter().enumerate() {
6379 if index > 0 {
6380 absorb(&mut hasher, b",");
6381 }
6382 match bytes {
6383 Some(bytes) => absorb(&mut hasher, bytes),
6384 None => {
6385 absorb(&mut hasher, b"{\"messages\":");
6386 if !self.messages.hash_sorted_canonical_into(&mut hasher) {
6387 return None;
6388 }
6389 absorb(&mut hasher, b",\"revision\":");
6390 absorb(
6391 &mut hasher,
6392 serde_json::to_string(revision).ok()?.as_bytes(),
6393 );
6394 absorb(&mut hasher, b"}");
6395 }
6396 }
6397 }
6398 absorb(&mut hasher, b"]}");
6399 crate::checkpoint::record_content_digest_computation();
6403 crate::checkpoint::record_content_digest_bytes(hashed_bytes);
6404 let digest = crate::checkpoint::SessionCheckpointDigest::from_assembled(format!(
6405 "sha256:{:x}",
6406 hasher.finalize()
6407 ));
6408
6409 if digest_accumulator_take_verification_sample() {
6410 let recomputed =
6411 crate::checkpoint::session_checkpoint_history_digest_uncounted(history).ok()?;
6412 assert_eq!(
6413 digest, recomputed,
6414 "incremental history-witness assembly diverged from the canonical \
6415 derivation: a cached segment or the sorted transcript stream is \
6416 stale, or a keying assumption (content-addressed bodies, \
6417 append-only commits) was violated"
6418 );
6419 }
6420 Some(digest)
6421 }
6422
6423 fn set_metadata_unchecked(&mut self, key: &str, value: serde_json::Value) {
6424 if self.metadata.get(key) == Some(&value) {
6431 return;
6432 }
6433 self.metadata.insert(key.to_string(), value);
6434 if key == SESSION_TRANSCRIPT_HISTORY_STATE_KEY {
6435 self.metadata
6436 .remove(SESSION_TRANSCRIPT_HISTORY_CHECKPOINT_DIGEST_KEY);
6437 self.history_caches.witness = std::sync::OnceLock::new();
6438 self.history_caches.shared_state.clear();
6439 self.transcript_history_metadata_validation =
6440 TranscriptHistoryMetadataValidation::RequiresValidation;
6441 }
6442 self.updated_at = SystemTime::now();
6443 }
6444
6445 fn set_validated_transcript_history_metadata(&mut self, value: serde_json::Value) {
6448 self.metadata
6449 .insert(SESSION_TRANSCRIPT_HISTORY_STATE_KEY.to_string(), value);
6450 self.metadata
6451 .remove(SESSION_TRANSCRIPT_HISTORY_CHECKPOINT_DIGEST_KEY);
6452 self.history_caches.witness = std::sync::OnceLock::new();
6453 self.history_caches.shared_state.clear();
6454 self.transcript_history_metadata_validation =
6455 TranscriptHistoryMetadataValidation::Validated;
6456 self.updated_at = SystemTime::now();
6457 }
6458
6459 fn set_validated_transcript_history_metadata_with_state(
6463 &mut self,
6464 value: serde_json::Value,
6465 state: std::sync::Arc<TranscriptHistoryState>,
6466 ) {
6467 self.set_validated_transcript_history_metadata(value);
6468 self.history_caches.shared_state.set(state);
6469 }
6470
6471 #[cfg(test)]
6472 pub(crate) fn set_metadata_unchecked_for_test(&mut self, key: &str, value: serde_json::Value) {
6473 self.set_metadata_unchecked(key, value);
6474 }
6475
6476 fn fork_metadata_projection(&self) -> serde_json::Map<String, serde_json::Value> {
6477 let mut metadata = self.metadata.clone();
6478 metadata.retain(|key, _| !is_session_authority_metadata_key(key));
6479 metadata
6480 }
6481
6482 fn remove_metadata_unchecked(&mut self, key: &str) {
6483 let removed = self.metadata.remove(key).is_some();
6484 let mut changed = removed;
6485 if key == SESSION_TRANSCRIPT_HISTORY_STATE_KEY {
6486 changed |= self
6487 .metadata
6488 .remove(SESSION_TRANSCRIPT_HISTORY_CHECKPOINT_DIGEST_KEY)
6489 .is_some();
6490 self.history_caches.witness = std::sync::OnceLock::new();
6491 self.history_caches.shared_state.clear();
6492 self.transcript_history_metadata_validation =
6493 TranscriptHistoryMetadataValidation::Validated;
6494 }
6495 if changed {
6496 self.updated_at = SystemTime::now();
6497 }
6498 }
6499
6500 pub fn try_set_metadata(
6502 &mut self,
6503 key: &str,
6504 value: serde_json::Value,
6505 ) -> Result<(), ReservedSessionMetadataKey> {
6506 if is_session_authority_metadata_key(key) {
6507 return Err(ReservedSessionMetadataKey::new(key));
6508 }
6509 self.set_metadata_unchecked(key, value);
6510 Ok(())
6511 }
6512
6513 pub fn set_metadata(&mut self, key: &str, value: serde_json::Value) {
6518 if let Err(err) = self.try_set_metadata(key, value) {
6519 tracing::warn!(error = %err, "rejected raw session metadata mutation");
6520 }
6521 }
6522
6523 pub fn backfill_metadata_if_absent(&mut self, key: &str, value: serde_json::Value) -> bool {
6529 if is_session_authority_metadata_key(key) {
6530 tracing::warn!(
6531 metadata_key = key,
6532 "rejected raw session metadata backfill for authority key"
6533 );
6534 return false;
6535 }
6536 if self.metadata.contains_key(key) {
6537 false
6538 } else {
6539 self.metadata.insert(key.to_string(), value);
6540 true
6541 }
6542 }
6543
6544 pub fn remove_metadata(&mut self, key: &str) {
6546 if is_session_authority_metadata_key(key) {
6547 tracing::warn!(
6548 metadata_key = key,
6549 "rejected raw session metadata removal for authority key"
6550 );
6551 return;
6552 }
6553 if self.metadata.remove(key).is_some() {
6554 self.updated_at = SystemTime::now();
6555 }
6556 }
6557
6558 pub fn set_session_metadata(
6560 &mut self,
6561 metadata: SessionMetadata,
6562 ) -> Result<(), serde_json::Error> {
6563 let metadata =
6564 session_durable_config_authority::authorize_session_metadata_persist(metadata)
6565 .map_err(<serde_json::Error as serde::ser::Error>::custom)?
6566 .into_metadata();
6567 let value = serde_json::to_value(metadata)?;
6568 self.set_metadata_unchecked(SESSION_METADATA_KEY, value);
6569 Ok(())
6570 }
6571
6572 pub fn session_metadata(&self) -> Option<SessionMetadata> {
6577 match self.try_session_metadata() {
6578 Ok(metadata) => metadata,
6579 Err(err) => fail_closed_generated_restore("session-metadata", err),
6580 }
6581 }
6582
6583 pub fn try_session_metadata(&self) -> Result<Option<SessionMetadata>, serde_json::Error> {
6585 try_session_metadata_from_map(&self.metadata)
6586 }
6587
6588 pub fn set_system_context_state(
6590 &mut self,
6591 state: SessionSystemContextState,
6592 ) -> Result<(), serde_json::Error> {
6593 let state = system_context_authority::restore_system_context_state(state)
6594 .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
6595 let value = serde_json::to_value(state)?;
6596 self.set_metadata_unchecked(SESSION_SYSTEM_CONTEXT_STATE_KEY, value);
6597 Ok(())
6598 }
6599
6600 pub fn try_system_context_state(
6602 &self,
6603 ) -> Result<Option<SessionSystemContextState>, serde_json::Error> {
6604 self.metadata
6605 .get(SESSION_SYSTEM_CONTEXT_STATE_KEY)
6606 .map(|value| {
6607 let state = serde_json::from_value(value.clone())?;
6608 system_context_authority::restore_system_context_state(state)
6609 .map_err(<serde_json::Error as serde::de::Error>::custom)
6610 })
6611 .transpose()
6612 }
6613
6614 pub fn system_context_state(&self) -> Option<SessionSystemContextState> {
6620 match self.try_system_context_state() {
6621 Ok(state) => state,
6622 Err(err) => fail_closed_generated_restore("system-context", err),
6623 }
6624 }
6625
6626 pub fn set_deferred_turn_state(
6628 &mut self,
6629 state: SessionDeferredTurnState,
6630 ) -> Result<(), serde_json::Error> {
6631 let state = validate_deferred_turn_snapshot(state)
6632 .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
6633 let value = serde_json::to_value(state)?;
6634 self.set_metadata_unchecked(SESSION_DEFERRED_TURN_STATE_KEY, value);
6635 Ok(())
6636 }
6637
6638 pub fn try_deferred_turn_state(
6640 &self,
6641 ) -> Result<Option<SessionDeferredTurnState>, serde_json::Error> {
6642 self.metadata
6643 .get(SESSION_DEFERRED_TURN_STATE_KEY)
6644 .map(|value| {
6645 let state = serde_json::from_value(value.clone())?;
6646 validate_deferred_turn_snapshot(state)
6647 .map_err(<serde_json::Error as serde::de::Error>::custom)
6648 })
6649 .transpose()
6650 }
6651
6652 pub fn deferred_turn_state(&self) -> Option<SessionDeferredTurnState> {
6658 match self.try_deferred_turn_state() {
6659 Ok(state) => state,
6660 Err(err) => fail_closed_generated_restore("deferred-turn", err),
6661 }
6662 }
6663
6664 pub(crate) fn stage_pending_callback_tool_batch(
6667 &mut self,
6668 batch: PendingCallbackToolBatch,
6669 ) -> Result<(), PendingCallbackBatchError> {
6670 if matches!(
6671 self.callback_tool_batch_state()?,
6672 Some(CallbackToolBatchState::Pending { .. })
6673 ) {
6674 return Err(PendingCallbackBatchError::AlreadyStaged);
6675 }
6676 validate_pending_callback_batch(self.messages(), &batch)?;
6677 let value = serde_json::to_value(CallbackToolBatchState::Pending { batch })
6678 .map_err(|error| PendingCallbackBatchError::Malformed(error.to_string()))?;
6679 self.set_metadata_unchecked(SESSION_PENDING_CALLBACK_BATCH_KEY, value);
6680 Ok(())
6681 }
6682
6683 fn callback_tool_batch_state(
6684 &self,
6685 ) -> Result<Option<CallbackToolBatchState>, PendingCallbackBatchError> {
6686 self.metadata
6687 .get(SESSION_PENDING_CALLBACK_BATCH_KEY)
6688 .map(|value| {
6689 serde_json::from_value(value.clone())
6690 .map_err(|error| PendingCallbackBatchError::Malformed(error.to_string()))
6691 })
6692 .transpose()
6693 }
6694
6695 pub(crate) fn pending_callback_tool_batch(
6698 &self,
6699 ) -> Result<Option<PendingCallbackToolBatch>, PendingCallbackBatchError> {
6700 match self.callback_tool_batch_state()? {
6701 Some(CallbackToolBatchState::Pending { batch }) => {
6702 validate_pending_callback_batch(self.messages(), &batch)?;
6703 Ok(Some(batch))
6704 }
6705 Some(CallbackToolBatchState::Applied { .. }) | None => Ok(None),
6706 }
6707 }
6708
6709 pub(crate) fn resolve_pending_callback_tool_results(
6712 &self,
6713 incoming: Vec<ToolResult>,
6714 ) -> Result<ResolvedPendingCallbackToolResults, PendingCallbackBatchError> {
6715 let Some(state) = self.callback_tool_batch_state()? else {
6716 return Ok(ResolvedPendingCallbackToolResults::NoState);
6717 };
6718 let batch = match state {
6719 CallbackToolBatchState::Pending { batch } => batch,
6720 CallbackToolBatchState::Applied {
6721 tool_use_order,
6722 results,
6723 async_ops,
6724 ..
6725 } => {
6726 let incoming_by_id = unique_tool_results(incoming)?;
6727 let expected = tool_use_order.iter().cloned().collect::<BTreeSet<_>>();
6728 let actual = incoming_by_id.keys().cloned().collect::<BTreeSet<_>>();
6729 if actual != expected {
6730 return Err(PendingCallbackBatchError::ResultSetMismatch { expected, actual });
6731 }
6732 let delivered = tool_use_order
6733 .iter()
6734 .map(|id| incoming_by_id.get(id).cloned())
6735 .collect::<Option<Vec<_>>>()
6736 .ok_or_else(|| {
6737 PendingCallbackBatchError::Malformed(
6738 "applied callback receipt is missing an ordered result".to_string(),
6739 )
6740 })?;
6741 return if delivered == results {
6742 Ok(ResolvedPendingCallbackToolResults::AlreadyApplied { async_ops })
6743 } else {
6744 Err(PendingCallbackBatchError::ConflictingRedelivery)
6745 };
6746 }
6747 };
6748 validate_pending_callback_batch(self.messages(), &batch)?;
6749 let incoming_by_id = unique_tool_results(incoming)?;
6750 let expected = batch
6751 .pending_tool_use_ids
6752 .iter()
6753 .cloned()
6754 .collect::<BTreeSet<_>>();
6755 let actual = incoming_by_id.keys().cloned().collect::<BTreeSet<_>>();
6756 if actual != expected {
6757 return Err(PendingCallbackBatchError::ResultSetMismatch { expected, actual });
6758 }
6759 let mut all_by_id = unique_tool_results(batch.completed_results.clone())?;
6760 all_by_id.extend(incoming_by_id);
6761 let ordered = batch
6762 .tool_use_order
6763 .iter()
6764 .map(|id| {
6765 all_by_id.remove(id).ok_or_else(|| {
6766 PendingCallbackBatchError::Malformed(format!(
6767 "no result is available for assistant tool id '{id}'"
6768 ))
6769 })
6770 })
6771 .collect::<Result<Vec<_>, _>>()?;
6772 if !all_by_id.is_empty() {
6773 return Err(PendingCallbackBatchError::Malformed(format!(
6774 "results contain ids absent from assistant tool-use order: {:?}",
6775 all_by_id.keys().collect::<Vec<_>>()
6776 )));
6777 }
6778 Ok(ResolvedPendingCallbackToolResults::Pending {
6779 batch,
6780 ordered_results: ordered,
6781 })
6782 }
6783
6784 pub(crate) fn commit_pending_callback_tool_results(
6788 &mut self,
6789 batch: &PendingCallbackToolBatch,
6790 ordered_results: Vec<ToolResult>,
6791 post_tool_messages: Vec<Message>,
6792 ) -> Result<(), PendingCallbackBatchError> {
6793 let current = self
6794 .pending_callback_tool_batch()?
6795 .ok_or(PendingCallbackBatchError::Missing)?;
6796 if ¤t != batch {
6797 return Err(PendingCallbackBatchError::Malformed(
6798 "pending callback batch changed between prepare and commit".to_string(),
6799 ));
6800 }
6801 let actual_order = ordered_results
6802 .iter()
6803 .map(|result| result.tool_use_id.clone())
6804 .collect::<Vec<_>>();
6805 if actual_order != batch.tool_use_order {
6806 return Err(PendingCallbackBatchError::Malformed(format!(
6807 "resolved result order {actual_order:?} does not match assistant order {:?}",
6808 batch.tool_use_order
6809 )));
6810 }
6811 self.push(Message::tool_results(ordered_results.clone()));
6812 let pending_ids = batch
6813 .pending_tool_use_ids
6814 .iter()
6815 .cloned()
6816 .collect::<BTreeSet<_>>();
6817 let applied_callback_results = ordered_results
6818 .into_iter()
6819 .filter(|result| pending_ids.contains(&result.tool_use_id))
6820 .collect();
6821 let value = serde_json::to_value(CallbackToolBatchState::Applied {
6822 tool_use_order: batch.pending_tool_use_ids.clone(),
6823 results: applied_callback_results,
6824 async_ops: batch.async_ops.clone(),
6825 post_tool_messages,
6826 post_tool_messages_applied: false,
6827 })
6828 .map_err(|error| PendingCallbackBatchError::Malformed(error.to_string()))?;
6829 self.set_metadata_unchecked(SESSION_PENDING_CALLBACK_BATCH_KEY, value);
6830 Ok(())
6831 }
6832
6833 pub(crate) fn apply_pending_callback_resume_effects(
6838 &mut self,
6839 ) -> Result<Vec<crate::event::AssistantImageEvent>, PendingCallbackBatchError> {
6840 let Some(CallbackToolBatchState::Applied {
6841 tool_use_order,
6842 results,
6843 async_ops,
6844 post_tool_messages,
6845 post_tool_messages_applied,
6846 }) = self.callback_tool_batch_state()?
6847 else {
6848 return Ok(Vec::new());
6849 };
6850 if post_tool_messages_applied {
6851 return Ok(Vec::new());
6852 }
6853 let image_events = post_tool_messages
6854 .iter()
6855 .filter_map(|message| match message {
6856 Message::BlockAssistant(assistant) => Some(assistant.blocks.as_slice()),
6857 _ => None,
6858 })
6859 .flatten()
6860 .filter_map(crate::event::AssistantImageEvent::from_assistant_block)
6861 .collect::<Vec<_>>();
6862 let applied_state = CallbackToolBatchState::Applied {
6863 tool_use_order,
6864 results,
6865 async_ops,
6866 post_tool_messages: post_tool_messages.clone(),
6867 post_tool_messages_applied: true,
6868 };
6869 let value = serde_json::to_value(applied_state)
6870 .map_err(|error| PendingCallbackBatchError::Malformed(error.to_string()))?;
6871 self.push_batch(post_tool_messages);
6872 self.set_metadata_unchecked(SESSION_PENDING_CALLBACK_BATCH_KEY, value);
6873 Ok(image_events)
6874 }
6875
6876 pub fn set_lifecycle_terminal(
6885 &mut self,
6886 terminal: SessionLifecycleTerminal,
6887 ) -> Result<(), serde_json::Error> {
6888 let value = serde_json::to_value(terminal)?;
6889 self.set_metadata_unchecked(SESSION_LIFECYCLE_TERMINAL_KEY, value);
6890 Ok(())
6891 }
6892
6893 pub fn try_lifecycle_terminal(
6898 &self,
6899 ) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
6900 try_lifecycle_terminal_from_map(&self.metadata)
6901 }
6902
6903 pub fn lifecycle_terminal(&self) -> Option<SessionLifecycleTerminal> {
6909 match self.try_lifecycle_terminal() {
6910 Ok(state) => state,
6911 Err(err) => fail_closed_generated_restore("session-lifecycle-terminal", err),
6912 }
6913 }
6914
6915 pub fn set_build_state(&mut self, state: SessionBuildState) -> Result<(), serde_json::Error> {
6917 let state = session_durable_config_authority::authorize_session_build_state_persist(state)
6918 .map_err(<serde_json::Error as serde::ser::Error>::custom)?
6919 .into_state();
6920 let value = serde_json::to_value(state)?;
6921 self.set_metadata_unchecked(SESSION_BUILD_STATE_KEY, value);
6922 Ok(())
6923 }
6924
6925 pub fn build_state(&self) -> Option<SessionBuildState> {
6930 match self.try_build_state() {
6931 Ok(state) => state,
6932 Err(err) => fail_closed_generated_restore("session-build-state", err),
6933 }
6934 }
6935
6936 pub fn try_build_state(&self) -> Result<Option<SessionBuildState>, serde_json::Error> {
6938 let Some(value) = self.metadata.get(SESSION_BUILD_STATE_KEY) else {
6939 return Ok(None);
6940 };
6941 let state = serde_json::from_value::<SessionBuildState>(value.clone())?;
6942 session_durable_config_authority::restore_session_build_state(state)
6943 .map(Some)
6944 .map_err(<serde_json::Error as serde::de::Error>::custom)
6945 }
6946
6947 pub fn set_tool_visibility_state(
6949 &mut self,
6950 state: AuthorizedSessionToolVisibilityState,
6951 ) -> Result<(), serde_json::Error> {
6952 let value = serde_json::to_value(state.into_state())?;
6953 self.set_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY, value);
6954 Ok(())
6955 }
6956
6957 #[cfg(test)]
6962 pub(crate) fn clear_tool_visibility_state(&mut self) {
6963 self.remove_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY);
6964 }
6965
6966 pub fn tool_visibility_state(
6968 &self,
6969 ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
6970 self.try_tool_visibility_state()
6971 }
6972
6973 pub fn try_tool_visibility_state(
6976 &self,
6977 ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
6978 self.metadata
6979 .get(SESSION_TOOL_VISIBILITY_STATE_KEY)
6980 .map(|value| serde_json::from_value(value.clone()))
6981 .transpose()
6982 }
6983
6984 pub fn transcript_history_state(
6986 &self,
6987 ) -> Result<Option<TranscriptHistoryState>, serde_json::Error> {
6988 self.metadata
6989 .get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
6990 .map(|value| serde_json::from_value(value.clone()))
6991 .transpose()
6992 }
6993
6994 pub(crate) fn transcript_history_state_shared(
6998 &self,
6999 ) -> Result<Option<std::sync::Arc<TranscriptHistoryState>>, serde_json::Error> {
7000 if let Some(state) = self.history_caches.shared_state.get() {
7001 return Ok(Some(state));
7002 }
7003 let Some(state) = self.transcript_history_state()? else {
7004 return Ok(None);
7005 };
7006 let state = std::sync::Arc::new(state);
7007 self.history_caches
7008 .shared_state
7009 .set(std::sync::Arc::clone(&state));
7010 Ok(Some(state))
7011 }
7012
7013 pub(crate) fn validated_transcript_history_head(
7021 &self,
7022 ) -> Result<Option<&str>, TranscriptEditError> {
7023 self.validate_transcript_history_state()?;
7024 let Some(value) = self.metadata.get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY) else {
7025 return Ok(None);
7026 };
7027 value
7028 .get("head")
7029 .and_then(serde_json::Value::as_str)
7030 .map(Some)
7031 .ok_or_else(|| {
7032 TranscriptEditError::HistoryStateMalformed(
7033 "validated transcript history metadata omitted a string head".to_string(),
7034 )
7035 })
7036 }
7037
7038 pub fn compaction_projection_intents(
7041 &self,
7042 ) -> Result<Vec<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
7043 self.metadata
7044 .get(crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY)
7045 .map(|value| serde_json::from_value(value.clone()))
7046 .transpose()
7047 .map(Option::unwrap_or_default)
7048 }
7049
7050 pub fn validated_compaction_projection_intents(
7058 &self,
7059 ) -> Result<Vec<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
7060 self.validate_transcript_history_state()
7061 .map_err(|error| <serde_json::Error as serde::ser::Error>::custom(error.to_string()))?;
7062 let intents = self.compaction_projection_intents()?;
7063 if intents.is_empty() {
7064 return Ok(intents);
7065 }
7066 let history = self.transcript_history_state()?;
7067 let commits = history
7068 .as_ref()
7069 .map(|history| history.commits.as_slice())
7070 .unwrap_or_default();
7071 let mut unique = std::collections::HashSet::new();
7072 for intent in &intents {
7073 if intent.projection.session_id() != self.id() {
7074 return Err(<serde_json::Error as serde::ser::Error>::custom(
7075 "compaction projection outbox intent has a foreign session id",
7076 ));
7077 }
7078 if !unique.insert(intent.projection.clone()) {
7079 return Err(<serde_json::Error as serde::ser::Error>::custom(
7080 "compaction projection outbox contains a duplicate rewrite identity",
7081 ));
7082 }
7083 let backed = commits.iter().any(|commit| {
7084 intent
7085 .projection
7086 .matches_transcript_rewrite(self.id(), commit)
7087 });
7088 if !backed {
7089 return Err(<serde_json::Error as serde::ser::Error>::custom(format!(
7090 "compaction projection outbox intent {} has no matching TranscriptRewriteCommit",
7091 intent.projection.revision()
7092 )));
7093 }
7094 }
7095 Ok(intents)
7096 }
7097
7098 pub fn add_compaction_projection_intent(
7101 &mut self,
7102 intent: crate::memory::CompactionProjectionIntent,
7103 ) -> Result<(), serde_json::Error> {
7104 if intent.projection.session_id() != self.id() {
7105 return Err(<serde_json::Error as serde::ser::Error>::custom(
7106 "compaction projection intent session does not match snapshot session",
7107 ));
7108 }
7109 self.validate_transcript_history_state()
7110 .map_err(|error| <serde_json::Error as serde::ser::Error>::custom(error.to_string()))?;
7111 let history = self.transcript_history_state()?.ok_or_else(|| {
7112 <serde_json::Error as serde::ser::Error>::custom(
7113 "compaction projection intent requires transcript history state",
7114 )
7115 })?;
7116 let owns_commit = history.commits.iter().any(|commit| {
7117 commit.parent_revision == intent.projection.parent_revision()
7118 && commit.revision == intent.projection.revision()
7119 && intent
7120 .projection
7121 .matches_transcript_rewrite(self.id(), commit)
7122 });
7123 if !owns_commit {
7124 return Err(<serde_json::Error as serde::ser::Error>::custom(
7125 "compaction projection intent is not backed by the session transcript graph",
7126 ));
7127 }
7128 let mut intents = self.validated_compaction_projection_intents()?;
7129 if let Some(existing) = intents
7130 .iter()
7131 .find(|existing| existing.projection == intent.projection)
7132 {
7133 if existing == &intent {
7134 return Ok(());
7135 }
7136 return Err(<serde_json::Error as serde::ser::Error>::custom(
7137 "compaction projection intent conflicts with an existing rewrite identity",
7138 ));
7139 }
7140 intents.push(intent);
7141 self.set_metadata_unchecked(
7142 crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
7143 serde_json::to_value(intents)?,
7144 );
7145 Ok(())
7146 }
7147
7148 pub fn complete_compaction_projection_intent(
7151 &mut self,
7152 projection: &crate::memory::CompactionProjectionId,
7153 ) -> Result<Option<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
7154 let mut intents = self.compaction_projection_intents()?;
7155 let Some(position) = intents
7156 .iter()
7157 .position(|intent| &intent.projection == projection)
7158 else {
7159 return Ok(None);
7160 };
7161 let completed = intents.remove(position);
7162 if intents.is_empty() {
7163 self.remove_metadata_unchecked(
7164 crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
7165 );
7166 } else {
7167 self.set_metadata_unchecked(
7168 crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
7169 serde_json::to_value(intents)?,
7170 );
7171 }
7172 Ok(Some(completed))
7173 }
7174
7175 pub fn validate_transcript_history_state(&self) -> Result<(), TranscriptEditError> {
7177 if self.transcript_history_metadata_validation
7178 == TranscriptHistoryMetadataValidation::Validated
7179 {
7180 return Ok(());
7181 }
7182 let Some(state) = self
7183 .transcript_history_state()
7184 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
7185 else {
7186 return Ok(());
7187 };
7188 validate_transcript_history_state(&state)
7189 }
7190
7191 pub fn clear_transcript_history_state(&mut self) {
7194 self.remove_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY);
7195 }
7196
7197 pub fn try_checkpoint_state(
7203 &self,
7204 ) -> Result<crate::checkpoint::SessionCheckpointState, crate::checkpoint::SessionCheckpointError>
7205 {
7206 let stamp =
7207 match crate::checkpoint::session_checkpoint_metadata_state(&self.id, &self.metadata)? {
7208 crate::checkpoint::SessionCheckpointMetadataState::Stamped(stamp) => stamp,
7209 crate::checkpoint::SessionCheckpointMetadataState::LegacyUnverified {
7210 legacy_runtime_checkpoint,
7211 } => {
7212 return Ok(
7213 crate::checkpoint::SessionCheckpointState::LegacyUnverified {
7214 legacy_runtime_checkpoint,
7215 },
7216 );
7217 }
7218 };
7219 let actual = crate::checkpoint::session_checkpoint_digest(self)?;
7220 if stamp.digest() != &actual {
7221 return Err(crate::checkpoint::SessionCheckpointError::DigestMismatch {
7222 expected: stamp.digest().clone(),
7223 actual,
7224 });
7225 }
7226 crate::checkpoint::record_checkpoint_stamp_verification(self, &actual);
7227 Ok(crate::checkpoint::SessionCheckpointState::Verified(stamp))
7228 }
7229
7230 pub fn try_checkpoint_state_cached(
7245 &self,
7246 ) -> Result<crate::checkpoint::SessionCheckpointState, crate::checkpoint::SessionCheckpointError>
7247 {
7248 let stamp =
7249 match crate::checkpoint::session_checkpoint_metadata_state(&self.id, &self.metadata)? {
7250 crate::checkpoint::SessionCheckpointMetadataState::Stamped(stamp) => stamp,
7251 crate::checkpoint::SessionCheckpointMetadataState::LegacyUnverified {
7252 legacy_runtime_checkpoint,
7253 } => {
7254 return Ok(
7255 crate::checkpoint::SessionCheckpointState::LegacyUnverified {
7256 legacy_runtime_checkpoint,
7257 },
7258 );
7259 }
7260 };
7261 if crate::checkpoint::checkpoint_stamp_verification_is_cached(self, stamp.digest()) {
7262 return Ok(crate::checkpoint::SessionCheckpointState::Verified(stamp));
7263 }
7264 let actual = crate::checkpoint::session_checkpoint_digest(self)?;
7265 if stamp.digest() != &actual {
7266 return Err(crate::checkpoint::SessionCheckpointError::DigestMismatch {
7267 expected: stamp.digest().clone(),
7268 actual,
7269 });
7270 }
7271 crate::checkpoint::record_checkpoint_stamp_verification(self, &actual);
7272 Ok(crate::checkpoint::SessionCheckpointState::Verified(stamp))
7273 }
7274
7275 pub fn install_checkpoint_stamp(
7283 &mut self,
7284 stamp: crate::checkpoint::SessionCheckpointStamp,
7285 ) -> Result<(), crate::checkpoint::SessionCheckpointError> {
7286 stamp.validate_for_session(&self.id)?;
7287 let actual =
7294 if crate::checkpoint::checkpoint_stamp_verification_is_cached(self, stamp.digest()) {
7295 stamp.digest().clone()
7296 } else {
7297 let actual = crate::checkpoint::session_checkpoint_digest(self)?;
7298 if stamp.digest() != &actual {
7299 return Err(crate::checkpoint::SessionCheckpointError::DigestMismatch {
7300 expected: stamp.digest().clone(),
7301 actual,
7302 });
7303 }
7304 actual
7305 };
7306 let value = serde_json::to_value(&stamp)?;
7307 self.metadata
7308 .remove(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY);
7309 self.metadata
7310 .insert(SESSION_CHECKPOINT_STAMP_KEY.to_string(), value);
7311 crate::checkpoint::record_checkpoint_stamp_verification(self, &actual);
7314 Ok(())
7315 }
7316
7317 pub fn try_has_runtime_checkpoint_provenance(
7319 &self,
7320 ) -> Result<bool, crate::checkpoint::SessionCheckpointError> {
7321 match self.try_checkpoint_state()? {
7322 crate::checkpoint::SessionCheckpointState::Verified(stamp) => Ok(matches!(
7323 stamp.provenance(),
7324 crate::checkpoint::SessionCheckpointProvenance::IntraTurnCheckpoint
7325 )),
7326 crate::checkpoint::SessionCheckpointState::LegacyUnverified { .. } => {
7327 Err(crate::checkpoint::SessionCheckpointError::LegacyCheckpointUnverified)
7328 }
7329 }
7330 }
7331
7332 #[deprecated(
7334 note = "legacy compatibility only; typed writers must install an exact checkpoint stamp"
7335 )]
7336 pub fn set_runtime_checkpoint_provenance(
7337 &mut self,
7338 ) -> Result<(), crate::checkpoint::SessionCheckpointError> {
7339 if matches!(
7340 self.try_checkpoint_state()?,
7341 crate::checkpoint::SessionCheckpointState::Verified(_)
7342 ) {
7343 return Err(
7344 crate::checkpoint::SessionCheckpointError::LegacyProvenanceMutationOnTypedCheckpoint,
7345 );
7346 }
7347 self.set_metadata_unchecked(
7348 SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY,
7349 serde_json::Value::Bool(true),
7350 );
7351 Ok(())
7352 }
7353
7354 #[deprecated(
7356 note = "legacy compatibility only; typed writers must install an exact run-boundary successor"
7357 )]
7358 pub fn clear_runtime_checkpoint_provenance(
7359 &mut self,
7360 ) -> Result<(), crate::checkpoint::SessionCheckpointError> {
7361 if matches!(
7362 self.try_checkpoint_state()?,
7363 crate::checkpoint::SessionCheckpointState::Verified(_)
7364 ) {
7365 return Err(
7366 crate::checkpoint::SessionCheckpointError::LegacyProvenanceMutationOnTypedCheckpoint,
7367 );
7368 }
7369 self.remove_metadata_unchecked(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY);
7370 Ok(())
7371 }
7372
7373 pub fn transcript_revision_body(
7375 &self,
7376 revision: &str,
7377 ) -> Result<Option<TranscriptRevisionBody>, serde_json::Error> {
7378 Ok(self.transcript_history_state()?.and_then(|state| {
7379 state
7380 .revisions
7381 .into_iter()
7382 .find(|body| body.revision == revision)
7383 }))
7384 }
7385
7386 pub fn transcript_revision_messages(
7388 &self,
7389 revision: &str,
7390 ) -> Result<Option<Vec<Message>>, serde_json::Error> {
7391 Ok(self
7392 .transcript_revision_body(revision)?
7393 .map(|body| body.messages))
7394 }
7395
7396 pub fn apply_transcript_history_state(
7398 &mut self,
7399 mut state: TranscriptHistoryState,
7400 ) -> Result<(), TranscriptEditError> {
7401 state.compact_mechanical_revision_bodies()?;
7402 let head_body = state
7403 .revisions
7404 .iter()
7405 .find(|body| body.revision == state.head)
7406 .ok_or_else(|| {
7407 TranscriptEditError::HistoryStateMalformed(format!(
7408 "missing transcript head body {}",
7409 state.head
7410 ))
7411 })?
7412 .clone();
7413 let realtime_state =
7414 self.reconciled_realtime_transcript_metadata_after_rewrite(&head_body.messages)?;
7415 let value = serde_json::to_value(&state)
7416 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
7417 self.set_validated_transcript_history_metadata(value);
7418 if let Some(value) = realtime_state {
7419 self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
7420 }
7421 let mut updated_at = head_body.created_at;
7422 for commit in &state.commits {
7423 if commit.committed_at > updated_at {
7424 updated_at = commit.committed_at;
7425 }
7426 }
7427 self.messages.replace(head_body.messages);
7429 self.updated_at = updated_at;
7430 Ok(())
7431 }
7432
7433 pub fn transcript_revision(&self) -> Result<String, serde_json::Error> {
7436 if let Some(state) = self.transcript_history_state()? {
7437 Ok(state.head)
7438 } else {
7439 transcript_messages_digest(self.messages())
7440 }
7441 }
7442
7443 pub fn transcript_rewrite_generation(&self) -> Result<u64, serde_json::Error> {
7448 Ok(self.transcript_history_state()?.map_or(0, |state| {
7449 u64::try_from(state.commits.len()).unwrap_or(u64::MAX)
7450 }))
7451 }
7452
7453 pub fn commit_transcript_rewrite(
7455 &mut self,
7456 selection: TranscriptRewriteSelection,
7457 replacement: Vec<Message>,
7458 reason: TranscriptRewriteReason,
7459 actor: Option<String>,
7460 expected_parent_revision: Option<String>,
7461 ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
7462 let selection = selection.into_current_edit_semantic();
7463 if selection.semantic() == TranscriptRewriteSemantic::Compaction {
7464 return Err(TranscriptEditError::InvalidTranscriptShape(
7465 "typed compaction rewrites require a core-validated compaction witness".to_string(),
7466 ));
7467 }
7468 self.commit_transcript_rewrite_authorized(
7469 selection,
7470 replacement,
7471 reason,
7472 actor,
7473 expected_parent_revision,
7474 )
7475 }
7476
7477 fn commit_transcript_rewrite_authorized(
7478 &mut self,
7479 selection: TranscriptRewriteSelection,
7480 replacement: Vec<Message>,
7481 reason: TranscriptRewriteReason,
7482 actor: Option<String>,
7483 expected_parent_revision: Option<String>,
7484 ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
7485 let parent_revision = self
7486 .transcript_revision()
7487 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
7488 if let Some(expected) = expected_parent_revision
7489 && expected != parent_revision
7490 {
7491 return Err(TranscriptEditError::RevisionConflict {
7492 expected,
7493 actual: parent_revision,
7494 });
7495 }
7496
7497 let (start, end) = selection.bounds();
7498 let message_count = self.messages.len();
7499 if start > end || end > message_count {
7500 return Err(TranscriptEditError::InvalidRewriteRange {
7501 start,
7502 end,
7503 message_count,
7504 });
7505 }
7506
7507 let replacement_len = replacement.len();
7508 let mut rewritten = Vec::with_capacity(
7509 start
7510 .saturating_add(replacement_len)
7511 .saturating_add(message_count.saturating_sub(end)),
7512 );
7513 rewritten.extend_from_slice(&self.messages[..start]);
7514 rewritten.extend(replacement);
7515 rewritten.extend_from_slice(&self.messages[end..]);
7516 validate_transcript_tool_result_shape(&rewritten)?;
7517
7518 let original_span_digest = transcript_messages_digest(&self.messages[start..end])
7519 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
7520 let replacement_digest =
7521 transcript_messages_digest(&rewritten[start..start + replacement_len])
7522 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
7523 let revision = transcript_messages_digest(&rewritten)
7524 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
7525 if revision == parent_revision {
7526 return Err(TranscriptEditError::NoOpRewrite { revision });
7527 }
7528 let realtime_state =
7529 self.reconciled_realtime_transcript_metadata_after_rewrite(&rewritten)?;
7530
7531 let commit = TranscriptRewriteCommit {
7532 parent_revision,
7533 revision: revision.clone(),
7534 selection,
7535 original_span_digest,
7536 replacement_digest,
7537 messages_before: message_count,
7538 messages_after: rewritten.len(),
7539 reason,
7540 actor,
7541 committed_at: SystemTime::now(),
7542 };
7543
7544 let mut state = self
7545 .transcript_history_state()
7546 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
7547 .unwrap_or_else(|| TranscriptHistoryState {
7548 head: commit.parent_revision.clone(),
7549 commits: Vec::new(),
7550 revisions: Vec::new(),
7551 digest_format: TRANSCRIPT_DIGEST_FORMAT_CURRENT,
7552 });
7553 if !state
7554 .revisions
7555 .iter()
7556 .any(|body| body.revision == commit.parent_revision)
7557 {
7558 state.revisions.push(TranscriptRevisionBody {
7559 revision: commit.parent_revision.clone(),
7560 parent_revision: None,
7561 messages: self.messages().to_vec(),
7562 created_at: self.updated_at,
7563 });
7564 }
7565 if !state
7566 .revisions
7567 .iter()
7568 .any(|body| body.revision == commit.revision)
7569 {
7570 state.revisions.push(TranscriptRevisionBody {
7571 revision: commit.revision.clone(),
7572 parent_revision: Some(commit.parent_revision.clone()),
7573 messages: rewritten.clone(),
7574 created_at: commit.committed_at,
7575 });
7576 }
7577 state.head = revision;
7578 state.commits.push(commit.clone());
7579 state.compact_mechanical_revision_bodies()?;
7580 let value = serde_json::to_value(state)
7581 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
7582 self.set_validated_transcript_history_metadata(value);
7583 if let Some(value) = realtime_state {
7584 self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
7585 }
7586
7587 self.messages.replace(rewritten);
7589 self.updated_at = SystemTime::now();
7590 Ok(commit)
7591 }
7592
7593 fn transcript_history_state_after_message_mutation(
7594 &self,
7595 messages: &[Message],
7596 head: String,
7597 created_at: SystemTime,
7598 shape: TranscriptMutationShape,
7599 ) -> Result<Option<TranscriptHistoryState>, TranscriptEditError> {
7600 if !self
7601 .metadata
7602 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
7603 {
7604 return Ok(None);
7605 }
7606 let mut state = self
7607 .transcript_history_state_shared()
7608 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?
7609 .map(|state| (*state).clone())
7610 .ok_or_else(|| {
7611 TranscriptEditError::HistoryStateMalformed(
7612 "transcript history metadata key decoded without state".to_string(),
7613 )
7614 })?;
7615
7616 if shape == TranscriptMutationShape::Appended
7626 && self.transcript_history_metadata_validation
7627 == TranscriptHistoryMetadataValidation::Validated
7628 && let Some(previous_head_body) = state
7629 .revisions
7630 .iter()
7631 .find(|body| body.revision == state.head)
7632 && previous_head_body.messages.len() <= messages.len()
7633 && self
7634 .messages
7635 .prefix_digest_witness(previous_head_body.messages.len())
7636 .as_deref()
7637 == Some(state.head.as_str())
7638 {
7639 if !state.revisions.iter().any(|body| body.revision == head) {
7640 state.revisions.push(TranscriptRevisionBody {
7641 revision: head.clone(),
7642 parent_revision: state.commits.last().map(|commit| commit.revision.clone()),
7643 messages: messages.to_vec(),
7644 created_at,
7645 });
7646 }
7647 state.head = head;
7648 state.prune_mechanical_revision_bodies();
7649 return Ok(Some(state));
7650 }
7651
7652 state.compact_mechanical_revision_bodies()?;
7653 if !state.revisions.iter().any(|body| body.revision == head) {
7654 state.revisions.push(TranscriptRevisionBody {
7655 revision: head.clone(),
7656 parent_revision: state.commits.last().map(|commit| commit.revision.clone()),
7657 messages: messages.to_vec(),
7658 created_at,
7659 });
7660 }
7661 state.head = head;
7662 state.compact_mechanical_revision_bodies()?;
7663 Ok(Some(state))
7664 }
7665
7666 fn refresh_transcript_head_after_message_mutation(&mut self, shape: TranscriptMutationShape) {
7667 if !self
7668 .metadata
7669 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
7670 {
7671 return;
7672 }
7673 let head = match self.messages.digest() {
7674 Ok(head) => head,
7675 Err(error) => {
7676 tracing::warn!(
7677 session_id = %self.id,
7678 error = %error,
7679 "failed to digest transcript after message mutation"
7680 );
7681 return;
7682 }
7683 };
7684 match self.transcript_history_state_after_message_mutation(
7685 self.messages(),
7686 head,
7687 SystemTime::now(),
7688 shape,
7689 ) {
7690 Ok(Some(state)) => match serde_json::to_value(&state) {
7691 Ok(value) => {
7692 self.set_validated_transcript_history_metadata_with_state(
7693 value,
7694 std::sync::Arc::new(state),
7695 );
7696 }
7697 Err(error) => {
7698 tracing::warn!(
7699 session_id = %self.id,
7700 error = %error,
7701 "failed to serialize transcript history state after message mutation"
7702 );
7703 }
7704 },
7705 Ok(None) => {}
7706 Err(error) => {
7707 tracing::warn!(
7708 session_id = %self.id,
7709 error = %error,
7710 "transcript history state failed validation after message mutation"
7711 );
7712 }
7713 }
7714 }
7715
7716 pub fn set_mob_tool_authority_context(
7724 &mut self,
7725 authority_context: Option<MobToolAuthorityContext>,
7726 ) -> Result<(), serde_json::Error> {
7727 if let Some(authority_context) = authority_context.as_ref()
7728 && !authority_context.is_generated_authority_context()
7729 {
7730 return Err(<serde_json::Error as serde::de::Error>::custom(
7731 "mob authority context was not minted by generated authority",
7732 ));
7733 }
7734 let mut build_state = self.build_state().ok_or_else(|| {
7735 <serde_json::Error as serde::de::Error>::custom(format!(
7736 "session {} is missing session build state",
7737 self.id
7738 ))
7739 })?;
7740 build_state.mob_tool_authority_context = authority_context;
7741 self.set_build_state(build_state)
7742 }
7743
7744 pub fn mob_tool_authority_context(&self) -> Option<MobToolAuthorityContext> {
7749 self.build_state()
7750 .and_then(|state| state.mob_tool_authority_context)
7751 .filter(MobToolAuthorityContext::is_generated_authority_context)
7752 }
7753
7754 pub fn fork_at(&self, index: usize) -> Self {
7759 let now = SystemTime::now();
7760 let truncated = self.messages[..index.min(self.messages.len())].to_vec();
7761 Self {
7762 version: session_version(),
7763 id: SessionId::new(),
7764 messages: TranscriptMessages::from_vec(truncated),
7765 created_at: now,
7766 updated_at: now,
7767 metadata: self.fork_metadata_projection(),
7768 history_caches: Box::default(),
7769 transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
7770 usage: self.usage.clone(),
7771 }
7772 }
7773
7774 pub fn fork_replacing(
7781 &self,
7782 message_index: usize,
7783 replacement: TranscriptReplacement,
7784 ) -> Result<Self, TranscriptEditError> {
7785 let Some(original) = self.messages.get(message_index) else {
7786 return Err(TranscriptEditError::MessageIndexOutOfBounds {
7787 message_index,
7788 message_count: self.messages.len(),
7789 });
7790 };
7791
7792 let replacement_message = match replacement {
7793 TranscriptReplacement::Message { message } => message,
7794 TranscriptReplacement::UserContentBlock { block_index, block } => {
7795 let Message::User(user) = original else {
7796 return Err(TranscriptEditError::MessageRoleMismatch {
7797 message_index,
7798 expected: "user",
7799 actual: message_role_name(original),
7800 });
7801 };
7802 if block_index >= user.content.len() {
7803 return Err(TranscriptEditError::BlockIndexOutOfBounds {
7804 block_kind: "user content block",
7805 block_index,
7806 block_count: user.content.len(),
7807 });
7808 }
7809 let mut edited = user.clone();
7810 edited.content[block_index] = block;
7811 Message::User(edited)
7812 }
7813 TranscriptReplacement::AssistantBlock { block_index, block } => {
7814 let Message::BlockAssistant(assistant) = original else {
7815 return Err(TranscriptEditError::MessageRoleMismatch {
7816 message_index,
7817 expected: "block_assistant",
7818 actual: message_role_name(original),
7819 });
7820 };
7821 if block_index >= assistant.blocks.len() {
7822 return Err(TranscriptEditError::BlockIndexOutOfBounds {
7823 block_kind: "assistant block",
7824 block_index,
7825 block_count: assistant.blocks.len(),
7826 });
7827 }
7828 let mut edited = assistant.clone();
7829 edited.blocks[block_index] = block;
7830 Message::BlockAssistant(edited)
7831 }
7832 TranscriptReplacement::ToolResultContentBlock {
7833 result_index,
7834 block_index,
7835 block,
7836 } => {
7837 let Message::ToolResults {
7838 results,
7839 created_at,
7840 } = original
7841 else {
7842 return Err(TranscriptEditError::MessageRoleMismatch {
7843 message_index,
7844 expected: "tool_results",
7845 actual: message_role_name(original),
7846 });
7847 };
7848 let Some(result) = results.get(result_index) else {
7849 return Err(TranscriptEditError::BlockIndexOutOfBounds {
7850 block_kind: "tool result",
7851 block_index: result_index,
7852 block_count: results.len(),
7853 });
7854 };
7855 if block_index >= result.content.len() {
7856 return Err(TranscriptEditError::BlockIndexOutOfBounds {
7857 block_kind: "tool result content block",
7858 block_index,
7859 block_count: result.content.len(),
7860 });
7861 }
7862 let mut edited_results = results.clone();
7863 edited_results[result_index].content[block_index] = block;
7864 Message::ToolResults {
7865 results: edited_results,
7866 created_at: *created_at,
7867 }
7868 }
7869 };
7870
7871 let mut forked = self.fork_at(message_index);
7872 forked.push(replacement_message);
7873 Ok(forked)
7874 }
7875
7876 pub fn fork(&self) -> Self {
7881 let now = SystemTime::now();
7882 Self {
7883 version: session_version(),
7884 id: SessionId::new(),
7885 messages: self.messages.clone(),
7886 created_at: now,
7887 updated_at: now,
7888 metadata: self.fork_metadata_projection(),
7889 history_caches: Box::default(),
7890 transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
7891 usage: self.usage.clone(),
7892 }
7893 }
7894}
7895
7896impl Default for Session {
7897 fn default() -> Self {
7898 Self::new()
7899 }
7900}
7901
7902#[derive(Debug, Clone, Serialize, Deserialize)]
7904#[serde(rename_all = "snake_case")]
7905pub struct SessionMeta {
7906 pub id: SessionId,
7907 pub created_at: SystemTime,
7908 pub updated_at: SystemTime,
7909 pub message_count: usize,
7910 pub total_tokens: u64,
7911 #[serde(default)]
7912 pub metadata: serde_json::Map<String, serde_json::Value>,
7913}
7914
7915#[derive(Debug, Clone, Serialize, Deserialize)]
7917#[serde(rename_all = "snake_case")]
7918pub struct SessionMetadata {
7919 pub schema_version: u32,
7926 pub model: String,
7927 pub max_tokens: u32,
7928 #[serde(default = "crate::config::default_structured_output_retries")]
7929 pub structured_output_retries: u32,
7930 pub provider: Provider,
7931 #[serde(default, skip_serializing_if = "Option::is_none")]
7932 pub self_hosted_server_id: Option<String>,
7933 #[serde(default, skip_serializing_if = "Option::is_none")]
7936 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
7937 pub tooling: SessionTooling,
7938 #[serde(default)]
7939 pub keep_alive: bool,
7940 pub comms_name: Option<String>,
7941 #[serde(default, skip_serializing_if = "Option::is_none")]
7943 pub peer_meta: Option<PeerMeta>,
7944 #[serde(default, skip_serializing_if = "Option::is_none")]
7950 pub realm_id: Option<crate::RealmId>,
7951 #[serde(default, skip_serializing_if = "Option::is_none")]
7953 pub instance_id: Option<String>,
7954 #[serde(default, skip_serializing_if = "Option::is_none")]
7956 pub backend: Option<String>,
7957 #[serde(default, skip_serializing_if = "Option::is_none")]
7959 pub config_generation: Option<u64>,
7960 #[serde(default, skip_serializing_if = "Option::is_none")]
7970 pub auth_binding: Option<crate::AuthBindingRef>,
7971 #[serde(default, skip_serializing_if = "Option::is_none")]
7984 pub mob_member_binding: Option<crate::MobMemberBinding>,
7985}
7986
7987#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7989#[serde(rename_all = "snake_case")]
7990pub struct SessionLlmIdentity {
7991 pub model: String,
7992 pub provider: Provider,
7993 #[serde(default, skip_serializing_if = "Option::is_none")]
7994 pub self_hosted_server_id: Option<String>,
7995 #[serde(default, skip_serializing_if = "Option::is_none")]
7997 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
7998 #[serde(default, skip_serializing_if = "Option::is_none")]
8011 pub auth_binding: Option<crate::AuthBindingRef>,
8012}
8013
8014pub struct SessionLlmIdentityOverride<'a> {
8022 pub model: Option<&'a str>,
8023 pub provider: Option<Provider>,
8024 pub self_hosted_server_id: Option<&'a str>,
8028 pub provider_params:
8029 Option<TurnMetadataOverride<&'a crate::lifecycle::run_primitive::ProviderParamsOverride>>,
8030 pub auth_binding: Option<TurnMetadataOverride<&'a crate::AuthBindingRef>>,
8031}
8032
8033#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
8034pub enum SessionLlmIdentityOverrideError {
8035 #[error("provider override requires model on an existing session")]
8036 ProviderRequiresModel,
8037 #[error("{0}")]
8038 ProviderModelMismatch(String),
8039 #[error("self-hosted provider requires a registered model alias; '{model}' is not configured")]
8040 MissingSelfHostedAlias { model: String },
8041 #[error("self_hosted_server_id requires provider 'self_hosted'")]
8042 SelfHostedServerRequiresSelfHostedProvider,
8043 #[error("self_hosted_server_id must not be empty")]
8044 EmptySelfHostedServerId,
8045 #[error(
8046 "self-hosted model '{model}' is configured on server '{configured}', not requested server '{requested}'"
8047 )]
8048 SelfHostedServerMismatch {
8049 model: String,
8050 requested: String,
8051 configured: String,
8052 },
8053}
8054
8055pub fn resolve_session_llm_identity_override(
8063 current: &SessionLlmIdentity,
8064 registry: &crate::ModelRegistry,
8065 overrides: SessionLlmIdentityOverride<'_>,
8066) -> Result<SessionLlmIdentity, SessionLlmIdentityOverrideError> {
8067 if overrides.provider.is_some() && overrides.model.is_none() {
8068 return Err(SessionLlmIdentityOverrideError::ProviderRequiresModel);
8069 }
8070
8071 let model = overrides
8072 .model
8073 .map(str::to_string)
8074 .unwrap_or_else(|| current.model.clone());
8075 let provider = if let Some(provider) = overrides.provider {
8076 provider
8077 } else if overrides.model.is_some() {
8078 registry
8079 .entry(&model)
8080 .map_or(current.provider, |entry| entry.provider)
8081 } else {
8082 current.provider
8083 };
8084
8085 if (overrides.model.is_some() || overrides.provider.is_some())
8086 && let Some(reason) = registry.provider_override_mismatch_reason(provider, &model)
8087 {
8088 return Err(SessionLlmIdentityOverrideError::ProviderModelMismatch(
8089 reason,
8090 ));
8091 }
8092
8093 let provider_params = match overrides.provider_params {
8094 Some(TurnMetadataOverride::Clear) => None,
8095 Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
8096 None => current.provider_params.clone(),
8097 };
8098 if overrides.self_hosted_server_id.is_some() && provider != Provider::SelfHosted {
8099 return Err(SessionLlmIdentityOverrideError::SelfHostedServerRequiresSelfHostedProvider);
8100 }
8101 let self_hosted_server_id = if provider == Provider::SelfHosted {
8102 if let Some(requested_server_id) = overrides.self_hosted_server_id {
8103 if requested_server_id.trim().is_empty() {
8104 return Err(SessionLlmIdentityOverrideError::EmptySelfHostedServerId);
8105 }
8106 let entry = registry
8107 .entry_for_provider(Provider::SelfHosted, &model)
8108 .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
8109 model: model.clone(),
8110 })?;
8111 let configured_server_id = entry
8112 .self_hosted
8113 .as_ref()
8114 .map(|server| server.server_id.as_str())
8115 .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
8116 model: model.clone(),
8117 })?;
8118 if configured_server_id != requested_server_id {
8119 return Err(SessionLlmIdentityOverrideError::SelfHostedServerMismatch {
8120 model,
8121 requested: requested_server_id.to_string(),
8122 configured: configured_server_id.to_string(),
8123 });
8124 }
8125 Some(requested_server_id.to_string())
8126 } else if overrides.model.is_none() {
8127 current.self_hosted_server_id.clone().or_else(|| {
8128 registry
8129 .entry_for_provider(Provider::SelfHosted, &model)
8130 .and_then(|entry| entry.self_hosted.as_ref())
8131 .map(|server| server.server_id.clone())
8132 })
8133 } else {
8134 let entry = registry
8135 .entry_for_provider(Provider::SelfHosted, &model)
8136 .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
8137 model: model.clone(),
8138 })?;
8139 entry
8140 .self_hosted
8141 .as_ref()
8142 .map(|server| server.server_id.clone())
8143 }
8144 } else {
8145 None
8146 };
8147
8148 let auth_binding = match overrides.auth_binding {
8149 Some(TurnMetadataOverride::Clear) => None,
8150 Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
8151 None if provider != current.provider => None,
8154 None => current.auth_binding.clone(),
8155 };
8156
8157 Ok(SessionLlmIdentity {
8158 model,
8159 provider,
8160 self_hosted_server_id,
8161 provider_params,
8162 auth_binding,
8163 })
8164}
8165
8166#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8173#[serde(rename_all = "snake_case")]
8174pub struct SessionLlmRequestPolicy {
8175 pub model: String,
8176 #[serde(default, skip_serializing_if = "Option::is_none")]
8178 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
8179 #[serde(default, skip_serializing_if = "Option::is_none")]
8181 pub provider_tool_defaults: Option<crate::lifecycle::run_primitive::ProviderTag>,
8182}
8183
8184impl SessionMetadata {
8185 pub fn llm_identity(&self) -> SessionLlmIdentity {
8187 SessionLlmIdentity {
8188 model: self.model.clone(),
8189 provider: self.provider,
8190 self_hosted_server_id: self.self_hosted_server_id.clone(),
8191 provider_params: self.provider_params.clone(),
8192 auth_binding: self.auth_binding.clone(),
8193 }
8194 }
8195
8196 pub fn apply_llm_identity(&mut self, identity: &SessionLlmIdentity) {
8198 self.model = identity.model.clone();
8199 self.provider = identity.provider;
8200 self.self_hosted_server_id = identity.self_hosted_server_id.clone();
8201 self.provider_params = identity.provider_params.clone();
8202 self.auth_binding = identity.auth_binding.clone();
8203 }
8204}
8205
8206pub const SESSION_METADATA_KEY: &str = "session_metadata";
8208
8209#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
8217#[serde(rename_all = "snake_case")]
8218pub enum ToolCategoryOverride {
8219 #[default]
8221 Inherit,
8222 Enable,
8224 Disable,
8226}
8227
8228impl ToolCategoryOverride {
8229 #[must_use]
8235 pub fn resolve(self, runtime_default: bool) -> bool {
8236 match self {
8237 Self::Enable => true,
8238 Self::Disable => false,
8239 Self::Inherit => runtime_default,
8240 }
8241 }
8242
8243 #[must_use]
8249 pub fn to_override(self) -> Option<bool> {
8250 match self {
8251 Self::Enable => Some(true),
8252 Self::Disable => Some(false),
8253 Self::Inherit => None,
8254 }
8255 }
8256
8257 #[must_use]
8265 pub fn from_effective(enabled: bool) -> Self {
8266 if enabled { Self::Enable } else { Self::Disable }
8267 }
8268
8269 #[must_use]
8279 pub fn from_override(value: Option<bool>) -> Self {
8280 match value {
8281 Some(true) => Self::Enable,
8282 Some(false) => Self::Disable,
8283 None => Self::Inherit,
8284 }
8285 }
8286}
8287
8288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
8295#[serde(rename_all = "snake_case")]
8296pub struct SessionTooling {
8297 #[serde(default)]
8298 pub builtins: ToolCategoryOverride,
8299 #[serde(default)]
8300 pub shell: ToolCategoryOverride,
8301 #[serde(default)]
8302 pub comms: ToolCategoryOverride,
8303 #[serde(default)]
8305 pub mob: ToolCategoryOverride,
8306 #[serde(default)]
8308 pub memory: ToolCategoryOverride,
8309 #[serde(default)]
8311 pub schedule: ToolCategoryOverride,
8312 #[serde(default)]
8314 pub workgraph: ToolCategoryOverride,
8315 #[serde(default)]
8317 pub image_generation: ToolCategoryOverride,
8318 #[serde(default)]
8320 pub web_search: ToolCategoryOverride,
8321 #[serde(default, skip_serializing_if = "Option::is_none")]
8331 pub tool_access_policy: Option<crate::ops::ToolAccessPolicy>,
8332 #[serde(default, skip_serializing_if = "Option::is_none")]
8334 pub active_skills: Option<Vec<crate::skills::SkillKey>>,
8335}
8336
8337impl From<&Session> for SessionMeta {
8338 fn from(session: &Session) -> Self {
8339 Self {
8340 id: session.id.clone(),
8341 created_at: session.created_at,
8342 updated_at: session.updated_at,
8343 message_count: session.messages.len(),
8344 total_tokens: session.total_tokens(),
8345 metadata: session.metadata.clone(),
8346 }
8347 }
8348}
8349
8350pub fn try_session_metadata_from_map(
8359 metadata: &serde_json::Map<String, serde_json::Value>,
8360) -> Result<Option<SessionMetadata>, serde_json::Error> {
8361 let Some(value) = metadata.get(SESSION_METADATA_KEY) else {
8362 return Ok(None);
8363 };
8364 let mut metadata = serde_json::from_value::<SessionMetadata>(value.clone())?;
8365 metadata.schema_version =
8366 session_persistence_version_authority::restore_session_metadata_schema_version(
8367 metadata.schema_version,
8368 )
8369 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
8370 session_durable_config_authority::restore_session_metadata(metadata)
8371 .map(Some)
8372 .map_err(<serde_json::Error as serde::de::Error>::custom)
8373}
8374
8375pub fn try_lifecycle_terminal_from_map(
8382 metadata: &serde_json::Map<String, serde_json::Value>,
8383) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
8384 match metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY) {
8385 Some(value) => serde_json::from_value(value.clone()).map(Some),
8386 None => Ok(None),
8387 }
8388}
8389
8390#[derive(Debug, Clone)]
8398pub struct PersistedSessionMetadataView {
8399 pub session_id: SessionId,
8400 pub session_metadata: Option<SessionMetadata>,
8401 pub lifecycle_terminal: Option<SessionLifecycleTerminal>,
8402}
8403
8404impl PersistedSessionMetadataView {
8405 pub fn try_from_metadata_map(
8411 session_id: SessionId,
8412 metadata: &serde_json::Map<String, serde_json::Value>,
8413 ) -> Result<Self, serde_json::Error> {
8414 Ok(Self {
8415 session_id,
8416 session_metadata: try_session_metadata_from_map(metadata)?,
8417 lifecycle_terminal: try_lifecycle_terminal_from_map(metadata)?,
8418 })
8419 }
8420
8421 pub fn try_from_session(session: &Session) -> Result<Self, serde_json::Error> {
8423 Ok(Self {
8424 session_id: session.id().clone(),
8425 session_metadata: session.try_session_metadata()?,
8426 lifecycle_terminal: session.try_lifecycle_terminal()?,
8427 })
8428 }
8429
8430 pub fn mob_member_binding(&self) -> Option<&crate::MobMemberBinding> {
8433 self.session_metadata.as_ref()?.mob_member_binding.as_ref()
8434 }
8435}
8436
8437#[cfg(test)]
8438#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
8439mod tests {
8440
8441 #[test]
8452 fn append_fast_path_installs_the_same_graph_as_the_validating_path()
8453 -> Result<(), Box<dyn std::error::Error>> {
8454 fn seeded() -> Result<Session, Box<dyn std::error::Error>> {
8455 let mut session = Session::new();
8456 session.push(Message::User(UserMessage::text("A".to_string())));
8457 session.push(Message::User(UserMessage::text("B".to_string())));
8458 session.commit_transcript_rewrite(
8459 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8460 vec![Message::User(UserMessage::text("B2".to_string()))],
8461 TranscriptRewriteReason::new("unit-test"),
8462 Some("unit-test".to_string()),
8463 None,
8464 )?;
8465 Ok(session)
8466 }
8467
8468 let mut subject = seeded()?;
8469 let mut control = subject.clone();
8470
8471 for index in 0..4 {
8472 let message = Message::User(UserMessage::text(format!("append {index}")));
8473 subject.push(message.clone());
8474
8475 let value = control
8477 .metadata()
8478 .get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
8479 .cloned()
8480 .ok_or_else(|| std::io::Error::other("control history missing"))?;
8481 control.transcript_history_metadata_validation =
8482 TranscriptHistoryMetadataValidation::RequiresValidation;
8483 control.set_validated_transcript_history_metadata(value);
8484 control.transcript_history_metadata_validation =
8485 TranscriptHistoryMetadataValidation::RequiresValidation;
8486 control.push(message);
8487
8488 let subject_state = subject
8489 .transcript_history_state()?
8490 .ok_or_else(|| std::io::Error::other("subject history missing"))?;
8491 let control_state = control
8492 .transcript_history_state()?
8493 .ok_or_else(|| std::io::Error::other("control history missing"))?;
8494 assert_eq!(subject_state.head, control_state.head, "head at {index}");
8495 assert_eq!(
8496 subject_state.commits, control_state.commits,
8497 "commits at {index}"
8498 );
8499 let project = |state: &TranscriptHistoryState| {
8500 let mut bodies = state
8501 .revisions
8502 .iter()
8503 .map(|body| (body.revision.clone(), body.messages.clone()))
8504 .collect::<Vec<_>>();
8505 bodies.sort_by(|left, right| left.0.cmp(&right.0));
8506 bodies
8507 };
8508 assert_eq!(
8509 project(&subject_state),
8510 project(&control_state),
8511 "retained bodies at {index}"
8512 );
8513 subject.validate_transcript_history_state()?;
8514 }
8515 Ok(())
8516 }
8517 use super::*;
8518 use crate::realtime_transcript::RealtimeTranscriptRole;
8519 use crate::types::{
8520 AssistantBlock, BlockAssistantMessage, ContentBlock, StopReason, SystemMessage, Usage,
8521 UserMessage,
8522 };
8523 use std::sync::Arc;
8524
8525 fn exact_boundary_append(key: &str, text: &str) -> PendingSystemContextAppend {
8526 PendingSystemContextAppend {
8527 content: crate::lifecycle::CoreRenderable::text(text.to_string()),
8528 source: Some("test:exact-boundary".to_string()),
8529 idempotency_key: Some(key.to_string()),
8530 source_kind: SystemContextSource::RuntimeSteer,
8531 accepted_at: SystemTime::now(),
8532 peer_response_terminal: None,
8533 }
8534 }
8535
8536 async fn wait_for_exact_boundary_request(handle: &SystemContextStateHandle) {
8537 for _ in 0..1_000 {
8538 let registered = matches!(
8539 &handle.boundary.lock().window,
8540 SystemContextBoundaryWindow::Open {
8541 request: Some(_),
8542 ..
8543 }
8544 );
8545 if registered {
8546 return;
8547 }
8548 tokio::task::yield_now().await;
8549 }
8550 panic!("exact boundary request did not register");
8551 }
8552
8553 fn assert_send<T: Send>() {}
8554
8555 #[test]
8556 fn prepared_boundary_authority_is_send_for_owned_commit_handoff() {
8557 assert_send::<PreparedSystemContextBoundary>();
8558 assert_send::<crate::lifecycle::CoreBoundaryStageOutput>();
8559 assert_send::<ModelBoundarySystemContext>();
8560 }
8561
8562 #[tokio::test]
8563 async fn exact_boundary_runner_first_is_typed_unavailable() {
8564 let state = SystemContextStateHandle::new(Default::default()).expect("state");
8565 let run_id = RunId::new();
8566 let _run = state
8567 .begin_boundary_run(run_id.clone())
8568 .expect("open boundary");
8569
8570 let consuming = state
8571 .take_pending_at_exact_boundary(&run_id)
8572 .await
8573 .expect("runner claims open boundary");
8574 assert!(consuming.appends().is_empty());
8575 assert!(
8576 consuming
8577 .consume()
8578 .expect("runner consumes open boundary")
8579 .is_empty()
8580 );
8581 let error = state
8582 .prepare_active_turn_boundary(
8583 &run_id,
8584 vec![exact_boundary_append("runner-first", "too late")],
8585 )
8586 .await
8587 .expect_err("consumed generation cannot mint a preparation");
8588 assert!(matches!(error, CoreBoundaryStageError::Unavailable { .. }));
8589 }
8590
8591 #[tokio::test]
8592 async fn exact_boundary_wrong_run_is_stale_without_claiming_or_mutating_window() {
8593 let state = SystemContextStateHandle::new(Default::default()).expect("state");
8594 let active_run_id = RunId::new();
8595 let wrong_run_id = RunId::new();
8596 let _run = state
8597 .begin_boundary_run(active_run_id.clone())
8598 .expect("open boundary");
8599
8600 let error = state
8601 .prepare_active_turn_boundary(
8602 &wrong_run_id,
8603 vec![exact_boundary_append("wrong-run", "must not stage")],
8604 )
8605 .await
8606 .expect_err("a different run cannot claim the active window");
8607 assert!(matches!(error, CoreBoundaryStageError::Stale { .. }));
8608 assert!(state.snapshot().seen().is_empty());
8609
8610 let consuming = state
8611 .take_pending_at_exact_boundary(&active_run_id)
8612 .await
8613 .expect("the correct run still owns its unclaimed boundary");
8614 assert!(
8615 consuming
8616 .consume()
8617 .expect("consume unchanged active window")
8618 .is_empty()
8619 );
8620 }
8621
8622 #[tokio::test]
8623 async fn exact_boundary_conflicting_batch_is_atomic_and_leaves_window_unclaimed() {
8624 let state = SystemContextStateHandle::new(Default::default()).expect("state");
8625 let run_id = RunId::new();
8626 let _run = state
8627 .begin_boundary_run(run_id.clone())
8628 .expect("open boundary");
8629
8630 let error = state
8631 .prepare_active_turn_boundary(
8632 &run_id,
8633 vec![
8634 exact_boundary_append("conflict", "first"),
8635 exact_boundary_append("conflict", "different"),
8636 ],
8637 )
8638 .await
8639 .expect_err("a conflicting batch must fail before registration");
8640 assert!(matches!(error, CoreBoundaryStageError::Fault { .. }));
8641 assert!(state.snapshot().seen().is_empty());
8642
8643 let consuming = state
8644 .take_pending_at_exact_boundary(&run_id)
8645 .await
8646 .expect("failed validation must leave the exact window unclaimed");
8647 assert!(
8648 consuming
8649 .consume()
8650 .expect("consume unchanged active window")
8651 .is_empty()
8652 );
8653 }
8654
8655 #[tokio::test]
8656 async fn exact_boundary_prepare_first_parks_until_commit() {
8657 let state = SystemContextStateHandle::new(Default::default()).expect("state");
8658 let run_id = RunId::new();
8659 let _run = state
8660 .begin_boundary_run(run_id.clone())
8661 .expect("open boundary");
8662
8663 let prepare_state = state.clone();
8664 let prepare_run_id = run_id.clone();
8665 let prepare = tokio::spawn(async move {
8666 prepare_state
8667 .prepare_active_turn_boundary(
8668 &prepare_run_id,
8669 vec![exact_boundary_append("prepare-first", "parked context")],
8670 )
8671 .await
8672 });
8673 wait_for_exact_boundary_request(&state).await;
8674
8675 let runner_state = state.clone();
8676 let runner_run_id = run_id.clone();
8677 let runner = tokio::spawn(async move {
8678 runner_state
8679 .take_pending_at_exact_boundary(&runner_run_id)
8680 .await
8681 });
8682 let prepared = prepare
8683 .await
8684 .expect("prepare task")
8685 .expect("prepare must return only after park");
8686 assert_eq!(prepared.expected_run_id(), &run_id);
8687 assert!(prepared.boundary_generation() > 0);
8688 assert!(state.snapshot().pending().is_empty());
8689 assert!(
8690 !runner.is_finished(),
8691 "runner must remain parked before commit"
8692 );
8693
8694 prepared
8695 .into_stage_output(None)
8696 .commit()
8697 .expect("exact commit");
8698 let consuming = runner
8699 .await
8700 .expect("runner task")
8701 .expect("runner resumes after commit");
8702 assert_eq!(state.snapshot().pending().len(), 1);
8703 assert!(state.snapshot().applied().is_empty());
8704 let consumed = consuming.consume().expect("consume at model call seam");
8705 assert_eq!(consumed.len(), 1);
8706 assert_eq!(consumed[0].content.render_text(), "parked context");
8707 assert!(state.snapshot().pending().is_empty());
8708 assert!(state.snapshot().applied().is_empty());
8709 }
8710
8711 #[tokio::test]
8712 async fn exact_boundary_preprocessing_drop_does_not_claim_model_consumption() {
8713 let state = SystemContextStateHandle::new(Default::default()).expect("state");
8714 let run_id = RunId::new();
8715 let _run = state
8716 .begin_boundary_run(run_id.clone())
8717 .expect("open boundary");
8718 let prepare_state = state.clone();
8719 let prepare_run_id = run_id.clone();
8720 let prepare = tokio::spawn(async move {
8721 prepare_state
8722 .prepare_active_turn_boundary(
8723 &prepare_run_id,
8724 vec![exact_boundary_append(
8725 "preprocess-drop",
8726 "retryable context",
8727 )],
8728 )
8729 .await
8730 });
8731 wait_for_exact_boundary_request(&state).await;
8732 let runner_state = state.clone();
8733 let runner_run_id = run_id.clone();
8734 let runner = tokio::spawn(async move {
8735 runner_state
8736 .take_pending_at_exact_boundary(&runner_run_id)
8737 .await
8738 });
8739 prepare
8740 .await
8741 .expect("prepare task")
8742 .expect("prepare parks")
8743 .into_stage_output(None)
8744 .commit()
8745 .expect("publish pending candidate");
8746
8747 let consuming = runner
8748 .await
8749 .expect("runner task")
8750 .expect("runner enters preprocessing");
8751 assert_eq!(consuming.appends().len(), 1);
8752 drop(consuming);
8753
8754 let snapshot = state.snapshot();
8755 assert_eq!(snapshot.pending().len(), 1);
8756 assert!(snapshot.applied().is_empty());
8757 assert!(snapshot.seen().contains_key("preprocess-drop"));
8758
8759 assert_eq!(
8764 state
8765 .discard_unapplied_active_turn_pending()
8766 .expect("closed consuming window permits active-turn cleanup"),
8767 1
8768 );
8769 assert!(state.snapshot().pending().is_empty());
8770 }
8771
8772 #[tokio::test]
8773 async fn exact_boundary_drop_aborts_and_preserves_ordinary_pending() {
8774 let state = SystemContextStateHandle::new(Default::default()).expect("state");
8775 state
8776 .stage_append_with_snapshot(
8777 &AppendSystemContextRequest {
8778 content: crate::lifecycle::CoreRenderable::text("ordinary"),
8779 source: Some("test:ordinary".to_string()),
8780 idempotency_key: Some("ordinary".to_string()),
8781 source_kind: SystemContextSource::Normal,
8782 peer_response_terminal: None,
8783 },
8784 SystemTime::now(),
8785 )
8786 .expect("ordinary append");
8787 let run_id = RunId::new();
8788 let _run = state
8789 .begin_boundary_run(run_id.clone())
8790 .expect("open boundary");
8791 let prepare_state = state.clone();
8792 let prepare_run_id = run_id.clone();
8793 let prepare = tokio::spawn(async move {
8794 prepare_state
8795 .prepare_active_turn_boundary(
8796 &prepare_run_id,
8797 vec![exact_boundary_append("drop-abort", "must not publish")],
8798 )
8799 .await
8800 });
8801 wait_for_exact_boundary_request(&state).await;
8802 let runner_state = state.clone();
8803 let runner_run_id = run_id.clone();
8804 let runner = tokio::spawn(async move {
8805 runner_state
8806 .take_pending_at_exact_boundary(&runner_run_id)
8807 .await
8808 });
8809 let prepared = prepare.await.expect("prepare task").expect("parked");
8810 drop(prepared);
8811 let consuming = runner
8812 .await
8813 .expect("runner task")
8814 .expect("drop abort wakes runner");
8815 let consumed = consuming
8816 .consume()
8817 .expect("consume ordinary pending context");
8818 assert_eq!(consumed.len(), 1);
8819 assert_eq!(consumed[0].content.render_text(), "ordinary");
8820 assert!(!state.snapshot().seen().contains_key("drop-abort"));
8821 }
8822
8823 #[tokio::test]
8824 async fn exact_boundary_duplicate_prepare_cannot_overwrite_generation() {
8825 let state = SystemContextStateHandle::new(Default::default()).expect("state");
8826 let run_id = RunId::new();
8827 let _run = state
8828 .begin_boundary_run(run_id.clone())
8829 .expect("open boundary");
8830 let first_state = state.clone();
8831 let first_run_id = run_id.clone();
8832 let first = tokio::spawn(async move {
8833 first_state
8834 .prepare_active_turn_boundary(
8835 &first_run_id,
8836 vec![exact_boundary_append("first", "first")],
8837 )
8838 .await
8839 });
8840 wait_for_exact_boundary_request(&state).await;
8841 let duplicate = state
8842 .prepare_active_turn_boundary(&run_id, vec![exact_boundary_append("second", "second")])
8843 .await
8844 .expect_err("duplicate preparation must fail closed");
8845 assert!(duplicate.is_unavailable());
8846
8847 let runner_state = state.clone();
8848 let runner_run_id = run_id.clone();
8849 let runner = tokio::spawn(async move {
8850 runner_state
8851 .take_pending_at_exact_boundary(&runner_run_id)
8852 .await
8853 });
8854 let prepared = first.await.expect("first task").expect("first parks");
8855 prepared
8856 .into_stage_output(None)
8857 .abort()
8858 .expect("explicit abort");
8859 runner
8860 .await
8861 .expect("runner task")
8862 .expect("runner resumes after abort")
8863 .consume()
8864 .expect("consume ordinary pending after abort");
8865 assert!(!state.snapshot().seen().contains_key("second"));
8866 }
8867
8868 #[tokio::test]
8869 async fn exact_boundary_concurrent_conflict_surfaces_fault_to_runner_and_preparer() {
8870 let state = SystemContextStateHandle::new(Default::default()).expect("state");
8871 let run_id = RunId::new();
8872 let _run = state
8873 .begin_boundary_run(run_id.clone())
8874 .expect("open boundary");
8875 let prepare_state = state.clone();
8876 let prepare_run_id = run_id.clone();
8877 let prepare = tokio::spawn(async move {
8878 prepare_state
8879 .prepare_active_turn_boundary(
8880 &prepare_run_id,
8881 vec![exact_boundary_append("shared-key", "prepared context")],
8882 )
8883 .await
8884 });
8885 wait_for_exact_boundary_request(&state).await;
8886
8887 state
8888 .stage_append_with_snapshot(
8889 &AppendSystemContextRequest {
8890 content: crate::lifecycle::CoreRenderable::text("ordinary conflict"),
8891 source: Some("test:exact-boundary".to_string()),
8892 idempotency_key: Some("shared-key".to_string()),
8893 source_kind: SystemContextSource::Normal,
8894 peer_response_terminal: None,
8895 },
8896 SystemTime::now(),
8897 )
8898 .expect("ordinary mutation remains legal before the runner parks");
8899
8900 let runner_error = state
8901 .take_pending_at_exact_boundary(&run_id)
8902 .await
8903 .err()
8904 .expect("runner must surface candidate recomputation conflict");
8905 assert!(matches!(runner_error, CoreBoundaryStageError::Fault { .. }));
8906 let prepare_error = prepare
8907 .await
8908 .expect("prepare task")
8909 .expect_err("preparer must receive the same typed failure class");
8910 assert!(matches!(
8911 prepare_error,
8912 CoreBoundaryStageError::Fault { .. }
8913 ));
8914
8915 let snapshot = state.snapshot();
8916 assert_eq!(snapshot.pending().len(), 1);
8917 assert_eq!(
8918 snapshot.pending()[0].content.render_text(),
8919 "ordinary conflict"
8920 );
8921 assert!(snapshot.applied().is_empty());
8922 }
8923
8924 #[tokio::test]
8925 async fn exact_boundary_nonconflicting_open_mutation_is_preserved_in_candidate() {
8926 let state = SystemContextStateHandle::new(Default::default()).expect("state");
8927 let run_id = RunId::new();
8928 let _run = state
8929 .begin_boundary_run(run_id.clone())
8930 .expect("open boundary");
8931 let prepare_state = state.clone();
8932 let prepare_run_id = run_id.clone();
8933 let prepare = tokio::spawn(async move {
8934 prepare_state
8935 .prepare_active_turn_boundary(
8936 &prepare_run_id,
8937 vec![exact_boundary_append("prepared", "prepared context")],
8938 )
8939 .await
8940 });
8941 wait_for_exact_boundary_request(&state).await;
8942
8943 state
8944 .stage_append_with_snapshot(
8945 &AppendSystemContextRequest {
8946 content: crate::lifecycle::CoreRenderable::text("ordinary context"),
8947 source: Some("test:ordinary".to_string()),
8948 idempotency_key: Some("ordinary".to_string()),
8949 source_kind: SystemContextSource::Normal,
8950 peer_response_terminal: None,
8951 },
8952 SystemTime::now(),
8953 )
8954 .expect("nonconflicting mutation remains legal before park");
8955
8956 let runner_state = state.clone();
8957 let runner_run_id = run_id.clone();
8958 let runner = tokio::spawn(async move {
8959 runner_state
8960 .take_pending_at_exact_boundary(&runner_run_id)
8961 .await
8962 });
8963 let prepared = prepare.await.expect("prepare task").expect("parked");
8964 assert_eq!(prepared.candidate_state().pending().len(), 2);
8965 prepared
8966 .into_stage_output(None)
8967 .commit()
8968 .expect("commit exact candidate");
8969 let consuming = runner
8970 .await
8971 .expect("runner task")
8972 .expect("runner resumes after commit");
8973 let consumed = consuming.consume().expect("consume exact candidate");
8974 assert_eq!(consumed.len(), 2);
8975 assert!(
8976 consumed
8977 .iter()
8978 .any(|append| append.content.render_text() == "ordinary context")
8979 );
8980 assert!(
8981 consumed
8982 .iter()
8983 .any(|append| append.content.render_text() == "prepared context")
8984 );
8985 }
8986
8987 #[tokio::test]
8988 async fn exact_boundary_actor_replacement_rejects_old_commit() {
8989 let actor_a = SystemContextStateHandle::new(Default::default()).expect("actor A");
8990 let run_id = RunId::new();
8991 let _run_a = actor_a
8992 .begin_boundary_run(run_id.clone())
8993 .expect("open A boundary");
8994 let prepare_state = actor_a.clone();
8995 let prepare_run_id = run_id.clone();
8996 let prepare = tokio::spawn(async move {
8997 prepare_state
8998 .prepare_active_turn_boundary(
8999 &prepare_run_id,
9000 vec![exact_boundary_append("actor-a", "stale A")],
9001 )
9002 .await
9003 });
9004 wait_for_exact_boundary_request(&actor_a).await;
9005 let runner_state = actor_a.clone();
9006 let runner_run_id = run_id.clone();
9007 let runner = tokio::spawn(async move {
9008 runner_state
9009 .take_pending_at_exact_boundary(&runner_run_id)
9010 .await
9011 });
9012 let prepared_a = prepare.await.expect("prepare task").expect("A parked");
9013
9014 actor_a.revoke_boundary_actor();
9015 let actor_b = SystemContextStateHandle::new(Default::default()).expect("actor B");
9016 let _run_b = actor_b
9017 .begin_boundary_run(run_id.clone())
9018 .expect("replacement opens independently");
9019 let error = prepared_a
9020 .into_stage_output(None)
9021 .commit()
9022 .expect_err("A cannot commit after replacement revoke");
9023 assert!(matches!(error, CoreBoundaryStageError::Stale { .. }));
9024 assert!(runner.await.expect("A runner task").is_err());
9025 assert!(actor_b.snapshot().seen().is_empty());
9026 }
9027
9028 #[tokio::test]
9029 async fn exact_boundary_hard_interrupt_and_concurrent_append_fail_closed() {
9030 let state = SystemContextStateHandle::new(Default::default()).expect("state");
9031 let run_id = RunId::new();
9032 let _run = state
9033 .begin_boundary_run(run_id.clone())
9034 .expect("open boundary");
9035 let prepare_state = state.clone();
9036 let prepare_run_id = run_id.clone();
9037 let prepare = tokio::spawn(async move {
9038 prepare_state
9039 .prepare_active_turn_boundary(
9040 &prepare_run_id,
9041 vec![exact_boundary_append("interrupt", "stale")],
9042 )
9043 .await
9044 });
9045 wait_for_exact_boundary_request(&state).await;
9046 let runner_state = state.clone();
9047 let runner_run_id = run_id.clone();
9048 let runner = tokio::spawn(async move {
9049 runner_state
9050 .take_pending_at_exact_boundary(&runner_run_id)
9051 .await
9052 });
9053 let prepared = prepare.await.expect("prepare task").expect("parked");
9054 let concurrent = state.stage_append_with_snapshot(
9055 &AppendSystemContextRequest {
9056 content: crate::lifecycle::CoreRenderable::text("concurrent"),
9057 source: Some("test:concurrent".to_string()),
9058 idempotency_key: Some("concurrent".to_string()),
9059 source_kind: SystemContextSource::Normal,
9060 peer_response_terminal: None,
9061 },
9062 SystemTime::now(),
9063 );
9064 assert!(
9065 concurrent.is_err(),
9066 "parked candidate must not be overwritten"
9067 );
9068 let discard_error = state
9069 .discard_unapplied_active_turn_pending()
9070 .expect_err("parked authority must reject cleanup, not report an empty success");
9071 assert!(matches!(
9072 discard_error,
9073 CoreBoundaryStageError::Fault { .. }
9074 ));
9075 let keyed_discard_error = state
9076 .discard_active_turn_pending_by_keys(&["interrupt".to_string()])
9077 .expect_err("parked authority must reject keyed rollback");
9078 assert!(matches!(
9079 keyed_discard_error,
9080 CoreBoundaryStageError::Fault { .. }
9081 ));
9082
9083 runner.abort();
9084 let _ = runner.await;
9085 let error = prepared
9086 .into_stage_output(None)
9087 .commit()
9088 .expect_err("hard-interrupted parked request cannot commit later");
9089 assert!(matches!(error, CoreBoundaryStageError::Stale { .. }));
9090 assert!(state.snapshot().seen().is_empty());
9091 }
9092
9093 #[tokio::test]
9094 async fn exact_boundary_run_exit_wakes_prepare_before_parking() {
9095 let state = SystemContextStateHandle::new(Default::default()).expect("state");
9096 let run_id = RunId::new();
9097 let run = state
9098 .begin_boundary_run(run_id.clone())
9099 .expect("open boundary");
9100 let prepare_state = state.clone();
9101 let prepare_run_id = run_id.clone();
9102 let prepare = tokio::spawn(async move {
9103 prepare_state
9104 .prepare_active_turn_boundary(
9105 &prepare_run_id,
9106 vec![exact_boundary_append("run-exit", "never parks")],
9107 )
9108 .await
9109 });
9110 wait_for_exact_boundary_request(&state).await;
9111 drop(run);
9112 let error = prepare
9113 .await
9114 .expect("prepare task")
9115 .expect_err("run exit must release preparer");
9116 assert!(matches!(
9117 error,
9118 CoreBoundaryStageError::Unavailable { .. } | CoreBoundaryStageError::Stale { .. }
9119 ));
9120 }
9121
9122 fn block_assistant_text(message: &BlockAssistantMessage) -> String {
9123 message
9124 .blocks
9125 .iter()
9126 .filter_map(|block| match block {
9127 AssistantBlock::Text { text, .. } => Some(text.as_str()),
9128 _ => None,
9129 })
9130 .collect()
9131 }
9132
9133 fn append_staged_user_image(
9138 session: &mut Session,
9139 event: &RealtimeTranscriptEvent,
9140 ) -> RealtimeTranscriptApplyOutcome {
9141 let RealtimeTranscriptEvent::UserContentFinal {
9142 idempotency_key,
9143 item_id,
9144 previous_item_id,
9145 content_index,
9146 content,
9147 } = event
9148 else {
9149 panic!("test helper requires user content final")
9150 };
9151 let [ContentBlock::Image { media_type, data }] = content.as_slice() else {
9152 panic!("test helper requires exactly one image")
9153 };
9154 let media_type = crate::image_generation::MediaType::canonical_str(media_type);
9155 let blob_id = match data {
9156 crate::types::ImageData::Inline { data } => {
9157 crate::blob::content_blob_id(&media_type, data)
9158 }
9159 crate::types::ImageData::Blob { blob_id } => blob_id.clone(),
9160 };
9161 let pending = crate::PendingRealtimeUserContentBlob {
9162 idempotency_key: idempotency_key.clone(),
9163 item_id: item_id.clone(),
9164 previous_item_id: previous_item_id.clone(),
9165 content_index: *content_index,
9166 blob_id,
9167 media_type,
9168 };
9169 assert_eq!(
9170 session
9171 .stage_pending_realtime_user_content_blob(pending.clone())
9172 .expect("test pending anchor should stage"),
9173 crate::generated::session_document::RealtimeUserContentBlobStageDisposition::StageNew
9174 );
9175 session.append_realtime_transcript_event(pending.canonical_event())
9176 }
9177
9178 #[test]
9179 fn transcript_digest_is_content_addressed() {
9180 let base_time = crate::types::message_timestamp_now();
9181 let stamped = vec![
9182 Message::User(UserMessage::text("turn one".to_string())),
9183 Message::BlockAssistant(BlockAssistantMessage {
9184 blocks: vec![AssistantBlock::Text {
9185 text: "answer one".to_string(),
9186 meta: None,
9187 }],
9188 stop_reason: StopReason::EndTurn,
9189 identity: crate::types::TranscriptMessageIdentity {
9190 interaction_id: None,
9191 run_id: Some(crate::lifecycle::RunId::new()),
9192 objective_id: None,
9193 },
9194 created_at: base_time,
9195 }),
9196 ];
9197 let mut restamped = stamped.clone();
9198 for message in &mut restamped {
9199 match message {
9200 Message::User(user) => {
9201 user.created_at = base_time + chrono::Duration::hours(2);
9202 }
9203 Message::BlockAssistant(assistant) => {
9204 assistant.identity = crate::types::TranscriptMessageIdentity {
9205 interaction_id: None,
9206 run_id: Some(crate::lifecycle::RunId::new()),
9207 objective_id: None,
9208 };
9209 assistant.created_at = base_time + chrono::Duration::hours(2);
9210 }
9211 _ => {}
9212 }
9213 }
9214 assert_eq!(
9215 transcript_messages_digest(&stamped).expect("digest"),
9216 transcript_messages_digest(&restamped).expect("digest"),
9217 "bookkeeping variance must not fork the transcript revision"
9218 );
9219
9220 let mut content_changed = stamped.clone();
9221 if let Message::User(user) = &mut content_changed[0] {
9222 user.content = vec![ContentBlock::Text {
9223 text: "a different turn".to_string(),
9224 }];
9225 }
9226 assert_ne!(
9227 transcript_messages_digest(&stamped).expect("digest"),
9228 transcript_messages_digest(&content_changed).expect("digest"),
9229 "content changes must fork the transcript revision"
9230 );
9231 }
9232
9233 #[test]
9234 fn public_generic_rewrite_api_rejects_typed_compaction_semantic() {
9235 let mut session = Session::new();
9236 session.push(Message::User(UserMessage::text("old context")));
9237 let error = session
9238 .commit_transcript_rewrite(
9239 TranscriptRewriteSelection::typed_compaction_for_test(0, 1),
9240 vec![Message::User(UserMessage::compaction_summary("summary"))],
9241 TranscriptRewriteReason::new("anything"),
9242 None,
9243 None,
9244 )
9245 .unwrap_err();
9246 assert!(matches!(
9247 error,
9248 TranscriptEditError::InvalidTranscriptShape(_)
9249 ));
9250 assert_eq!(session.messages().len(), 1);
9251 }
9252
9253 #[test]
9254 fn compaction_witness_authorizes_only_the_exact_validated_rebuild() {
9255 let mut session = Session::new();
9256 session.push(Message::User(UserMessage::text("old context one")));
9257 session.push(Message::User(UserMessage::text("old context two")));
9258 let validated = vec![Message::User(UserMessage::compaction_summary(
9259 "validated summary",
9260 ))];
9261 let authority = crate::agent::compact::ValidatedCompactionRewrite::for_test(
9262 session.messages(),
9263 &validated,
9264 )
9265 .unwrap();
9266 let error = session
9267 .replace_messages_for_compaction_internal(
9268 vec![Message::User(UserMessage::compaction_summary(
9269 "substituted summary",
9270 ))],
9271 &authority,
9272 )
9273 .unwrap_err();
9274 assert!(matches!(
9275 error,
9276 TranscriptEditError::InvalidTranscriptShape(_)
9277 ));
9278 assert_eq!(session.messages().len(), 2);
9279 }
9280
9281 #[test]
9282 fn semantic_marker_prevents_new_generic_compaction_forgery_and_heals_prior_data() {
9283 let mut session = Session::new();
9284 session.push(Message::User(UserMessage::text("old context one")));
9285 session.push(Message::User(UserMessage::text("old context two")));
9286 session
9287 .commit_transcript_rewrite(
9288 TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
9289 vec![Message::User(UserMessage::compaction_summary("summary"))],
9290 TranscriptRewriteReason::new("compaction"),
9291 None,
9292 None,
9293 )
9294 .unwrap();
9295 let session: Session =
9296 serde_json::from_value(serde_json::to_value(&session).unwrap()).unwrap();
9297 let history = session.transcript_history_state().unwrap().unwrap();
9298 assert_eq!(
9299 history.commits[0].selection.semantic(),
9300 TranscriptRewriteSemantic::Edit,
9301 "new generic rewrites retain an explicit typed edit marker after roundtrip"
9302 );
9303 assert_eq!(history.commits[0].reason.kind, "compaction");
9304
9305 let mut legacy = history;
9306 legacy.commits[0].selection = TranscriptRewriteSelection::MessageRange { start: 0, end: 2 };
9307 let legacy: TranscriptHistoryState =
9308 serde_json::from_value(serde_json::to_value(legacy).unwrap()).unwrap();
9309 assert_eq!(
9310 legacy.commits[0].selection.semantic(),
9311 TranscriptRewriteSemantic::Compaction,
9312 "marker-absent prior data derives compaction from typed transcript evidence"
9313 );
9314
9315 let mut ordinary = Session::new();
9316 ordinary.push(Message::User(UserMessage::text("ordinary old one")));
9317 ordinary.push(Message::User(UserMessage::text("ordinary old two")));
9318 ordinary
9319 .commit_transcript_rewrite(
9320 TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
9321 vec![Message::User(UserMessage::text("ordinary replacement"))],
9322 TranscriptRewriteReason::new("compaction"),
9323 None,
9324 None,
9325 )
9326 .unwrap();
9327 let history = ordinary.transcript_history_state().unwrap().unwrap();
9328 assert_eq!(
9329 history.commits[0].selection.semantic(),
9330 TranscriptRewriteSemantic::Edit,
9331 "free-form reason must not upgrade an ordinary edit"
9332 );
9333 }
9334
9335 #[test]
9343 fn marker_less_document_decode_memoizes_probe_and_graph_validation() {
9344 use crate::checkpoint::session_content_digest_computations;
9345
9346 let mut session = Session::new();
9349 session.push(Message::User(UserMessage::text(
9350 "marker-less-memo old context one",
9351 )));
9352 session.push(Message::User(UserMessage::text(
9353 "marker-less-memo old context two",
9354 )));
9355 session
9356 .commit_transcript_rewrite(
9357 TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
9358 vec![Message::User(UserMessage::text(
9359 "marker-less-memo replacement",
9360 ))],
9361 TranscriptRewriteReason::new("edit"),
9362 None,
9363 None,
9364 )
9365 .unwrap();
9366 let mut document = serde_json::to_value(&session).unwrap();
9367 document["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]
9370 .as_object_mut()
9371 .unwrap()
9372 .remove("digest_format")
9373 .expect("fixture blob was written with the marker");
9374
9375 let before_first = session_content_digest_computations();
9376 let first: Session = serde_json::from_value(document.clone()).unwrap();
9377 let after_first = session_content_digest_computations();
9378 assert!(
9379 after_first > before_first,
9380 "first decode of a marker-less document must fully verify \
9381 (heal probe + per-body graph validation)"
9382 );
9383 let restamped = serde_json::to_value(&first).unwrap();
9386 assert_eq!(
9387 restamped["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["digest_format"],
9388 serde_json::json!(TRANSCRIPT_DIGEST_FORMAT_CURRENT),
9389 "decoding a marker-less document must stamp the current digest format"
9390 );
9391
9392 for _ in 0..3 {
9393 let repeat: Session = serde_json::from_value(document.clone()).unwrap();
9394 assert_eq!(repeat.messages().len(), first.messages().len());
9395 }
9396 assert_eq!(
9397 session_content_digest_computations(),
9398 after_first,
9399 "repeat decodes of unchanged marker-less bytes must not recompute \
9400 content digests (O(1) per repeat load within a process)"
9401 );
9402
9403 let stamped_repeat: Session = serde_json::from_value(restamped).unwrap();
9407 assert_eq!(stamped_repeat.messages().len(), first.messages().len());
9408 assert_eq!(
9409 session_content_digest_computations(),
9410 after_first,
9411 "repeat decode of the marker-stamped spelling must not recompute digests"
9412 );
9413
9414 let mut tampered = document.clone();
9419 let head = tampered["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["head"]
9420 .as_str()
9421 .unwrap()
9422 .to_string();
9423 let bodies = tampered["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["revisions"]
9424 .as_array_mut()
9425 .unwrap();
9426 let head_body = bodies
9427 .iter_mut()
9428 .find(|body| body["revision"].as_str() == Some(head.as_str()))
9429 .unwrap();
9430 let smuggled = head_body["messages"][0].clone();
9431 head_body["messages"].as_array_mut().unwrap().push(smuggled);
9432 let digests_before_tampered = session_content_digest_computations();
9433 let error = serde_json::from_value::<Session>(tampered).unwrap_err();
9434 assert!(
9435 error.to_string().contains("digest"),
9436 "tampered head body must fail digest validation, got: {error}"
9437 );
9438 assert!(
9439 session_content_digest_computations() > digests_before_tampered,
9440 "a changed graph shape must re-verify, not hit the memo"
9441 );
9442 }
9443
9444 fn legacy_rewrite_fixture() -> (TranscriptRewriteCommit, Vec<Message>, Vec<Message>) {
9445 let parent_messages = vec![
9446 Message::User(UserMessage::text("before rewrite".to_string())),
9447 Message::User(UserMessage::text("retained tail".to_string())),
9448 ];
9449 let revision_messages = vec![
9450 Message::User(UserMessage::text("after rewrite".to_string())),
9451 Message::User(UserMessage::text("retained tail".to_string())),
9452 ];
9453 let parent_revision =
9456 legacy_transcript_messages_digest(&parent_messages).expect("legacy parent digest");
9457 let revision =
9458 legacy_transcript_messages_digest(&revision_messages).expect("legacy revision digest");
9459 let commit = TranscriptRewriteCommit {
9460 parent_revision,
9461 revision,
9462 selection: TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9463 original_span_digest: legacy_transcript_messages_digest(&parent_messages[0..1])
9464 .expect("legacy span digest"),
9465 replacement_digest: legacy_transcript_messages_digest(&revision_messages[0..1])
9466 .expect("legacy replacement digest"),
9467 messages_before: 2,
9468 messages_after: 2,
9469 reason: TranscriptRewriteReason::new("compaction"),
9470 actor: Some("legacy-test".to_string()),
9471 committed_at: SystemTime::now(),
9472 };
9473 (commit, parent_messages, revision_messages)
9474 }
9475
9476 #[test]
9477 fn legacy_transcript_history_state_heals_to_content_addressed_on_parse() {
9478 let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
9479 let state = TranscriptHistoryState {
9480 head: commit.revision.clone(),
9481 digest_format: 0,
9482 commits: vec![commit.clone()],
9483 revisions: vec![
9484 TranscriptRevisionBody {
9485 revision: commit.parent_revision.clone(),
9486 parent_revision: None,
9487 messages: parent_messages.clone(),
9488 created_at: SystemTime::now(),
9489 },
9490 TranscriptRevisionBody {
9491 revision: commit.revision.clone(),
9492 parent_revision: Some(commit.parent_revision),
9493 messages: revision_messages.clone(),
9494 created_at: SystemTime::now(),
9495 },
9496 ],
9497 };
9498 let value = serde_json::to_value(&state).expect("serialize legacy state");
9499 let healed: TranscriptHistoryState =
9500 serde_json::from_value(value).expect("parse legacy state");
9501
9502 let content_parent =
9503 transcript_messages_digest(&parent_messages).expect("content parent digest");
9504 let content_revision =
9505 transcript_messages_digest(&revision_messages).expect("content revision digest");
9506 assert_eq!(healed.head, content_revision, "head must re-derive");
9507 assert_eq!(healed.commits[0].parent_revision, content_parent);
9508 assert_eq!(healed.commits[0].revision, content_revision);
9509 assert_eq!(healed.revisions[0].revision, content_parent);
9510 assert_eq!(healed.revisions[1].revision, content_revision);
9511 assert_eq!(
9512 healed.revisions[1].parent_revision.as_deref(),
9513 Some(content_parent.as_str())
9514 );
9515 validate_transcript_history_state(&healed).expect("healed graph must validate");
9516
9517 let mut session = Session::new();
9520 session
9521 .apply_transcript_history_state(healed)
9522 .expect("apply healed graph");
9523 session
9524 .commit_transcript_rewrite(
9525 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9526 vec![Message::User(UserMessage::text(
9527 "rewritten again".to_string(),
9528 ))],
9529 TranscriptRewriteReason::new("unit-test"),
9530 None,
9531 None,
9532 )
9533 .expect("extend healed graph with a new rewrite");
9534 session
9535 .validate_transcript_history_state()
9536 .expect("extended graph must validate");
9537 }
9538
9539 #[test]
9540 fn legacy_transcript_rewrite_record_heals_on_parse() {
9541 let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
9542 let record_value = serde_json::json!({
9543 "commit": commit,
9544 "parent_body": TranscriptRevisionBody {
9545 revision: commit.parent_revision.clone(),
9546 parent_revision: None,
9547 messages: parent_messages,
9548 created_at: SystemTime::now(),
9549 },
9550 "revision_body": TranscriptRevisionBody {
9551 revision: commit.revision.clone(),
9552 parent_revision: Some(commit.parent_revision),
9553 messages: revision_messages.clone(),
9554 created_at: SystemTime::now(),
9555 },
9556 });
9557 let healed: TranscriptRewriteRecord =
9558 serde_json::from_value(record_value).expect("parse legacy record");
9559 assert_eq!(
9560 healed.commit.revision,
9561 transcript_messages_digest(&revision_messages).expect("content digest")
9562 );
9563 TranscriptRewriteRecord::new(healed.commit, healed.parent_body, healed.revision_body)
9565 .expect("healed record must validate");
9566 }
9567
9568 #[test]
9569 fn corrupt_transcript_history_strings_stay_untouched_and_fail_validation() {
9570 let (commit, parent_messages, _revision_messages) = legacy_rewrite_fixture();
9571 let bogus = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
9572 let state = TranscriptHistoryState {
9573 digest_format: 0,
9574 head: bogus.to_string(),
9575 commits: Vec::new(),
9576 revisions: vec![TranscriptRevisionBody {
9577 revision: bogus.to_string(),
9578 parent_revision: None,
9579 messages: parent_messages,
9580 created_at: SystemTime::now(),
9581 }],
9582 };
9583 let _ = commit;
9584 let value = serde_json::to_value(&state).expect("serialize corrupt state");
9585 let parsed: TranscriptHistoryState =
9586 serde_json::from_value(value).expect("corrupt strings still parse");
9587 assert_eq!(
9588 parsed.head, bogus,
9589 "unverifiable strings must not be rewritten"
9590 );
9591 assert!(
9592 validate_transcript_history_state(&parsed).is_err(),
9593 "corrupt graph must keep failing validation"
9594 );
9595 }
9596
9597 #[test]
9601 fn replace_synthetic_notices_leaves_only_replacements_of_kind() {
9602 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
9603
9604 let mut session = Session::new();
9605 session.push(Message::User(UserMessage::text("hello".to_string())));
9606 session.push(Message::SystemNotice(SystemNoticeMessage::new(
9607 SystemNoticeKind::McpPending,
9608 "stale one",
9609 )));
9610 session.push(Message::SystemNotice(SystemNoticeMessage::new(
9611 SystemNoticeKind::McpPending,
9612 "stale two",
9613 )));
9614 session.push(Message::SystemNotice(SystemNoticeMessage::new(
9616 SystemNoticeKind::BackgroundJob,
9617 "other-kind",
9618 )));
9619
9620 session
9621 .replace_synthetic_notices(
9622 SystemNoticeKind::McpPending,
9623 vec![Message::SystemNotice(SystemNoticeMessage::new(
9624 SystemNoticeKind::McpPending,
9625 "fresh",
9626 ))],
9627 )
9628 .expect("notice refresh succeeds");
9629
9630 let mcp_pending: Vec<&SystemNoticeMessage> = session
9631 .messages()
9632 .iter()
9633 .filter_map(|message| match message {
9634 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending => {
9635 Some(notice)
9636 }
9637 _ => None,
9638 })
9639 .collect();
9640 assert_eq!(mcp_pending.len(), 1, "exactly one notice of the kind");
9641 assert_eq!(mcp_pending[0].body.as_deref(), Some("fresh"));
9642 assert!(
9643 session.messages().iter().any(|message| matches!(
9644 message,
9645 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::BackgroundJob
9646 )),
9647 "other-kind notices are untouched"
9648 );
9649
9650 session
9652 .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
9653 .expect("pure strip succeeds");
9654 assert!(
9655 !session.messages().iter().any(|message| matches!(
9656 message,
9657 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending
9658 )),
9659 "empty replacement clears the kind"
9660 );
9661 }
9662
9663 #[test]
9664 fn ordinary_appends_after_rewrite_coalesce_mechanical_revision_bodies() {
9665 let mut session = Session::new();
9666 for message in 0..133 {
9667 session.push(Message::User(UserMessage::text(format!(
9668 "seed message {message}"
9669 ))));
9670 }
9671 let parent = session.transcript_revision().expect("parent revision");
9672 session
9673 .commit_transcript_rewrite(
9674 TranscriptRewriteSelection::MessageRange {
9675 start: 132,
9676 end: 133,
9677 },
9678 vec![Message::User(UserMessage::text("edited question"))],
9679 TranscriptRewriteReason::new("unit-test-edit"),
9680 Some("unit-test".to_string()),
9681 Some(parent),
9682 )
9683 .expect("rewrite should commit");
9684
9685 for turn in 0..762 {
9686 session.push(Message::User(UserMessage::text(format!("turn {turn}"))));
9687 }
9688
9689 let state = session
9690 .transcript_history_state()
9691 .expect("history state should decode")
9692 .expect("rewrite should create history state");
9693 assert_eq!(session.messages().len(), 895);
9694 assert_eq!(state.commits.len(), 1, "ordinary appends are not rewrites");
9695 assert_eq!(
9696 state.revisions.len(),
9697 3,
9698 "one real rewrite retains its two audited endpoints plus one live head"
9699 );
9700 let retained_message_entries = state
9701 .revisions
9702 .iter()
9703 .map(|body| body.messages.len())
9704 .sum::<usize>();
9705 assert!(retained_message_entries <= 3 * session.messages().len());
9706
9707 let live_bytes = serde_json::to_vec(session.messages())
9708 .expect("live transcript should serialize")
9709 .len();
9710 let snapshot_bytes = serde_json::to_vec(&session)
9711 .expect("session snapshot should serialize")
9712 .len();
9713 assert!(
9714 snapshot_bytes <= live_bytes.saturating_mul(5).saturating_add(64 * 1024),
9715 "snapshot must remain linear in the live transcript: {snapshot_bytes} bytes for {live_bytes} live bytes"
9716 );
9717 }
9718
9719 #[test]
9720 fn repeated_synthetic_notice_refreshes_do_not_mint_rewrite_commits() {
9721 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
9722
9723 let mut session = Session::new();
9724 session.push(Message::User(UserMessage::text("before".to_string())));
9725 session
9726 .commit_transcript_rewrite(
9727 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9728 vec![Message::User(UserMessage::text("after".to_string()))],
9729 TranscriptRewriteReason::new("unit-test-edit"),
9730 Some("unit-test".to_string()),
9731 None,
9732 )
9733 .expect("seed rewrite");
9734
9735 for refresh in 0..64 {
9736 session
9737 .replace_synthetic_notices(
9738 SystemNoticeKind::McpPending,
9739 vec![Message::SystemNotice(SystemNoticeMessage::new(
9740 SystemNoticeKind::McpPending,
9741 format!("refresh {refresh}"),
9742 ))],
9743 )
9744 .expect("mechanical refresh");
9745 }
9746
9747 let state = session
9748 .transcript_history_state()
9749 .expect("history state")
9750 .expect("seed rewrite history");
9751 assert_eq!(state.commits.len(), 1);
9752 assert_eq!(session.transcript_rewrite_generation().unwrap(), 1);
9753 assert_eq!(state.revisions.len(), 3);
9754 }
9755
9756 #[test]
9757 fn legacy_append_head_chain_compacts_during_session_restore() {
9758 let mut session = Session::new();
9759 session.push(Message::User(UserMessage::text("seed".to_string())));
9760 session
9761 .commit_transcript_rewrite(
9762 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9763 vec![Message::User(UserMessage::text(
9764 "rewritten seed".to_string(),
9765 ))],
9766 TranscriptRewriteReason::new("unit-test-edit"),
9767 Some("unit-test".to_string()),
9768 None,
9769 )
9770 .expect("seed rewrite");
9771
9772 let mut legacy = session
9773 .transcript_history_state()
9774 .expect("history state")
9775 .expect("seed history");
9776 let mut messages = session.messages().to_vec();
9777 let mut previous_head = legacy.head.clone();
9778 for append in 0..32 {
9779 messages.push(Message::User(UserMessage::text(format!(
9780 "legacy append {append}"
9781 ))));
9782 let revision = transcript_messages_digest(&messages).expect("revision digest");
9783 legacy.revisions.push(TranscriptRevisionBody {
9784 revision: revision.clone(),
9785 parent_revision: Some(previous_head),
9786 messages: messages.clone(),
9787 created_at: SystemTime::now(),
9788 });
9789 previous_head = revision;
9790 }
9791 legacy.head = previous_head;
9792 assert_eq!(legacy.revisions.len(), 34, "fixture matches old shape");
9793
9794 let mut envelope = serde_json::to_value(&session).expect("base envelope");
9795 envelope["messages"] = serde_json::to_value(&messages).expect("legacy live messages");
9796 envelope["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY] =
9797 serde_json::to_value(&legacy).expect("legacy unbounded history");
9798 for body in envelope["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["revisions"]
9799 .as_array_mut()
9800 .expect("legacy revisions")
9801 {
9802 body.as_object_mut()
9803 .expect("legacy revision body")
9804 .remove("parent_revision");
9805 }
9806 let raw = serde_json::to_vec(&envelope).expect("raw legacy bytes");
9807
9808 let restored: Session = serde_json::from_slice(&raw).expect("legacy restore");
9809 let compact = restored
9810 .transcript_history_state()
9811 .expect("compacted state")
9812 .expect("history retained");
9813 assert_eq!(compact.commits, legacy.commits);
9814 assert_eq!(compact.revisions.len(), 3);
9815 validate_transcript_history_state(&compact).expect("compacted history remains valid");
9816 let repaired = serde_json::to_vec(&restored).expect("repaired snapshot");
9817 assert!(
9818 repaired.len() * 4 < raw.len(),
9819 "repair should shed old bodies"
9820 );
9821 }
9822
9823 #[test]
9824 fn snapshot_compaction_does_not_launder_corrupt_old_body() {
9825 let mut session = Session::new();
9826 session.push(Message::User(UserMessage::text("seed".to_string())));
9827 session
9828 .commit_transcript_rewrite(
9829 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9830 vec![Message::User(UserMessage::text("rewritten".to_string()))],
9831 TranscriptRewriteReason::new("unit-test-edit"),
9832 Some("unit-test".to_string()),
9833 None,
9834 )
9835 .expect("seed rewrite");
9836 let mut state = session
9837 .transcript_history_state()
9838 .expect("state")
9839 .expect("history");
9840 state.revisions.push(TranscriptRevisionBody {
9841 revision: "sha256:corrupt-old-body".to_string(),
9842 parent_revision: Some(state.head.clone()),
9843 messages: vec![Message::User(UserMessage::text("tampered".to_string()))],
9844 created_at: SystemTime::now(),
9845 });
9846 session.set_metadata_unchecked_for_test(
9847 SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
9848 serde_json::to_value(state).expect("corrupt history value"),
9849 );
9850
9851 assert!(
9852 serde_json::to_vec(&session).is_err(),
9853 "serialization must fail before pruning a corrupt old body"
9854 );
9855 }
9856
9857 #[test]
9858 fn unchecked_valid_history_is_validated_and_compacted_at_snapshot_boundary() {
9859 let mut session = Session::new();
9860 session.push(Message::User(UserMessage::text("seed".to_string())));
9861 session
9862 .commit_transcript_rewrite(
9863 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9864 vec![Message::User(UserMessage::text("rewritten".to_string()))],
9865 TranscriptRewriteReason::new("unit-test-edit"),
9866 Some("unit-test".to_string()),
9867 None,
9868 )
9869 .expect("seed rewrite");
9870 let mut state = session
9871 .transcript_history_state()
9872 .expect("state")
9873 .expect("history");
9874 let mut messages = session.messages().to_vec();
9875 let mut parent = state.head.clone();
9876 for index in 0..8 {
9877 messages.push(Message::User(UserMessage::text(format!(
9878 "legacy append {index}"
9879 ))));
9880 let revision = transcript_messages_digest(&messages).expect("revision digest");
9881 state.revisions.push(TranscriptRevisionBody {
9882 revision: revision.clone(),
9883 parent_revision: Some(parent),
9884 messages: messages.clone(),
9885 created_at: SystemTime::now(),
9886 });
9887 parent = revision;
9888 }
9889 state.head = parent;
9890 session.messages.replace(messages);
9891 session.set_metadata_unchecked_for_test(
9892 SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
9893 serde_json::to_value(state).expect("uncompacted history"),
9894 );
9895 assert_eq!(
9896 session.transcript_history_metadata_validation,
9897 TranscriptHistoryMetadataValidation::RequiresValidation
9898 );
9899
9900 let snapshot = serde_json::to_vec(&session)
9901 .expect("valid unchecked history should serialize after validation");
9902 let snapshot: serde_json::Value = serde_json::from_slice(&snapshot).expect("snapshot JSON");
9903 let compact: TranscriptHistoryState = serde_json::from_value(
9904 snapshot["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY].clone(),
9905 )
9906 .expect("compacted history");
9907
9908 assert_eq!(
9909 compact.revisions.len(),
9910 3,
9911 "snapshot boundary should retain two audited endpoints plus the live head"
9912 );
9913 validate_transcript_history_state(&compact).expect("compacted history remains valid");
9914 }
9915
9916 #[test]
9917 fn transcript_history_rejects_stale_branch_after_digest_recurrence() {
9918 let mut restored = Session::new();
9919 restored.push(Message::User(UserMessage::text("A".to_string())));
9920 restored
9921 .commit_transcript_rewrite(
9922 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9923 vec![Message::User(UserMessage::text("B".to_string()))],
9924 TranscriptRewriteReason::new("to-b"),
9925 Some("unit-test".to_string()),
9926 None,
9927 )
9928 .expect("A to B");
9929 let mut stale_branch = restored.clone();
9930 restored
9931 .commit_transcript_rewrite(
9932 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9933 vec![Message::User(UserMessage::text("A".to_string()))],
9934 TranscriptRewriteReason::new("restore-a"),
9935 Some("unit-test".to_string()),
9936 None,
9937 )
9938 .expect("B back to A");
9939 stale_branch
9940 .commit_transcript_rewrite(
9941 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9942 vec![Message::User(UserMessage::text("C".to_string()))],
9943 TranscriptRewriteReason::new("stale-b-to-c"),
9944 Some("unit-test".to_string()),
9945 None,
9946 )
9947 .expect("stale B to C is locally valid");
9948
9949 let stale_state = stale_branch
9950 .transcript_history_state()
9951 .expect("stale state")
9952 .expect("stale history");
9953 let stale_commit = stale_state.commits.last().expect("stale commit").clone();
9954 let stale_body = stale_state
9955 .revisions
9956 .iter()
9957 .find(|body| body.revision == stale_commit.revision)
9958 .expect("stale revision body")
9959 .clone();
9960 let mut forged = restored
9961 .transcript_history_state()
9962 .expect("restored state")
9963 .expect("restored history");
9964 forged.commits.push(stale_commit);
9965 forged.revisions.push(stale_body);
9966 forged.head = forged
9967 .commits
9968 .last()
9969 .expect("forged commit")
9970 .revision
9971 .clone();
9972
9973 assert!(
9974 validate_transcript_history_state(&forged).is_err(),
9975 "an old B<-A body edge cannot authorize stale B->C after B->A restored A"
9976 );
9977 }
9978
9979 #[test]
9980 fn transcript_history_rejects_orphan_head_parent_cycle() {
9981 let mut session = Session::new();
9982 session.push(Message::User(UserMessage::text("P".to_string())));
9983 session
9984 .commit_transcript_rewrite(
9985 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9986 vec![Message::User(UserMessage::text("Q".to_string()))],
9987 TranscriptRewriteReason::new("valid"),
9988 Some("unit-test".to_string()),
9989 None,
9990 )
9991 .expect("valid seed rewrite");
9992 let mut state = session
9993 .transcript_history_state()
9994 .expect("state")
9995 .expect("history");
9996 let x_messages = vec![Message::User(UserMessage::text("X".to_string()))];
9997 let y_messages = vec![Message::User(UserMessage::text("Y".to_string()))];
9998 let x = transcript_messages_digest(&x_messages).expect("X digest");
9999 let y = transcript_messages_digest(&y_messages).expect("Y digest");
10000 state.revisions.push(TranscriptRevisionBody {
10001 revision: x.clone(),
10002 parent_revision: Some(y.clone()),
10003 messages: x_messages,
10004 created_at: SystemTime::now(),
10005 });
10006 state.revisions.push(TranscriptRevisionBody {
10007 revision: y,
10008 parent_revision: Some(x.clone()),
10009 messages: y_messages,
10010 created_at: SystemTime::now(),
10011 });
10012 state.head = x;
10013 session.set_metadata_unchecked_for_test(
10014 SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
10015 serde_json::to_value(state).expect("cyclic state"),
10016 );
10017
10018 assert!(
10019 serde_json::to_vec(&session).is_err(),
10020 "cyclic orphan head lineage must fail instead of looping"
10021 );
10022 }
10023
10024 #[test]
10025 fn mechanical_append_can_recur_to_an_audited_digest_without_mutating_its_body() {
10026 let a = Message::User(UserMessage::text("A".to_string()));
10027 let b = Message::User(UserMessage::text("B".to_string()));
10028 let mut session = Session::new();
10029 session.push(Message::User(UserMessage::text("X".to_string())));
10030 let first = session
10031 .commit_transcript_rewrite(
10032 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
10033 vec![a.clone(), b.clone()],
10034 TranscriptRewriteReason::new("to-a-b"),
10035 Some("unit-test".to_string()),
10036 None,
10037 )
10038 .expect("X to [A,B]");
10039 let h_parent = session
10040 .transcript_revision_body(&first.revision)
10041 .expect("H body")
10042 .expect("H retained")
10043 .parent_revision;
10044 session
10045 .commit_transcript_rewrite(
10046 TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
10047 vec![a],
10048 TranscriptRewriteReason::new("to-a"),
10049 Some("unit-test".to_string()),
10050 None,
10051 )
10052 .expect("[A,B] to [A]");
10053
10054 session.push(b);
10055
10056 let state = session
10057 .transcript_history_state()
10058 .expect("state")
10059 .expect("history");
10060 assert_eq!(state.head, first.revision);
10061 assert_eq!(session.transcript_revision().unwrap(), first.revision);
10062 assert_eq!(
10063 state
10064 .revisions
10065 .iter()
10066 .find(|body| body.revision == first.revision)
10067 .expect("recurred H body")
10068 .parent_revision,
10069 h_parent,
10070 "reusing an audited digest must not rewrite its occurrence metadata"
10071 );
10072 validate_transcript_history_state(&state).expect("recurred mechanical head is valid");
10073 }
10074
10075 #[test]
10079 fn replace_synthetic_notices_rejects_mismatched_kind_without_mutation() {
10080 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
10081
10082 let mut session = Session::new();
10083 session.push(Message::SystemNotice(SystemNoticeMessage::new(
10084 SystemNoticeKind::McpPending,
10085 "stale",
10086 )));
10087 let before = session.messages().to_vec();
10088
10089 let err = session
10090 .replace_synthetic_notices(
10091 SystemNoticeKind::McpPending,
10092 vec![Message::User(UserMessage::text("not a notice".to_string()))],
10093 )
10094 .expect_err("mismatched replacement must fail typed");
10095 assert!(
10096 matches!(err, TranscriptEditError::InvalidTranscriptShape(_)),
10097 "expected InvalidTranscriptShape, got {err:?}"
10098 );
10099 assert_eq!(
10100 session.messages(),
10101 before.as_slice(),
10102 "fault must leave the transcript unchanged (no partial strip)"
10103 );
10104 }
10105
10106 #[test]
10107 fn replace_synthetic_notices_rejects_malformed_history_atomically() {
10108 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
10109
10110 let mut session = Session::new();
10111 session.push(Message::User(UserMessage::text("before".to_string())));
10112 session
10113 .commit_transcript_rewrite(
10114 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
10115 vec![Message::User(UserMessage::text("after".to_string()))],
10116 TranscriptRewriteReason::new("unit-test-edit"),
10117 Some("unit-test".to_string()),
10118 None,
10119 )
10120 .expect("seed rewrite");
10121 session.push(Message::SystemNotice(SystemNoticeMessage::new(
10122 SystemNoticeKind::McpPending,
10123 "stale",
10124 )));
10125 let mut state = session
10126 .transcript_history_state()
10127 .expect("state")
10128 .expect("history");
10129 state.revisions[0].messages[0] = Message::User(UserMessage::text("tampered".to_string()));
10130 session.set_metadata_unchecked_for_test(
10131 SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
10132 serde_json::to_value(state).expect("corrupt state"),
10133 );
10134 let before_messages = session.messages.clone();
10135 let before_metadata = session.metadata.clone();
10136 let before_updated_at = session.updated_at;
10137
10138 assert!(
10139 session
10140 .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
10141 .is_err()
10142 );
10143 assert_eq!(session.messages(), before_messages.as_slice());
10144 assert_eq!(session.metadata, before_metadata);
10145 assert_eq!(session.updated_at, before_updated_at);
10146 }
10147
10148 #[test]
10149 fn replace_synthetic_notices_rejects_durable_notice_kinds() {
10150 use crate::types::SystemNoticeKind;
10151
10152 let mut session = Session::new();
10153 let before = session.messages().to_vec();
10154 assert!(
10155 session
10156 .replace_synthetic_notices(SystemNoticeKind::Comms, Vec::new())
10157 .is_err()
10158 );
10159 assert_eq!(session.messages(), before);
10160 }
10161
10162 #[test]
10163 fn replace_synthetic_notices_preserves_persisted_mcp_pending_notice() {
10164 use crate::types::{SystemNoticeBlock, SystemNoticeKind, SystemNoticeMessage};
10165
10166 let mut session = Session::new();
10167 session.push(Message::SystemNotice(SystemNoticeMessage::with_block(
10168 SystemNoticeKind::McpPending,
10169 Some("persisted pending fact".to_string()),
10170 SystemNoticeBlock::Mcp {
10171 server_id: Some("server".to_string()),
10172 operation: None,
10173 phase: None,
10174 persisted: true,
10175 detail: None,
10176 pending_sources: Vec::new(),
10177 },
10178 )));
10179 let before = session.messages().to_vec();
10180
10181 session
10182 .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
10183 .expect("synthetic refresh must coexist with a durable notice of the same kind");
10184 assert_eq!(session.messages(), before);
10185 }
10186
10187 #[test]
10188 fn replace_synthetic_notices_replaces_projection_beside_persisted_mcp_fact() {
10189 use crate::types::{SystemNoticeBlock, SystemNoticeKind, SystemNoticeMessage};
10190
10191 let durable = Message::SystemNotice(SystemNoticeMessage::with_block(
10192 SystemNoticeKind::McpPending,
10193 Some("persisted pending fact".to_string()),
10194 SystemNoticeBlock::Mcp {
10195 server_id: Some("server".to_string()),
10196 operation: None,
10197 phase: None,
10198 persisted: true,
10199 detail: None,
10200 pending_sources: Vec::new(),
10201 },
10202 ));
10203 let stale = Message::SystemNotice(SystemNoticeMessage::new(
10204 SystemNoticeKind::McpPending,
10205 "stale synthetic projection",
10206 ));
10207 let fresh = Message::SystemNotice(SystemNoticeMessage::new(
10208 SystemNoticeKind::McpPending,
10209 "fresh synthetic projection",
10210 ));
10211 let mut session = Session::new();
10212 session.push(durable.clone());
10213 session.push(stale);
10214
10215 session
10216 .replace_synthetic_notices(SystemNoticeKind::McpPending, vec![fresh.clone()])
10217 .expect("synthetic refresh beside durable fact");
10218
10219 assert_eq!(session.messages(), &[durable, fresh]);
10220 }
10221
10222 #[test]
10223 fn transcript_rewrite_preserves_full_assistant_block_trace() {
10224 let mut session = Session::new();
10225 session.push(Message::User(UserMessage::text(
10226 "run the trace".to_string(),
10227 )));
10228 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
10229 vec![AssistantBlock::Text {
10230 text: "original assistant trace".to_string(),
10231 meta: None,
10232 }],
10233 StopReason::EndTurn,
10234 )));
10235
10236 let parent_revision = session.transcript_revision().expect("parent revision");
10237 let replacement = vec![
10238 Message::BlockAssistant(BlockAssistantMessage::new(
10239 vec![
10240 AssistantBlock::Text {
10241 text: "compacted assistant trace".to_string(),
10242 meta: None,
10243 },
10244 AssistantBlock::ToolUse {
10245 id: "toolu_trace".to_string(),
10246 name: "trace_probe".to_string(),
10247 args: serde_json::value::RawValue::from_string(
10248 r#"{"path":"N-3"}"#.to_string(),
10249 )
10250 .expect("valid tool args"),
10251 meta: None,
10252 },
10253 ],
10254 StopReason::ToolUse,
10255 )),
10256 Message::tool_results(vec![ToolResult::new(
10257 "toolu_trace".to_string(),
10258 "trace complete".to_string(),
10259 false,
10260 )]),
10261 ];
10262
10263 let commit = session
10264 .commit_transcript_rewrite(
10265 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10266 replacement,
10267 TranscriptRewriteReason::new("compaction"),
10268 Some("unit-test".to_string()),
10269 Some(parent_revision.clone()),
10270 )
10271 .expect("rewrite should commit");
10272
10273 assert_eq!(commit.parent_revision, parent_revision);
10274 let current = session
10275 .transcript_revision_messages(&commit.revision)
10276 .expect("history state should decode")
10277 .expect("current revision should be retained");
10278 let Message::BlockAssistant(assistant) = ¤t[1] else {
10279 panic!("replacement should remain a block assistant message");
10280 };
10281 assert!(assistant.blocks.iter().any(|block| matches!(
10282 block,
10283 AssistantBlock::ToolUse { name, args, .. }
10284 if name == "trace_probe" && args.get().contains("\"N-3\"")
10285 )));
10286
10287 let parent = session
10288 .transcript_revision_messages(&parent_revision)
10289 .expect("history state should decode")
10290 .expect("parent revision should remain retained");
10291 assert!(matches!(
10292 &parent[1],
10293 Message::BlockAssistant(assistant)
10294 if block_assistant_text(assistant).contains("original assistant trace")
10295 ));
10296 }
10297
10298 #[test]
10299 fn transcript_rewrite_rejects_trailing_block_assistant_tool_call() {
10300 let mut session = Session::new();
10301 session.push(Message::User(UserMessage::text("question".to_string())));
10302 session.push(Message::BlockAssistant(BlockAssistantMessage {
10303 blocks: vec![AssistantBlock::Text {
10304 text: "plain answer".to_string(),
10305 meta: None,
10306 }],
10307 stop_reason: StopReason::EndTurn,
10308 identity: crate::types::TranscriptMessageIdentity::default(),
10309 created_at: crate::types::message_timestamp_now(),
10310 }));
10311 let parent_revision = session.transcript_revision().expect("parent revision");
10312
10313 let err = session
10314 .commit_transcript_rewrite(
10315 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10316 vec![Message::BlockAssistant(BlockAssistantMessage::new(
10317 vec![AssistantBlock::ToolUse {
10318 id: "toolu_1".to_string(),
10319 name: "lookup".to_string(),
10320 args: serde_json::value::RawValue::from_string("{}".to_string())
10321 .expect("valid args"),
10322 meta: None,
10323 }],
10324 StopReason::ToolUse,
10325 ))],
10326 TranscriptRewriteReason::new("compaction"),
10327 Some("unit-test".to_string()),
10328 Some(parent_revision),
10329 )
10330 .expect_err("rewrite should reject trailing unresolved block-assistant tool call");
10331 assert!(matches!(
10332 err,
10333 TranscriptEditError::InvalidTranscriptShape(_)
10334 ));
10335 }
10336
10337 #[test]
10338 fn transcript_rewrite_rejects_no_op_self_edge() {
10339 let mut session = Session::new();
10340 session.push(Message::User(UserMessage::text(
10341 "keep this exact transcript".to_string(),
10342 )));
10343 session.push(Message::BlockAssistant(BlockAssistantMessage {
10344 blocks: vec![AssistantBlock::Text {
10345 text: "unchanged".to_string(),
10346 meta: None,
10347 }],
10348 stop_reason: StopReason::EndTurn,
10349 identity: crate::types::TranscriptMessageIdentity::default(),
10350 created_at: crate::types::message_timestamp_now(),
10351 }));
10352
10353 let parent_revision = session.transcript_revision().expect("parent revision");
10354 let err = session
10355 .commit_transcript_rewrite(
10356 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10357 vec![session.messages()[1].clone()],
10358 TranscriptRewriteReason::new("retry"),
10359 Some("unit-test".to_string()),
10360 Some(parent_revision.clone()),
10361 )
10362 .expect_err("same-content rewrite should not emit a self-edge commit");
10363
10364 assert!(matches!(
10365 err,
10366 TranscriptEditError::NoOpRewrite { revision } if revision == parent_revision
10367 ));
10368 assert!(
10369 session
10370 .transcript_history_state()
10371 .expect("history state should decode")
10372 .is_none()
10373 );
10374 }
10375
10376 #[test]
10377 fn transcript_rewrite_run_boundary_guard_accepts_rewrite_then_append() {
10378 let mut original = Session::new();
10379 original.push(Message::User(UserMessage::text("question".to_string())));
10380 original.push(Message::BlockAssistant(BlockAssistantMessage {
10381 blocks: vec![AssistantBlock::Text {
10382 text: "verbose answer".to_string(),
10383 meta: None,
10384 }],
10385 stop_reason: StopReason::EndTurn,
10386 identity: crate::types::TranscriptMessageIdentity::default(),
10387 created_at: crate::types::message_timestamp_now(),
10388 }));
10389
10390 let parent_revision = original.transcript_revision().expect("parent revision");
10391 let mut incoming = original.clone();
10392 incoming
10393 .commit_transcript_rewrite(
10394 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10395 vec![Message::BlockAssistant(BlockAssistantMessage {
10396 blocks: vec![AssistantBlock::Text {
10397 text: "compact answer".to_string(),
10398 meta: None,
10399 }],
10400 stop_reason: StopReason::EndTurn,
10401 identity: crate::types::TranscriptMessageIdentity::default(),
10402 created_at: crate::types::message_timestamp_now(),
10403 })],
10404 TranscriptRewriteReason::new("compaction"),
10405 Some("unit-test".to_string()),
10406 Some(parent_revision),
10407 )
10408 .expect("rewrite should commit");
10409 incoming.push(Message::User(UserMessage::text("follow-up".to_string())));
10410 incoming.push(Message::BlockAssistant(BlockAssistantMessage {
10411 blocks: vec![AssistantBlock::Text {
10412 text: "follow-up answer".to_string(),
10413 meta: None,
10414 }],
10415 stop_reason: StopReason::EndTurn,
10416 identity: crate::types::TranscriptMessageIdentity::default(),
10417 created_at: crate::types::message_timestamp_now(),
10418 }));
10419
10420 crate::session_store::run_boundary_snapshot_save_guard(&incoming, Some(&original))
10421 .expect("rewrite plus appended turn should be a valid run-boundary commit");
10422 }
10423
10424 #[test]
10425 fn transcript_rewrite_rejects_orphaned_tool_results() {
10426 let mut session = Session::new();
10427 session.push(Message::User(UserMessage::text("use a tool".to_string())));
10428 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
10429 vec![AssistantBlock::ToolUse {
10430 id: "toolu_1".to_string(),
10431 name: "lookup".to_string(),
10432 args: serde_json::value::RawValue::from_string("{}".to_string())
10433 .expect("valid args"),
10434 meta: None,
10435 }],
10436 StopReason::ToolUse,
10437 )));
10438 session.push(Message::tool_results(vec![ToolResult::new(
10439 "toolu_1".to_string(),
10440 "done".to_string(),
10441 false,
10442 )]));
10443 let parent_revision = session.transcript_revision().expect("parent revision");
10444
10445 let err = session
10446 .commit_transcript_rewrite(
10447 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10448 vec![Message::BlockAssistant(BlockAssistantMessage {
10449 blocks: vec![AssistantBlock::Text {
10450 text: "no tool after all".to_string(),
10451 meta: None,
10452 }],
10453 stop_reason: StopReason::EndTurn,
10454 identity: crate::types::TranscriptMessageIdentity::default(),
10455 created_at: crate::types::message_timestamp_now(),
10456 })],
10457 TranscriptRewriteReason::new("compaction"),
10458 Some("unit-test".to_string()),
10459 Some(parent_revision),
10460 )
10461 .expect_err("rewrite should reject stranded tool results");
10462 assert!(matches!(
10463 err,
10464 TranscriptEditError::InvalidTranscriptShape(_)
10465 ));
10466 }
10467
10468 #[test]
10469 fn transcript_rewrite_rejects_trailing_assistant_tool_call() {
10470 let mut session = Session::new();
10471 session.push(Message::User(UserMessage::text("question".to_string())));
10472 session.push(Message::BlockAssistant(BlockAssistantMessage {
10473 blocks: vec![AssistantBlock::Text {
10474 text: "plain answer".to_string(),
10475 meta: None,
10476 }],
10477 stop_reason: StopReason::EndTurn,
10478 identity: crate::types::TranscriptMessageIdentity::default(),
10479 created_at: crate::types::message_timestamp_now(),
10480 }));
10481 let parent_revision = session.transcript_revision().expect("parent revision");
10482
10483 let err = session
10484 .commit_transcript_rewrite(
10485 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10486 vec![Message::BlockAssistant(BlockAssistantMessage {
10487 blocks: vec![AssistantBlock::ToolUse {
10488 id: "toolu_1".to_string(),
10489 name: "lookup".to_string(),
10490 args: serde_json::value::RawValue::from_string("{}".to_string())
10491 .expect("valid args"),
10492 meta: None,
10493 }],
10494 stop_reason: StopReason::ToolUse,
10495 identity: crate::types::TranscriptMessageIdentity::default(),
10496 created_at: crate::types::message_timestamp_now(),
10497 })],
10498 TranscriptRewriteReason::new("compaction"),
10499 Some("unit-test".to_string()),
10500 Some(parent_revision),
10501 )
10502 .expect_err("rewrite should reject trailing unresolved tool call");
10503 assert!(matches!(
10504 err,
10505 TranscriptEditError::InvalidTranscriptShape(_)
10506 ));
10507 }
10508
10509 #[test]
10510 fn transcript_rewrite_rejects_duplicate_tool_results() {
10511 let mut session = Session::new();
10512 session.push(Message::User(UserMessage::text("use a tool".to_string())));
10513 session.push(Message::BlockAssistant(BlockAssistantMessage {
10514 blocks: vec![AssistantBlock::Text {
10515 text: "plain answer".to_string(),
10516 meta: None,
10517 }],
10518 stop_reason: StopReason::EndTurn,
10519 identity: crate::types::TranscriptMessageIdentity::default(),
10520 created_at: crate::types::message_timestamp_now(),
10521 }));
10522 let parent_revision = session.transcript_revision().expect("parent revision");
10523
10524 let err = session
10525 .commit_transcript_rewrite(
10526 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10527 vec![
10528 Message::BlockAssistant(BlockAssistantMessage::new(
10529 vec![AssistantBlock::ToolUse {
10530 id: "toolu_1".to_string(),
10531 name: "lookup".to_string(),
10532 args: serde_json::value::RawValue::from_string("{}".to_string())
10533 .expect("valid args"),
10534 meta: None,
10535 }],
10536 StopReason::ToolUse,
10537 )),
10538 Message::tool_results(vec![
10539 ToolResult::new("toolu_1".to_string(), "one".to_string(), false),
10540 ToolResult::new("toolu_1".to_string(), "two".to_string(), false),
10541 ]),
10542 ],
10543 TranscriptRewriteReason::new("compaction"),
10544 Some("unit-test".to_string()),
10545 Some(parent_revision),
10546 )
10547 .expect_err("rewrite should reject duplicate tool results");
10548 assert!(matches!(
10549 err,
10550 TranscriptEditError::InvalidTranscriptShape(_)
10551 ));
10552 }
10553
10554 #[test]
10555 fn transcript_rewrite_record_rejects_prefix_or_suffix_tampering() {
10556 let mut session = Session::new();
10557 session.push(Message::System(SystemMessage::new("keep prefix")));
10558 session.push(Message::BlockAssistant(BlockAssistantMessage {
10559 blocks: vec![AssistantBlock::Text {
10560 text: "verbose answer".to_string(),
10561 meta: None,
10562 }],
10563 stop_reason: StopReason::EndTurn,
10564 identity: crate::types::TranscriptMessageIdentity::default(),
10565 created_at: crate::types::message_timestamp_now(),
10566 }));
10567 session.push(Message::User(UserMessage::text("keep suffix".to_string())));
10568
10569 let parent_revision = session.transcript_revision().expect("parent revision");
10570 let commit = session
10571 .commit_transcript_rewrite(
10572 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10573 vec![Message::BlockAssistant(BlockAssistantMessage {
10574 blocks: vec![AssistantBlock::Text {
10575 text: "compact answer".to_string(),
10576 meta: None,
10577 }],
10578 stop_reason: StopReason::EndTurn,
10579 identity: crate::types::TranscriptMessageIdentity::default(),
10580 created_at: crate::types::message_timestamp_now(),
10581 })],
10582 TranscriptRewriteReason::new("compaction"),
10583 Some("unit-test".to_string()),
10584 Some(parent_revision),
10585 )
10586 .expect("rewrite should commit");
10587 let state = session
10588 .transcript_history_state()
10589 .expect("history state should decode")
10590 .expect("history state should exist");
10591 let parent_body = state
10592 .revisions
10593 .iter()
10594 .find(|body| body.revision == commit.parent_revision)
10595 .expect("parent body retained")
10596 .clone();
10597 let revision_body = state
10598 .revisions
10599 .iter()
10600 .find(|body| body.revision == commit.revision)
10601 .expect("revision body retained")
10602 .clone();
10603
10604 let mut forged_body = revision_body;
10605 forged_body.messages[0] = Message::System(SystemMessage::new("tampered prefix"));
10606 forged_body.revision =
10607 transcript_messages_digest(&forged_body.messages).expect("forged digest");
10608 let mut forged_commit = commit;
10609 forged_commit.revision = forged_body.revision.clone();
10610 let err = TranscriptRewriteRecord::new(forged_commit, parent_body, forged_body)
10611 .expect_err("record validation must reject changes outside selected span");
10612 assert!(
10613 err.to_string().contains("before the selected span"),
10614 "unexpected error: {err}"
10615 );
10616 }
10617
10618 #[test]
10619 fn transcript_rewrite_replay_allows_normal_turn_revisions_between_rewrites() {
10620 let mut session = Session::new();
10621 session.push(Message::User(UserMessage::text("first".to_string())));
10622 session.push(Message::BlockAssistant(BlockAssistantMessage {
10623 blocks: vec![AssistantBlock::Text {
10624 text: "verbose first answer".to_string(),
10625 meta: None,
10626 }],
10627 stop_reason: StopReason::EndTurn,
10628 identity: crate::types::TranscriptMessageIdentity::default(),
10629 created_at: crate::types::message_timestamp_now(),
10630 }));
10631
10632 let first_parent = session.transcript_revision().expect("first parent");
10633 let first_commit = session
10634 .commit_transcript_rewrite(
10635 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10636 vec![Message::BlockAssistant(BlockAssistantMessage {
10637 blocks: vec![AssistantBlock::Text {
10638 text: "compact first answer".to_string(),
10639 meta: None,
10640 }],
10641 stop_reason: StopReason::EndTurn,
10642 identity: crate::types::TranscriptMessageIdentity::default(),
10643 created_at: crate::types::message_timestamp_now(),
10644 })],
10645 TranscriptRewriteReason::new("compaction"),
10646 Some("unit-test".to_string()),
10647 Some(first_parent),
10648 )
10649 .expect("first rewrite");
10650
10651 session.push(Message::User(UserMessage::text("normal turn".to_string())));
10652 session.push(Message::BlockAssistant(BlockAssistantMessage {
10653 blocks: vec![AssistantBlock::Text {
10654 text: "verbose second answer".to_string(),
10655 meta: None,
10656 }],
10657 stop_reason: StopReason::EndTurn,
10658 identity: crate::types::TranscriptMessageIdentity::default(),
10659 created_at: crate::types::message_timestamp_now(),
10660 }));
10661 let bridge_parent = session
10662 .transcript_revision()
10663 .expect("normal turn should advance transcript head");
10664 assert_ne!(bridge_parent, first_commit.revision);
10665 validate_transcript_history_state(
10666 &session
10667 .transcript_history_state()
10668 .expect("history state should decode")
10669 .expect("history state should exist"),
10670 )
10671 .expect("normal turn head may legitimately differ from last rewrite commit");
10672
10673 let second_commit = session
10674 .commit_transcript_rewrite(
10675 TranscriptRewriteSelection::MessageRange { start: 3, end: 4 },
10676 vec![Message::BlockAssistant(BlockAssistantMessage {
10677 blocks: vec![AssistantBlock::Text {
10678 text: "compact second answer".to_string(),
10679 meta: None,
10680 }],
10681 stop_reason: StopReason::EndTurn,
10682 identity: crate::types::TranscriptMessageIdentity::default(),
10683 created_at: crate::types::message_timestamp_now(),
10684 })],
10685 TranscriptRewriteReason::new("compaction"),
10686 Some("unit-test".to_string()),
10687 Some(bridge_parent.clone()),
10688 )
10689 .expect("second rewrite");
10690
10691 let state = session
10692 .transcript_history_state()
10693 .expect("history state should decode")
10694 .expect("history state should exist");
10695 let records = state.commits.iter().map(|commit| {
10696 let parent_body = state
10697 .revisions
10698 .iter()
10699 .find(|body| body.revision == commit.parent_revision)
10700 .expect("parent body retained")
10701 .clone();
10702 let revision_body = state
10703 .revisions
10704 .iter()
10705 .find(|body| body.revision == commit.revision)
10706 .expect("revision body retained")
10707 .clone();
10708 TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
10709 .expect("record should validate")
10710 });
10711
10712 let replayed = TranscriptHistoryState::from_rewrite_records(records)
10713 .expect("rewrite replay should accept normal-turn bridge revisions")
10714 .expect("rewrite records should exist");
10715 assert_eq!(replayed.head, second_commit.revision);
10716 assert!(
10717 replayed
10718 .revisions
10719 .iter()
10720 .any(|body| body.revision == bridge_parent)
10721 );
10722 }
10723
10724 #[test]
10725 fn transcript_rewrite_replay_rejects_branched_rewrite_records() {
10726 let mut base = Session::new();
10727 base.push(Message::User(UserMessage::text("question".to_string())));
10728 base.push(Message::BlockAssistant(BlockAssistantMessage {
10729 blocks: vec![AssistantBlock::Text {
10730 text: "verbose answer".to_string(),
10731 meta: None,
10732 }],
10733 stop_reason: StopReason::EndTurn,
10734 identity: crate::types::TranscriptMessageIdentity::default(),
10735 created_at: crate::types::message_timestamp_now(),
10736 }));
10737 let parent = base.transcript_revision().expect("parent revision");
10738
10739 let mut first = base.clone();
10740 let first_commit = first
10741 .commit_transcript_rewrite(
10742 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10743 vec![Message::BlockAssistant(BlockAssistantMessage {
10744 blocks: vec![AssistantBlock::Text {
10745 text: "first compact answer".to_string(),
10746 meta: None,
10747 }],
10748 stop_reason: StopReason::EndTurn,
10749 identity: crate::types::TranscriptMessageIdentity::default(),
10750 created_at: crate::types::message_timestamp_now(),
10751 })],
10752 TranscriptRewriteReason::new("compaction"),
10753 Some("unit-test".to_string()),
10754 Some(parent.clone()),
10755 )
10756 .expect("first rewrite");
10757 let first_state = first
10758 .transcript_history_state()
10759 .expect("first state decodes")
10760 .expect("first state exists");
10761
10762 let mut second = base;
10763 let second_commit = second
10764 .commit_transcript_rewrite(
10765 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10766 vec![Message::BlockAssistant(BlockAssistantMessage {
10767 blocks: vec![AssistantBlock::Text {
10768 text: "second compact answer".to_string(),
10769 meta: None,
10770 }],
10771 stop_reason: StopReason::EndTurn,
10772 identity: crate::types::TranscriptMessageIdentity::default(),
10773 created_at: crate::types::message_timestamp_now(),
10774 })],
10775 TranscriptRewriteReason::new("compaction"),
10776 Some("unit-test".to_string()),
10777 Some(parent),
10778 )
10779 .expect("second rewrite");
10780 let second_state = second
10781 .transcript_history_state()
10782 .expect("second state decodes")
10783 .expect("second state exists");
10784
10785 let record = |state: &TranscriptHistoryState, commit: &TranscriptRewriteCommit| {
10786 let parent_body = state
10787 .revisions
10788 .iter()
10789 .find(|body| body.revision == commit.parent_revision)
10790 .expect("parent body retained")
10791 .clone();
10792 let revision_body = state
10793 .revisions
10794 .iter()
10795 .find(|body| body.revision == commit.revision)
10796 .expect("revision body retained")
10797 .clone();
10798 TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
10799 .expect("record should validate")
10800 };
10801
10802 let err = TranscriptHistoryState::from_rewrite_records(vec![
10803 record(&first_state, &first_commit),
10804 record(&second_state, &second_commit),
10805 ])
10806 .expect_err("branched rewrite records must not replay as a linear source history");
10807 assert!(
10808 err.to_string().contains("does not extend transcript head"),
10809 "unexpected error: {err}"
10810 );
10811 }
10812
10813 #[test]
10814 fn internal_message_rewrites_refresh_transcript_history_head() {
10815 let mut session = Session::new();
10816 session.push(Message::User(UserMessage::text("question".to_string())));
10817 session.push(Message::BlockAssistant(BlockAssistantMessage {
10818 blocks: vec![AssistantBlock::Text {
10819 text: "verbose answer".to_string(),
10820 meta: None,
10821 }],
10822 stop_reason: StopReason::EndTurn,
10823 identity: crate::types::TranscriptMessageIdentity::default(),
10824 created_at: crate::types::message_timestamp_now(),
10825 }));
10826
10827 let parent = session.transcript_revision().expect("parent revision");
10828 session
10829 .commit_transcript_rewrite(
10830 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10831 vec![Message::BlockAssistant(BlockAssistantMessage {
10832 blocks: vec![AssistantBlock::Text {
10833 text: "compact answer".to_string(),
10834 meta: None,
10835 }],
10836 stop_reason: StopReason::EndTurn,
10837 identity: crate::types::TranscriptMessageIdentity::default(),
10838 created_at: crate::types::message_timestamp_now(),
10839 })],
10840 TranscriptRewriteReason::new("compaction"),
10841 Some("unit-test".to_string()),
10842 Some(parent),
10843 )
10844 .expect("rewrite should commit");
10845
10846 session.push(Message::User(UserMessage::text(
10847 "notice-bearing turn".to_string(),
10848 )));
10849 let retained = session
10850 .messages()
10851 .iter()
10852 .filter(|message| {
10853 !matches!(
10854 message,
10855 Message::User(user)
10856 if user.content.iter().any(|block| matches!(
10857 block,
10858 ContentBlock::Text { text } if text.contains("notice-bearing")
10859 ))
10860 )
10861 })
10862 .cloned()
10863 .collect();
10864 session
10865 .replace_messages_internal(
10866 retained,
10867 TranscriptRewriteReason::new("synthetic_notice_cleanup"),
10868 )
10869 .expect("retain should commit internal rewrite");
10870 let retained_digest =
10871 transcript_messages_digest(session.messages()).expect("retained digest");
10872 assert_eq!(
10873 session.transcript_revision().expect("retained head"),
10874 retained_digest
10875 );
10876
10877 session
10878 .replace_messages_internal(
10879 vec![
10880 Message::User(UserMessage::text("compacted question".to_string())),
10881 Message::BlockAssistant(BlockAssistantMessage {
10882 blocks: vec![AssistantBlock::Text {
10883 text: "compacted answer".to_string(),
10884 meta: None,
10885 }],
10886 stop_reason: StopReason::EndTurn,
10887 identity: crate::types::TranscriptMessageIdentity::default(),
10888 created_at: crate::types::message_timestamp_now(),
10889 }),
10890 ],
10891 TranscriptRewriteReason::new("compaction"),
10892 )
10893 .expect("replace should commit internal rewrite");
10894 let replaced_digest =
10895 transcript_messages_digest(session.messages()).expect("replaced digest");
10896 assert_eq!(
10897 session.transcript_revision().expect("replaced head"),
10898 replaced_digest
10899 );
10900 let state = session
10901 .transcript_history_state()
10902 .expect("history state should decode")
10903 .expect("history state should exist");
10904 assert!(
10905 state
10906 .revisions
10907 .iter()
10908 .any(|body| body.revision == replaced_digest)
10909 );
10910 validate_transcript_history_state(&state).expect("history state remains valid");
10911 }
10912
10913 #[test]
10914 fn set_system_prompt_refreshes_transcript_history_head_after_rewrite() {
10915 let mut session = Session::new();
10916 session.push(Message::User(UserMessage::text("question".to_string())));
10917 session.push(Message::BlockAssistant(BlockAssistantMessage {
10918 blocks: vec![AssistantBlock::Text {
10919 text: "verbose answer".to_string(),
10920 meta: None,
10921 }],
10922 stop_reason: StopReason::EndTurn,
10923 identity: crate::types::TranscriptMessageIdentity::default(),
10924 created_at: crate::types::message_timestamp_now(),
10925 }));
10926
10927 let parent = session.transcript_revision().expect("parent revision");
10928 let rewrite = session
10929 .commit_transcript_rewrite(
10930 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10931 vec![Message::BlockAssistant(BlockAssistantMessage {
10932 blocks: vec![AssistantBlock::Text {
10933 text: "compact answer".to_string(),
10934 meta: None,
10935 }],
10936 stop_reason: StopReason::EndTurn,
10937 identity: crate::types::TranscriptMessageIdentity::default(),
10938 created_at: crate::types::message_timestamp_now(),
10939 })],
10940 TranscriptRewriteReason::new("compaction"),
10941 Some("unit-test".to_string()),
10942 Some(parent),
10943 )
10944 .expect("rewrite should commit");
10945
10946 session.set_system_prompt("durable system prompt".to_string());
10947
10948 let head = session
10949 .transcript_revision()
10950 .expect("system prompt should refresh transcript head");
10951 assert_ne!(head, rewrite.revision);
10952 assert_eq!(
10953 head,
10954 transcript_messages_digest(session.messages()).expect("current digest")
10955 );
10956 let head_messages = session
10957 .transcript_revision_messages(&head)
10958 .expect("history state should decode")
10959 .expect("refreshed head body should be retained");
10960 assert_eq!(
10961 serde_json::to_value(&head_messages).expect("head serializes"),
10962 serde_json::to_value(session.messages()).expect("session serializes")
10963 );
10964 validate_transcript_history_state(
10965 &session
10966 .transcript_history_state()
10967 .expect("history state should decode")
10968 .expect("history state should exist"),
10969 )
10970 .expect("history state remains valid after system prompt update");
10971 }
10972
10973 #[test]
10974 fn apply_transcript_history_state_uses_latest_commit_time_for_restored_head() {
10975 let mut session = Session::new();
10976 session.push(Message::User(UserMessage::text("question".to_string())));
10977 session.push(Message::BlockAssistant(BlockAssistantMessage {
10978 blocks: vec![AssistantBlock::Text {
10979 text: "verbose answer".to_string(),
10980 meta: None,
10981 }],
10982 stop_reason: StopReason::EndTurn,
10983 identity: crate::types::TranscriptMessageIdentity::default(),
10984 created_at: crate::types::message_timestamp_now(),
10985 }));
10986 let original_messages = session.messages().to_vec();
10987 let parent = session.transcript_revision().expect("parent revision");
10988 let compact = session
10989 .commit_transcript_rewrite(
10990 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
10991 vec![Message::BlockAssistant(BlockAssistantMessage {
10992 blocks: vec![AssistantBlock::Text {
10993 text: "compact answer".to_string(),
10994 meta: None,
10995 }],
10996 stop_reason: StopReason::EndTurn,
10997 identity: crate::types::TranscriptMessageIdentity::default(),
10998 created_at: crate::types::message_timestamp_now(),
10999 })],
11000 TranscriptRewriteReason::new("compaction"),
11001 Some("unit-test".to_string()),
11002 Some(parent.clone()),
11003 )
11004 .expect("rewrite should commit");
11005
11006 std::thread::sleep(std::time::Duration::from_millis(2));
11007 let restore = session
11008 .commit_transcript_rewrite(
11009 TranscriptRewriteSelection::MessageRange {
11010 start: 0,
11011 end: session.messages().len(),
11012 },
11013 original_messages.clone(),
11014 TranscriptRewriteReason::new("restore"),
11015 Some("unit-test".to_string()),
11016 Some(compact.revision),
11017 )
11018 .expect("restore should commit");
11019 assert_eq!(restore.revision, parent);
11020
11021 let state = session
11022 .transcript_history_state()
11023 .expect("history state should decode")
11024 .expect("history state should exist");
11025 let restored_body_created_at = state
11026 .revisions
11027 .iter()
11028 .find(|body| body.revision == restore.revision)
11029 .expect("restored body should be retained")
11030 .created_at;
11031 assert!(
11032 restored_body_created_at < restore.committed_at,
11033 "test requires restore commit to be newer than retained body"
11034 );
11035
11036 let mut replayed = Session::new();
11037 replayed
11038 .apply_transcript_history_state(state)
11039 .expect("replay should materialize restored head");
11040 assert_eq!(
11041 serde_json::to_value(replayed.messages()).expect("replayed serializes"),
11042 serde_json::to_value(&original_messages).expect("original serializes")
11043 );
11044 assert_eq!(replayed.updated_at(), restore.committed_at);
11045 }
11046
11047 #[test]
11048 fn test_session_new() {
11049 let session = Session::new();
11050 assert_eq!(session.version(), SESSION_VERSION);
11051 assert!(session.messages().is_empty());
11052 assert!(session.created_at() <= session.updated_at());
11053 }
11054
11055 #[test]
11056 fn llm_identity_model_override_switches_to_catalog_provider() {
11057 let registry = crate::ModelRegistry::from_config(
11058 &crate::Config::default(),
11059 *crate::model_profile::test_catalog::TEST_CATALOG,
11060 )
11061 .unwrap();
11062 let current = SessionLlmIdentity {
11063 model: "test-anthropic-default".to_string(),
11064 provider: Provider::Anthropic,
11065 self_hosted_server_id: None,
11066 provider_params: None,
11067 auth_binding: Some(crate::AuthBindingRef {
11068 realm: crate::RealmId::parse("tenant_a").unwrap(),
11069 binding: crate::BindingId::parse("anthropic_default").unwrap(),
11070 profile: None,
11071 origin: crate::BindingOrigin::Configured,
11072 }),
11073 };
11074
11075 let resolved = resolve_session_llm_identity_override(
11076 ¤t,
11077 ®istry,
11078 SessionLlmIdentityOverride {
11079 model: Some("test-openai-default"),
11080 provider: None,
11081 self_hosted_server_id: None,
11082 provider_params: None,
11083 auth_binding: None,
11084 },
11085 )
11086 .unwrap();
11087
11088 assert_eq!(resolved.model, "test-openai-default");
11089 assert_eq!(resolved.provider, Provider::OpenAI);
11090 assert!(
11091 resolved.auth_binding.is_none(),
11092 "provider switches must not inherit a binding from the previous provider"
11093 );
11094 }
11095
11096 #[test]
11097 fn llm_identity_model_override_keeps_uncatalogued_model_on_current_provider() {
11098 let registry = crate::ModelRegistry::from_config(
11099 &crate::Config::default(),
11100 *crate::model_profile::test_catalog::TEST_CATALOG,
11101 )
11102 .unwrap();
11103 let current = SessionLlmIdentity {
11104 model: "custom-model".to_string(),
11105 provider: Provider::Anthropic,
11106 self_hosted_server_id: None,
11107 provider_params: None,
11108 auth_binding: None,
11109 };
11110
11111 let resolved = resolve_session_llm_identity_override(
11112 ¤t,
11113 ®istry,
11114 SessionLlmIdentityOverride {
11115 model: Some("uncatalogued-custom-model"),
11116 provider: None,
11117 self_hosted_server_id: None,
11118 provider_params: None,
11119 auth_binding: None,
11120 },
11121 )
11122 .unwrap();
11123
11124 assert_eq!(resolved.model, "uncatalogued-custom-model");
11125 assert_eq!(resolved.provider, Provider::Anthropic);
11126 }
11127
11128 fn self_hosted_registry_with_shared_remote_model() -> crate::ModelRegistry {
11129 use crate::config::{
11130 SelfHostedApiStyle, SelfHostedModelConfig, SelfHostedServerConfig, SelfHostedTransport,
11131 };
11132 use crate::model_profile::catalog::ModelTier;
11133
11134 let mut config = crate::Config::default();
11135 for server_id in ["local-a", "local-b"] {
11136 config.self_hosted.servers.insert(
11137 server_id.to_string(),
11138 SelfHostedServerConfig {
11139 transport: SelfHostedTransport::OpenAiCompatible,
11140 base_url: format!("http://{server_id}.test"),
11141 api_style: SelfHostedApiStyle::Responses,
11142 },
11143 );
11144 config.self_hosted.models.insert(
11145 format!("shared-local-{server_id}"),
11146 SelfHostedModelConfig {
11147 server: server_id.to_string(),
11148 remote_model: "shared-local-model".to_string(),
11149 display_name: "Shared local model".to_string(),
11150 family: "shared-local".to_string(),
11151 tier: ModelTier::Supported,
11152 ..Default::default()
11153 },
11154 );
11155 }
11156 config.self_hosted.default_model = Some("shared-local-local-a".to_string());
11157 crate::ModelRegistry::from_config(
11158 &config,
11159 *crate::model_profile::test_catalog::TEST_CATALOG,
11160 )
11161 .expect("shared local registry")
11162 }
11163
11164 #[test]
11165 fn llm_identity_override_preserves_exact_self_hosted_server_route() {
11166 let registry = self_hosted_registry_with_shared_remote_model();
11167 let current = SessionLlmIdentity {
11168 model: "shared-local-local-a".to_string(),
11169 provider: Provider::SelfHosted,
11170 self_hosted_server_id: Some("local-a".to_string()),
11171 provider_params: None,
11172 auth_binding: None,
11173 };
11174
11175 let resolved = resolve_session_llm_identity_override(
11176 ¤t,
11177 ®istry,
11178 SessionLlmIdentityOverride {
11179 model: Some("shared-local-local-b"),
11180 provider: Some(Provider::SelfHosted),
11181 self_hosted_server_id: Some("local-b"),
11182 provider_params: None,
11183 auth_binding: None,
11184 },
11185 )
11186 .expect("exact configured local route should resolve");
11187
11188 assert_eq!(resolved.model, "shared-local-local-b");
11189 assert_eq!(resolved.provider, Provider::SelfHosted);
11190 assert_eq!(resolved.self_hosted_server_id.as_deref(), Some("local-b"));
11191 }
11192
11193 #[test]
11194 fn llm_identity_override_rejects_self_hosted_server_model_mismatch() {
11195 let registry = self_hosted_registry_with_shared_remote_model();
11196 let current = SessionLlmIdentity {
11197 model: "shared-local-local-a".to_string(),
11198 provider: Provider::SelfHosted,
11199 self_hosted_server_id: Some("local-a".to_string()),
11200 provider_params: None,
11201 auth_binding: None,
11202 };
11203
11204 let error = resolve_session_llm_identity_override(
11205 ¤t,
11206 ®istry,
11207 SessionLlmIdentityOverride {
11208 model: Some("shared-local-local-b"),
11209 provider: Some(Provider::SelfHosted),
11210 self_hosted_server_id: Some("local-a"),
11211 provider_params: None,
11212 auth_binding: None,
11213 },
11214 )
11215 .expect_err("server id must match the requested model alias route");
11216
11217 assert!(matches!(
11218 error,
11219 SessionLlmIdentityOverrideError::SelfHostedServerMismatch {
11220 requested,
11221 configured,
11222 ..
11223 } if requested == "local-a" && configured == "local-b"
11224 ));
11225 }
11226
11227 #[test]
11228 fn realtime_transcript_append_is_idempotent_by_provider_item_and_delta_id() {
11229 let mut session = Session::new();
11230
11231 let user = RealtimeTranscriptEvent::UserTranscriptFinal {
11232 item_id: "item_user".to_string(),
11233 previous_item_id: None,
11234 content_index: 0,
11235 text: "hello".to_string(),
11236 };
11237 assert!(
11238 !session
11239 .append_realtime_transcript_event(user.clone())
11240 .is_inert()
11241 );
11242 assert!(session.append_realtime_transcript_event(user).is_inert());
11243
11244 let delta = RealtimeTranscriptEvent::AssistantTextDelta {
11245 response_id: "resp_assistant".to_string(),
11246 delta_id: "evt_delta_1".to_string(),
11247 item_id: "item_assistant".to_string(),
11248 previous_item_id: Some("item_user".to_string()),
11249 content_index: 0,
11250 delta: "hi".to_string(),
11251 };
11252 assert!(
11253 session
11254 .append_realtime_transcript_event(delta.clone())
11255 .is_inert()
11256 );
11257 assert!(session.append_realtime_transcript_event(delta).is_inert());
11258
11259 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
11260 response_id: "resp_assistant".to_string(),
11261 stop_reason: StopReason::EndTurn,
11262 usage: Usage::default(),
11263 };
11264 assert!(
11265 !session
11266 .append_realtime_transcript_event(terminal.clone())
11267 .is_inert()
11268 );
11269 assert!(
11270 session
11271 .append_realtime_transcript_event(terminal)
11272 .is_inert()
11273 );
11274
11275 assert_eq!(session.messages().len(), 2);
11276 assert!(matches!(
11277 &session.messages()[0],
11278 Message::User(user) if user.text_content() == "hello"
11279 ));
11280 assert!(matches!(
11281 &session.messages()[1],
11282 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "hi"
11283 ));
11284 }
11285
11286 #[test]
11287 fn realtime_user_image_materializes_once_and_unblocks_causal_assistant() {
11288 let mut session = Session::new();
11289 let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
11290 let image = RealtimeTranscriptEvent::UserContentFinal {
11291 idempotency_key: "image-request-1".to_string(),
11292 item_id: "item_image".to_string(),
11293 previous_item_id: None,
11294 content_index: 0,
11295 content: vec![ContentBlock::Image {
11296 media_type: "image/png".to_string(),
11297 data: crate::types::ImageData::Inline {
11298 data: image_data.clone(),
11299 },
11300 }],
11301 };
11302
11303 assert!(
11304 !append_staged_user_image(&mut session, &image).is_inert(),
11305 "first image final must materialize canonical user content"
11306 );
11307 let replay = session
11308 .preflight_realtime_user_content_event(&image)
11309 .expect("exact retry should preflight as committed");
11310 assert!(matches!(
11311 replay,
11312 crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(_)
11313 ));
11314
11315 let staged_state = session
11316 .metadata
11317 .get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY)
11318 .expect("realtime state must be persisted");
11319 assert!(
11320 !staged_state.to_string().contains(&image_data),
11321 "materialized image bytes must not remain duplicated in transcript metadata"
11322 );
11323
11324 assert!(
11325 session
11326 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
11327 response_id: "resp_image".to_string(),
11328 delta_id: "delta_image".to_string(),
11329 item_id: "item_assistant".to_string(),
11330 previous_item_id: Some("item_image".to_string()),
11331 content_index: 0,
11332 delta: "I see red.".to_string(),
11333 })
11334 .is_inert()
11335 );
11336 assert!(
11337 !session
11338 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
11339 response_id: "resp_image".to_string(),
11340 stop_reason: StopReason::EndTurn,
11341 usage: Usage::default(),
11342 },)
11343 .is_inert(),
11344 "materialized image predecessor must unblock the assistant response"
11345 );
11346
11347 assert_eq!(session.messages().len(), 2);
11348 assert!(matches!(
11349 &session.messages()[0],
11350 Message::User(user)
11351 if matches!(
11352 user.content.as_slice(),
11353 [ContentBlock::Image {
11354 media_type,
11355 data: crate::types::ImageData::Blob { blob_id },
11356 }] if media_type == "image/png"
11357 && blob_id == &crate::blob::content_blob_id("image/png", &image_data)
11358 )
11359 ));
11360 assert!(matches!(
11361 &session.messages()[1],
11362 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "I see red."
11363 ));
11364 }
11365
11366 #[test]
11367 fn realtime_user_image_identity_is_durable_canonical_and_conflict_safe() {
11368 let mut session = Session::new();
11369 let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
11370 let initial = RealtimeTranscriptEvent::UserContentFinal {
11371 idempotency_key: "stable-image-key".to_string(),
11372 item_id: "canonical-image-item".to_string(),
11373 previous_item_id: None,
11374 content_index: 0,
11375 content: vec![ContentBlock::Image {
11376 media_type: " image/PNG; charset=binary ".to_string(),
11377 data: crate::types::ImageData::Inline { data: data.clone() },
11378 }],
11379 };
11380 let committed = append_staged_user_image(&mut session, &initial);
11381 let Some(crate::RealtimeUserContentApplyOutcome::Committed(identity)) =
11382 committed.user_content
11383 else {
11384 panic!("first image must commit its durable identity");
11385 };
11386 assert_eq!(identity.item_id, "canonical-image-item");
11387 assert_eq!(identity.media_type, "image/png");
11388
11389 let encoded = serde_json::to_string(&session).expect("session should serialize");
11390 let restored: Session =
11391 serde_json::from_str(&encoded).expect("committed identity should restore");
11392
11393 let replay_event = RealtimeTranscriptEvent::UserContentFinal {
11394 idempotency_key: "stable-image-key".to_string(),
11395 item_id: "ignored-retry-item".to_string(),
11396 previous_item_id: None,
11397 content_index: 0,
11398 content: vec![ContentBlock::Image {
11399 media_type: "image/png".to_string(),
11400 data: crate::types::ImageData::Inline { data: data.clone() },
11401 }],
11402 };
11403 let replay = restored
11404 .preflight_realtime_user_content_event(&replay_event)
11405 .expect("exact retry should preflight");
11406 assert!(matches!(
11407 replay,
11408 crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(
11409 crate::RealtimeUserContentIdentity { ref item_id, .. }
11410 ) if item_id == "canonical-image-item"
11411 ));
11412
11413 let conflict = restored
11414 .preflight_realtime_user_content_event(&RealtimeTranscriptEvent::UserContentFinal {
11415 idempotency_key: "stable-image-key".to_string(),
11416 item_id: "conflicting-item".to_string(),
11417 previous_item_id: None,
11418 content_index: 0,
11419 content: vec![ContentBlock::Image {
11420 media_type: "image/png".to_string(),
11421 data: crate::types::ImageData::Inline {
11422 data: "different-payload".to_string(),
11423 },
11424 }],
11425 })
11426 .expect("conflicting retry should preflight");
11427 assert!(matches!(
11428 conflict,
11429 crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. }
11430 ));
11431
11432 let item_collision = restored
11433 .preflight_realtime_user_content_event(&RealtimeTranscriptEvent::UserContentFinal {
11434 idempotency_key: "another-key".to_string(),
11435 item_id: "canonical-image-item".to_string(),
11436 previous_item_id: None,
11437 content_index: 0,
11438 content: vec![ContentBlock::Image {
11439 media_type: "image/png".to_string(),
11440 data: crate::types::ImageData::Inline { data },
11441 }],
11442 })
11443 .expect("item collision should preflight");
11444 assert!(matches!(
11445 item_collision,
11446 crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. }
11447 ));
11448 assert_eq!(restored.messages().len(), 1);
11449 serde_json::to_string(&restored).expect("rejections must not corrupt durable state");
11450 }
11451
11452 #[test]
11453 fn realtime_user_image_reducer_never_receipts_without_pending_blob_proof() {
11454 for data in [
11455 crate::types::ImageData::Inline {
11456 data: "iVBORw0KGgo=".to_string(),
11457 },
11458 crate::types::ImageData::Blob {
11459 blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgo="),
11460 },
11461 ] {
11462 let mut session = Session::new();
11463 let outcome = session.append_realtime_transcript_event(
11464 RealtimeTranscriptEvent::UserContentFinal {
11465 idempotency_key: "unstaged-image-key".to_string(),
11466 item_id: "unstaged-image-item".to_string(),
11467 previous_item_id: None,
11468 content_index: 0,
11469 content: vec![ContentBlock::Image {
11470 media_type: "image/png".to_string(),
11471 data,
11472 }],
11473 },
11474 );
11475 assert!(matches!(
11476 outcome.user_content,
11477 Some(crate::RealtimeUserContentApplyOutcome::RejectedInvalidIdentity { .. })
11478 ));
11479 assert!(session.messages().is_empty());
11480 assert!(session.realtime_user_content_identities().is_empty());
11481 }
11482 }
11483
11484 #[test]
11485 fn realtime_user_image_pending_slot_is_generated_bounded_and_recovery_typed() {
11486 use crate::generated::session_document::{
11487 RealtimeUserContentBlobRecoveryDisposition, RealtimeUserContentBlobStageDisposition,
11488 };
11489 let mut session = Session::new();
11490 let pending = crate::PendingRealtimeUserContentBlob {
11491 idempotency_key: "pending-key-a".to_string(),
11492 item_id: "pending-item-a".to_string(),
11493 previous_item_id: None,
11494 content_index: 0,
11495 blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgo="),
11496 media_type: "image/png".to_string(),
11497 };
11498 let different = crate::PendingRealtimeUserContentBlob {
11499 idempotency_key: "pending-key-b".to_string(),
11500 item_id: "pending-item-b".to_string(),
11501 previous_item_id: None,
11502 content_index: 0,
11503 blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgoB"),
11504 media_type: "image/png".to_string(),
11505 };
11506 assert_eq!(
11507 session
11508 .stage_pending_realtime_user_content_blob(pending.clone())
11509 .expect("empty slot stages"),
11510 RealtimeUserContentBlobStageDisposition::StageNew
11511 );
11512 assert_eq!(
11513 session
11514 .stage_pending_realtime_user_content_blob(pending.clone())
11515 .expect("exact stage retry is idempotent"),
11516 RealtimeUserContentBlobStageDisposition::ReuseExact
11517 );
11518 assert_eq!(
11519 session
11520 .stage_pending_realtime_user_content_blob(different.clone())
11521 .expect("occupied decision is typed"),
11522 RealtimeUserContentBlobStageDisposition::RejectOccupied
11523 );
11524 assert_eq!(
11525 session.pending_realtime_user_content_blob(),
11526 Some(pending.clone())
11527 );
11528 assert_eq!(
11529 session
11530 .resolve_pending_realtime_user_content_blob_recovery(Some(&pending), false)
11531 .expect("exact recovery decision"),
11532 RealtimeUserContentBlobRecoveryDisposition::RetryExact
11533 );
11534 assert_eq!(
11535 session
11536 .resolve_pending_realtime_user_content_blob_recovery(Some(&different), true)
11537 .expect("verified older recovery decision"),
11538 RealtimeUserContentBlobRecoveryDisposition::CommitVerifiedBeforeCurrent
11539 );
11540 assert_eq!(
11541 session
11542 .resolve_pending_realtime_user_content_blob_recovery(Some(&different), false)
11543 .expect("invalid older recovery decision"),
11544 RealtimeUserContentBlobRecoveryDisposition::ClearInvalidBeforeCurrent
11545 );
11546 session
11547 .clear_invalid_pending_realtime_user_content_blob(Some(&different))
11548 .expect("generated clear-invalid disposition authorizes clear");
11549 assert!(session.pending_realtime_user_content_blob().is_none());
11550 }
11551
11552 #[test]
11553 fn transcript_rewrite_tombstones_removed_image_key_and_accepts_new_key() {
11554 let mut session = Session::new();
11555 let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
11556 let original = RealtimeTranscriptEvent::UserContentFinal {
11557 idempotency_key: "removed-image-key".to_string(),
11558 item_id: "removed-image-item".to_string(),
11559 previous_item_id: None,
11560 content_index: 0,
11561 content: vec![ContentBlock::Image {
11562 media_type: "image/png".to_string(),
11563 data: crate::types::ImageData::Inline { data: data.clone() },
11564 }],
11565 };
11566 assert!(matches!(
11567 append_staged_user_image(&mut session, &original).user_content,
11568 Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
11569 ));
11570
11571 let parent = session.transcript_revision().expect("parent revision");
11572 session
11573 .commit_transcript_rewrite(
11574 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
11575 vec![Message::User(UserMessage::text("image removed"))],
11576 TranscriptRewriteReason::new("remove-image"),
11577 None,
11578 Some(parent),
11579 )
11580 .expect("rewrite should tombstone removed image identity");
11581
11582 assert!(session.realtime_user_content_identities().is_empty());
11583 assert_eq!(
11584 session.realtime_user_content_tombstones(),
11585 vec![crate::RealtimeUserContentTombstone {
11586 idempotency_key: "removed-image-key".to_string(),
11587 }]
11588 );
11589 assert!(matches!(
11590 session.preflight_realtime_user_content_event(&original),
11591 Some(crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. })
11592 ));
11593 assert!(matches!(
11594 session
11595 .append_realtime_transcript_event(original)
11596 .user_content,
11597 Some(crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. })
11598 ));
11599 assert_eq!(
11600 session.messages().len(),
11601 1,
11602 "stale retry emits no receipt content"
11603 );
11604
11605 let new_image = RealtimeTranscriptEvent::UserContentFinal {
11606 idempotency_key: "new-image-key".to_string(),
11607 item_id: "new-image-item".to_string(),
11608 previous_item_id: None,
11609 content_index: 0,
11610 content: vec![ContentBlock::Image {
11611 media_type: "image/png".to_string(),
11612 data: crate::types::ImageData::Inline { data },
11613 }],
11614 };
11615 assert!(matches!(
11616 append_staged_user_image(&mut session, &new_image).user_content,
11617 Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
11618 ));
11619 assert_eq!(session.messages().len(), 2);
11620
11621 let restored: Session = serde_json::from_str(
11622 &serde_json::to_string(&session).expect("serialize rewritten session"),
11623 )
11624 .expect("cold restore rewritten session");
11625 assert_eq!(restored.realtime_user_content_identities().len(), 1);
11626 assert_eq!(restored.realtime_user_content_tombstones().len(), 1);
11627 }
11628
11629 #[test]
11630 fn transcript_rewrite_retains_only_canonical_image_occurrence_for_exact_replay() {
11631 let mut session = Session::new();
11632 let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
11633 let original = RealtimeTranscriptEvent::UserContentFinal {
11634 idempotency_key: "retained-image-key".to_string(),
11635 item_id: "retained-image-item".to_string(),
11636 previous_item_id: None,
11637 content_index: 0,
11638 content: vec![ContentBlock::Image {
11639 media_type: "image/png".to_string(),
11640 data: crate::types::ImageData::Inline { data },
11641 }],
11642 };
11643 assert!(matches!(
11644 append_staged_user_image(&mut session, &original).user_content,
11645 Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
11646 ));
11647 let retained_message = session.messages()[0].clone();
11648 let parent = session.transcript_revision().expect("parent revision");
11649 session
11650 .commit_transcript_rewrite(
11651 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
11652 vec![
11653 retained_message,
11654 Message::User(UserMessage::text("new canonical neighbor")),
11655 ],
11656 TranscriptRewriteReason::new("retain-image"),
11657 None,
11658 Some(parent),
11659 )
11660 .expect("rewrite retaining exact inline image should reconcile");
11661
11662 assert!(session.realtime_user_content_tombstones().is_empty());
11663 let replay = session
11664 .preflight_realtime_user_content_event(&original)
11665 .expect("retained image should preflight as exact replay");
11666 assert!(matches!(
11667 replay,
11668 crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(_)
11669 ));
11670 assert_eq!(session.messages().len(), 2);
11671 }
11672
11673 #[test]
11674 fn transcript_rewrite_rejects_atomically_while_image_blob_anchor_is_pending() {
11675 let mut session = Session::new();
11676 session.push(Message::User(UserMessage::text("before rewrite")));
11677 let pending = crate::PendingRealtimeUserContentBlob {
11678 idempotency_key: "pending-rewrite-key".to_string(),
11679 item_id: "pending-rewrite-item".to_string(),
11680 previous_item_id: None,
11681 content_index: 0,
11682 blob_id: crate::blob::content_blob_id("image/png", "pending-bytes"),
11683 media_type: "image/png".to_string(),
11684 };
11685 session
11686 .stage_pending_realtime_user_content_blob(pending.clone())
11687 .expect("stage durable pending anchor");
11688 let parent = session.transcript_revision().expect("parent revision");
11689 let error = session
11690 .commit_transcript_rewrite(
11691 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
11692 vec![Message::User(UserMessage::text("after rewrite"))],
11693 TranscriptRewriteReason::new("blocked-pending-image"),
11694 None,
11695 Some(parent),
11696 )
11697 .expect_err("rewrite must not cross an unresolved image anchor");
11698 assert!(
11699 error
11700 .to_string()
11701 .contains("history_rewrite_pending_user_content_blob")
11702 );
11703 assert!(matches!(
11704 &session.messages()[0],
11705 Message::User(user) if user.text_content() == "before rewrite"
11706 ));
11707 assert_eq!(session.pending_realtime_user_content_blob(), Some(pending));
11708 }
11709
11710 #[test]
11711 fn realtime_user_image_rejects_noncanonical_blob_and_multiblock_shape() {
11712 let mut session = Session::new();
11713 for (key, content) in [
11714 (
11715 "invalid-blob",
11716 vec![ContentBlock::Image {
11717 media_type: "image/png".to_string(),
11718 data: crate::types::ImageData::Blob {
11719 blob_id: crate::BlobId::new("sha256:not-a-digest"),
11720 },
11721 }],
11722 ),
11723 (
11724 "multi-block",
11725 vec![
11726 ContentBlock::Image {
11727 media_type: "image/png".to_string(),
11728 data: crate::types::ImageData::Inline {
11729 data: "payload".to_string(),
11730 },
11731 },
11732 ContentBlock::Text {
11733 text: "smuggled".to_string(),
11734 },
11735 ],
11736 ),
11737 ] {
11738 let outcome = session.append_realtime_transcript_event(
11739 RealtimeTranscriptEvent::UserContentFinal {
11740 idempotency_key: key.to_string(),
11741 item_id: format!("item-{key}"),
11742 previous_item_id: None,
11743 content_index: 0,
11744 content,
11745 },
11746 );
11747 assert!(matches!(
11748 outcome.user_content,
11749 Some(crate::RealtimeUserContentApplyOutcome::RejectedInvalidIdentity { .. })
11750 ));
11751 }
11752 assert!(session.messages().is_empty());
11753 let encoded = serde_json::to_string(&session).expect("session should serialize");
11754 serde_json::from_str::<Session>(&encoded).expect("rejections must leave restorable state");
11755 }
11756
11757 #[test]
11758 fn realtime_restore_rejects_malformed_causal_graphs_and_accepts_waiting_dag() {
11759 fn restore(
11760 items: serde_json::Value,
11761 first_seen_order: Vec<&str>,
11762 ) -> Result<
11763 crate::realtime_transcript_revision::SessionRealtimeTranscriptState,
11764 crate::realtime_transcript_revision::RealtimeTranscriptShellError,
11765 > {
11766 let state = serde_json::from_value(serde_json::json!({
11767 "items": items,
11768 "first_seen_order": first_seen_order,
11769 }))
11770 .expect("test state shape should deserialize");
11771 crate::realtime_transcript_revision::restore_realtime_transcript_state(state)
11772 }
11773
11774 assert!(
11775 restore(
11776 serde_json::json!({
11777 "child": { "role": "user", "previous_item_id": "missing" }
11778 }),
11779 vec!["child"],
11780 )
11781 .is_ok(),
11782 "an unmaterialized out-of-order item must survive cold restore until its predecessor arrives"
11783 );
11784 assert!(
11785 restore(
11786 serde_json::json!({
11787 "child": {
11788 "role": "user",
11789 "previous_item_id": "missing",
11790 "ready": true,
11791 "materialized": true
11792 }
11793 }),
11794 vec!["child"],
11795 )
11796 .is_err(),
11797 "a materialized item cannot reference a missing predecessor"
11798 );
11799 assert!(
11800 restore(
11801 serde_json::json!({
11802 "self": { "role": "user", "previous_item_id": "self" }
11803 }),
11804 vec!["self"],
11805 )
11806 .is_err(),
11807 "self edge must fail cold restore"
11808 );
11809 assert!(
11810 restore(
11811 serde_json::json!({
11812 "a": { "role": "user", "previous_item_id": "b" },
11813 "b": { "role": "user", "previous_item_id": "a" }
11814 }),
11815 vec!["a", "b"],
11816 )
11817 .is_err(),
11818 "cycle must fail cold restore"
11819 );
11820 assert!(
11821 restore(
11822 serde_json::json!({
11823 "root": { "role": "user" },
11824 "materialized_child": {
11825 "role": "user",
11826 "previous_item_id": "root",
11827 "ready": true,
11828 "materialized": true
11829 }
11830 }),
11831 vec!["root", "materialized_child"],
11832 )
11833 .is_err(),
11834 "materialized child cannot have unmaterialized ancestry"
11835 );
11836 assert!(
11837 restore(
11838 serde_json::json!({
11839 "root": { "role": "user" },
11840 "waiting_child": { "role": "user", "previous_item_id": "root" }
11841 }),
11842 vec!["waiting_child", "root"],
11843 )
11844 .is_ok(),
11845 "valid acyclic waiting graph should restore even when first-seen order is child-first"
11846 );
11847 }
11848
11849 #[test]
11850 fn realtime_restore_handles_long_waiting_chain_with_bounded_graph_walk() {
11851 const ITEM_COUNT: usize = 4_096;
11852 let mut items = serde_json::Map::new();
11853 let mut order = Vec::with_capacity(ITEM_COUNT);
11854 for index in 0..ITEM_COUNT {
11855 let item_id = format!("item-{index:04}");
11856 let value = if index == 0 {
11857 serde_json::json!({ "role": "user" })
11858 } else {
11859 serde_json::json!({
11860 "role": "user",
11861 "previous_item_id": format!("item-{:04}", index - 1),
11862 })
11863 };
11864 order.push(item_id.clone());
11865 items.insert(item_id, value);
11866 }
11867 let state = serde_json::from_value(serde_json::json!({
11868 "items": items,
11869 "first_seen_order": order,
11870 }))
11871 .expect("long-chain fixture should deserialize");
11872 crate::realtime_transcript_revision::restore_realtime_transcript_state(state)
11873 .expect("long valid waiting DAG should restore in one bounded graph walk");
11874 }
11875
11876 #[test]
11881 fn realtime_transcript_final_text_overrides_partial_delta_and_promotes_to_spoken_lane() {
11882 let mut session = Session::new();
11883
11884 assert!(
11887 session
11888 .append_realtime_transcript_event(
11889 RealtimeTranscriptEvent::AssistantTranscriptDelta {
11890 response_id: "resp_a".to_string(),
11891 delta_id: "evt_1".to_string(),
11892 item_id: "item_a".to_string(),
11893 previous_item_id: None,
11894 content_index: 0,
11895 delta: "incom".to_string(),
11896 }
11897 )
11898 .is_inert()
11899 );
11900
11901 assert!(
11903 session
11904 .append_realtime_transcript_event(
11905 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
11906 response_id: "resp_a".to_string(),
11907 item_id: "item_a".to_string(),
11908 content_index: 0,
11909 text: "complete answer".to_string(),
11910 }
11911 )
11912 .is_inert()
11913 );
11914
11915 let outcome = session.append_realtime_transcript_event(
11917 RealtimeTranscriptEvent::AssistantTurnCompleted {
11918 response_id: "resp_a".to_string(),
11919 stop_reason: StopReason::EndTurn,
11920 usage: Usage::default(),
11921 },
11922 );
11923 assert!(!outcome.is_inert());
11924
11925 assert_eq!(session.messages().len(), 1);
11928 match &session.messages()[0] {
11929 Message::BlockAssistant(assistant) => {
11930 let mut found_transcript = false;
11931 for block in &assistant.blocks {
11932 if let AssistantBlock::Transcript { text, .. } = block {
11933 assert_eq!(text, "complete answer");
11934 found_transcript = true;
11935 }
11936 }
11937 assert!(
11938 found_transcript,
11939 "AssistantTranscriptFinalText must promote to the Spoken lane and \
11940 materialize as AssistantBlock::Transcript"
11941 );
11942 }
11943 other => unreachable!("expected BlockAssistant, got {other:?}"),
11944 }
11945 }
11946
11947 #[test]
11950 fn realtime_transcript_final_text_creates_item_when_no_delta_staged() {
11951 let mut session = Session::new();
11952
11953 assert!(
11954 session
11955 .append_realtime_transcript_event(
11956 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
11957 response_id: "resp_a".to_string(),
11958 item_id: "item_a".to_string(),
11959 content_index: 0,
11960 text: "spoken-final-only".to_string(),
11961 }
11962 )
11963 .is_inert()
11964 );
11965
11966 let outcome = session.append_realtime_transcript_event(
11967 RealtimeTranscriptEvent::AssistantTurnCompleted {
11968 response_id: "resp_a".to_string(),
11969 stop_reason: StopReason::EndTurn,
11970 usage: Usage::default(),
11971 },
11972 );
11973 assert!(!outcome.is_inert());
11974
11975 assert_eq!(session.messages().len(), 1);
11976 match &session.messages()[0] {
11977 Message::BlockAssistant(assistant) => {
11978 let has_transcript = assistant.blocks.iter().any(|b| {
11979 matches!(b, AssistantBlock::Transcript { text, .. } if text == "spoken-final-only")
11980 });
11981 assert!(
11982 has_transcript,
11983 "final-only provider path must materialize as Transcript on the Spoken lane"
11984 );
11985 }
11986 other => unreachable!("expected BlockAssistant, got {other:?}"),
11987 }
11988 }
11989
11990 #[test]
11991 fn realtime_transcript_append_orders_causally_equivalent_out_of_order_items() {
11992 let mut session = Session::new();
11993
11994 assert!(
11995 session
11996 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
11997 response_id: "resp_assistant".to_string(),
11998 delta_id: "evt_delta_1".to_string(),
11999 item_id: "item_assistant".to_string(),
12000 previous_item_id: Some("item_user".to_string()),
12001 content_index: 0,
12002 delta: "answer".to_string(),
12003 })
12004 .is_inert()
12005 );
12006 assert!(
12007 session
12008 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
12009 response_id: "resp_assistant".to_string(),
12010 stop_reason: StopReason::EndTurn,
12011 usage: Usage::default(),
12012 })
12013 .is_inert()
12014 );
12015
12016 let outcome = session.append_realtime_transcript_event(
12017 RealtimeTranscriptEvent::UserTranscriptFinal {
12018 item_id: "item_user".to_string(),
12019 previous_item_id: None,
12020 content_index: 0,
12021 text: "question".to_string(),
12022 },
12023 );
12024
12025 assert_eq!(outcome.materialized_messages.len(), 2);
12026 assert_eq!(session.messages().len(), 2);
12027 assert!(matches!(
12028 &session.messages()[0],
12029 Message::User(user) if user.text_content() == "question"
12030 ));
12031 assert!(matches!(
12032 &session.messages()[1],
12033 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer"
12034 ));
12035 }
12036
12037 #[test]
12038 fn realtime_transcript_replay_of_seen_provider_items_is_inert() {
12039 let mut session = Session::new();
12040 let events = vec![
12041 RealtimeTranscriptEvent::UserTranscriptFinal {
12042 item_id: "item_user".to_string(),
12043 previous_item_id: None,
12044 content_index: 0,
12045 text: "hello".to_string(),
12046 },
12047 RealtimeTranscriptEvent::AssistantTextDelta {
12048 response_id: "resp_assistant".to_string(),
12049 delta_id: "evt_delta_1".to_string(),
12050 item_id: "item_assistant".to_string(),
12051 previous_item_id: Some("item_user".to_string()),
12052 content_index: 0,
12053 delta: "world".to_string(),
12054 },
12055 RealtimeTranscriptEvent::AssistantTurnCompleted {
12056 response_id: "resp_assistant".to_string(),
12057 stop_reason: StopReason::EndTurn,
12058 usage: Usage::default(),
12059 },
12060 ];
12061
12062 for event in events.iter().cloned() {
12063 let _ = session.append_realtime_transcript_event(event);
12064 }
12065 let first_messages = serde_json::to_value(session.messages()).unwrap();
12066
12067 for event in events {
12068 assert!(session.append_realtime_transcript_event(event).is_inert());
12069 }
12070
12071 assert_eq!(
12072 serde_json::to_value(session.messages()).unwrap(),
12073 first_messages
12074 );
12075 }
12076
12077 #[test]
12078 fn realtime_transcript_user_final_replay_cannot_erase_existing_segment() {
12079 let mut session = Session::new();
12080
12081 let user = RealtimeTranscriptEvent::UserTranscriptFinal {
12082 item_id: "item_user".to_string(),
12083 previous_item_id: None,
12084 content_index: 0,
12085 text: "remember amber lantern".to_string(),
12086 };
12087 assert!(
12088 !session
12089 .append_realtime_transcript_event(user.clone())
12090 .is_inert()
12091 );
12092 let first_messages = serde_json::to_value(session.messages()).unwrap();
12093
12094 assert!(
12095 session
12096 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
12097 item_id: "item_user".to_string(),
12098 previous_item_id: None,
12099 content_index: 0,
12100 text: String::new(),
12101 })
12102 .is_inert()
12103 );
12104 assert!(session.append_realtime_transcript_event(user).is_inert());
12105 assert_eq!(
12106 serde_json::to_value(session.messages()).unwrap(),
12107 first_messages
12108 );
12109 }
12110
12111 #[test]
12112 fn realtime_transcript_empty_user_final_can_be_filled_by_later_nonempty_replay() {
12113 let mut session = Session::new();
12114
12115 assert!(
12116 session
12117 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
12118 item_id: "item_user".to_string(),
12119 previous_item_id: None,
12120 content_index: 0,
12121 text: String::new(),
12122 })
12123 .is_inert()
12124 );
12125 assert!(session.messages().is_empty());
12126
12127 let outcome = session.append_realtime_transcript_event(
12128 RealtimeTranscriptEvent::UserTranscriptFinal {
12129 item_id: "item_user".to_string(),
12130 previous_item_id: None,
12131 content_index: 0,
12132 text: "remember amber lantern".to_string(),
12133 },
12134 );
12135 assert_eq!(outcome.materialized_messages.len(), 1);
12136 assert_eq!(session.messages().len(), 1);
12137 assert!(matches!(
12138 &session.messages()[0],
12139 Message::User(user) if user.text_content() == "remember amber lantern"
12140 ));
12141 }
12142
12143 #[test]
12144 fn realtime_transcript_skipped_provider_items_preserve_causal_order_without_content() {
12145 let mut session = Session::new();
12146
12147 let assistant_delta = RealtimeTranscriptEvent::AssistantTextDelta {
12148 response_id: "resp_assistant".to_string(),
12149 delta_id: "evt_delta_1".to_string(),
12150 item_id: "item_assistant".to_string(),
12151 previous_item_id: Some("item_tool".to_string()),
12152 content_index: 0,
12153 delta: "done".to_string(),
12154 };
12155 assert!(
12156 session
12157 .append_realtime_transcript_event(assistant_delta.clone())
12158 .is_inert()
12159 );
12160 let assistant_complete = RealtimeTranscriptEvent::AssistantTurnCompleted {
12161 response_id: "resp_assistant".to_string(),
12162 stop_reason: StopReason::EndTurn,
12163 usage: Usage::default(),
12164 };
12165 assert!(
12166 session
12167 .append_realtime_transcript_event(assistant_complete.clone())
12168 .is_inert()
12169 );
12170
12171 let skipped = RealtimeTranscriptEvent::ItemSkipped {
12172 item_id: "item_tool".to_string(),
12173 previous_item_id: Some("item_user".to_string()),
12174 };
12175 assert!(
12176 session
12177 .append_realtime_transcript_event(skipped.clone())
12178 .is_inert(),
12179 "a skipped provider item must not append transcript content"
12180 );
12181 assert!(session.messages().is_empty());
12182
12183 let outcome = session.append_realtime_transcript_event(
12184 RealtimeTranscriptEvent::UserTranscriptFinal {
12185 item_id: "item_user".to_string(),
12186 previous_item_id: None,
12187 content_index: 0,
12188 text: "please use the tool".to_string(),
12189 },
12190 );
12191 assert_eq!(outcome.materialized_messages.len(), 2);
12192 assert_eq!(session.messages().len(), 2);
12193 assert!(matches!(
12194 &session.messages()[0],
12195 Message::User(user) if user.text_content() == "please use the tool"
12196 ));
12197 assert!(matches!(
12198 &session.messages()[1],
12199 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "done"
12200 ));
12201
12202 let first_messages = serde_json::to_value(session.messages()).unwrap();
12203 assert!(session.append_realtime_transcript_event(skipped).is_inert());
12204 assert!(
12205 session
12206 .append_realtime_transcript_event(assistant_delta)
12207 .is_inert()
12208 );
12209 assert!(
12210 session
12211 .append_realtime_transcript_event(assistant_complete)
12212 .is_inert()
12213 );
12214 assert_eq!(
12215 serde_json::to_value(session.messages()).unwrap(),
12216 first_messages
12217 );
12218 }
12219
12220 #[test]
12221 fn realtime_transcript_interrupted_assistant_item_unblocks_later_provider_items() {
12222 let mut session = Session::new();
12229
12230 let _ = session.append_realtime_transcript_event(
12231 RealtimeTranscriptEvent::UserTranscriptFinal {
12232 item_id: "item_repeat".to_string(),
12233 previous_item_id: None,
12234 content_index: 0,
12235 text: "repeat until stop".to_string(),
12236 },
12237 );
12238 assert!(
12239 session
12240 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12241 response_id: "resp_loop".to_string(),
12242 delta_id: "evt_loop_1".to_string(),
12243 item_id: "item_loop".to_string(),
12244 previous_item_id: Some("item_repeat".to_string()),
12245 content_index: 0,
12246 delta: "Looping now".to_string(),
12247 })
12248 .is_inert()
12249 );
12250 assert!(
12251 session
12252 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
12253 item_id: "item_stop".to_string(),
12254 previous_item_id: Some("item_loop".to_string()),
12255 content_index: 0,
12256 text: "Stop.".to_string(),
12257 })
12258 .is_inert(),
12259 "the stop turn waits until the interrupted assistant provider item is resolved"
12260 );
12261
12262 let outcome = session.append_realtime_transcript_event(
12263 RealtimeTranscriptEvent::AssistantTurnInterrupted {
12264 response_id: "resp_loop".to_string(),
12265 },
12266 );
12267
12268 assert_eq!(outcome.materialized_messages.len(), 2);
12271 assert_eq!(session.messages().len(), 3);
12273 assert!(matches!(
12274 &session.messages()[0],
12275 Message::User(user) if user.text_content() == "repeat until stop"
12276 ));
12277 match &session.messages()[1] {
12278 Message::BlockAssistant(assistant) => {
12279 let text = block_assistant_text(assistant);
12280 assert_eq!(text, "Looping now");
12281 }
12282 other => unreachable!(
12283 "Display lane assistant item must be retained on Interrupted, got {other:?}"
12284 ),
12285 }
12286 assert!(matches!(
12287 &session.messages()[2],
12288 Message::User(user) if user.text_content() == "Stop."
12289 ));
12290 }
12291
12292 #[test]
12293 fn realtime_transcript_late_interrupted_assistant_delta_stays_noncanonical() {
12294 let mut session = Session::new();
12295
12296 let _ = session.append_realtime_transcript_event(
12297 RealtimeTranscriptEvent::UserTranscriptFinal {
12298 item_id: "item_repeat".to_string(),
12299 previous_item_id: None,
12300 content_index: 0,
12301 text: "repeat until stop".to_string(),
12302 },
12303 );
12304 assert!(
12305 session
12306 .append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
12307 item_id: "item_loop".to_string(),
12308 previous_item_id: Some("item_repeat".to_string()),
12309 role: RealtimeTranscriptRole::Assistant,
12310 response_id: None,
12311 })
12312 .is_inert(),
12313 "provider can observe an assistant item before the adapter learns its response id"
12314 );
12315 assert!(
12316 session
12317 .append_realtime_transcript_event(
12318 RealtimeTranscriptEvent::AssistantTurnInterrupted {
12319 response_id: "resp_loop".to_string(),
12320 }
12321 )
12322 .is_inert(),
12323 "an interruption can arrive before delayed transcript deltas for the response"
12324 );
12325 assert!(
12326 session
12327 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
12328 item_id: "item_stop".to_string(),
12329 previous_item_id: Some("item_loop".to_string()),
12330 content_index: 0,
12331 text: "Stop.".to_string(),
12332 })
12333 .is_inert(),
12334 "the stop turn waits for the provider's interrupted assistant item anchor"
12335 );
12336
12337 let late_delta_outcome =
12338 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12339 response_id: "resp_loop".to_string(),
12340 delta_id: "evt_loop_late".to_string(),
12341 item_id: "item_loop".to_string(),
12342 previous_item_id: Some("item_repeat".to_string()),
12343 content_index: 0,
12344 delta: "Looping now".to_string(),
12345 });
12346 assert_eq!(late_delta_outcome.materialized_messages.len(), 1);
12347 assert!(matches!(
12348 &session.messages()[1],
12349 Message::User(user) if user.text_content() == "Stop."
12350 ));
12351 assert!(
12352 session
12353 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
12354 response_id: "resp_loop".to_string(),
12355 stop_reason: StopReason::EndTurn,
12356 usage: Usage::default(),
12357 })
12358 .is_inert(),
12359 "late completion for an interrupted response must not resurrect its deltas"
12360 );
12361 assert!(
12362 session
12363 .messages()
12364 .iter()
12365 .filter_map(|message| match message {
12366 Message::BlockAssistant(assistant) => Some(block_assistant_text(assistant)),
12367 _ => None,
12368 })
12369 .all(|text| !text.contains("Looping now")),
12370 "late interrupted assistant text must remain non-canonical"
12371 );
12372 }
12373
12374 #[test]
12375 fn realtime_transcript_completion_only_finalizes_matching_response() {
12376 let mut session = Session::new();
12377
12378 let _ = session.append_realtime_transcript_event(
12379 RealtimeTranscriptEvent::UserTranscriptFinal {
12380 item_id: "item_user".to_string(),
12381 previous_item_id: None,
12382 content_index: 0,
12383 text: "question".to_string(),
12384 },
12385 );
12386 assert!(
12387 session
12388 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12389 response_id: "resp_a".to_string(),
12390 delta_id: "evt_a".to_string(),
12391 item_id: "item_a".to_string(),
12392 previous_item_id: Some("item_user".to_string()),
12393 content_index: 0,
12394 delta: "answer a".to_string(),
12395 })
12396 .is_inert()
12397 );
12398
12399 assert!(
12400 session
12401 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
12402 response_id: "resp_b".to_string(),
12403 stop_reason: StopReason::EndTurn,
12404 usage: Usage::default(),
12405 })
12406 .is_inert(),
12407 "a completion for another response must not finalize buffered assistant text"
12408 );
12409 assert_eq!(session.messages().len(), 1);
12410
12411 let outcome = session.append_realtime_transcript_event(
12412 RealtimeTranscriptEvent::AssistantTurnCompleted {
12413 response_id: "resp_a".to_string(),
12414 stop_reason: StopReason::EndTurn,
12415 usage: Usage::default(),
12416 },
12417 );
12418 assert_eq!(outcome.materialized_messages.len(), 1);
12419 assert_eq!(session.messages().len(), 2);
12420 assert!(matches!(
12421 &session.messages()[1],
12422 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer a"
12423 ));
12424 }
12425
12426 #[test]
12427 fn realtime_transcript_completion_before_later_delta_is_response_scoped() {
12428 let mut session = Session::new();
12429
12430 let _ = session.append_realtime_transcript_event(
12431 RealtimeTranscriptEvent::UserTranscriptFinal {
12432 item_id: "item_user".to_string(),
12433 previous_item_id: None,
12434 content_index: 0,
12435 text: "question".to_string(),
12436 },
12437 );
12438 assert!(
12439 session
12440 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
12441 response_id: "resp_a".to_string(),
12442 stop_reason: StopReason::EndTurn,
12443 usage: Usage::default(),
12444 })
12445 .is_inert()
12446 );
12447 assert!(
12448 session
12449 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12450 response_id: "resp_b".to_string(),
12451 delta_id: "evt_b".to_string(),
12452 item_id: "item_b".to_string(),
12453 previous_item_id: Some("item_user".to_string()),
12454 content_index: 0,
12455 delta: "wrong response".to_string(),
12456 })
12457 .is_inert(),
12458 "a later delta for another response must not be finalized by resp_a's pending completion"
12459 );
12460
12461 let outcome =
12462 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12463 response_id: "resp_a".to_string(),
12464 delta_id: "evt_a".to_string(),
12465 item_id: "item_a".to_string(),
12466 previous_item_id: Some("item_user".to_string()),
12467 content_index: 0,
12468 delta: "right response".to_string(),
12469 });
12470
12471 assert_eq!(outcome.materialized_messages.len(), 1);
12472 assert_eq!(session.messages().len(), 2);
12473 assert!(matches!(
12474 &session.messages()[1],
12475 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "right response"
12476 ));
12477 }
12478
12479 #[test]
12480 fn realtime_transcript_late_duplicate_completion_cannot_finalize_unrelated_response() {
12481 let mut session = Session::new();
12482
12483 let _ = session.append_realtime_transcript_event(
12484 RealtimeTranscriptEvent::UserTranscriptFinal {
12485 item_id: "item_user".to_string(),
12486 previous_item_id: None,
12487 content_index: 0,
12488 text: "question".to_string(),
12489 },
12490 );
12491 let _ =
12492 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12493 response_id: "resp_a".to_string(),
12494 delta_id: "evt_a".to_string(),
12495 item_id: "item_a".to_string(),
12496 previous_item_id: Some("item_user".to_string()),
12497 content_index: 0,
12498 delta: "first".to_string(),
12499 });
12500 let _ = session.append_realtime_transcript_event(
12501 RealtimeTranscriptEvent::AssistantTurnCompleted {
12502 response_id: "resp_a".to_string(),
12503 stop_reason: StopReason::EndTurn,
12504 usage: Usage::default(),
12505 },
12506 );
12507 assert_eq!(session.messages().len(), 2);
12508
12509 assert!(
12510 session
12511 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12512 response_id: "resp_b".to_string(),
12513 delta_id: "evt_b".to_string(),
12514 item_id: "item_b".to_string(),
12515 previous_item_id: Some("item_a".to_string()),
12516 content_index: 0,
12517 delta: "second".to_string(),
12518 })
12519 .is_inert()
12520 );
12521 assert!(
12522 session
12523 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
12524 response_id: "resp_a".to_string(),
12525 stop_reason: StopReason::EndTurn,
12526 usage: Usage::default(),
12527 })
12528 .is_inert(),
12529 "a duplicate late terminal for resp_a must not finalize resp_b"
12530 );
12531 assert_eq!(session.messages().len(), 2);
12532
12533 let outcome = session.append_realtime_transcript_event(
12534 RealtimeTranscriptEvent::AssistantTurnCompleted {
12535 response_id: "resp_b".to_string(),
12536 stop_reason: StopReason::EndTurn,
12537 usage: Usage::default(),
12538 },
12539 );
12540 assert_eq!(outcome.materialized_messages.len(), 1);
12541 assert_eq!(session.messages().len(), 3);
12542 }
12543
12544 #[test]
12545 fn realtime_transcript_interruption_discards_only_matching_response() {
12546 let mut session = Session::new();
12552
12553 let _ = session.append_realtime_transcript_event(
12554 RealtimeTranscriptEvent::UserTranscriptFinal {
12555 item_id: "item_user".to_string(),
12556 previous_item_id: None,
12557 content_index: 0,
12558 text: "question".to_string(),
12559 },
12560 );
12561 let _ =
12562 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12563 response_id: "resp_a".to_string(),
12564 delta_id: "evt_a".to_string(),
12565 item_id: "item_a".to_string(),
12566 previous_item_id: Some("item_user".to_string()),
12567 content_index: 0,
12568 delta: "interrupted display".to_string(),
12569 });
12570 let _ =
12571 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
12572 response_id: "resp_b".to_string(),
12573 delta_id: "evt_b".to_string(),
12574 item_id: "item_b".to_string(),
12575 previous_item_id: Some("item_user".to_string()),
12576 content_index: 0,
12577 delta: "keep me".to_string(),
12578 });
12579
12580 let interrupt_outcome = session.append_realtime_transcript_event(
12583 RealtimeTranscriptEvent::AssistantTurnInterrupted {
12584 response_id: "resp_a".to_string(),
12585 },
12586 );
12587 assert_eq!(
12588 interrupt_outcome.materialized_messages.len(),
12589 1,
12590 "resp_a's Display item commits on Interrupted"
12591 );
12592
12593 let outcome = session.append_realtime_transcript_event(
12594 RealtimeTranscriptEvent::AssistantTurnCompleted {
12595 response_id: "resp_b".to_string(),
12596 stop_reason: StopReason::EndTurn,
12597 usage: Usage::default(),
12598 },
12599 );
12600 assert_eq!(
12601 outcome.materialized_messages.len(),
12602 1,
12603 "resp_b commits on its TurnCompleted, untouched by resp_a's Interrupted"
12604 );
12605
12606 assert_eq!(session.messages().len(), 3);
12608 assert!(matches!(
12609 &session.messages()[1],
12610 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "interrupted display"
12611 ));
12612 assert!(matches!(
12613 &session.messages()[2],
12614 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "keep me"
12615 ));
12616 }
12617
12618 #[test]
12621 fn test_fork_shares_arc_no_clone() {
12622 let mut session = Session::new();
12623 for i in 0..100 {
12624 session.push(Message::User(UserMessage::text(format!("Message {i}"))));
12625 }
12626
12627 let forked = session.fork();
12629
12630 assert!(Arc::ptr_eq(session.messages.arc(), forked.messages.arc()));
12632 assert_eq!(forked.messages().len(), 100);
12633 }
12634
12635 #[test]
12636 fn test_fork_at_shares_arc_prefix() {
12637 let mut session = Session::new();
12638 for i in 0..100 {
12639 session.push(Message::User(UserMessage::text(format!("Message {i}"))));
12640 }
12641
12642 let forked = session.fork_at(50);
12644 assert_eq!(forked.messages().len(), 50);
12645
12646 assert_eq!(session.messages().len(), 100);
12648 }
12649
12650 #[test]
12651 fn test_fork_at_resets_transcript_history_state_for_branch_identity() {
12652 let mut session = Session::new();
12653 session.push(Message::User(UserMessage::text(
12654 "summarize this".to_string(),
12655 )));
12656 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
12657 vec![AssistantBlock::Text {
12658 text: "long assistant trace".to_string(),
12659 meta: None,
12660 }],
12661 StopReason::EndTurn,
12662 )));
12663 let parent_revision = session.transcript_revision().expect("parent revision");
12664 session
12665 .commit_transcript_rewrite(
12666 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
12667 vec![Message::BlockAssistant(BlockAssistantMessage::new(
12668 vec![AssistantBlock::Text {
12669 text: "compact trace".to_string(),
12670 meta: None,
12671 }],
12672 StopReason::EndTurn,
12673 ))],
12674 TranscriptRewriteReason::new("compaction"),
12675 Some("test".to_string()),
12676 Some(parent_revision),
12677 )
12678 .expect("rewrite should commit");
12679
12680 let source_head = session.transcript_revision().expect("source head");
12681 let mut forked = session.fork_at(1);
12682 assert_ne!(forked.id(), session.id());
12683 assert!(
12684 !forked
12685 .metadata()
12686 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
12687 );
12688 assert_eq!(
12689 forked.transcript_revision().expect("fork head"),
12690 transcript_messages_digest(forked.messages()).expect("fork digest")
12691 );
12692 assert!(
12693 forked
12694 .transcript_revision_messages(&source_head)
12695 .expect("fork history lookup")
12696 .is_none()
12697 );
12698
12699 let fork_parent = forked.transcript_revision().expect("fork parent");
12700 let commit = forked
12701 .commit_transcript_rewrite(
12702 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
12703 vec![Message::User(UserMessage::text(
12704 "branch prompt".to_string(),
12705 ))],
12706 TranscriptRewriteReason::new("branch_edit"),
12707 Some("test".to_string()),
12708 Some(fork_parent.clone()),
12709 )
12710 .expect("fork rewrite should use fork-local parent");
12711 assert_eq!(commit.parent_revision, fork_parent);
12712 }
12713
12714 #[test]
12715 fn test_push_cow_behavior() {
12716 let mut session = Session::new();
12717 session.push(Message::User(UserMessage::text("First".to_string())));
12718
12719 let forked = session.fork();
12721 assert!(Arc::ptr_eq(session.messages.arc(), forked.messages.arc()));
12722
12723 session.push(Message::User(UserMessage::text("Second".to_string())));
12725
12726 assert!(!Arc::ptr_eq(session.messages.arc(), forked.messages.arc()));
12728 assert_eq!(session.messages().len(), 2);
12729 assert_eq!(forked.messages().len(), 1);
12730 }
12731
12732 #[test]
12735 fn test_push_batch_single_timestamp() {
12736 let mut session = Session::new();
12737 let initial_updated = session.updated_at();
12738
12739 session.push_batch(vec![
12741 Message::User(UserMessage::text("First".to_string())),
12742 Message::User(UserMessage::text("Second".to_string())),
12743 Message::User(UserMessage::text("Third".to_string())),
12744 ]);
12745
12746 assert_eq!(session.messages().len(), 3);
12747 assert!(session.updated_at() >= initial_updated);
12749 }
12750
12751 #[test]
12752 fn test_touch_updates_timestamp() {
12753 let mut session = Session::new();
12754 let initial = session.updated_at();
12755
12756 std::thread::sleep(std::time::Duration::from_millis(10));
12757
12758 session.touch();
12760
12761 assert!(session.updated_at() > initial);
12762 }
12763
12764 #[test]
12765 fn test_session_push() {
12766 let mut session = Session::new();
12767 let initial_updated = session.updated_at();
12768
12769 std::thread::sleep(std::time::Duration::from_millis(10));
12771
12772 session.push(Message::User(UserMessage::text("Hello".to_string())));
12773
12774 assert_eq!(session.messages().len(), 1);
12775 assert!(session.updated_at() > initial_updated);
12776 }
12777
12778 #[test]
12779 fn test_session_fork() {
12780 let mut session = Session::new();
12781 session.push(Message::System(SystemMessage::new("System prompt")));
12782 session.push(Message::User(UserMessage::text("Hello".to_string())));
12783 session.push(Message::BlockAssistant(BlockAssistantMessage {
12784 blocks: vec![AssistantBlock::Text {
12785 text: "Hi!".to_string(),
12786 meta: None,
12787 }],
12788 stop_reason: StopReason::EndTurn,
12789 identity: crate::types::TranscriptMessageIdentity::default(),
12790 created_at: crate::types::message_timestamp_now(),
12791 }));
12792
12793 let forked = session.fork_at(2);
12795 assert_eq!(forked.messages().len(), 2);
12796 assert_ne!(forked.id(), session.id());
12797
12798 let full_fork = session.fork();
12800 assert_eq!(full_fork.messages().len(), 3);
12801 }
12802
12803 #[test]
12804 fn test_session_forks_drop_generated_authority_metadata() {
12805 let mut session = Session::new();
12806 session.push(Message::User(UserMessage::text("original")));
12807 session.set_metadata("ordinary", serde_json::json!("keep"));
12808 session
12809 .set_build_state(SessionBuildState::default())
12810 .expect("build state should serialize");
12811 session
12812 .set_system_context_state(SessionSystemContextState::default())
12813 .expect("system-context state should serialize");
12814 session
12815 .set_deferred_turn_state(SessionDeferredTurnState::default())
12816 .expect("deferred-turn state should serialize");
12817 session
12818 .set_tool_visibility_state(
12819 AuthorizedSessionToolVisibilityState::from_generated_authority(
12820 SessionToolVisibilityState::default(),
12821 ),
12822 )
12823 .expect("visibility state should serialize");
12824 let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
12825 item_id: "rt-item".to_string(),
12826 previous_item_id: None,
12827 role: RealtimeTranscriptRole::User,
12828 response_id: None,
12829 });
12830 session.metadata.insert(
12831 crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY.to_string(),
12832 serde_json::json!([{"sealed_projection": "must-not-fork"}]),
12833 );
12834 assert!(
12835 session
12836 .metadata()
12837 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
12838 "test setup should install realtime transcript authority state"
12839 );
12840
12841 let forked_at = session.fork_at(1);
12842 let full_fork = session.fork();
12843 let replaced = session
12844 .fork_replacing(
12845 0,
12846 TranscriptReplacement::Message {
12847 message: Message::User(UserMessage::text("replacement")),
12848 },
12849 )
12850 .expect("replacement fork should succeed");
12851
12852 for forked in [&forked_at, &full_fork, &replaced] {
12853 assert_eq!(forked.metadata().get("ordinary").unwrap(), "keep");
12854 assert!(
12855 !forked.metadata().contains_key(SESSION_BUILD_STATE_KEY),
12856 "forked sessions must not raw-copy durable build-state authority"
12857 );
12858 assert!(
12859 !forked
12860 .metadata()
12861 .contains_key(SESSION_SYSTEM_CONTEXT_STATE_KEY),
12862 "forked sessions must not raw-copy system-context authority state"
12863 );
12864 assert!(
12865 !forked
12866 .metadata()
12867 .contains_key(SESSION_DEFERRED_TURN_STATE_KEY),
12868 "forked sessions must not raw-copy deferred-turn authority state"
12869 );
12870 assert!(
12871 !forked
12872 .metadata()
12873 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY),
12874 "forked sessions must not raw-copy tool-visibility authority state"
12875 );
12876 assert!(
12877 !forked
12878 .metadata()
12879 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
12880 "forked sessions must not raw-copy realtime transcript authority state"
12881 );
12882 assert!(
12883 !forked
12884 .metadata()
12885 .contains_key(crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY),
12886 "forked sessions must not raw-copy compaction outbox authority"
12887 );
12888 }
12889 }
12890
12891 #[test]
12892 fn test_session_metadata() {
12893 let mut session = Session::new();
12894 session.set_metadata("key", serde_json::json!("value"));
12895
12896 assert_eq!(session.metadata().get("key").unwrap(), "value");
12897 }
12898
12899 #[test]
12900 fn identical_metadata_projection_is_checkpoint_idempotent() {
12901 let mut session = Session::new();
12902 session.set_metadata("key", serde_json::json!({ "value": 1 }));
12903 let updated_at = session.updated_at;
12904 let digest = crate::session_checkpoint_digest(&session)
12905 .expect("checkpoint digest before identical projection");
12906
12907 session.set_metadata("key", serde_json::json!({ "value": 1 }));
12908 session.remove_metadata("already_absent");
12909
12910 assert_eq!(
12911 session.updated_at, updated_at,
12912 "an identical durable projection must not manufacture a content mutation"
12913 );
12914 assert_eq!(
12915 crate::session_checkpoint_digest(&session)
12916 .expect("checkpoint digest after identical projection"),
12917 digest,
12918 "an identical durable projection must not rotate checkpoint authority"
12919 );
12920 }
12921
12922 #[test]
12923 fn session_metadata_realm_id_is_back_read_compatible_string() {
12924 let metadata = SessionMetadata {
12927 schema_version: SESSION_METADATA_SCHEMA_VERSION,
12928 model: "test-model".to_string(),
12929 max_tokens: 1024,
12930 structured_output_retries: 2,
12931 provider: Provider::Other,
12932 self_hosted_server_id: None,
12933 provider_params: None,
12934 tooling: SessionTooling::default(),
12935 keep_alive: false,
12936 comms_name: None,
12937 peer_meta: None,
12938 realm_id: Some(crate::RealmId::parse("env_default").unwrap()),
12939 instance_id: None,
12940 backend: None,
12941 config_generation: None,
12942 auth_binding: None,
12943 mob_member_binding: None,
12944 };
12945 let value = serde_json::to_value(&metadata).unwrap();
12946 assert_eq!(
12947 value.get("realm_id"),
12948 Some(&serde_json::json!("env_default")),
12949 "typed realm_id must serialize as a bare slug string"
12950 );
12951
12952 let legacy = serde_json::json!({
12955 "schema_version": SESSION_METADATA_SCHEMA_VERSION,
12956 "model": "test-model",
12957 "max_tokens": 1024,
12958 "structured_output_retries": 2,
12959 "provider": "other",
12960 "tooling": SessionTooling::default(),
12961 "keep_alive": false,
12962 "comms_name": null,
12963 "realm_id": "legacy_realm",
12964 });
12965 let restored: SessionMetadata = serde_json::from_value(legacy).unwrap();
12966 assert_eq!(
12967 restored.realm_id.as_ref().map(crate::RealmId::as_str),
12968 Some("legacy_realm")
12969 );
12970 }
12971
12972 #[test]
12977 fn session_tooling_tool_access_policy_round_trip_and_absent_default() {
12978 let legacy = serde_json::json!({});
12980 let restored: SessionTooling = serde_json::from_value(legacy).unwrap();
12981 assert_eq!(restored.tool_access_policy, None);
12982
12983 let value = serde_json::to_value(SessionTooling::default()).unwrap();
12985 assert!(
12986 value.get("tool_access_policy").is_none(),
12987 "None policy must not serialize"
12988 );
12989
12990 let tooling = SessionTooling {
12992 tool_access_policy: Some(crate::ops::ToolAccessPolicy::AllowList(
12993 ["read_file", "send_message"].into_iter().collect(),
12994 )),
12995 ..SessionTooling::default()
12996 };
12997 let value = serde_json::to_value(&tooling).unwrap();
12998 let restored: SessionTooling = serde_json::from_value(value).unwrap();
12999 assert_eq!(restored.tool_access_policy, tooling.tool_access_policy);
13000 }
13001
13002 #[test]
13003 fn lifecycle_terminal_typed_round_trip() {
13004 let mut session = Session::new();
13005 assert_eq!(session.lifecycle_terminal(), None);
13006
13007 session
13008 .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
13009 .expect("typed terminal write should serialize");
13010 assert_eq!(
13011 session.lifecycle_terminal(),
13012 Some(SessionLifecycleTerminal::Archived)
13013 );
13014 assert!(
13015 session
13016 .lifecycle_terminal()
13017 .is_some_and(SessionLifecycleTerminal::is_archived)
13018 );
13019 assert_eq!(
13021 session
13022 .metadata()
13023 .get(SESSION_LIFECYCLE_TERMINAL_KEY)
13024 .unwrap(),
13025 &serde_json::json!("archived")
13026 );
13027 }
13028
13029 #[test]
13030 fn lifecycle_terminal_key_rejects_raw_mutation() {
13031 let mut session = Session::new();
13032 assert!(
13033 session
13034 .try_set_metadata(
13035 SESSION_LIFECYCLE_TERMINAL_KEY,
13036 serde_json::json!("archived")
13037 )
13038 .is_err(),
13039 "the typed lifecycle-terminal key is reserved for session authority"
13040 );
13041 }
13042
13043 #[test]
13044 fn test_session_metadata_backfill_preserves_timestamp() {
13045 let mut session = Session::new();
13046 let initial_updated = session.updated_at();
13047
13048 std::thread::sleep(std::time::Duration::from_millis(10));
13049
13050 assert!(session.backfill_metadata_if_absent("key", serde_json::json!("value")));
13051 assert_eq!(session.metadata().get("key").unwrap(), "value");
13052 assert_eq!(session.updated_at(), initial_updated);
13053 assert!(!session.backfill_metadata_if_absent("key", serde_json::json!("other")));
13054 assert_eq!(session.metadata().get("key").unwrap(), "value");
13055 assert_eq!(session.updated_at(), initial_updated);
13056 }
13057
13058 #[test]
13059 fn test_reserved_generated_authority_metadata_rejects_raw_mutation() {
13060 let mut session = Session::new();
13061
13062 assert!(
13063 session
13064 .try_set_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY, serde_json::json!({}))
13065 .is_err()
13066 );
13067 assert!(
13068 session
13069 .try_set_metadata(SESSION_METADATA_KEY, serde_json::json!({}))
13070 .is_err()
13071 );
13072 assert!(
13073 session
13074 .try_set_metadata(SESSION_BUILD_STATE_KEY, serde_json::json!({}))
13075 .is_err()
13076 );
13077 let compaction_intents_key = crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY;
13078 let sealed_compaction_intents =
13079 serde_json::json!([{"sealed_projection": "typed-owner-only"}]);
13080 session.metadata.insert(
13081 compaction_intents_key.to_string(),
13082 sealed_compaction_intents.clone(),
13083 );
13084 assert!(
13085 session
13086 .try_set_metadata(compaction_intents_key, serde_json::json!([]))
13087 .is_err(),
13088 "raw metadata must not overwrite compaction outbox authority"
13089 );
13090 session.remove_metadata(compaction_intents_key);
13091 assert_eq!(
13092 session.metadata().get(compaction_intents_key),
13093 Some(&sealed_compaction_intents),
13094 "raw metadata removal must not erase compaction outbox authority"
13095 );
13096 let mut absent = Session::new();
13097 assert!(
13098 !absent.backfill_metadata_if_absent(
13099 compaction_intents_key,
13100 serde_json::json!([{"forged_projection": true}])
13101 ),
13102 "compatibility backfill must not fabricate compaction outbox authority"
13103 );
13104 assert!(!absent.metadata().contains_key(compaction_intents_key));
13105 session
13106 .set_session_metadata(SessionMetadata {
13107 schema_version: SESSION_METADATA_SCHEMA_VERSION,
13108 model: "test-model".to_string(),
13109 max_tokens: 1024,
13110 structured_output_retries: 2,
13111 provider: Provider::Other,
13112 self_hosted_server_id: None,
13113 provider_params: None,
13114 tooling: SessionTooling::default(),
13115 keep_alive: false,
13116 comms_name: None,
13117 peer_meta: None,
13118 realm_id: None,
13119 instance_id: None,
13120 backend: None,
13121 config_generation: None,
13122 auth_binding: None,
13123 mob_member_binding: None,
13124 })
13125 .expect("typed metadata setter should route through generated authority");
13126 session
13127 .set_build_state(SessionBuildState::default())
13128 .expect("typed build-state setter should route through generated authority");
13129 session.remove_metadata(SESSION_METADATA_KEY);
13130 session.remove_metadata(SESSION_BUILD_STATE_KEY);
13131 assert!(
13132 session.metadata().contains_key(SESSION_METADATA_KEY),
13133 "raw removal must not delete generated-authority session metadata"
13134 );
13135 assert!(
13136 session.metadata().contains_key(SESSION_BUILD_STATE_KEY),
13137 "raw removal must not delete generated-authority build state"
13138 );
13139 session.set_metadata(SESSION_DEFERRED_TURN_STATE_KEY, serde_json::json!({}));
13140 assert!(
13141 !session
13142 .metadata()
13143 .contains_key(SESSION_DEFERRED_TURN_STATE_KEY)
13144 );
13145 assert!(
13146 !session.backfill_metadata_if_absent(
13147 SESSION_SYSTEM_CONTEXT_STATE_KEY,
13148 serde_json::json!({})
13149 )
13150 );
13151
13152 let state = SessionSystemContextState::default();
13153 session
13154 .set_system_context_state(state.clone())
13155 .expect("typed setter should route through generated authority");
13156 session.remove_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY);
13157 assert_eq!(
13158 session
13159 .try_system_context_state()
13160 .expect("typed state should restore"),
13161 Some(state)
13162 );
13163
13164 session.metadata.insert(
13165 SESSION_SYSTEM_CONTEXT_STATE_KEY.to_string(),
13166 serde_json::json!("not-a-state"),
13167 );
13168 assert!(
13169 session.try_system_context_state().is_err(),
13170 "malformed generated authority state must not decode as absent/default"
13171 );
13172
13173 session.metadata.insert(
13174 SESSION_METADATA_KEY.to_string(),
13175 serde_json::json!("not-metadata"),
13176 );
13177 assert!(
13178 session.try_session_metadata().is_err(),
13179 "malformed session metadata must not decode as absent/default"
13180 );
13181
13182 session.metadata.insert(
13183 SESSION_BUILD_STATE_KEY.to_string(),
13184 serde_json::json!("not-build-state"),
13185 );
13186 assert!(
13187 session.try_build_state().is_err(),
13188 "malformed build state must not decode as absent/default"
13189 );
13190
13191 assert!(
13192 session
13193 .try_set_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY, serde_json::json!({}))
13194 .is_err()
13195 );
13196 session
13197 .set_tool_visibility_state(
13198 AuthorizedSessionToolVisibilityState::from_generated_authority(
13199 SessionToolVisibilityState::default(),
13200 ),
13201 )
13202 .expect("typed visibility setter should route through typed authority handoff");
13203 session.remove_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY);
13204 assert!(
13205 session
13206 .metadata()
13207 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
13208 );
13209 session.clear_tool_visibility_state();
13210 assert!(
13211 !session
13212 .metadata()
13213 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
13214 );
13215 assert!(
13216 session
13217 .try_set_metadata(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, serde_json::json!({}))
13218 .is_err()
13219 );
13220 let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
13221 item_id: "rt-item".to_string(),
13222 previous_item_id: None,
13223 role: RealtimeTranscriptRole::User,
13224 response_id: None,
13225 });
13226 assert!(
13227 session
13228 .metadata()
13229 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
13230 "typed realtime transcript append should retain authority to persist its state"
13231 );
13232 session.metadata.insert(
13233 SESSION_REALTIME_TRANSCRIPT_STATE_KEY.to_string(),
13234 serde_json::json!("not-a-state"),
13235 );
13236 assert!(
13237 session.try_realtime_transcript_state().is_err(),
13238 "malformed realtime generated authority state must not decode as absent/default"
13239 );
13240 }
13241
13242 #[test]
13243 fn test_session_mob_tool_authority_context_persists_projection_without_authority_seal() {
13244 let mut session = Session::new();
13245 session
13246 .set_build_state(SessionBuildState::default())
13247 .expect("session build state should serialize");
13248 let authority = MobToolAuthorityContext::generated_for_test(
13249 crate::service::OpaquePrincipalToken::new("opaque-principal"),
13250 false,
13251 false,
13252 false,
13253 std::collections::BTreeSet::from(["mob-a".to_string()]),
13254 std::collections::BTreeMap::new(),
13255 None,
13256 Some("audit-1".to_string()),
13257 );
13258
13259 session
13260 .set_mob_tool_authority_context(Some(authority))
13261 .expect("authority should serialize");
13262 assert!(session.mob_tool_authority_context().is_none());
13263 let stored = session
13264 .build_state()
13265 .and_then(|state| state.mob_tool_authority_context)
13266 .expect("stored projection should deserialize");
13267 assert!(!stored.is_generated_authority_context());
13268 assert!(!stored.can_manage_mob("mob-a"));
13269
13270 session
13271 .set_mob_tool_authority_context(None)
13272 .expect("authority should clear");
13273 assert!(session.mob_tool_authority_context().is_none());
13274 }
13275
13276 #[test]
13277 fn test_session_build_state_rejects_forged_mob_authority_projection() {
13278 let mut session = Session::new();
13279 let authority = MobToolAuthorityContext::generated_for_test(
13280 crate::service::OpaquePrincipalToken::new("opaque-principal"),
13281 false,
13282 false,
13283 false,
13284 std::collections::BTreeSet::from(["mob-a".to_string()]),
13285 std::collections::BTreeMap::new(),
13286 None,
13287 Some("audit-1".to_string()),
13288 );
13289 let forged_projection: MobToolAuthorityContext =
13290 serde_json::from_value(serde_json::to_value(authority).expect("serialize authority"))
13291 .expect("deserialize projection");
13292 assert!(!forged_projection.is_generated_authority_context());
13293
13294 let err = session
13295 .set_build_state(SessionBuildState {
13296 mob_tool_authority_context: Some(forged_projection),
13297 ..Default::default()
13298 })
13299 .expect_err("forged build state must be rejected by generated authority");
13300 assert!(
13304 err.to_string()
13305 .contains("generated session document authority rejected"),
13306 "unexpected error: {err}"
13307 );
13308 }
13309
13310 #[test]
13311 fn test_session_tool_visibility_state_roundtrip() {
13312 let mut session = Session::new();
13313 let state = SessionToolVisibilityState {
13314 inherited_base_filter: ToolFilter::Allow(["visible".to_string()].into_iter().collect()),
13315 active_filter: ToolFilter::Allow(
13316 ["visible".to_string(), "missing".to_string()]
13317 .into_iter()
13318 .collect(),
13319 ),
13320 staged_filter: ToolFilter::Allow(
13321 ["visible".to_string(), "missing".to_string()]
13322 .into_iter()
13323 .collect(),
13324 ),
13325 active_revision: 1,
13326 staged_revision: 2,
13327 ..Default::default()
13328 };
13329
13330 session
13331 .set_tool_visibility_state(
13332 AuthorizedSessionToolVisibilityState::from_generated_authority(state.clone()),
13333 )
13334 .expect("tool visibility state should serialize");
13335 assert_eq!(session.tool_visibility_state().unwrap(), Some(state));
13336 }
13337
13338 #[test]
13339 fn test_session_tool_visibility_state_malformed_returns_error() {
13340 let mut session = Session::new();
13341 session.metadata.insert(
13342 SESSION_TOOL_VISIBILITY_STATE_KEY.to_string(),
13343 serde_json::json!({
13344 "active_filter": {
13345 "unexpected_filter_kind": ["secret"]
13346 }
13347 }),
13348 );
13349
13350 assert!(
13351 session.tool_visibility_state().is_err(),
13352 "malformed canonical visibility metadata must not decode as absent/default"
13353 );
13354 }
13355
13356 #[test]
13357 fn test_session_serialization() {
13358 let mut session = Session::new();
13359 session.push(Message::User(UserMessage::text("Test".to_string())));
13360
13361 let json = serde_json::to_string(&session).unwrap();
13362 let parsed: Session = serde_json::from_str(&json).unwrap();
13363
13364 assert_eq!(parsed.id(), session.id());
13365 assert_eq!(parsed.messages().len(), 1);
13366 assert_eq!(parsed.version(), SESSION_VERSION);
13367 }
13368
13369 #[test]
13370 fn test_session_meta_from_session() {
13371 let mut session = Session::new();
13372 session.push(Message::User(UserMessage::text("Hello".to_string())));
13373 session.push(Message::BlockAssistant(BlockAssistantMessage {
13374 blocks: vec![AssistantBlock::Text {
13375 text: "Hi!".to_string(),
13376 meta: None,
13377 }],
13378 stop_reason: StopReason::EndTurn,
13379 identity: crate::types::TranscriptMessageIdentity::default(),
13380 created_at: crate::types::message_timestamp_now(),
13381 }));
13382 session.record_usage(Usage {
13383 input_tokens: 10,
13384 output_tokens: 5,
13385 cache_creation_tokens: None,
13386 cache_read_tokens: None,
13387 });
13388
13389 let meta = SessionMeta::from(&session);
13390 assert_eq!(meta.id, *session.id());
13391 assert_eq!(meta.message_count, 2);
13392 assert_eq!(meta.total_tokens, 15);
13393 }
13394
13395 #[test]
13396 fn deferred_tool_result_redelivery_is_idempotent_per_exact_payload() {
13397 let mut state = SessionDeferredTurnState::default();
13398 let results = vec![
13399 ToolResult::new("callback-a".to_string(), "a".to_string(), false),
13400 ToolResult::new("callback-b".to_string(), "b".to_string(), false),
13401 ];
13402 assert_eq!(
13403 state.stage_tool_results(results.clone(), SystemTime::UNIX_EPOCH),
13404 2
13405 );
13406 let before = serde_json::to_value(&state).expect("serialize staged state");
13407
13408 assert_eq!(
13409 state.stage_tool_results(
13410 results,
13411 SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
13412 ),
13413 0,
13414 "identical redelivery must coalesce without restaging"
13415 );
13416 assert_eq!(
13417 serde_json::to_value(&state).expect("serialize redelivered state"),
13418 before,
13419 "duplicate ingress must be a byte-identical no-op"
13420 );
13421 }
13422
13423 #[test]
13424 fn deferred_tool_result_conflict_and_wrong_id_fail_without_poison_after_replay() {
13425 let mut state = SessionDeferredTurnState::default();
13426 state
13427 .try_stage_tool_results(
13428 vec![ToolResult::new(
13429 "callback-a".to_string(),
13430 "approved".to_string(),
13431 false,
13432 )],
13433 SystemTime::UNIX_EPOCH,
13434 )
13435 .expect("first callback payload should stage");
13436 let mut replayed: SessionDeferredTurnState = serde_json::from_value(
13437 serde_json::to_value(&state).expect("serialize deferred callback state"),
13438 )
13439 .expect("restore deferred callback state");
13440 let before = serde_json::to_value(&replayed).expect("serialize replayed state");
13441
13442 assert!(matches!(
13443 replayed.try_stage_tool_results(
13444 vec![ToolResult::new(
13445 "callback-a".to_string(),
13446 "denied".to_string(),
13447 false,
13448 )],
13449 SystemTime::UNIX_EPOCH,
13450 ),
13451 Err(DeferredToolResultsIngressError::ConflictingRedelivery(id))
13452 if id == "callback-a"
13453 ));
13454 assert_eq!(serde_json::to_value(&replayed).unwrap(), before);
13455
13456 assert!(matches!(
13457 replayed.try_stage_tool_results(
13458 vec![ToolResult::new(
13459 "callback-b".to_string(),
13460 "wrong".to_string(),
13461 false,
13462 )],
13463 SystemTime::UNIX_EPOCH,
13464 ),
13465 Err(DeferredToolResultsIngressError::WrongToolUseId(id))
13466 if id == "callback-b"
13467 ));
13468 assert_eq!(
13469 serde_json::to_value(&replayed).unwrap(),
13470 before,
13471 "typed ingress refusals must leave the valid pending continuation intact"
13472 );
13473 }
13474
13475 #[test]
13476 fn system_context_state_preserves_applied_runtime_context() {
13477 let accepted_at = SystemTime::UNIX_EPOCH;
13478 let mut state = SessionSystemContextState::default();
13479 state
13480 .stage_append(
13481 &AppendSystemContextRequest {
13482 content: crate::lifecycle::run_primitive::CoreRenderable::text(
13483 "Authoritative peer token is birch seventeen.".to_string(),
13484 ),
13485 source: Some(
13486 "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1"
13487 .to_string(),
13488 ),
13489 idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
13490 source_kind: SystemContextSource::Normal,
13491 peer_response_terminal: None,
13492 },
13493 accepted_at,
13494 )
13495 .expect("append should stage");
13496
13497 state.mark_pending_applied();
13498
13499 assert!(state.pending.is_empty());
13500 assert_eq!(state.applied.len(), 1);
13501 assert_eq!(
13502 state.applied[0].content.render_text(),
13503 "Authoritative peer token is birch seventeen."
13504 );
13505 assert_eq!(
13506 state.applied[0].source.as_deref(),
13507 Some("peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1")
13508 );
13509
13510 let round_tripped: SessionSystemContextState =
13511 serde_json::from_value(serde_json::to_value(&state).expect("serialize state"))
13512 .expect("deserialize state");
13513 assert_eq!(round_tripped.applied, state.applied);
13514 }
13515
13516 #[test]
13517 fn active_turn_system_context_is_discarded_when_not_applied() {
13518 let mut state = SessionSystemContextState::default();
13519 state
13520 .stage_active_turn_append(
13521 &AppendSystemContextRequest {
13522 content: crate::lifecycle::run_primitive::CoreRenderable::text(
13523 "only for the active run".to_string(),
13524 ),
13525 source: Some("runtime:steer:input-1".to_string()),
13526 idempotency_key: Some("runtime:steer:input-1".to_string()),
13527 source_kind: SystemContextSource::RuntimeSteer,
13528 peer_response_terminal: None,
13529 },
13530 SystemTime::UNIX_EPOCH,
13531 )
13532 .expect("active context should stage");
13533
13534 let discarded = state.discard_unapplied_active_turn_pending();
13535
13536 assert_eq!(discarded.len(), 1);
13537 assert!(state.pending.is_empty());
13538 assert!(state.applied.is_empty());
13539 assert!(state.active_turn_pending_keys.is_empty());
13540 assert!(state.active_turn_pending_indices.is_empty());
13541 assert!(
13542 state.seen.is_empty(),
13543 "discarded active-turn context should not block later idempotency keys"
13544 );
13545 }
13546
13547 #[test]
13548 fn keyless_active_turn_system_context_is_owned_and_discarded() {
13549 let mut state = SessionSystemContextState::default();
13550 state
13551 .stage_active_turn_append(
13552 &AppendSystemContextRequest {
13553 content: crate::lifecycle::run_primitive::CoreRenderable::text(
13554 "keyless active-turn context".to_string(),
13555 ),
13556 source: Some("test:keyless-active-turn".to_string()),
13557 idempotency_key: None,
13558 source_kind: SystemContextSource::RuntimeSteer,
13559 peer_response_terminal: None,
13560 },
13561 SystemTime::UNIX_EPOCH,
13562 )
13563 .expect("keyless active context should stage");
13564
13565 assert!(state.active_turn_pending_keys.is_empty());
13566 assert_eq!(state.active_turn_pending_len(), 1);
13567 let discarded = state.discard_unapplied_active_turn_pending();
13568
13569 assert_eq!(discarded.len(), 1);
13570 assert!(state.pending.is_empty());
13571 assert_eq!(state.active_turn_pending_len(), 0);
13572 }
13573
13574 #[test]
13575 fn active_turn_system_context_can_roll_back_targeted_keys() {
13576 let mut state = SessionSystemContextState::default();
13577 for key in ["runtime:steer:input-1", "runtime:steer:input-2"] {
13578 state
13579 .stage_active_turn_append(
13580 &AppendSystemContextRequest {
13581 content: crate::lifecycle::run_primitive::CoreRenderable::text(format!(
13582 "context for {key}"
13583 )),
13584 source: Some(key.to_string()),
13585 idempotency_key: Some(key.to_string()),
13586 source_kind: SystemContextSource::RuntimeSteer,
13587 peer_response_terminal: None,
13588 },
13589 SystemTime::UNIX_EPOCH,
13590 )
13591 .expect("active context should stage");
13592 }
13593
13594 let discarded =
13595 state.discard_active_turn_pending_by_keys(&["runtime:steer:input-1".to_string()]);
13596
13597 assert_eq!(discarded.len(), 1);
13598 assert_eq!(
13599 discarded[0].idempotency_key.as_deref(),
13600 Some("runtime:steer:input-1")
13601 );
13602 assert_eq!(state.pending.len(), 1);
13603 assert_eq!(
13604 state.pending[0].idempotency_key.as_deref(),
13605 Some("runtime:steer:input-2")
13606 );
13607 assert!(!state.seen.contains_key("runtime:steer:input-1"));
13608 assert!(state.seen.contains_key("runtime:steer:input-2"));
13609 assert!(
13610 !state
13611 .active_turn_pending_keys
13612 .contains("runtime:steer:input-1")
13613 );
13614 assert!(
13615 state
13616 .active_turn_pending_keys
13617 .contains("runtime:steer:input-2")
13618 );
13619 }
13620
13621 #[test]
13622 fn active_turn_system_context_is_transient_when_boundary_consumes_it() {
13623 let mut state = SessionSystemContextState::default();
13624 state
13625 .stage_active_turn_append(
13626 &AppendSystemContextRequest {
13627 content: crate::lifecycle::run_primitive::CoreRenderable::text(
13628 "visible to this run".to_string(),
13629 ),
13630 source: Some("runtime:steer:input-2".to_string()),
13631 idempotency_key: Some("runtime:steer:input-2".to_string()),
13632 source_kind: SystemContextSource::RuntimeSteer,
13633 peer_response_terminal: None,
13634 },
13635 SystemTime::UNIX_EPOCH,
13636 )
13637 .expect("active context should stage");
13638
13639 state.mark_pending_applied();
13640 let discarded = state.discard_unapplied_active_turn_pending();
13641
13642 assert!(discarded.is_empty());
13643 assert!(state.pending.is_empty());
13644 assert!(state.applied.is_empty());
13645 assert!(state.active_turn_pending_keys.is_empty());
13646 assert!(state.active_turn_pending_indices.is_empty());
13647 assert_eq!(
13648 state.seen.get("runtime:steer:input-2"),
13649 None,
13650 "consumed active-turn steer context must not become durable state"
13651 );
13652 }
13653
13654 #[test]
13655 fn discard_transient_runtime_steer_context_removes_steer_via_typed_marker() {
13656 let mut session = Session::new();
13657 session.set_system_prompt(format!(
13661 "base{}{}{}{}",
13662 SYSTEM_CONTEXT_SEPARATOR,
13663 render_system_context_block(&PendingSystemContextAppend {
13664 content: crate::lifecycle::run_primitive::CoreRenderable::text(
13665 "old steer".to_string()
13666 ),
13667 source: Some("steer-source-old".to_string()),
13668 idempotency_key: Some("steer-key-old".to_string()),
13669 source_kind: SystemContextSource::RuntimeSteer,
13670 peer_response_terminal: None,
13671 accepted_at: SystemTime::UNIX_EPOCH,
13672 }),
13673 SYSTEM_CONTEXT_SEPARATOR,
13674 render_system_context_block(&PendingSystemContextAppend {
13675 content: crate::lifecycle::run_primitive::CoreRenderable::text(
13676 "durable peer fact".to_string()
13677 ),
13678 source: Some("peer_response_terminal:analyst:req".to_string()),
13679 idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
13680 source_kind: SystemContextSource::Normal,
13681 peer_response_terminal: None,
13682 accepted_at: SystemTime::UNIX_EPOCH,
13683 })
13684 ));
13685 session
13686 .set_system_context_state(SessionSystemContextState {
13687 pending: vec![PendingSystemContextAppend {
13688 content: crate::lifecycle::run_primitive::CoreRenderable::text(
13689 "pending steer".to_string(),
13690 ),
13691 source: Some("steer-source-pending".to_string()),
13692 idempotency_key: Some("steer-key-pending".to_string()),
13693 source_kind: SystemContextSource::RuntimeSteer,
13694 peer_response_terminal: None,
13695 accepted_at: SystemTime::UNIX_EPOCH,
13696 }],
13697 applied: vec![
13698 PendingSystemContextAppend {
13699 content: crate::lifecycle::run_primitive::CoreRenderable::text(
13700 "old steer".to_string(),
13701 ),
13702 source: Some("steer-source-old".to_string()),
13703 idempotency_key: Some("steer-key-old".to_string()),
13704 source_kind: SystemContextSource::RuntimeSteer,
13705 peer_response_terminal: None,
13706 accepted_at: SystemTime::UNIX_EPOCH,
13707 },
13708 PendingSystemContextAppend {
13709 content: crate::lifecycle::run_primitive::CoreRenderable::text(
13710 "durable peer fact".to_string(),
13711 ),
13712 source: Some("peer_response_terminal:analyst:req".to_string()),
13713 idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
13714 source_kind: SystemContextSource::Normal,
13715 peer_response_terminal: None,
13716 accepted_at: SystemTime::UNIX_EPOCH,
13717 },
13718 ],
13719 seen: BTreeMap::from([(
13720 "steer-key-old".to_string(),
13721 SeenSystemContextKey {
13722 content: crate::lifecycle::run_primitive::CoreRenderable::text(
13723 "old steer".to_string(),
13724 ),
13725 source: Some("steer-source-old".to_string()),
13726 source_kind: SystemContextSource::RuntimeSteer,
13727 state: SeenSystemContextState::Applied,
13728 },
13729 )]),
13730 active_turn_pending_keys: BTreeSet::from(["steer-key-pending".to_string()]),
13731 active_turn_pending_indices: BTreeSet::from([0]),
13732 })
13733 .expect("system context state should serialize");
13734
13735 let removed = session.discard_transient_runtime_steer_context();
13736
13737 assert!(removed >= 4);
13738 let system_prompt = match session.messages().first() {
13739 Some(Message::System(system)) => system.content.as_str(),
13740 other => panic!("expected system prompt, got {other:?}"),
13741 };
13742 assert!(!system_prompt.contains("old steer"));
13743 assert!(system_prompt.contains("durable peer fact"));
13744 let state = session.system_context_state().unwrap_or_default();
13745 assert!(state.pending.is_empty());
13746 assert_eq!(state.applied.len(), 1);
13747 assert_eq!(state.applied[0].content.render_text(), "durable peer fact");
13748 assert!(state.seen.is_empty());
13749 assert!(state.active_turn_pending_keys.is_empty());
13750 }
13751
13752 #[test]
13753 fn append_system_context_blocks_records_typed_applied_context() {
13754 let append = PendingSystemContextAppend {
13755 content: crate::lifecycle::run_primitive::CoreRenderable::text(
13756 "Authoritative peer token is birch seventeen.".to_string(),
13757 ),
13758 source: Some(
13759 "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string(),
13760 ),
13761 idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
13762 source_kind: SystemContextSource::Normal,
13763 peer_response_terminal: None,
13764 accepted_at: SystemTime::UNIX_EPOCH,
13765 };
13766 let mut session = Session::new();
13767
13768 session.append_system_context_blocks(std::slice::from_ref(&append));
13769
13770 let state = session
13771 .system_context_state()
13772 .expect("append should persist typed context state");
13773 assert_eq!(state.applied, vec![append]);
13774 }
13775
13776 fn roster_append() -> PendingSystemContextAppend {
13777 PendingSystemContextAppend {
13778 content: crate::lifecycle::run_primitive::CoreRenderable::text(
13779 "peer roster: lead-1, w-1".to_string(),
13780 ),
13781 source: Some("comms:roster".to_string()),
13782 idempotency_key: Some("comms:roster:v1".to_string()),
13783 source_kind: SystemContextSource::Normal,
13784 peer_response_terminal: None,
13785 accepted_at: SystemTime::UNIX_EPOCH,
13786 }
13787 }
13788
13789 fn resumed_session_with_context_appended_prompt(base: &str) -> Session {
13790 let mut session = Session::new();
13791 session.set_system_prompt(base.to_string());
13792 session.push(Message::User(UserMessage::text("hello".to_string())));
13793 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
13794 session
13795 }
13796
13797 #[test]
13798 fn reconcile_resumed_system_prompt_preserves_identical_base() {
13799 let mut session = Session::new();
13800 session.set_system_prompt("base prompt".to_string());
13801 session.push(Message::User(UserMessage::text("hello".to_string())));
13802 let digest_before = transcript_messages_digest(session.messages()).unwrap();
13803
13804 let outcome = session
13805 .reconcile_resumed_system_prompt("base prompt".to_string(), None)
13806 .expect("reconcile");
13807
13808 assert_eq!(
13809 outcome,
13810 ResumedSystemPromptReconciliation::PreservedContinuation
13811 );
13812 assert_eq!(
13813 transcript_messages_digest(session.messages()).unwrap(),
13814 digest_before,
13815 "identical base must leave the transcript revision unchanged"
13816 );
13817 }
13818
13819 #[test]
13820 fn reconcile_resumed_system_prompt_preserves_context_appended_base() {
13821 let mut session = resumed_session_with_context_appended_prompt("base prompt");
13822 let digest_before = transcript_messages_digest(session.messages()).unwrap();
13823
13824 let outcome = session
13825 .reconcile_resumed_system_prompt("base prompt".to_string(), None)
13826 .expect("reconcile");
13827
13828 assert_eq!(
13829 outcome,
13830 ResumedSystemPromptReconciliation::PreservedContinuation
13831 );
13832 assert_eq!(
13833 transcript_messages_digest(session.messages()).unwrap(),
13834 digest_before,
13835 "a base extended only by runtime context appends must stay untouched"
13836 );
13837 let system = match session.messages().first() {
13838 Some(Message::System(system)) => system.clone(),
13839 other => panic!("expected system message, got {other:?}"),
13840 };
13841 assert!(system.content.contains("peer roster: lead-1, w-1"));
13842 assert!(
13843 system.mutation_kind.is_runtime_context_append(),
13844 "the persisted mutation provenance must survive reconciliation"
13845 );
13846 }
13847
13848 #[test]
13849 fn reconcile_resumed_system_prompt_rewrites_changed_base_preserving_tail() {
13850 let mut session = resumed_session_with_context_appended_prompt("base prompt");
13851
13852 let outcome = session
13853 .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
13854 .expect("reconcile");
13855
13856 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
13857 let system_content = match session.messages().first() {
13858 Some(Message::System(system)) => system.content.clone(),
13859 other => panic!("expected system message, got {other:?}"),
13860 };
13861 assert!(
13862 system_content.starts_with("new base prompt"),
13863 "the changed base must be applied: {system_content}"
13864 );
13865 assert!(
13866 system_content.contains("peer roster: lead-1, w-1"),
13867 "the runtime-applied context tail must survive the base change: {system_content}"
13868 );
13869 let state = session
13870 .transcript_history_state()
13871 .expect("history state deserializes")
13872 .expect("rewrite must record transcript history");
13873 assert_eq!(state.commits.len(), 1);
13874 assert_eq!(
13875 state.commits[0].reason.kind,
13876 RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON
13877 );
13878 assert_eq!(
13879 state.head,
13880 transcript_messages_digest(session.messages()).unwrap(),
13881 "the committed head must match the rewritten transcript"
13882 );
13883 }
13884
13885 #[test]
13886 fn reconcile_resumed_system_prompt_inserts_prompt_on_promptless_transcript() {
13887 let mut session = Session::new();
13888 session.push(Message::User(UserMessage::text("hello".to_string())));
13889
13890 let outcome = session
13891 .reconcile_resumed_system_prompt("late prompt".to_string(), None)
13892 .expect("reconcile");
13893
13894 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
13895 assert!(matches!(
13896 session.messages().first(),
13897 Some(Message::System(system)) if system.content == "late prompt"
13898 ));
13899 let state = session
13900 .transcript_history_state()
13901 .expect("history state deserializes")
13902 .expect("insert must record transcript history");
13903 assert_eq!(state.commits.len(), 1);
13904 assert_eq!(
13905 state.commits[0].reason.kind,
13906 RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON
13907 );
13908 }
13909
13910 fn leading_system_content(session: &Session) -> String {
13911 match session.messages().first() {
13912 Some(Message::System(system)) => system.content.clone(),
13913 other => panic!("expected leading system message, got {other:?}"),
13914 }
13915 }
13916
13917 #[test]
13918 fn reconcile_resumed_system_prompt_preserves_full_context_prompt_from_empty_base() {
13919 let mut session = Session::new();
13924 session.push(Message::User(UserMessage::text("hello".to_string())));
13925 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
13926 let all_context_content = leading_system_content(&session);
13927 assert!(all_context_content.contains("peer roster: lead-1, w-1"));
13928
13929 let outcome = session
13930 .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
13931 .expect("reconcile");
13932
13933 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
13934 assert_eq!(
13935 leading_system_content(&session),
13936 format!("new base prompt{SYSTEM_CONTEXT_SEPARATOR}{all_context_content}"),
13937 "the all-context prompt must survive as the runtime tail of the new base"
13938 );
13939 }
13940
13941 #[test]
13942 fn reconcile_resumed_system_prompt_preserves_context_only_prompt_on_empty_base_resume() {
13943 let mut session = Session::new();
13946 session.push(Message::User(UserMessage::text("hello".to_string())));
13947 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
13948 let digest_before = transcript_messages_digest(session.messages()).unwrap();
13949
13950 let outcome = session
13951 .reconcile_resumed_system_prompt(String::new(), None)
13952 .expect("reconcile");
13953
13954 assert_eq!(
13955 outcome,
13956 ResumedSystemPromptReconciliation::PreservedContinuation
13957 );
13958 assert_eq!(
13959 transcript_messages_digest(session.messages()).unwrap(),
13960 digest_before
13961 );
13962 }
13963
13964 #[test]
13965 fn reconcile_resumed_system_prompt_applies_shortened_base_with_recorded_prior() {
13966 let full_base = format!("part one{SYSTEM_CONTEXT_SEPARATOR}part two");
13970 let mut session = Session::new();
13971 session.set_system_prompt(full_base.clone());
13972 session.push(Message::User(UserMessage::text("hello".to_string())));
13973 session
13974 .set_build_state(SessionBuildState {
13975 assembled_system_prompt: Some(full_base),
13976 ..Default::default()
13977 })
13978 .expect("build state");
13979
13980 let outcome = session
13981 .reconcile_resumed_system_prompt("part one".to_string(), None)
13982 .expect("reconcile");
13983
13984 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
13985 assert_eq!(leading_system_content(&session), "part one");
13986 }
13987
13988 #[test]
13989 fn reconcile_resumed_system_prompt_applies_shortened_base_without_context_provenance() {
13990 let full_base = format!("part one{SYSTEM_CONTEXT_SEPARATOR}part two");
13995 let mut session = Session::new();
13996 session.set_system_prompt(full_base);
13997 session.push(Message::User(UserMessage::text("hello".to_string())));
13998
13999 let outcome = session
14000 .reconcile_resumed_system_prompt("part one".to_string(), None)
14001 .expect("reconcile");
14002
14003 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
14004 assert_eq!(leading_system_content(&session), "part one");
14005 }
14006
14007 #[test]
14008 fn reconcile_resumed_system_prompt_preserves_appended_prompt_without_applied_records() {
14009 let mut session = resumed_session_with_context_appended_prompt("base prompt");
14014 session
14015 .set_system_context_state(SessionSystemContextState::default())
14016 .expect("sweep applied records");
14017 let digest_before = transcript_messages_digest(session.messages()).unwrap();
14018
14019 let outcome = session
14020 .reconcile_resumed_system_prompt("base prompt".to_string(), None)
14021 .expect("reconcile");
14022
14023 assert_eq!(
14024 outcome,
14025 ResumedSystemPromptReconciliation::PreservedContinuation
14026 );
14027 assert_eq!(
14028 transcript_messages_digest(session.messages()).unwrap(),
14029 digest_before
14030 );
14031 }
14032
14033 #[test]
14034 fn reconcile_resumed_system_prompt_clears_orphaned_applied_records_on_tail_drop() {
14035 let mut session = resumed_session_with_context_appended_prompt("base prompt");
14036 session.set_system_prompt(format!(
14041 "mutated base{SYSTEM_CONTEXT_SEPARATOR}stale-looking tail"
14042 ));
14043
14044 let outcome = session
14045 .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
14046 .expect("reconcile");
14047
14048 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
14049 assert_eq!(leading_system_content(&session), "new base prompt");
14050 let state = session.system_context_state().unwrap_or_default();
14051 assert!(
14052 state.applied.is_empty(),
14053 "orphaned applied records must be cleared so the context stays restorable"
14054 );
14055 assert!(
14056 state.seen.is_empty(),
14057 "orphaned idempotency keys must be cleared so keyed re-sends re-apply"
14058 );
14059
14060 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
14063 assert!(
14064 leading_system_content(&session).contains("peer roster: lead-1, w-1"),
14065 "re-sent keyed context must re-apply after the drop"
14066 );
14067 }
14068
14069 #[test]
14070 fn append_system_context_blocks_renders_pre_marked_pending_context() {
14071 let accepted_at = SystemTime::UNIX_EPOCH;
14072 let mut state = SessionSystemContextState::default();
14073 state
14074 .stage_append(
14075 &AppendSystemContextRequest {
14076 content: crate::lifecycle::run_primitive::CoreRenderable::text(
14077 "Apply this staged context at the request boundary.".to_string(),
14078 ),
14079 source: Some("rpc/session_inject_context".to_string()),
14080 idempotency_key: Some("ctx-boundary".to_string()),
14081 source_kind: SystemContextSource::Normal,
14082 peer_response_terminal: None,
14083 },
14084 accepted_at,
14085 )
14086 .expect("append should stage");
14087 let pending = state.pending.clone();
14088 state.mark_pending_applied();
14089 let mut session = Session::new();
14090 session
14091 .set_system_context_state(state)
14092 .expect("state should serialize");
14093
14094 session.append_system_context_blocks(&pending);
14095
14096 let system_prompt = session
14097 .messages()
14098 .first()
14099 .and_then(|message| match message {
14100 Message::System(system) => Some(system.content.as_str()),
14101 _ => None,
14102 })
14103 .unwrap_or_default();
14104 assert!(system_prompt.contains("Apply this staged context at the request boundary."));
14105 let state = session
14106 .system_context_state()
14107 .expect("append should persist typed context state");
14108 assert_eq!(state.applied.len(), 1);
14109 assert_eq!(
14110 state.seen["ctx-boundary"].state,
14111 SeenSystemContextState::Applied
14112 );
14113 }
14114
14115 #[test]
14116 fn append_system_context_blocks_renders_pre_marked_context_without_idempotency_key() {
14117 let accepted_at = SystemTime::UNIX_EPOCH;
14118 let mut state = SessionSystemContextState::default();
14119 state
14120 .stage_append(
14121 &AppendSystemContextRequest {
14122 content: crate::lifecycle::run_primitive::CoreRenderable::text(
14123 "Apply this unkeyed staged context at the request boundary.".to_string(),
14124 ),
14125 source: Some("rpc/session_inject_context".to_string()),
14126 idempotency_key: None,
14127 source_kind: SystemContextSource::Normal,
14128 peer_response_terminal: None,
14129 },
14130 accepted_at,
14131 )
14132 .expect("append should stage");
14133 let pending = state.pending.clone();
14134 state.mark_pending_applied();
14135 let mut session = Session::new();
14136 session
14137 .set_system_context_state(state)
14138 .expect("state should serialize");
14139
14140 session.append_system_context_blocks(&pending);
14141
14142 let system_prompt = session
14143 .messages()
14144 .first()
14145 .and_then(|message| match message {
14146 Message::System(system) => Some(system.content.as_str()),
14147 _ => None,
14148 })
14149 .unwrap_or_default();
14150 assert!(
14151 system_prompt.contains("Apply this unkeyed staged context at the request boundary.")
14152 );
14153 }
14154
14155 #[test]
14159 fn staged_system_context_carries_typed_renderable_to_render_seam() {
14160 use crate::lifecycle::run_primitive::CoreRenderable;
14161
14162 let accepted_at = SystemTime::UNIX_EPOCH;
14163 let mut state = SessionSystemContextState::default();
14164 let renderable = CoreRenderable::Json {
14165 value: serde_json::json!({"alert": "disk-full", "severity": 2}),
14166 };
14167 state
14168 .stage_append(
14169 &AppendSystemContextRequest {
14170 content: renderable.clone(),
14171 source: Some("ops/monitor".to_string()),
14172 idempotency_key: Some("alert-1".to_string()),
14173 source_kind: SystemContextSource::Normal,
14174 peer_response_terminal: None,
14175 },
14176 accepted_at,
14177 )
14178 .expect("typed renderable append should stage");
14179
14180 assert_eq!(state.pending.len(), 1);
14183 assert_eq!(state.pending[0].content, renderable);
14184
14185 let rendered = render_system_context_block(&state.pending[0]);
14188 assert!(rendered.starts_with(SYSTEM_CONTEXT_RENDER_LABEL));
14189 assert!(
14190 rendered.contains(renderable.render_text().trim()),
14191 "render seam must lower via CoreRenderable::render_text: {rendered}"
14192 );
14193 }
14194
14195 #[test]
14196 fn append_system_context_blocks_skips_duplicate_idempotency_key() {
14197 let first = PendingSystemContextAppend {
14198 content: crate::lifecycle::run_primitive::CoreRenderable::text(
14199 "Authoritative peer token is birch seventeen.".to_string(),
14200 ),
14201 source: Some("peer_response_terminal:analyst:req-1".to_string()),
14202 idempotency_key: Some("req-1".to_string()),
14203 source_kind: SystemContextSource::Normal,
14204 peer_response_terminal: None,
14205 accepted_at: SystemTime::UNIX_EPOCH,
14206 };
14207 let duplicate = PendingSystemContextAppend {
14208 accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
14209 ..first.clone()
14210 };
14211 let mut session = Session::new();
14212
14213 session.append_system_context_blocks(std::slice::from_ref(&first));
14214 session.append_system_context_blocks(std::slice::from_ref(&duplicate));
14215
14216 let state = session
14217 .system_context_state()
14218 .expect("append should persist typed context state");
14219 assert_eq!(state.applied, vec![first]);
14220 let system_prompt = session
14221 .messages()
14222 .first()
14223 .and_then(|message| match message {
14224 Message::System(system) => Some(system.content.as_str()),
14225 _ => None,
14226 })
14227 .unwrap_or_default();
14228 assert_eq!(
14229 system_prompt
14230 .matches("Authoritative peer token is birch seventeen.")
14231 .count(),
14232 1
14233 );
14234 }
14235
14236 #[test]
14237 fn append_system_context_blocks_skips_conflicting_duplicate_idempotency_key() {
14238 let first = PendingSystemContextAppend {
14239 content: crate::lifecycle::run_primitive::CoreRenderable::text(
14240 "Authoritative peer token is birch seventeen.".to_string(),
14241 ),
14242 source: Some("peer_response_terminal:analyst:req-1".to_string()),
14243 idempotency_key: Some("req-1".to_string()),
14244 source_kind: SystemContextSource::Normal,
14245 peer_response_terminal: None,
14246 accepted_at: SystemTime::UNIX_EPOCH,
14247 };
14248 let conflicting = PendingSystemContextAppend {
14249 content: crate::lifecycle::run_primitive::CoreRenderable::text(
14250 "Conflicting peer token should not reach the prompt.".to_string(),
14251 ),
14252 accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
14253 ..first.clone()
14254 };
14255 let mut session = Session::new();
14256
14257 session.append_system_context_blocks(std::slice::from_ref(&first));
14258 session.append_system_context_blocks(std::slice::from_ref(&conflicting));
14259
14260 let state = session
14261 .system_context_state()
14262 .expect("append should persist typed context state");
14263 assert_eq!(state.applied, vec![first]);
14264 let system_prompt = session
14265 .messages()
14266 .first()
14267 .and_then(|message| match message {
14268 Message::System(system) => Some(system.content.as_str()),
14269 _ => None,
14270 })
14271 .unwrap_or_default();
14272 assert!(system_prompt.contains("Authoritative peer token is birch seventeen."));
14273 assert!(!system_prompt.contains("Conflicting peer token should not reach the prompt."));
14274 }
14275
14276 #[test]
14288 fn realtime_transcript_assistant_transcript_delta_materializes_transcript_block() {
14289 let mut session = Session::new();
14290
14291 let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
14292 response_id: "resp_spoken".to_string(),
14293 delta_id: "evt_delta_spoken_1".to_string(),
14294 item_id: "item_spoken".to_string(),
14295 previous_item_id: None,
14296 content_index: 0,
14297 delta: "I said hi".to_string(),
14298 };
14299 assert!(
14300 session.append_realtime_transcript_event(delta).is_inert(),
14301 "delta alone is inert until turn-completed flushes"
14302 );
14303
14304 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
14305 response_id: "resp_spoken".to_string(),
14306 stop_reason: StopReason::EndTurn,
14307 usage: Usage::default(),
14308 };
14309 let outcome = session.append_realtime_transcript_event(terminal);
14310 assert_eq!(outcome.materialized_messages.len(), 1);
14311
14312 let messages = session.messages();
14314 assert_eq!(messages.len(), 1);
14315 match &messages[0] {
14316 Message::BlockAssistant(assistant) => {
14317 assert_eq!(assistant.blocks.len(), 1);
14318 match &assistant.blocks[0] {
14319 AssistantBlock::Transcript { text, source, .. } => {
14320 assert_eq!(text, "I said hi");
14321 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
14322 }
14323 other => unreachable!(
14324 "AssistantTranscriptDelta must materialize as AssistantBlock::Transcript, got {other:?}"
14325 ),
14326 }
14327 }
14328 other => unreachable!("expected BlockAssistant message, got {other:?}"),
14329 }
14330 }
14331
14332 #[test]
14333 fn round4_cc4_in_flight_response_ids_lists_distinct_unmaterialized_responses() {
14334 let mut session = Session::new();
14340
14341 for (i, response_id) in [
14345 ("resp_a", "resp_a"),
14346 ("resp_a_extra", "resp_a"),
14347 ("resp_b", "resp_b"),
14348 ("resp_c", "resp_c"),
14349 ]
14350 .iter()
14351 .enumerate()
14352 {
14353 let event = RealtimeTranscriptEvent::AssistantTranscriptDelta {
14354 response_id: response_id.1.to_string(),
14355 delta_id: format!("delta_{i}"),
14356 item_id: response_id.0.to_string(),
14357 previous_item_id: None,
14358 content_index: 0,
14359 delta: "x".to_string(),
14360 };
14361 let _ = session.append_realtime_transcript_event(event);
14362 }
14363
14364 let _ = session.append_realtime_transcript_event(
14366 RealtimeTranscriptEvent::AssistantTurnInterrupted {
14367 response_id: "resp_c".to_string(),
14368 },
14369 );
14370
14371 let _ = session.append_realtime_transcript_event(
14374 RealtimeTranscriptEvent::UserTranscriptFinal {
14375 item_id: "u_item".to_string(),
14376 previous_item_id: None,
14377 content_index: 0,
14378 text: "hi".to_string(),
14379 },
14380 );
14381
14382 let in_flight = session.in_flight_realtime_assistant_response_ids();
14383 assert!(in_flight.contains(&"resp_a".to_string()), "{in_flight:?}");
14384 assert!(in_flight.contains(&"resp_b".to_string()), "{in_flight:?}");
14385 assert!(
14386 !in_flight.contains(&"resp_c".to_string()),
14387 "discarded response must not appear in in_flight: {in_flight:?}"
14388 );
14389 assert_eq!(
14391 in_flight.iter().filter(|r| *r == "resp_a").count(),
14392 1,
14393 "distinct response_ids only: {in_flight:?}"
14394 );
14395 }
14396
14397 #[test]
14398 fn round4_cc2_assistant_turn_completed_after_transcript_deltas_materializes_transcript() {
14399 let mut session = Session::new();
14406
14407 let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
14408 response_id: "resp_cc2".to_string(),
14409 delta_id: "delta_cc2_1".to_string(),
14410 item_id: "item_cc2".to_string(),
14411 previous_item_id: None,
14412 content_index: 0,
14413 delta: "hello world".to_string(),
14414 };
14415 assert!(session.append_realtime_transcript_event(delta).is_inert());
14416
14417 assert_eq!(
14419 session.in_flight_realtime_assistant_response_ids(),
14420 vec!["resp_cc2".to_string()]
14421 );
14422
14423 let outcome = session.append_realtime_transcript_event(
14424 RealtimeTranscriptEvent::AssistantTurnCompleted {
14425 response_id: "resp_cc2".to_string(),
14426 stop_reason: StopReason::EndTurn,
14427 usage: Usage::default(),
14428 },
14429 );
14430 assert_eq!(outcome.materialized_messages.len(), 1);
14431
14432 assert!(
14434 session
14435 .in_flight_realtime_assistant_response_ids()
14436 .is_empty(),
14437 "materialized items must not appear in in_flight_realtime_assistant_response_ids"
14438 );
14439
14440 let messages = session.messages();
14441 let assistant = messages.iter().find_map(|m| match m {
14442 Message::BlockAssistant(a) => Some(a),
14443 _ => None,
14444 });
14445 let assistant = assistant.expect("assistant block message expected");
14446 assert_eq!(assistant.blocks.len(), 1);
14447 assert!(matches!(
14448 &assistant.blocks[0],
14449 AssistantBlock::Transcript {
14450 source: crate::types::TranscriptSource::Spoken,
14451 ..
14452 }
14453 ));
14454 }
14455
14456 #[test]
14457 fn realtime_transcript_assistant_text_delta_still_materializes_text_block() {
14458 let mut session = Session::new();
14462
14463 let delta = RealtimeTranscriptEvent::AssistantTextDelta {
14464 response_id: "resp_display".to_string(),
14465 delta_id: "evt_delta_display_1".to_string(),
14466 item_id: "item_display".to_string(),
14467 previous_item_id: None,
14468 content_index: 0,
14469 delta: "I wrote".to_string(),
14470 };
14471 let _ = session.append_realtime_transcript_event(delta);
14472
14473 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
14474 response_id: "resp_display".to_string(),
14475 stop_reason: StopReason::EndTurn,
14476 usage: Usage::default(),
14477 };
14478 let outcome = session.append_realtime_transcript_event(terminal);
14479 assert_eq!(outcome.materialized_messages.len(), 1);
14480
14481 let messages = session.messages();
14482 match &messages[0] {
14483 Message::BlockAssistant(assistant) => match &assistant.blocks[0] {
14484 AssistantBlock::Text { text, .. } => assert_eq!(text, "I wrote"),
14485 other => unreachable!(
14486 "AssistantTextDelta must keep materializing AssistantBlock::Text, got {other:?}"
14487 ),
14488 },
14489 other => unreachable!("expected BlockAssistant message, got {other:?}"),
14490 }
14491 }
14492
14493 #[test]
14494 fn round4_cc7_mixed_response_persists_text_and_transcript_in_order() {
14495 let mut session = Session::new();
14513
14514 let display_a = RealtimeTranscriptEvent::AssistantTextDelta {
14516 response_id: "resp_mixed_1".to_string(),
14517 delta_id: "delta_disp_1".to_string(),
14518 item_id: "item_display".to_string(),
14519 previous_item_id: None,
14520 content_index: 0,
14521 delta: "Here's the report:".to_string(),
14522 };
14523 assert!(
14524 session
14525 .append_realtime_transcript_event(display_a)
14526 .is_inert()
14527 );
14528
14529 let display_b = RealtimeTranscriptEvent::AssistantTextDelta {
14530 response_id: "resp_mixed_1".to_string(),
14531 delta_id: "delta_disp_2".to_string(),
14532 item_id: "item_display".to_string(),
14533 previous_item_id: None,
14534 content_index: 0,
14535 delta: " (still writing)".to_string(),
14536 };
14537 assert!(
14538 session
14539 .append_realtime_transcript_event(display_b)
14540 .is_inert()
14541 );
14542
14543 let spoken_a = RealtimeTranscriptEvent::AssistantTranscriptDelta {
14548 response_id: "resp_mixed_1".to_string(),
14549 delta_id: "delta_spoken_1".to_string(),
14550 item_id: "item_spoken".to_string(),
14551 previous_item_id: Some("item_display".to_string()),
14552 content_index: 0,
14553 delta: "I'm reading the report aloud:".to_string(),
14554 };
14555 assert!(
14556 session
14557 .append_realtime_transcript_event(spoken_a)
14558 .is_inert()
14559 );
14560
14561 let spoken_b = RealtimeTranscriptEvent::AssistantTranscriptDelta {
14562 response_id: "resp_mixed_1".to_string(),
14563 delta_id: "delta_spoken_2".to_string(),
14564 item_id: "item_spoken".to_string(),
14565 previous_item_id: Some("item_display".to_string()),
14566 content_index: 0,
14567 delta: " sentence two.".to_string(),
14568 };
14569 assert!(
14570 session
14571 .append_realtime_transcript_event(spoken_b)
14572 .is_inert()
14573 );
14574
14575 let outcome = session.append_realtime_transcript_event(
14578 RealtimeTranscriptEvent::AssistantTurnCompleted {
14579 response_id: "resp_mixed_1".to_string(),
14580 stop_reason: StopReason::EndTurn,
14581 usage: Usage {
14582 input_tokens: 11,
14583 output_tokens: 22,
14584 cache_creation_tokens: None,
14585 cache_read_tokens: None,
14586 },
14587 },
14588 );
14589 assert_eq!(outcome.materialized_messages.len(), 2);
14591
14592 let messages = session.messages();
14595 let assistants: Vec<&BlockAssistantMessage> = messages
14596 .iter()
14597 .filter_map(|m| match m {
14598 Message::BlockAssistant(a) => Some(a),
14599 _ => None,
14600 })
14601 .collect();
14602 assert_eq!(
14603 assistants.len(),
14604 1,
14605 "mixed display+spoken response under one response_id must produce exactly ONE BlockAssistant message, got: {assistants:?}"
14606 );
14607 let assistant = assistants[0];
14608 assert_eq!(
14609 assistant.blocks.len(),
14610 2,
14611 "mixed response message must carry both blocks: {:?}",
14612 assistant.blocks
14613 );
14614
14615 match &assistant.blocks[0] {
14617 AssistantBlock::Text { text, .. } => {
14618 assert_eq!(text, "Here's the report: (still writing)");
14619 }
14620 other => unreachable!(
14621 "first block must be AssistantBlock::Text (display lane), got {other:?}"
14622 ),
14623 }
14624 match &assistant.blocks[1] {
14626 AssistantBlock::Transcript { text, source, .. } => {
14627 assert_eq!(text, "I'm reading the report aloud: sentence two.");
14628 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
14629 }
14630 other => unreachable!(
14631 "second block must be AssistantBlock::Transcript {{ source: Spoken }}, got {other:?}"
14632 ),
14633 }
14634
14635 assert_eq!(session.usage.input_tokens, 11);
14637 assert_eq!(session.usage.output_tokens, 22);
14638 }
14639
14640 #[test]
14641 fn round5_r55_mixed_response_barge_in_preserves_display_drops_spoken() {
14642 let mut session = Session::new();
14658
14659 let display = RealtimeTranscriptEvent::AssistantTextDelta {
14660 response_id: "resp_mixed_2".to_string(),
14661 delta_id: "delta_disp_1".to_string(),
14662 item_id: "item_display_2".to_string(),
14663 previous_item_id: None,
14664 content_index: 0,
14665 delta: "Working on the report...".to_string(),
14666 };
14667 let _ = session.append_realtime_transcript_event(display);
14668
14669 let spoken = RealtimeTranscriptEvent::AssistantTranscriptDelta {
14670 response_id: "resp_mixed_2".to_string(),
14671 delta_id: "delta_spoken_1".to_string(),
14672 item_id: "item_spoken_2".to_string(),
14673 previous_item_id: Some("item_display_2".to_string()),
14674 content_index: 0,
14675 delta: "I'm reading the report".to_string(),
14676 };
14677 let _ = session.append_realtime_transcript_event(spoken);
14678
14679 let outcome = session.append_realtime_transcript_event(
14683 RealtimeTranscriptEvent::AssistantTurnInterrupted {
14684 response_id: "resp_mixed_2".to_string(),
14685 },
14686 );
14687 assert_eq!(
14688 outcome.materialized_messages.len(),
14689 1,
14690 "Display lane item must materialize on Interrupted: {outcome:?}"
14691 );
14692
14693 let late_completion = session.append_realtime_transcript_event(
14697 RealtimeTranscriptEvent::AssistantTurnCompleted {
14698 response_id: "resp_mixed_2".to_string(),
14699 stop_reason: StopReason::Cancelled,
14700 usage: Usage::default(),
14701 },
14702 );
14703 assert_eq!(
14704 late_completion.materialized_messages.len(),
14705 0,
14706 "post-barge-in TurnCompleted must not resurrect anything"
14707 );
14708
14709 let messages = session.messages();
14712 let assistants: Vec<&BlockAssistantMessage> = messages
14713 .iter()
14714 .filter_map(|m| match m {
14715 Message::BlockAssistant(a) => Some(a),
14716 _ => None,
14717 })
14718 .collect();
14719 assert_eq!(
14720 assistants.len(),
14721 1,
14722 "barge-in must commit exactly one BlockAssistant containing the Display lane: {assistants:?}"
14723 );
14724 let assistant = assistants[0];
14725 assert_eq!(assistant.blocks.len(), 1, "blocks: {:?}", assistant.blocks);
14726 match &assistant.blocks[0] {
14727 AssistantBlock::Text { text, .. } => {
14728 assert_eq!(text, "Working on the report...");
14729 }
14730 other => {
14731 unreachable!("Display lane must materialize as AssistantBlock::Text, got {other:?}")
14732 }
14733 }
14734 assert!(
14736 !assistant
14737 .blocks
14738 .iter()
14739 .any(|b| matches!(b, AssistantBlock::Transcript { .. })),
14740 "Spoken lane must be dropped on barge-in"
14741 );
14742
14743 assert!(
14746 !session
14747 .in_flight_realtime_assistant_response_ids()
14748 .contains(&"resp_mixed_2".to_string()),
14749 "barged-in response must not appear in in_flight_realtime_assistant_response_ids"
14750 );
14751 }
14752
14753 #[test]
14754 fn round5_r55_barge_in_preserves_display_lane_drops_spoken() {
14755 let mut session = Session::new();
14759
14760 let _ =
14761 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
14762 response_id: "resp_a".to_string(),
14763 delta_id: "delta_d_1".to_string(),
14764 item_id: "item_display".to_string(),
14765 previous_item_id: None,
14766 content_index: 0,
14767 delta: "display-text".to_string(),
14768 });
14769 let _ = session.append_realtime_transcript_event(
14770 RealtimeTranscriptEvent::AssistantTranscriptDelta {
14771 response_id: "resp_a".to_string(),
14772 delta_id: "delta_s_1".to_string(),
14773 item_id: "item_spoken".to_string(),
14774 previous_item_id: None,
14775 content_index: 0,
14776 delta: "spoken-transcript".to_string(),
14777 },
14778 );
14779
14780 let outcome = session.append_realtime_transcript_event(
14781 RealtimeTranscriptEvent::AssistantTurnInterrupted {
14782 response_id: "resp_a".to_string(),
14783 },
14784 );
14785 assert_eq!(outcome.materialized_messages.len(), 1);
14787
14788 let messages = session.messages();
14789 let assistants: Vec<&BlockAssistantMessage> = messages
14790 .iter()
14791 .filter_map(|m| match m {
14792 Message::BlockAssistant(a) => Some(a),
14793 _ => None,
14794 })
14795 .collect();
14796 assert_eq!(assistants.len(), 1);
14797 assert_eq!(assistants[0].blocks.len(), 1);
14799 match &assistants[0].blocks[0] {
14800 AssistantBlock::Text { text, .. } => assert_eq!(text, "display-text"),
14801 other => unreachable!("expected Text, got {other:?}"),
14802 }
14803 }
14804
14805 #[test]
14806 fn round5_r55_barge_in_finalizes_retained_display_into_committed_block() {
14807 let mut session = Session::new();
14812
14813 let _ =
14814 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
14815 response_id: "resp_a".to_string(),
14816 delta_id: "delta_d_1".to_string(),
14817 item_id: "item_display".to_string(),
14818 previous_item_id: None,
14819 content_index: 0,
14820 delta: "committed-display-text".to_string(),
14821 });
14822
14823 assert!(session.messages().is_empty());
14825
14826 let outcome = session.append_realtime_transcript_event(
14827 RealtimeTranscriptEvent::AssistantTurnInterrupted {
14828 response_id: "resp_a".to_string(),
14829 },
14830 );
14831 assert_eq!(
14832 outcome.materialized_messages.len(),
14833 1,
14834 "Interrupted must finalize retained Display lane immediately"
14835 );
14836
14837 let messages = session.messages();
14839 assert_eq!(messages.len(), 1);
14840 match &messages[0] {
14841 Message::BlockAssistant(assistant) => {
14842 assert_eq!(assistant.blocks.len(), 1);
14843 match &assistant.blocks[0] {
14844 AssistantBlock::Text { text, .. } => {
14845 assert_eq!(text, "committed-display-text");
14846 }
14847 other => unreachable!("expected Text, got {other:?}"),
14848 }
14849 }
14850 other => unreachable!("expected BlockAssistant, got {other:?}"),
14851 }
14852 }
14853
14854 #[test]
14855 fn round5_r56_truncation_promotes_default_lane_item_to_spoken() {
14856 let mut session = Session::new();
14863
14864 let _ = session.append_realtime_transcript_event(
14865 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
14866 response_id: "resp_a".to_string(),
14867 item_id: "item_a".to_string(),
14868 content_index: 0,
14869 text: "what was actually heard".to_string(),
14870 },
14871 );
14872
14873 let outcome = session.append_realtime_transcript_event(
14874 RealtimeTranscriptEvent::AssistantTurnCompleted {
14875 response_id: "resp_a".to_string(),
14876 stop_reason: StopReason::EndTurn,
14877 usage: Usage::default(),
14878 },
14879 );
14880 assert_eq!(outcome.materialized_messages.len(), 1);
14881
14882 assert_eq!(session.messages().len(), 1);
14883 match &session.messages()[0] {
14884 Message::BlockAssistant(assistant) => {
14885 assert_eq!(assistant.blocks.len(), 1);
14886 match &assistant.blocks[0] {
14887 AssistantBlock::Transcript { text, source, .. } => {
14888 assert_eq!(text, "what was actually heard");
14889 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
14890 }
14891 other => unreachable!(
14892 "truncation-only path must materialize as AssistantBlock::Transcript, got {other:?}"
14893 ),
14894 }
14895 }
14896 other => unreachable!("expected BlockAssistant, got {other:?}"),
14897 }
14898 }
14899
14900 #[test]
14901 fn round5_r56_truncation_after_display_delta_is_no_op_keeping_display_content() {
14902 let mut session = Session::new();
14910
14911 let _ =
14912 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
14913 response_id: "resp_a".to_string(),
14914 delta_id: "delta_d_1".to_string(),
14915 item_id: "item_a".to_string(),
14916 previous_item_id: None,
14917 content_index: 0,
14918 delta: "display-text-from-delta".to_string(),
14919 });
14920
14921 let _ = session.append_realtime_transcript_event(
14922 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
14923 response_id: "resp_a".to_string(),
14924 item_id: "item_a".to_string(),
14925 content_index: 0,
14926 text: "spoken-truncation-text".to_string(),
14927 },
14928 );
14929
14930 let _ = session.append_realtime_transcript_event(
14931 RealtimeTranscriptEvent::AssistantTurnCompleted {
14932 response_id: "resp_a".to_string(),
14933 stop_reason: StopReason::EndTurn,
14934 usage: Usage::default(),
14935 },
14936 );
14937
14938 assert_eq!(session.messages().len(), 1);
14941 match &session.messages()[0] {
14942 Message::BlockAssistant(assistant) => {
14943 assert_eq!(assistant.blocks.len(), 1);
14944 match &assistant.blocks[0] {
14945 AssistantBlock::Text { text, .. } => {
14946 assert_eq!(text, "display-text-from-delta");
14947 }
14948 other => unreachable!(
14949 "Display content must survive misrouted truncation, got {other:?}"
14950 ),
14951 }
14952 }
14953 other => unreachable!("expected BlockAssistant, got {other:?}"),
14954 }
14955 }
14956
14957 #[test]
14965 fn round5_r56_sibling_display_delta_skipped_on_spoken_item() {
14966 let mut session = Session::new();
14967
14968 let _ = session.append_realtime_transcript_event(
14970 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
14971 response_id: "resp_a".to_string(),
14972 item_id: "item_a".to_string(),
14973 content_index: 0,
14974 text: "what was actually heard".to_string(),
14975 },
14976 );
14977
14978 let _ =
14981 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
14982 response_id: "resp_a".to_string(),
14983 delta_id: "delta_d_1".to_string(),
14984 item_id: "item_a".to_string(),
14985 previous_item_id: None,
14986 content_index: 0,
14987 delta: "should-not-appear".to_string(),
14988 });
14989
14990 let _ = session.append_realtime_transcript_event(
14991 RealtimeTranscriptEvent::AssistantTurnCompleted {
14992 response_id: "resp_a".to_string(),
14993 stop_reason: StopReason::EndTurn,
14994 usage: Usage::default(),
14995 },
14996 );
14997
14998 assert_eq!(session.messages().len(), 1);
15001 match &session.messages()[0] {
15002 Message::BlockAssistant(assistant) => {
15003 assert_eq!(assistant.blocks.len(), 1);
15004 match &assistant.blocks[0] {
15005 AssistantBlock::Transcript { text, source, .. } => {
15006 assert_eq!(text, "what was actually heard");
15007 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
15008 }
15009 other => unreachable!(
15010 "Spoken-locked item must materialize as Transcript, got {other:?}"
15011 ),
15012 }
15013 }
15014 other => unreachable!("expected BlockAssistant, got {other:?}"),
15015 }
15016 }
15017
15018 #[test]
15025 fn round5_r56_sibling_spoken_delta_skipped_on_display_item() {
15026 let mut session = Session::new();
15027
15028 let _ =
15030 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
15031 response_id: "resp_a".to_string(),
15032 delta_id: "delta_d_1".to_string(),
15033 item_id: "item_a".to_string(),
15034 previous_item_id: None,
15035 content_index: 0,
15036 delta: "display-locked-text".to_string(),
15037 });
15038
15039 let _ = session.append_realtime_transcript_event(
15042 RealtimeTranscriptEvent::AssistantTranscriptDelta {
15043 response_id: "resp_a".to_string(),
15044 delta_id: "delta_s_1".to_string(),
15045 item_id: "item_a".to_string(),
15046 previous_item_id: None,
15047 content_index: 0,
15048 delta: "should-not-appear".to_string(),
15049 },
15050 );
15051
15052 let _ = session.append_realtime_transcript_event(
15053 RealtimeTranscriptEvent::AssistantTurnCompleted {
15054 response_id: "resp_a".to_string(),
15055 stop_reason: StopReason::EndTurn,
15056 usage: Usage::default(),
15057 },
15058 );
15059
15060 assert_eq!(session.messages().len(), 1);
15062 match &session.messages()[0] {
15063 Message::BlockAssistant(assistant) => {
15064 assert_eq!(assistant.blocks.len(), 1);
15065 match &assistant.blocks[0] {
15066 AssistantBlock::Text { text, .. } => {
15067 assert_eq!(text, "display-locked-text");
15068 }
15069 other => {
15070 unreachable!("Display-locked item must materialize as Text, got {other:?}")
15071 }
15072 }
15073 }
15074 other => unreachable!("expected BlockAssistant, got {other:?}"),
15075 }
15076 }
15077
15078 #[test]
15086 fn round5_r57_late_final_text_after_turn_completed_warns_and_skips() {
15087 let mut session = Session::new();
15088
15089 let _ = session.append_realtime_transcript_event(
15091 RealtimeTranscriptEvent::AssistantTranscriptDelta {
15092 response_id: "resp_a".to_string(),
15093 delta_id: "delta_s_1".to_string(),
15094 item_id: "item_a".to_string(),
15095 previous_item_id: None,
15096 content_index: 0,
15097 delta: "delta-accumulated".to_string(),
15098 },
15099 );
15100
15101 let commit_outcome = session.append_realtime_transcript_event(
15103 RealtimeTranscriptEvent::AssistantTurnCompleted {
15104 response_id: "resp_a".to_string(),
15105 stop_reason: StopReason::EndTurn,
15106 usage: Usage::default(),
15107 },
15108 );
15109 assert_eq!(commit_outcome.materialized_messages.len(), 1);
15110
15111 let late_outcome = session.append_realtime_transcript_event(
15115 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
15116 response_id: "resp_a".to_string(),
15117 item_id: "item_a".to_string(),
15118 content_index: 0,
15119 text: "authoritative-final-that-must-not-land".to_string(),
15120 },
15121 );
15122 assert!(
15123 late_outcome.is_inert(),
15124 "late FinalText after materialization must produce inert outcome"
15125 );
15126
15127 assert_eq!(session.messages().len(), 1);
15130 match &session.messages()[0] {
15131 Message::BlockAssistant(assistant) => {
15132 assert_eq!(assistant.blocks.len(), 1);
15133 match &assistant.blocks[0] {
15134 AssistantBlock::Transcript { text, .. } => {
15135 assert_eq!(
15136 text, "delta-accumulated",
15137 "canonical message must preserve delta-accumulated text; \
15138 append-only history forbids late FinalText repair"
15139 );
15140 }
15141 other => unreachable!("expected Transcript, got {other:?}"),
15142 }
15143 }
15144 other => unreachable!("expected BlockAssistant, got {other:?}"),
15145 }
15146 }
15147
15148 fn metadata_seam_session_metadata() -> SessionMetadata {
15149 SessionMetadata {
15150 schema_version: SESSION_METADATA_SCHEMA_VERSION,
15151 model: "test-model".to_string(),
15152 max_tokens: 1024,
15153 structured_output_retries: 2,
15154 provider: Provider::Anthropic,
15155 self_hosted_server_id: None,
15156 provider_params: None,
15157 tooling: SessionTooling::default(),
15158 keep_alive: false,
15159 comms_name: Some("team/reviewer/alice".to_string()),
15160 peer_meta: None,
15161 realm_id: None,
15162 instance_id: None,
15163 backend: None,
15164 config_generation: None,
15165 auth_binding: None,
15166 mob_member_binding: Some(crate::MobMemberBinding {
15167 mob_id: "team".to_string(),
15168 role: "reviewer".to_string(),
15169 member: "alice".to_string(),
15170 }),
15171 }
15172 }
15173
15174 #[test]
15179 fn session_metadata_document_lockstep_with_full_envelope() {
15180 let mut session = Session::new();
15181 session.push(Message::User(UserMessage::text("hello".to_string())));
15182 session
15183 .set_session_metadata(metadata_seam_session_metadata())
15184 .expect("session metadata should persist");
15185 session
15186 .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
15187 .expect("lifecycle terminal should persist");
15188
15189 let bytes = serde_json::to_vec(&session).expect("session should serialize");
15190 let document = session_metadata_document_from_slice(&bytes)
15191 .expect("partial decode must accept the canonical envelope");
15192
15193 assert_eq!(document.session_id(), session.id());
15194 assert_eq!(
15195 document.session_metadata_value(),
15196 session.metadata().get(SESSION_METADATA_KEY),
15197 "partial decode must project the identical raw session-metadata value"
15198 );
15199 assert_eq!(
15200 document.lifecycle_terminal_value(),
15201 session.metadata().get(SESSION_LIFECYCLE_TERMINAL_KEY),
15202 "partial decode must project the identical raw lifecycle-terminal value"
15203 );
15204
15205 let view = document
15206 .try_into_view()
15207 .expect("typed view must decode from the partial document");
15208 let full_view =
15209 PersistedSessionMetadataView::try_from_session(&session).expect("full-session view");
15210 assert_eq!(view.session_id, full_view.session_id);
15211 assert_eq!(
15212 view.session_metadata.as_ref().map(|m| m.model.clone()),
15213 full_view.session_metadata.as_ref().map(|m| m.model.clone())
15214 );
15215 assert_eq!(
15216 view.mob_member_binding(),
15217 full_view.mob_member_binding(),
15218 "typed binding must be identical across the two decode paths"
15219 );
15220 assert_eq!(
15221 view.lifecycle_terminal,
15222 Some(SessionLifecycleTerminal::Archived)
15223 );
15224 assert_eq!(
15225 full_view.lifecycle_terminal,
15226 Some(SessionLifecycleTerminal::Archived)
15227 );
15228 }
15229
15230 #[test]
15233 fn session_metadata_document_fails_closed_on_envelope_version() {
15234 let session = Session::new();
15235 let mut value = serde_json::to_value(&session).expect("session should serialize");
15236 value["version"] = serde_json::json!(SESSION_VERSION + 999);
15237 let bytes = serde_json::to_vec(&value).expect("mangled envelope should serialize");
15238
15239 session_metadata_document_from_slice(&bytes)
15240 .expect_err("an unsupported envelope version must fail the partial decode closed");
15241 }
15242
15243 #[test]
15246 fn persisted_session_metadata_view_fails_closed_on_corrupt_values() {
15247 let session_id = SessionId::new();
15248
15249 let mut corrupt_metadata = serde_json::Map::new();
15250 corrupt_metadata.insert(SESSION_METADATA_KEY.to_string(), serde_json::json!(42));
15251 PersistedSessionMetadataView::try_from_metadata_map(session_id.clone(), &corrupt_metadata)
15252 .expect_err("corrupt session_metadata must fail the view decode closed");
15253
15254 let mut corrupt_terminal = serde_json::Map::new();
15255 corrupt_terminal.insert(
15256 SESSION_LIFECYCLE_TERMINAL_KEY.to_string(),
15257 serde_json::json!("definitely-not-a-terminal"),
15258 );
15259 PersistedSessionMetadataView::try_from_metadata_map(session_id, &corrupt_terminal)
15260 .expect_err("corrupt lifecycle terminal must fail the view decode closed");
15261 }
15262
15263 #[test]
15265 fn persisted_session_metadata_view_reads_absent_facts_as_none() {
15266 let view = PersistedSessionMetadataView::try_from_metadata_map(
15267 SessionId::new(),
15268 &serde_json::Map::new(),
15269 )
15270 .expect("empty metadata map must decode");
15271 assert!(view.session_metadata.is_none());
15272 assert!(view.lifecycle_terminal.is_none());
15273 assert!(view.mob_member_binding().is_none());
15274 }
15275}