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};
35use std::sync::Arc;
36
37pub use crate::generated::session_persistence_version_authority::SESSION_VERSION;
44
45pub use crate::generated::session_persistence_version_authority::SESSION_METADATA_SCHEMA_VERSION;
50
51pub fn session_version() -> u32 {
53 session_persistence_version_authority::session_envelope_version()
54}
55
56pub fn session_metadata_schema_version() -> u32 {
58 session_persistence_version_authority::session_metadata_schema_version()
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(tag = "type", rename_all = "snake_case")]
67pub enum TranscriptReplacement {
68 Message { message: Message },
70 UserContentBlock {
72 block_index: usize,
73 block: ContentBlock,
74 },
75 AssistantBlock {
77 block_index: usize,
78 block: AssistantBlock,
79 },
80 ToolResultContentBlock {
82 result_index: usize,
83 block_index: usize,
84 block: ContentBlock,
85 },
86}
87
88pub const SESSION_TRANSCRIPT_HISTORY_STATE_KEY: &str = "session_transcript_history_state_v1";
90
91pub const SESSION_TRANSCRIPT_HISTORY_CHECKPOINT_DIGEST_KEY: &str =
99 "session_transcript_history_checkpoint_digest_v1";
100
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104#[serde(tag = "type", rename_all = "snake_case")]
105pub enum TranscriptRewriteSelection {
106 MessageRange { start: usize, end: usize },
110 EditMessageRange { range: TranscriptEditRewriteRange },
112 CompactionMessageRange { range: CompactionRewriteRange },
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
123pub struct TranscriptEditRewriteRange {
124 start: usize,
125 end: usize,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
130#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
131pub struct CompactionRewriteRange {
132 start: usize,
133 end: usize,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum TranscriptRewriteSemantic {
139 Edit,
141 Compaction,
143}
144
145impl TranscriptRewriteSelection {
146 pub fn bounds(&self) -> (usize, usize) {
149 match self {
150 Self::MessageRange { start, end } => (*start, *end),
151 Self::EditMessageRange { range } => (range.start, range.end),
152 Self::CompactionMessageRange { range } => (range.start, range.end),
153 }
154 }
155
156 pub fn semantic(&self) -> TranscriptRewriteSemantic {
157 match self {
158 Self::MessageRange { .. } | Self::EditMessageRange { .. } => {
159 TranscriptRewriteSemantic::Edit
160 }
161 Self::CompactionMessageRange { .. } => TranscriptRewriteSemantic::Compaction,
162 }
163 }
164
165 fn into_current_edit_semantic(self) -> Self {
166 match self {
167 Self::MessageRange { start, end } => Self::EditMessageRange {
168 range: TranscriptEditRewriteRange { start, end },
169 },
170 current => current,
171 }
172 }
173
174 fn is_legacy_untyped(&self) -> bool {
175 matches!(self, Self::MessageRange { .. })
176 }
177
178 fn validated_compaction(
179 start: usize,
180 end: usize,
181 _authority: &crate::agent::compact::ValidatedCompactionRewrite,
182 ) -> Self {
183 Self::CompactionMessageRange {
184 range: CompactionRewriteRange { start, end },
185 }
186 }
187
188 fn migrated_legacy_compaction(start: usize, end: usize) -> Self {
189 Self::CompactionMessageRange {
190 range: CompactionRewriteRange { start, end },
191 }
192 }
193
194 #[cfg(test)]
195 pub(crate) fn typed_compaction_for_test(start: usize, end: usize) -> Self {
196 Self::CompactionMessageRange {
197 range: CompactionRewriteRange { start, end },
198 }
199 }
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
208#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
209#[serde(rename_all = "snake_case")]
210pub struct TranscriptRewriteReason {
211 pub kind: String,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub note: Option<String>,
214}
215
216impl TranscriptRewriteReason {
217 pub fn new(kind: impl Into<String>) -> Self {
218 Self {
219 kind: kind.into(),
220 note: None,
221 }
222 }
223}
224
225pub const RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON: &str = "resume-system-prompt-refresh";
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub enum ResumedSystemPromptReconciliation {
232 PreservedContinuation,
237 RewrittenBase,
241 NoChange,
244}
245
246impl std::fmt::Display for TranscriptRewriteReason {
247 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251 match &self.note {
252 Some(note) => write!(f, "{}: {note}", self.kind),
253 None => f.write_str(&self.kind),
254 }
255 }
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
260#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
261#[serde(rename_all = "snake_case")]
262pub struct TranscriptRewriteCommit {
263 pub parent_revision: String,
264 pub revision: String,
265 pub selection: TranscriptRewriteSelection,
266 pub original_span_digest: String,
267 pub replacement_digest: String,
268 pub messages_before: usize,
269 pub messages_after: usize,
270 pub reason: TranscriptRewriteReason,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub actor: Option<String>,
273 #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
274 pub committed_at: SystemTime,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
279#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
280#[serde(rename_all = "snake_case")]
281pub struct TranscriptRevisionBody {
282 pub revision: String,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub parent_revision: Option<String>,
285 #[cfg_attr(feature = "schema", schemars(with = "Vec<serde_json::Value>"))]
286 pub messages: Vec<Message>,
287 #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
288 pub created_at: SystemTime,
289}
290
291#[cfg(feature = "schema")]
292#[allow(dead_code)]
293#[derive(schemars::JsonSchema)]
294#[schemars(rename = "SystemTime")]
295struct SchemaSystemTime {
296 secs_since_epoch: u64,
297 nanos_since_epoch: u32,
298}
299
300#[derive(Debug, Clone, Serialize)]
302#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
303#[serde(rename_all = "snake_case")]
304pub struct TranscriptRewriteRecord {
305 pub commit: TranscriptRewriteCommit,
306 pub parent_body: TranscriptRevisionBody,
307 pub revision_body: TranscriptRevisionBody,
308}
309
310impl<'de> Deserialize<'de> for TranscriptRewriteRecord {
311 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
312 where
313 D: Deserializer<'de>,
314 {
315 #[derive(Deserialize)]
316 #[serde(rename_all = "snake_case")]
317 struct Wire {
318 commit: TranscriptRewriteCommit,
319 parent_body: TranscriptRevisionBody,
320 revision_body: TranscriptRevisionBody,
321 }
322 let wire = Wire::deserialize(deserializer)?;
323 let mut revisions = vec![wire.parent_body, wire.revision_body];
324 let mut commits = vec![wire.commit];
325 heal_legacy_revision_strings(&mut revisions, &mut commits, None)
326 .map_err(serde::de::Error::custom)?;
327 heal_legacy_compaction_rewrite_semantics(&mut commits, &revisions);
328 let mut revisions = revisions.into_iter();
329 let parent_body = revisions
330 .next()
331 .ok_or_else(|| serde::de::Error::custom("rewrite record lost its parent body"))?;
332 let revision_body = revisions
333 .next()
334 .ok_or_else(|| serde::de::Error::custom("rewrite record lost its revision body"))?;
335 let commit = commits
336 .into_iter()
337 .next()
338 .ok_or_else(|| serde::de::Error::custom("rewrite record lost its commit"))?;
339 Ok(Self {
340 commit,
341 parent_body,
342 revision_body,
343 })
344 }
345}
346
347impl TranscriptRewriteRecord {
348 pub fn new(
349 commit: TranscriptRewriteCommit,
350 parent_body: TranscriptRevisionBody,
351 revision_body: TranscriptRevisionBody,
352 ) -> Result<Self, TranscriptEditError> {
353 validate_transcript_rewrite_record(&commit, &parent_body, &revision_body)?;
354 Ok(Self {
355 commit,
356 parent_body,
357 revision_body,
358 })
359 }
360}
361
362#[derive(Debug, Clone, Serialize)]
364#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
365#[serde(rename_all = "snake_case")]
366pub struct TranscriptHistoryState {
367 pub head: String,
368 #[serde(default, skip_serializing_if = "Vec::is_empty")]
369 pub commits: Vec<TranscriptRewriteCommit>,
370 #[serde(default, skip_serializing_if = "Vec::is_empty")]
371 pub revisions: Vec<TranscriptRevisionBody>,
372 #[serde(default, skip_serializing_if = "digest_format_is_unknown")]
379 pub digest_format: u32,
380}
381
382fn digest_format_is_unknown(format: &u32) -> bool {
383 *format == 0
384}
385
386pub(crate) const TRANSCRIPT_DIGEST_FORMAT_CURRENT: u32 = 2;
388
389impl<'de> Deserialize<'de> for TranscriptHistoryState {
390 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
391 where
392 D: Deserializer<'de>,
393 {
394 #[derive(Deserialize)]
395 #[serde(rename_all = "snake_case")]
396 struct Wire {
397 head: String,
398 #[serde(default)]
399 commits: Vec<TranscriptRewriteCommit>,
400 #[serde(default)]
401 revisions: Vec<TranscriptRevisionBody>,
402 #[serde(default)]
403 digest_format: u32,
404 }
405 let wire = Wire::deserialize(deserializer)?;
406 let mut state = TranscriptHistoryState {
407 head: wire.head,
408 commits: wire.commits,
409 revisions: wire.revisions,
410 digest_format: wire.digest_format,
411 };
412 if state.revisions.len() > 1
418 && state
419 .revisions
420 .iter()
421 .skip(1)
422 .all(|body| body.parent_revision.is_none())
423 {
424 for index in 1..state.revisions.len() {
425 let parent = state.revisions[index - 1].revision.clone();
426 state.revisions[index].parent_revision = Some(parent);
427 }
428 }
429 let head_is_current = state.digest_format >= TRANSCRIPT_DIGEST_FORMAT_CURRENT
435 || match state
436 .revisions
437 .iter()
438 .find(|body| body.revision == state.head)
439 {
440 Some(head_body) => {
441 transcript_messages_digest(&head_body.messages)
442 .map_err(serde::de::Error::custom)?
443 == state.head
444 }
445 None => true,
446 };
447 state.digest_format = TRANSCRIPT_DIGEST_FORMAT_CURRENT;
448 if !head_is_current {
449 let TranscriptHistoryState {
450 head,
451 commits,
452 digest_format: _,
453 revisions,
454 } = &mut state;
455 heal_legacy_revision_strings(revisions, commits, Some(head))
456 .map_err(serde::de::Error::custom)?;
457 }
458 heal_legacy_compaction_rewrite_semantics(&mut state.commits, &state.revisions);
459 Ok(state)
460 }
461}
462
463impl TranscriptHistoryState {
464 fn compact_mechanical_revision_bodies(&mut self) -> Result<(), TranscriptEditError> {
474 validate_transcript_history_state(self)?;
475
476 let mut retained = BTreeSet::from([self.head.clone()]);
477 for commit in &self.commits {
478 retained.insert(commit.parent_revision.clone());
479 retained.insert(commit.revision.clone());
480 }
481
482 let head_is_audited_endpoint = self
483 .commits
484 .iter()
485 .any(|commit| commit.parent_revision == self.head || commit.revision == self.head);
486 if !head_is_audited_endpoint
487 && let Some(last_commit) = self
488 .commits
489 .last()
490 .filter(|commit| commit.revision != self.head)
491 && let Some(head_body) = self
492 .revisions
493 .iter_mut()
494 .find(|body| body.revision == self.head)
495 {
496 head_body.parent_revision = Some(last_commit.revision.clone());
497 }
498
499 let mut seen = BTreeSet::new();
500 self.revisions
501 .retain(|body| retained.contains(&body.revision) && seen.insert(body.revision.clone()));
502
503 Ok(())
511 }
512}
513
514fn heal_legacy_revision_strings(
523 revisions: &mut [TranscriptRevisionBody],
524 commits: &mut [TranscriptRewriteCommit],
525 head: Option<&mut String>,
526) -> Result<(), serde_json::Error> {
527 let mut remap: BTreeMap<String, String> = BTreeMap::new();
528 for body in revisions.iter() {
529 let content = transcript_messages_digest(&body.messages)?;
530 if body.revision == content {
531 continue;
532 }
533 if body.revision == legacy_transcript_messages_digest(&body.messages)? {
534 remap.insert(body.revision.clone(), content);
535 }
536 }
537 if remap.is_empty() {
538 return Ok(());
539 }
540 for body in revisions.iter_mut() {
541 if let Some(current) = remap.get(&body.revision) {
542 body.revision = current.clone();
543 }
544 if let Some(parent) = body.parent_revision.as_ref()
545 && let Some(current) = remap.get(parent)
546 {
547 body.parent_revision = Some(current.clone());
548 }
549 }
550 for commit in commits.iter_mut() {
551 if let Some(current) = remap.get(&commit.parent_revision) {
552 commit.parent_revision = current.clone();
553 }
554 if let Some(current) = remap.get(&commit.revision) {
555 commit.revision = current.clone();
556 }
557 heal_legacy_commit_span_digests(commit, revisions)?;
558 }
559 if let Some(head) = head
560 && let Some(current) = remap.get(head.as_str())
561 {
562 *head = current.clone();
563 }
564 Ok(())
565}
566
567fn heal_legacy_commit_span_digests(
573 commit: &mut TranscriptRewriteCommit,
574 revisions: &[TranscriptRevisionBody],
575) -> Result<(), serde_json::Error> {
576 let Some(parent_body) = revisions
577 .iter()
578 .find(|body| body.revision == commit.parent_revision)
579 else {
580 return Ok(());
581 };
582 let Some(revision_body) = revisions
583 .iter()
584 .find(|body| body.revision == commit.revision)
585 else {
586 return Ok(());
587 };
588 let (start, end) = commit.selection.bounds();
589 if start > end || end > parent_body.messages.len() {
590 return Ok(());
591 }
592 let removed_len = end - start;
593 let Some(retained_len) = commit.messages_before.checked_sub(removed_len) else {
594 return Ok(());
595 };
596 let Some(replacement_len) = commit.messages_after.checked_sub(retained_len) else {
597 return Ok(());
598 };
599 let Some(replacement_end) = start.checked_add(replacement_len) else {
600 return Ok(());
601 };
602 if replacement_end > revision_body.messages.len() {
603 return Ok(());
604 }
605 let original_span = &parent_body.messages[start..end];
606 if commit.original_span_digest == legacy_transcript_messages_digest(original_span)? {
607 commit.original_span_digest = transcript_messages_digest(original_span)?;
608 }
609 let replacement_span = &revision_body.messages[start..replacement_end];
610 if commit.replacement_digest == legacy_transcript_messages_digest(replacement_span)? {
611 commit.replacement_digest = transcript_messages_digest(replacement_span)?;
612 }
613 Ok(())
614}
615
616fn heal_legacy_compaction_rewrite_semantics(
625 commits: &mut [TranscriptRewriteCommit],
626 revisions: &[TranscriptRevisionBody],
627) {
628 for commit in commits {
629 if !commit.selection.is_legacy_untyped() {
630 continue;
631 }
632 let (start, end) = commit.selection.bounds();
633 if start != 0
634 || end != commit.messages_before
635 || commit.messages_after >= commit.messages_before
636 {
637 continue;
638 }
639 let Some(parent) = revisions
640 .iter()
641 .find(|body| body.revision == commit.parent_revision)
642 else {
643 continue;
644 };
645 let Some(revision) = revisions
646 .iter()
647 .find(|body| body.revision == commit.revision)
648 else {
649 continue;
650 };
651 if parent.messages.len() != commit.messages_before
652 || revision.messages.len() != commit.messages_after
653 {
654 continue;
655 }
656 let summary_count = revision
657 .messages
658 .iter()
659 .filter(|message| {
660 matches!(message, Message::User(user) if user.transcript_role.is_compaction_summary())
661 })
662 .count();
663 if summary_count == 1 {
664 commit.selection = TranscriptRewriteSelection::migrated_legacy_compaction(start, end);
665 }
666 }
667}
668
669impl TranscriptHistoryState {
670 pub fn from_rewrite_records<I>(records: I) -> Result<Option<Self>, TranscriptEditError>
672 where
673 I: IntoIterator<Item = TranscriptRewriteRecord>,
674 {
675 let mut state: Option<Self> = None;
676 for record in records {
677 validate_transcript_rewrite_record(
678 &record.commit,
679 &record.parent_body,
680 &record.revision_body,
681 )?;
682 let state = state.get_or_insert_with(|| Self {
683 head: record.commit.parent_revision.clone(),
684 commits: Vec::new(),
685 revisions: Vec::new(),
686 digest_format: TRANSCRIPT_DIGEST_FORMAT_CURRENT,
687 });
688 if record.commit.parent_revision != state.head {
689 if revision_body_extends_head(&record.parent_body, &state.revisions, &state.head)? {
690 state.head = record.commit.parent_revision.clone();
691 } else {
692 return Err(TranscriptEditError::HistoryStateMalformed(format!(
693 "rewrite record parent {} does not extend transcript head {}",
694 record.commit.parent_revision, state.head
695 )));
696 }
697 }
698 if !state
699 .revisions
700 .iter()
701 .any(|body| body.revision == record.parent_body.revision)
702 {
703 state.revisions.push(record.parent_body);
704 }
705 if !state
706 .revisions
707 .iter()
708 .any(|body| body.revision == record.revision_body.revision)
709 {
710 state.revisions.push(record.revision_body);
711 }
712 state.head = record.commit.revision.clone();
713 state.commits.push(record.commit);
714 }
715 Ok(state)
716 }
717}
718
719#[derive(Debug, Clone, thiserror::Error)]
721pub enum TranscriptEditError {
722 #[error("message index {message_index} out of bounds for {message_count} messages")]
723 MessageIndexOutOfBounds {
724 message_index: usize,
725 message_count: usize,
726 },
727 #[error("{block_kind} index {block_index} out of bounds for {block_count} blocks")]
728 BlockIndexOutOfBounds {
729 block_kind: &'static str,
730 block_index: usize,
731 block_count: usize,
732 },
733 #[error("replacement expected {expected} at message index {message_index}, found {actual}")]
734 MessageRoleMismatch {
735 message_index: usize,
736 expected: &'static str,
737 actual: &'static str,
738 },
739 #[error("invalid transcript rewrite range {start}..{end} for {message_count} messages")]
740 InvalidRewriteRange {
741 start: usize,
742 end: usize,
743 message_count: usize,
744 },
745 #[error("transcript rewrite does not change transcript revision {revision}")]
746 NoOpRewrite { revision: String },
747 #[error("transcript rewrite parent revision mismatch: expected {expected}, actual {actual}")]
748 RevisionConflict { expected: String, actual: String },
749 #[error("transcript history state is malformed: {0}")]
750 HistoryStateMalformed(String),
751 #[error("invalid transcript shape after rewrite: {0}")]
752 InvalidTranscriptShape(String),
753}
754
755fn message_role_name(message: &Message) -> &'static str {
756 match message {
757 Message::System(_) => "system",
758 Message::SystemNotice(_) => "system_notice",
759 Message::User(_) => "user",
760 Message::BlockAssistant(_) => "block_assistant",
761 Message::ToolResults { .. } => "tool_results",
762 }
763}
764
765fn assistant_tool_use_ids(message: &Message) -> Vec<&str> {
766 match message {
767 Message::BlockAssistant(assistant) => assistant
768 .blocks
769 .iter()
770 .filter_map(|block| match block {
771 AssistantBlock::ToolUse { id, .. } => Some(id.as_str()),
772 _ => None,
773 })
774 .collect(),
775 _ => Vec::new(),
776 }
777}
778
779fn validate_transcript_tool_result_shape(messages: &[Message]) -> Result<(), TranscriptEditError> {
780 for (index, message) in messages.iter().enumerate() {
781 if let Message::ToolResults { results, .. } = message {
782 let Some(previous) = index
783 .checked_sub(1)
784 .and_then(|previous| messages.get(previous))
785 else {
786 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
787 "tool_results at message {index} has no preceding assistant tool-use message"
788 )));
789 };
790 let expected = assistant_tool_use_ids(previous);
791 if expected.is_empty() {
792 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
793 "tool_results at message {index} follows {}, not an assistant tool-use message",
794 message_role_name(previous)
795 )));
796 }
797 let actual = results
798 .iter()
799 .map(|result| result.tool_use_id.as_str())
800 .collect::<Vec<_>>();
801 let actual_set = actual.iter().copied().collect::<BTreeSet<_>>();
802 let expected_set = expected.iter().copied().collect::<BTreeSet<_>>();
803 if actual.len() != actual_set.len() {
804 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
805 "tool_results at message {index} contains duplicate tool ids"
806 )));
807 }
808 if expected.len() != expected_set.len() {
809 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
810 "assistant tool-use message before tool_results at message {index} contains duplicate tool ids"
811 )));
812 }
813 if actual_set != expected_set {
814 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
815 "tool_results at message {index} resolve tool ids {actual_set:?}, expected {expected_set:?}"
816 )));
817 }
818 }
819
820 let tool_use_ids = assistant_tool_use_ids(message);
821 if tool_use_ids.is_empty() {
822 continue;
823 }
824 let Some(next) = messages.get(index + 1) else {
825 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
826 "assistant tool-use message {index} has no following tool_results"
827 )));
828 };
829 if !matches!(next, Message::ToolResults { .. }) {
830 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
831 "assistant tool-use message {index} is followed by {}, not tool_results",
832 message_role_name(next)
833 )));
834 }
835 }
836 Ok(())
837}
838
839fn canonicalize_digest_image_blocks(blocks: &mut [crate::types::ContentBlock]) {
840 for block in blocks.iter_mut() {
841 if let crate::types::ContentBlock::Image {
842 media_type,
843 data: crate::types::ImageData::Inline { data },
844 } = block
845 {
846 let blob_id = crate::blob::content_blob_id(media_type, data);
849 *block = crate::types::ContentBlock::Image {
850 media_type: media_type.clone(),
851 data: crate::types::ImageData::Blob { blob_id },
852 };
853 }
854 }
855}
856
857fn canonicalize_message_images_for_digest(messages: &[Message]) -> Vec<Message> {
866 let mut canonical = messages.to_vec();
867 for message in &mut canonical {
868 match message {
869 Message::User(user) => canonicalize_digest_image_blocks(&mut user.content),
870 Message::ToolResults { results, .. } => {
871 for result in results.iter_mut() {
872 canonicalize_digest_image_blocks(&mut result.content);
873 }
874 }
875 Message::SystemNotice(notice) => {
876 for block in &mut notice.blocks {
877 match block {
878 crate::types::SystemNoticeBlock::Comms { content, .. }
879 | crate::types::SystemNoticeBlock::ExternalEvent { content, .. } => {
880 canonicalize_digest_image_blocks(content);
881 }
882 _ => {}
883 }
884 }
885 }
886 _ => {}
887 }
888 }
889 canonical
890}
891
892pub(crate) fn canonicalize_checkpoint_history_value(
898 value: &serde_json::Value,
899) -> Result<serde_json::Value, serde_json::Error> {
900 let state: TranscriptHistoryState = serde_json::from_value(value.clone())?;
901 let mut revisions = state
902 .revisions
903 .into_iter()
904 .map(|body| {
905 serde_json::json!({
906 "revision": body.revision,
907 "messages": canonicalize_messages_for_digest(&body.messages),
908 })
909 })
910 .collect::<Vec<_>>();
911 revisions.sort_by(|left, right| {
912 left.get("revision")
913 .and_then(serde_json::Value::as_str)
914 .cmp(&right.get("revision").and_then(serde_json::Value::as_str))
915 });
916 Ok(serde_json::json!({
917 "head": state.head,
918 "commits": state.commits,
919 "revisions": revisions,
920 }))
921}
922
923fn canonicalize_checkpoint_deferred_turn_value(
924 value: &serde_json::Value,
925) -> Result<serde_json::Value, serde_json::Error> {
926 let mut state: SessionDeferredTurnState = serde_json::from_value(value.clone())?;
927 if let Some(prompt) = state.pending_initial_prompt_mut_for_blob_rewrite()
928 && let crate::types::ContentInput::Blocks(blocks) = &mut prompt.prompt
929 {
930 canonicalize_digest_image_blocks(blocks);
931 }
932 for pending in state.pending_tool_results_mut_for_blob_rewrite() {
933 for result in &mut pending.results {
934 canonicalize_digest_image_blocks(&mut result.content);
935 }
936 }
937 serde_json::to_value(state)
938}
939
940fn digest_timestamp_sentinel() -> crate::types::MessageTimestamp {
944 chrono::DateTime::<chrono::Utc>::UNIX_EPOCH
945}
946
947fn canonicalize_messages_for_digest(messages: &[Message]) -> Vec<Message> {
965 let mut canonical = canonicalize_message_images_for_digest(messages);
966 for message in &mut canonical {
967 match message {
968 Message::System(system) => {
969 system.created_at = digest_timestamp_sentinel();
970 }
971 Message::SystemNotice(notice) => {
972 notice.created_at = digest_timestamp_sentinel();
973 }
974 Message::User(user) => {
975 user.identity = crate::types::TranscriptMessageIdentity::default();
976 user.created_at = digest_timestamp_sentinel();
977 }
978 Message::BlockAssistant(assistant) => {
979 assistant.identity = crate::types::TranscriptMessageIdentity::default();
980 assistant.created_at = digest_timestamp_sentinel();
981 }
982 Message::ToolResults { created_at, .. } => {
983 *created_at = digest_timestamp_sentinel();
984 }
985 }
986 }
987 canonical
988}
989
990pub fn transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
991 sha256_json_digest(&canonicalize_messages_for_digest(messages))
992}
993
994fn legacy_transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
1003 sha256_json_digest(&canonicalize_message_images_for_digest(messages))
1004}
1005
1006fn validate_transcript_rewrite_record(
1007 commit: &TranscriptRewriteCommit,
1008 parent_body: &TranscriptRevisionBody,
1009 revision_body: &TranscriptRevisionBody,
1010) -> Result<(), TranscriptEditError> {
1011 if parent_body.revision != commit.parent_revision {
1012 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1013 "parent body revision {} does not match commit parent {}",
1014 parent_body.revision, commit.parent_revision
1015 )));
1016 }
1017 if revision_body.revision != commit.revision {
1018 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1019 "revision body {} does not match commit revision {}",
1020 revision_body.revision, commit.revision
1021 )));
1022 }
1023 if commit.parent_revision == commit.revision {
1024 return Err(TranscriptEditError::NoOpRewrite {
1025 revision: commit.revision.clone(),
1026 });
1027 }
1028 let parent_digest = transcript_messages_digest(&parent_body.messages)
1029 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1030 if parent_digest != commit.parent_revision {
1031 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1032 "parent body digest {parent_digest} does not match commit parent {}",
1033 commit.parent_revision
1034 )));
1035 }
1036 let revision_digest = transcript_messages_digest(&revision_body.messages)
1037 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1038 if revision_digest != commit.revision {
1039 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1040 "revision body digest {revision_digest} does not match commit revision {}",
1041 commit.revision
1042 )));
1043 }
1044 let (start, end) = commit.selection.bounds();
1045 if start > end || end > parent_body.messages.len() {
1046 return Err(TranscriptEditError::InvalidRewriteRange {
1047 start,
1048 end,
1049 message_count: parent_body.messages.len(),
1050 });
1051 }
1052 if commit.messages_before != parent_body.messages.len()
1053 || commit.messages_after != revision_body.messages.len()
1054 {
1055 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1056 "commit message counts {} -> {} do not match revision bodies {} -> {}",
1057 commit.messages_before,
1058 commit.messages_after,
1059 parent_body.messages.len(),
1060 revision_body.messages.len()
1061 )));
1062 }
1063 let original_span_digest = transcript_messages_digest(&parent_body.messages[start..end])
1064 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1065 if original_span_digest != commit.original_span_digest {
1066 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1067 "original span digest {original_span_digest} does not match commit digest {}",
1068 commit.original_span_digest
1069 )));
1070 }
1071 let removed_len = end - start;
1072 let retained_len = commit
1073 .messages_before
1074 .checked_sub(removed_len)
1075 .ok_or_else(|| {
1076 TranscriptEditError::HistoryStateMalformed(
1077 "commit removed more messages than it recorded before rewrite".to_string(),
1078 )
1079 })?;
1080 let replacement_len = commit
1081 .messages_after
1082 .checked_sub(retained_len)
1083 .ok_or_else(|| {
1084 TranscriptEditError::HistoryStateMalformed(
1085 "commit message counts cannot describe a replacement span".to_string(),
1086 )
1087 })?;
1088 let replacement_end = start.checked_add(replacement_len).ok_or_else(|| {
1089 TranscriptEditError::HistoryStateMalformed("replacement span end overflowed".to_string())
1090 })?;
1091 if replacement_end > revision_body.messages.len() {
1092 return Err(TranscriptEditError::InvalidRewriteRange {
1093 start,
1094 end: replacement_end,
1095 message_count: revision_body.messages.len(),
1096 });
1097 }
1098 if commit.selection.semantic() == TranscriptRewriteSemantic::Compaction {
1099 let summary_count = revision_body.messages[start..replacement_end]
1100 .iter()
1101 .filter(|message| {
1102 matches!(message, Message::User(user) if user.transcript_role.is_compaction_summary())
1103 })
1104 .count();
1105 if start != 0
1106 || end != commit.messages_before
1107 || commit.messages_after >= commit.messages_before
1108 || summary_count != 1
1109 {
1110 return Err(TranscriptEditError::HistoryStateMalformed(
1111 "typed compaction rewrite must shrink the full transcript and carry exactly one CompactionSummary"
1112 .to_string(),
1113 ));
1114 }
1115 }
1116 let parent_prefix_digest = transcript_messages_digest(&parent_body.messages[..start])
1117 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1118 let revision_prefix_digest = transcript_messages_digest(&revision_body.messages[..start])
1119 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1120 if parent_prefix_digest != revision_prefix_digest {
1121 return Err(TranscriptEditError::HistoryStateMalformed(
1122 "rewrite revision changed messages before the selected span".to_string(),
1123 ));
1124 }
1125 let parent_suffix_digest = transcript_messages_digest(&parent_body.messages[end..])
1126 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1127 let revision_suffix_digest =
1128 transcript_messages_digest(&revision_body.messages[replacement_end..])
1129 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1130 if parent_suffix_digest != revision_suffix_digest {
1131 return Err(TranscriptEditError::HistoryStateMalformed(
1132 "rewrite revision changed messages after the selected span".to_string(),
1133 ));
1134 }
1135 let replacement_digest =
1136 transcript_messages_digest(&revision_body.messages[start..replacement_end])
1137 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1138 if replacement_digest != commit.replacement_digest {
1139 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1140 "replacement span digest {replacement_digest} does not match commit digest {}",
1141 commit.replacement_digest
1142 )));
1143 }
1144 Ok(())
1145}
1146
1147pub(crate) fn validate_transcript_history_state(
1148 state: &TranscriptHistoryState,
1149) -> Result<(), TranscriptEditError> {
1150 if state
1151 .revisions
1152 .iter()
1153 .all(|body| body.revision != state.head)
1154 {
1155 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1156 "missing transcript head body {}",
1157 state.head
1158 )));
1159 }
1160 for body in &state.revisions {
1161 let digest = transcript_messages_digest(&body.messages)
1162 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1163 if digest != body.revision {
1164 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1165 "transcript revision body {} has digest {digest}",
1166 body.revision
1167 )));
1168 }
1169 }
1170 for commit in &state.commits {
1171 let parent_body = state
1172 .revisions
1173 .iter()
1174 .find(|body| body.revision == commit.parent_revision)
1175 .ok_or_else(|| {
1176 TranscriptEditError::HistoryStateMalformed(format!(
1177 "missing parent transcript body {}",
1178 commit.parent_revision
1179 ))
1180 })?;
1181 let revision_body = state
1182 .revisions
1183 .iter()
1184 .find(|body| body.revision == commit.revision)
1185 .ok_or_else(|| {
1186 TranscriptEditError::HistoryStateMalformed(format!(
1187 "missing transcript revision body {}",
1188 commit.revision
1189 ))
1190 })?;
1191 validate_transcript_rewrite_record(commit, parent_body, revision_body)?;
1192 }
1193 let Some(first_commit) = state.commits.first() else {
1194 return Ok(());
1195 };
1196 let mut expected_head = first_commit.parent_revision.clone();
1197 for commit in &state.commits {
1198 let parent_body = state
1199 .revisions
1200 .iter()
1201 .find(|body| body.revision == commit.parent_revision)
1202 .ok_or_else(|| {
1203 TranscriptEditError::HistoryStateMalformed(format!(
1204 "missing parent transcript body {}",
1205 commit.parent_revision
1206 ))
1207 })?;
1208 if commit.parent_revision != expected_head
1209 && !revision_body_extends_head(parent_body, &state.revisions, &expected_head)?
1210 {
1211 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1212 "rewrite commit parent {} does not extend transcript head {}",
1213 commit.parent_revision, expected_head
1214 )));
1215 }
1216 expected_head = commit.revision.clone();
1217 }
1218 let head_is_audited_endpoint = state
1219 .commits
1220 .iter()
1221 .any(|commit| commit.parent_revision == state.head || commit.revision == state.head);
1222 let head_extends_latest_commit = if head_is_audited_endpoint {
1223 let Some(head_body) = state
1224 .revisions
1225 .iter()
1226 .find(|body| body.revision == state.head)
1227 else {
1228 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1229 "missing transcript head body {}",
1230 state.head
1231 )));
1232 };
1233 revision_body_extends_head(head_body, &state.revisions, &expected_head)?
1234 } else {
1235 let mut cursor = state.head.as_str();
1236 let mut visited = BTreeSet::new();
1237 while cursor != expected_head {
1238 if !visited.insert(cursor.to_string()) {
1239 break;
1240 }
1241 let Some(head_body) = state.revisions.iter().find(|body| body.revision == cursor)
1242 else {
1243 break;
1244 };
1245 let Some(parent) = head_body.parent_revision.as_deref() else {
1246 break;
1247 };
1248 cursor = parent;
1249 }
1250 cursor == expected_head
1251 };
1252 if !head_extends_latest_commit {
1253 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1254 "transcript head {} does not extend the rewrite chain",
1255 state.head
1256 )));
1257 }
1258 Ok(())
1259}
1260
1261fn revision_body_extends_head(
1262 candidate: &TranscriptRevisionBody,
1263 revisions: &[TranscriptRevisionBody],
1264 head: &str,
1265) -> Result<bool, TranscriptEditError> {
1266 let Some(head_body) = revisions.iter().find(|body| body.revision == head) else {
1267 return Ok(false);
1268 };
1269 if candidate.revision == head {
1270 return Ok(true);
1271 }
1272 if candidate.messages.len() < head_body.messages.len() {
1273 return Ok(false);
1274 }
1275 let prefix_digest = transcript_messages_digest(&candidate.messages[..head_body.messages.len()])
1276 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1277 if prefix_digest == head {
1278 return Ok(true);
1279 }
1280
1281 let (Some(Message::System(_)), Some(Message::System(_))) =
1287 (candidate.messages.first(), head_body.messages.first())
1288 else {
1289 return Ok(false);
1290 };
1291 let head_tail_len = head_body.messages.len().saturating_sub(1);
1292 if head_tail_len == 0 {
1293 return Ok(true);
1294 }
1295 let candidate_tail = &candidate.messages[1..];
1296 if candidate_tail.len() < head_tail_len {
1297 return Ok(false);
1298 }
1299 let head_tail_digest = transcript_messages_digest(&head_body.messages[1..])
1300 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1301 let candidate_tail_prefix_digest = transcript_messages_digest(&candidate_tail[..head_tail_len])
1302 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1303 Ok(candidate_tail_prefix_digest == head_tail_digest)
1304}
1305
1306fn sha256_json_digest<T: Serialize + ?Sized>(value: &T) -> Result<String, serde_json::Error> {
1307 crate::checkpoint::record_content_digest_computation();
1308 let bytes = serde_json::to_vec(value)?;
1309 let digest = Sha256::digest(bytes);
1310 let mut out = String::with_capacity(digest.len() * 2);
1311 const HEX: &[u8; 16] = b"0123456789abcdef";
1312 for byte in digest {
1313 out.push(HEX[(byte >> 4) as usize] as char);
1314 out.push(HEX[(byte & 0x0f) as usize] as char);
1315 }
1316 Ok(format!("sha256:{out}"))
1317}
1318
1319#[derive(Debug, Clone)]
1323pub struct Session {
1324 version: u32,
1327 id: SessionId,
1329 pub(crate) messages: Arc<Vec<Message>>,
1331 created_at: SystemTime,
1333 updated_at: SystemTime,
1335 metadata: serde_json::Map<String, serde_json::Value>,
1337 transcript_history_metadata_validation: TranscriptHistoryMetadataValidation,
1345 usage: Usage,
1347}
1348
1349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1350enum TranscriptHistoryMetadataValidation {
1351 Validated,
1352 RequiresValidation,
1353}
1354
1355#[derive(Deserialize)]
1357#[serde(rename_all = "snake_case")]
1358struct SessionSerde {
1359 version: u32,
1360 id: SessionId,
1361 messages: Vec<Message>,
1362 created_at: SystemTime,
1363 updated_at: SystemTime,
1364 #[serde(default)]
1365 metadata: serde_json::Map<String, serde_json::Value>,
1366 #[serde(default)]
1367 usage: Usage,
1368}
1369
1370#[derive(Serialize)]
1374#[serde(rename_all = "snake_case")]
1375struct SessionSerdeRef<'a> {
1376 version: u32,
1377 id: &'a SessionId,
1378 messages: &'a [Message],
1379 created_at: &'a SystemTime,
1380 updated_at: &'a SystemTime,
1381 metadata: &'a serde_json::Map<String, serde_json::Value>,
1382 usage: &'a Usage,
1383}
1384
1385impl Serialize for Session {
1386 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1387 where
1388 S: Serializer,
1389 {
1390 let compacted_metadata = if self.transcript_history_metadata_validation
1391 == TranscriptHistoryMetadataValidation::RequiresValidation
1392 {
1393 let mut metadata = self.metadata.clone();
1394 compact_transcript_history_metadata_for_snapshot(&mut metadata)
1395 .map_err(<S::Error as serde::ser::Error>::custom)?;
1396 Some(metadata)
1397 } else {
1398 None
1399 };
1400 let metadata = compacted_metadata.as_ref().unwrap_or(&self.metadata);
1401 let serde_repr = SessionSerdeRef {
1402 version: self.version,
1403 id: &self.id,
1404 messages: self.messages(),
1405 created_at: &self.created_at,
1406 updated_at: &self.updated_at,
1407 metadata,
1408 usage: &self.usage,
1409 };
1410 serde_repr.serialize(serializer)
1411 }
1412}
1413
1414fn compact_transcript_history_metadata_for_snapshot(
1415 metadata: &mut serde_json::Map<String, serde_json::Value>,
1416) -> Result<(), String> {
1417 let Some(value) = metadata.remove(SESSION_TRANSCRIPT_HISTORY_STATE_KEY) else {
1418 return Ok(());
1419 };
1420 let mut state: TranscriptHistoryState =
1421 serde_json::from_value(value).map_err(|error| error.to_string())?;
1422 state
1423 .compact_mechanical_revision_bodies()
1424 .map_err(|error| error.to_string())?;
1425 metadata.insert(
1426 SESSION_TRANSCRIPT_HISTORY_STATE_KEY.to_string(),
1427 serde_json::to_value(state).map_err(|error| error.to_string())?,
1428 );
1429 Ok(())
1430}
1431
1432impl<'de> Deserialize<'de> for Session {
1433 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1434 where
1435 D: Deserializer<'de>,
1436 {
1437 let serde_repr = SessionSerde::deserialize(deserializer)?;
1438 let version = session_persistence_version_authority::restore_session_envelope_version(
1439 serde_repr.version,
1440 )
1441 .map_err(<D::Error as serde::de::Error>::custom)?;
1442 let mut metadata = serde_repr.metadata;
1443 compact_transcript_history_metadata_for_snapshot(&mut metadata)
1444 .map_err(<D::Error as serde::de::Error>::custom)?;
1445 Ok(Session {
1446 version,
1447 id: serde_repr.id,
1448 messages: Arc::new(serde_repr.messages),
1449 created_at: serde_repr.created_at,
1450 updated_at: serde_repr.updated_at,
1451 metadata,
1452 transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
1453 usage: serde_repr.usage,
1454 })
1455 }
1456}
1457
1458#[derive(Deserialize)]
1467#[serde(rename_all = "snake_case")]
1468struct SessionMetadataDocumentSerde {
1469 version: u32,
1470 id: SessionId,
1471 #[serde(default)]
1472 metadata: serde_json::Map<String, serde_json::Value>,
1473}
1474
1475#[derive(Debug, Clone)]
1484pub struct SessionMetadataDocument {
1485 session_id: SessionId,
1486 metadata: serde_json::Map<String, serde_json::Value>,
1487}
1488
1489impl SessionMetadataDocument {
1490 pub fn session_id(&self) -> &SessionId {
1492 &self.session_id
1493 }
1494
1495 pub fn session_metadata_value(&self) -> Option<&serde_json::Value> {
1498 self.metadata.get(SESSION_METADATA_KEY)
1499 }
1500
1501 pub fn lifecycle_terminal_value(&self) -> Option<&serde_json::Value> {
1504 self.metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY)
1505 }
1506
1507 pub fn try_checkpoint_metadata_state(
1513 &self,
1514 ) -> Result<
1515 crate::checkpoint::SessionCheckpointMetadataState,
1516 crate::checkpoint::SessionCheckpointError,
1517 > {
1518 crate::checkpoint::session_checkpoint_metadata_state(&self.session_id, &self.metadata)
1519 }
1520
1521 pub fn try_into_view(self) -> Result<PersistedSessionMetadataView, serde_json::Error> {
1524 PersistedSessionMetadataView::try_from_metadata_map(self.session_id, &self.metadata)
1525 }
1526}
1527
1528pub fn session_metadata_document_from_slice(
1535 bytes: &[u8],
1536) -> Result<SessionMetadataDocument, serde_json::Error> {
1537 let serde_repr: SessionMetadataDocumentSerde = serde_json::from_slice(bytes)?;
1538 session_persistence_version_authority::restore_session_envelope_version(serde_repr.version)
1539 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1540 Ok(SessionMetadataDocument {
1541 session_id: serde_repr.id,
1542 metadata: serde_repr.metadata,
1543 })
1544}
1545
1546impl Session {
1547 pub(crate) fn from_head_parts(
1555 version: u32,
1556 id: SessionId,
1557 messages: Vec<Message>,
1558 created_at: SystemTime,
1559 updated_at: SystemTime,
1560 metadata: serde_json::Map<String, serde_json::Value>,
1561 usage: Usage,
1562 ) -> Result<Self, String> {
1563 let version =
1564 session_persistence_version_authority::restore_session_envelope_version(version)
1565 .map_err(|err| err.to_string())?;
1566 Ok(Self {
1567 version,
1568 id,
1569 messages: Arc::new(messages),
1570 created_at,
1571 updated_at,
1572 transcript_history_metadata_validation: if metadata
1573 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
1574 {
1575 TranscriptHistoryMetadataValidation::RequiresValidation
1576 } else {
1577 TranscriptHistoryMetadataValidation::Validated
1578 },
1579 metadata,
1580 usage,
1581 })
1582 }
1583
1584 pub(crate) fn checkpoint_digest_document(
1587 &self,
1588 ) -> Result<serde_json::Value, serde_json::Error> {
1589 let messages = canonicalize_messages_for_digest(self.messages());
1590 let mut metadata = self.metadata.clone();
1591 if self.transcript_history_metadata_validation
1592 == TranscriptHistoryMetadataValidation::RequiresValidation
1593 {
1594 compact_transcript_history_metadata_for_snapshot(&mut metadata)
1595 .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
1596 }
1597 if let Some(history) = metadata.get_mut(SESSION_TRANSCRIPT_HISTORY_STATE_KEY) {
1598 *history = canonicalize_checkpoint_history_value(history)?;
1599 }
1600 if let Some(deferred) = metadata.get_mut(SESSION_DEFERRED_TURN_STATE_KEY) {
1601 *deferred = canonicalize_checkpoint_deferred_turn_value(deferred)?;
1602 }
1603 serde_json::to_value(SessionSerdeRef {
1604 version: self.version,
1605 id: &self.id,
1606 messages: &messages,
1607 created_at: &self.created_at,
1608 updated_at: &self.updated_at,
1609 metadata: &metadata,
1610 usage: &self.usage,
1611 })
1612 }
1613}
1614
1615pub const SESSION_SYSTEM_CONTEXT_STATE_KEY: &str = "session_system_context_state";
1617
1618pub const SESSION_DEFERRED_TURN_STATE_KEY: &str = "session_deferred_turn_state";
1620
1621pub const SESSION_BUILD_STATE_KEY: &str = "session_build_state";
1623
1624pub const SESSION_TOOL_VISIBILITY_STATE_KEY: &str = "session_tool_visibility_state_v1";
1626
1627pub const SESSION_LIFECYCLE_TERMINAL_KEY: &str = "session_lifecycle_terminal";
1629
1630pub const SESSION_CHECKPOINT_STAMP_KEY: &str = "session_checkpoint_stamp_v1";
1632
1633pub const SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY: &str =
1640 "session_runtime_checkpoint_provenance_v1";
1641
1642pub const VIEW_IMAGE_TOOL_NAME: &str = "view_image";
1644
1645pub const SYSTEM_CONTEXT_SEPARATOR: &str = "\n\n---\n\n";
1647
1648#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1649#[error("metadata key `{key}` is reserved for session authority")]
1650pub struct ReservedSessionMetadataKey {
1651 key: String,
1652}
1653
1654impl ReservedSessionMetadataKey {
1655 fn new(key: &str) -> Self {
1656 Self {
1657 key: key.to_string(),
1658 }
1659 }
1660}
1661
1662fn is_session_authority_metadata_key(key: &str) -> bool {
1663 crate::surface_metadata::ReservedMetadataKey::is_session_authority(key)
1666}
1667
1668#[allow(clippy::panic)]
1669fn fail_closed_generated_restore(authority: &'static str, err: serde_json::Error) -> ! {
1670 tracing::error!(
1671 authority,
1672 error = %err,
1673 "generated authority rejected durable restore"
1674 );
1675 panic!("generated {authority} authority rejected durable restore: {err}");
1676}
1677
1678#[derive(Clone)]
1684pub struct SystemContextStateHandle {
1685 inner: Arc<std::sync::Mutex<SessionSystemContextState>>,
1686 boundary: Arc<SystemContextBoundaryCoordinator>,
1687}
1688
1689struct SystemContextBoundaryCoordinator {
1690 incarnation_id: uuid::Uuid,
1691 lifecycle: std::sync::Mutex<SystemContextBoundaryLifecycle>,
1692 notify: tokio::sync::Notify,
1693}
1694
1695struct SystemContextBoundaryLifecycle {
1696 actor_live: bool,
1697 next_generation: u64,
1698 next_request_id: u64,
1699 window: SystemContextBoundaryWindow,
1700}
1701
1702enum SystemContextBoundaryWindow {
1703 Closed,
1704 Open {
1705 run_id: RunId,
1706 generation: u64,
1707 request: Option<RegisteredSystemContextBoundaryRequest>,
1708 },
1709 Parked {
1710 run_id: RunId,
1711 generation: u64,
1712 request_id: u64,
1713 candidate_state: SessionSystemContextState,
1714 },
1715 Resolved {
1716 run_id: RunId,
1717 generation: u64,
1718 request_id: u64,
1719 resolution: SystemContextBoundaryResolution,
1720 },
1721 Consuming {
1726 run_id: RunId,
1727 generation: u64,
1728 request_id: Option<u64>,
1729 },
1730}
1731
1732struct RegisteredSystemContextBoundaryRequest {
1733 request_id: u64,
1734 appends: Vec<(AppendSystemContextRequest, SystemTime)>,
1735}
1736
1737#[derive(Clone)]
1738enum SystemContextBoundaryResolution {
1739 Committed,
1740 Aborted,
1741 Failed(CoreBoundaryStageError),
1742}
1743
1744impl Default for SystemContextBoundaryCoordinator {
1745 fn default() -> Self {
1746 Self {
1747 incarnation_id: uuid::Uuid::new_v4(),
1748 lifecycle: std::sync::Mutex::new(SystemContextBoundaryLifecycle {
1749 actor_live: true,
1750 next_generation: 0,
1751 next_request_id: 0,
1752 window: SystemContextBoundaryWindow::Closed,
1753 }),
1754 notify: tokio::sync::Notify::new(),
1755 }
1756 }
1757}
1758
1759impl SystemContextBoundaryCoordinator {
1760 fn lock(&self) -> std::sync::MutexGuard<'_, SystemContextBoundaryLifecycle> {
1761 self.lifecycle.lock().unwrap_or_else(|poisoned| {
1762 tracing::warn!(
1763 "system-context boundary coordinator lock poisoned; retaining exact authority"
1764 );
1765 poisoned.into_inner()
1766 })
1767 }
1768
1769 fn abort_request(&self, request_id: u64) -> Result<(), CoreBoundaryStageError> {
1770 let mut lifecycle = self.lock();
1771 let parked_owner = match &lifecycle.window {
1772 SystemContextBoundaryWindow::Parked {
1773 run_id,
1774 generation,
1775 request_id: current_request_id,
1776 ..
1777 } if *current_request_id == request_id => Some((run_id.clone(), *generation)),
1778 _ => None,
1779 };
1780 if let Some((run_id, generation)) = parked_owner {
1781 lifecycle.window = SystemContextBoundaryWindow::Resolved {
1782 run_id,
1783 generation,
1784 request_id,
1785 resolution: SystemContextBoundaryResolution::Aborted,
1786 };
1787 drop(lifecycle);
1788 self.notify.notify_waiters();
1789 return Ok(());
1790 }
1791 match &mut lifecycle.window {
1792 SystemContextBoundaryWindow::Open { request, .. }
1793 if request
1794 .as_ref()
1795 .is_some_and(|request| request.request_id == request_id) =>
1796 {
1797 *request = None;
1798 }
1799 SystemContextBoundaryWindow::Resolved {
1800 request_id: current_request_id,
1801 ..
1802 } if *current_request_id == request_id => return Ok(()),
1803 _ => {
1804 return Err(CoreBoundaryStageError::stale(format!(
1805 "boundary request {request_id} no longer owns its actor window"
1806 )));
1807 }
1808 }
1809 drop(lifecycle);
1810 self.notify.notify_waiters();
1811 Ok(())
1812 }
1813
1814 fn close_run(&self, run_id: &RunId) {
1815 let mut lifecycle = self.lock();
1816 let owns_window = match &lifecycle.window {
1817 SystemContextBoundaryWindow::Open {
1818 run_id: current, ..
1819 }
1820 | SystemContextBoundaryWindow::Parked {
1821 run_id: current, ..
1822 }
1823 | SystemContextBoundaryWindow::Resolved {
1824 run_id: current, ..
1825 }
1826 | SystemContextBoundaryWindow::Consuming {
1827 run_id: current, ..
1828 } => current == run_id,
1829 SystemContextBoundaryWindow::Closed => false,
1830 };
1831 if owns_window {
1832 lifecycle.window = SystemContextBoundaryWindow::Closed;
1833 drop(lifecycle);
1834 self.notify.notify_waiters();
1835 }
1836 }
1837
1838 fn revoke_actor(&self) {
1839 let mut lifecycle = self.lock();
1840 lifecycle.actor_live = false;
1841 lifecycle.window = SystemContextBoundaryWindow::Closed;
1842 drop(lifecycle);
1843 self.notify.notify_waiters();
1844 }
1845}
1846
1847#[must_use]
1851pub(crate) struct SystemContextBoundaryRunGuard {
1852 boundary: Arc<SystemContextBoundaryCoordinator>,
1853 run_id: RunId,
1854}
1855
1856impl Drop for SystemContextBoundaryRunGuard {
1857 fn drop(&mut self) {
1858 self.boundary.close_run(&self.run_id);
1859 }
1860}
1861
1862struct PendingSystemContextBoundaryPreparation {
1863 boundary: Arc<SystemContextBoundaryCoordinator>,
1864 request_id: u64,
1865 armed: bool,
1866}
1867
1868impl Drop for PendingSystemContextBoundaryPreparation {
1869 fn drop(&mut self) {
1870 if self.armed {
1871 let _ = self.boundary.abort_request(self.request_id);
1872 }
1873 }
1874}
1875
1876#[must_use = "model-boundary context must be consumed or dropped before opening another boundary"]
1884pub(crate) struct ModelBoundarySystemContext {
1885 state: SystemContextStateHandle,
1886 run_id: RunId,
1887 generation: u64,
1888 request_id: Option<u64>,
1889 appends: Vec<PendingSystemContextAppend>,
1890 armed: bool,
1891}
1892
1893impl ModelBoundarySystemContext {
1894 pub(crate) fn appends(&self) -> &[PendingSystemContextAppend] {
1895 &self.appends
1896 }
1897
1898 pub(crate) fn projected_state_after_consume(&self) -> SessionSystemContextState {
1902 let mut projected = self.state.snapshot();
1903 projected.mark_pending_applied();
1904 projected
1905 }
1906
1907 pub(crate) fn consume(
1908 mut self,
1909 ) -> Result<Vec<PendingSystemContextAppend>, CoreBoundaryStageError> {
1910 self.state.finish_model_boundary_consumption(
1911 &self.run_id,
1912 self.generation,
1913 self.request_id,
1914 true,
1915 )?;
1916 self.armed = false;
1917 Ok(std::mem::take(&mut self.appends))
1918 }
1919}
1920
1921impl Drop for ModelBoundarySystemContext {
1922 fn drop(&mut self) {
1923 if self.armed {
1924 let _ = self.state.finish_model_boundary_consumption(
1925 &self.run_id,
1926 self.generation,
1927 self.request_id,
1928 false,
1929 );
1930 self.armed = false;
1931 }
1932 }
1933}
1934
1935#[must_use = "prepared system context must be committed or aborted"]
1946pub struct PreparedSystemContextBoundary {
1947 state: SystemContextStateHandle,
1948 expected_run_id: RunId,
1949 generation: u64,
1950 request_id: u64,
1951 candidate_state: SessionSystemContextState,
1952 armed: bool,
1953 _not_sync: std::marker::PhantomData<std::cell::Cell<()>>,
1957}
1958
1959impl std::fmt::Debug for PreparedSystemContextBoundary {
1960 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1961 formatter
1962 .debug_struct("PreparedSystemContextBoundary")
1963 .field("actor_incarnation", &self.state.boundary.incarnation_id)
1964 .field("expected_run_id", &self.expected_run_id)
1965 .field("generation", &self.generation)
1966 .field("request_id", &self.request_id)
1967 .finish_non_exhaustive()
1968 }
1969}
1970
1971impl PreparedSystemContextBoundary {
1972 #[must_use]
1973 pub fn expected_run_id(&self) -> &RunId {
1974 &self.expected_run_id
1975 }
1976
1977 #[must_use]
1978 pub fn boundary_generation(&self) -> u64 {
1979 self.generation
1980 }
1981
1982 #[must_use]
1983 pub fn candidate_state(&self) -> &SessionSystemContextState {
1984 &self.candidate_state
1985 }
1986
1987 pub fn into_stage_output(
1991 self,
1992 session_snapshot: Option<Vec<u8>>,
1993 ) -> crate::lifecycle::CoreBoundaryStageOutput {
1994 crate::lifecycle::CoreBoundaryStageOutput::prepared(session_snapshot, Box::new(self))
1995 }
1996
1997 fn resolve(
1998 &mut self,
1999 resolution: SystemContextBoundaryResolution,
2000 ) -> Result<(), CoreBoundaryStageError> {
2001 if !self.armed {
2002 return Err(CoreBoundaryStageError::stale(
2003 "prepared boundary authority was already resolved",
2004 ));
2005 }
2006 let mut lifecycle = self.state.boundary.lock();
2007 if !lifecycle.actor_live {
2008 self.armed = false;
2009 return Err(CoreBoundaryStageError::stale(format!(
2010 "actor incarnation {} was revoked",
2011 self.state.boundary.incarnation_id
2012 )));
2013 }
2014 let matches_exact = matches!(
2015 &lifecycle.window,
2016 SystemContextBoundaryWindow::Parked {
2017 run_id,
2018 generation,
2019 request_id,
2020 ..
2021 } if run_id == &self.expected_run_id
2022 && *generation == self.generation
2023 && *request_id == self.request_id
2024 );
2025 if !matches_exact {
2026 self.armed = false;
2027 return Err(CoreBoundaryStageError::stale(format!(
2028 "actor/run/boundary witness no longer matches request {}",
2029 self.request_id
2030 )));
2031 }
2032 if matches!(&resolution, SystemContextBoundaryResolution::Committed) {
2033 let mut state = self
2034 .state
2035 .inner
2036 .lock()
2037 .unwrap_or_else(std::sync::PoisonError::into_inner);
2038 *state = self.candidate_state.clone();
2039 }
2040 lifecycle.window = SystemContextBoundaryWindow::Resolved {
2041 run_id: self.expected_run_id.clone(),
2042 generation: self.generation,
2043 request_id: self.request_id,
2044 resolution,
2045 };
2046 self.armed = false;
2047 drop(lifecycle);
2048 self.state.boundary.notify.notify_waiters();
2049 Ok(())
2050 }
2051}
2052
2053impl crate::lifecycle::core_executor::CoreBoundaryStageCommitAuthority
2054 for PreparedSystemContextBoundary
2055{
2056 fn commit(&mut self) -> Result<(), CoreBoundaryStageError> {
2057 self.resolve(SystemContextBoundaryResolution::Committed)
2058 }
2059
2060 fn abort(&mut self) -> Result<(), CoreBoundaryStageError> {
2061 self.resolve(SystemContextBoundaryResolution::Aborted)
2062 }
2063}
2064
2065impl Drop for PreparedSystemContextBoundary {
2066 fn drop(&mut self) {
2067 if self.armed {
2068 let _ = self.state.boundary.abort_request(self.request_id);
2069 self.armed = false;
2070 }
2071 }
2072}
2073
2074impl std::fmt::Debug for SystemContextStateHandle {
2075 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2076 f.debug_struct("SystemContextStateHandle")
2077 .field("inner", &"<Arc<Mutex<SessionSystemContextState>>>")
2078 .field("actor_incarnation", &self.boundary.incarnation_id)
2079 .finish()
2080 }
2081}
2082
2083impl SystemContextStateHandle {
2084 fn boundary_reserves_state(lifecycle: &SystemContextBoundaryLifecycle) -> bool {
2085 matches!(
2086 &lifecycle.window,
2087 SystemContextBoundaryWindow::Parked { .. }
2088 | SystemContextBoundaryWindow::Resolved { .. }
2089 | SystemContextBoundaryWindow::Consuming { .. }
2090 )
2091 }
2092
2093 pub fn new(state: SessionSystemContextState) -> Result<Self, serde_json::Error> {
2094 let state = system_context_authority::restore_system_context_state(state)
2095 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
2096 Ok(Self {
2097 inner: Arc::new(std::sync::Mutex::new(state)),
2098 boundary: Arc::new(SystemContextBoundaryCoordinator::default()),
2099 })
2100 }
2101
2102 pub(crate) fn begin_boundary_run(
2105 &self,
2106 run_id: RunId,
2107 ) -> Result<SystemContextBoundaryRunGuard, CoreBoundaryStageError> {
2108 self.open_next_boundary(&run_id)?;
2109 Ok(SystemContextBoundaryRunGuard {
2110 boundary: Arc::clone(&self.boundary),
2111 run_id,
2112 })
2113 }
2114
2115 pub(crate) fn open_next_boundary(&self, run_id: &RunId) -> Result<u64, CoreBoundaryStageError> {
2119 let mut lifecycle = self.boundary.lock();
2120 if !lifecycle.actor_live {
2121 return Err(CoreBoundaryStageError::stale(format!(
2122 "actor incarnation {} was revoked",
2123 self.boundary.incarnation_id
2124 )));
2125 }
2126 match &lifecycle.window {
2127 SystemContextBoundaryWindow::Open {
2128 run_id: current,
2129 generation,
2130 ..
2131 } if current == run_id => return Ok(*generation),
2132 SystemContextBoundaryWindow::Parked { .. }
2133 | SystemContextBoundaryWindow::Resolved { .. }
2134 | SystemContextBoundaryWindow::Consuming { .. } => {
2135 return Err(CoreBoundaryStageError::fault(
2136 "runner attempted to open a new boundary while the prior boundary was unresolved",
2137 ));
2138 }
2139 SystemContextBoundaryWindow::Open {
2140 run_id: current, ..
2141 } => {
2142 return Err(CoreBoundaryStageError::stale(format!(
2143 "run {run_id} cannot replace still-open boundary owned by {current}"
2144 )));
2145 }
2146 SystemContextBoundaryWindow::Closed => {}
2147 }
2148 lifecycle.next_generation = lifecycle
2149 .next_generation
2150 .checked_add(1)
2151 .ok_or_else(|| CoreBoundaryStageError::fault("boundary generation overflow"))?;
2152 let generation = lifecycle.next_generation;
2153 lifecycle.window = SystemContextBoundaryWindow::Open {
2154 run_id: run_id.clone(),
2155 generation,
2156 request: None,
2157 };
2158 drop(lifecycle);
2159 self.boundary.notify.notify_waiters();
2160 Ok(generation)
2161 }
2162
2163 pub async fn prepare_active_turn_boundary(
2168 &self,
2169 expected_run_id: &RunId,
2170 appends: Vec<PendingSystemContextAppend>,
2171 ) -> Result<PreparedSystemContextBoundary, CoreBoundaryStageError> {
2172 if appends.is_empty() {
2173 return Err(CoreBoundaryStageError::fault(
2174 "boundary preparation requires at least one context append",
2175 ));
2176 }
2177 let stage_inputs = appends
2178 .into_iter()
2179 .map(|append| {
2180 (
2181 AppendSystemContextRequest {
2182 content: append.content,
2183 source: append.source,
2184 idempotency_key: append.idempotency_key,
2185 source_kind: append.source_kind,
2186 peer_response_terminal: append.peer_response_terminal,
2187 },
2188 append.accepted_at,
2189 )
2190 })
2191 .collect::<Vec<_>>();
2192
2193 let request_id = {
2194 let mut lifecycle = self.boundary.lock();
2195 if !lifecycle.actor_live {
2196 return Err(CoreBoundaryStageError::stale(format!(
2197 "actor incarnation {} was revoked",
2198 self.boundary.incarnation_id
2199 )));
2200 }
2201 let (run_id, request) = match &mut lifecycle.window {
2202 SystemContextBoundaryWindow::Open {
2203 run_id, request, ..
2204 } => (run_id, request),
2205 SystemContextBoundaryWindow::Closed => {
2206 return Err(CoreBoundaryStageError::unavailable(format!(
2207 "run {expected_run_id} has no open cooperative model boundary"
2208 )));
2209 }
2210 SystemContextBoundaryWindow::Parked { .. }
2211 | SystemContextBoundaryWindow::Resolved { .. }
2212 | SystemContextBoundaryWindow::Consuming { .. } => {
2213 return Err(CoreBoundaryStageError::unavailable(format!(
2214 "the open boundary for run {expected_run_id} was already claimed or consumed"
2215 )));
2216 }
2217 };
2218 if run_id != expected_run_id {
2219 return Err(CoreBoundaryStageError::stale(format!(
2220 "open boundary belongs to run {run_id}, not {expected_run_id}"
2221 )));
2222 }
2223 if request.is_some() {
2224 return Err(CoreBoundaryStageError::unavailable(format!(
2225 "the next boundary for run {expected_run_id} already has a preparation"
2226 )));
2227 }
2228 let state = self
2231 .inner
2232 .lock()
2233 .unwrap_or_else(std::sync::PoisonError::into_inner);
2234 let mut candidate = state.clone();
2235 for (append, accepted_at) in &stage_inputs {
2236 candidate
2237 .stage_active_turn_append(append, *accepted_at)
2238 .map_err(|error| CoreBoundaryStageError::fault(error.to_string()))?;
2239 }
2240 drop(state);
2241 lifecycle.next_request_id = lifecycle
2242 .next_request_id
2243 .checked_add(1)
2244 .ok_or_else(|| CoreBoundaryStageError::fault("boundary request id overflow"))?;
2245 let request_id = lifecycle.next_request_id;
2246 let SystemContextBoundaryWindow::Open { request, .. } = &mut lifecycle.window else {
2247 return Err(CoreBoundaryStageError::fault(
2248 "boundary window changed while registering preparation",
2249 ));
2250 };
2251 *request = Some(RegisteredSystemContextBoundaryRequest {
2252 request_id,
2253 appends: stage_inputs,
2254 });
2255 request_id
2256 };
2257
2258 let mut pending = PendingSystemContextBoundaryPreparation {
2259 boundary: Arc::clone(&self.boundary),
2260 request_id,
2261 armed: true,
2262 };
2263 self.boundary.notify.notify_waiters();
2264
2265 loop {
2266 let notified = self.boundary.notify.notified();
2267 tokio::pin!(notified);
2268 notified.as_mut().enable();
2269 let poll = {
2270 let lifecycle = self.boundary.lock();
2271 if lifecycle.actor_live {
2272 match &lifecycle.window {
2273 SystemContextBoundaryWindow::Parked {
2274 run_id,
2275 generation,
2276 request_id: parked_request_id,
2277 candidate_state,
2278 } if *parked_request_id == request_id => {
2279 Ok(Some(PreparedSystemContextBoundary {
2280 state: self.clone(),
2281 expected_run_id: run_id.clone(),
2282 generation: *generation,
2283 request_id,
2284 candidate_state: candidate_state.clone(),
2285 armed: true,
2286 _not_sync: std::marker::PhantomData,
2287 }))
2288 }
2289 SystemContextBoundaryWindow::Open { request, .. }
2290 if request
2291 .as_ref()
2292 .is_some_and(|request| request.request_id == request_id) =>
2293 {
2294 Ok(None)
2295 }
2296 SystemContextBoundaryWindow::Resolved {
2297 request_id: resolved_request_id,
2298 resolution,
2299 ..
2300 } if *resolved_request_id == request_id => match resolution {
2301 SystemContextBoundaryResolution::Failed(error) => Err(error.clone()),
2302 SystemContextBoundaryResolution::Committed
2303 | SystemContextBoundaryResolution::Aborted => {
2304 Err(CoreBoundaryStageError::stale(format!(
2305 "boundary request {request_id} resolved before its authority was delivered"
2306 )))
2307 }
2308 },
2309 _ => Err(CoreBoundaryStageError::unavailable(format!(
2310 "run {expected_run_id} ended before boundary request {request_id} parked"
2311 ))),
2312 }
2313 } else {
2314 Err(CoreBoundaryStageError::stale(format!(
2315 "actor incarnation {} was revoked while preparing boundary",
2316 self.boundary.incarnation_id
2317 )))
2318 }
2319 };
2320 match poll {
2321 Ok(Some(prepared)) => {
2322 pending.armed = false;
2323 return Ok(prepared);
2324 }
2325 Ok(None) => notified.as_mut().await,
2326 Err(error) => return Err(error),
2327 }
2328 }
2329 }
2330
2331 pub(crate) async fn take_pending_at_exact_boundary(
2337 &self,
2338 run_id: &RunId,
2339 ) -> Result<ModelBoundarySystemContext, CoreBoundaryStageError> {
2340 let parked_request_id;
2341 {
2342 let mut lifecycle = self.boundary.lock();
2343 if !lifecycle.actor_live {
2344 return Err(CoreBoundaryStageError::stale(format!(
2345 "actor incarnation {} was revoked",
2346 self.boundary.incarnation_id
2347 )));
2348 }
2349 let (generation, request) = match &mut lifecycle.window {
2350 SystemContextBoundaryWindow::Open {
2351 run_id: current,
2352 generation,
2353 request,
2354 } if current == run_id => (*generation, request.take()),
2355 SystemContextBoundaryWindow::Open {
2356 run_id: current, ..
2357 } => {
2358 return Err(CoreBoundaryStageError::stale(format!(
2359 "runner {run_id} reached boundary owned by {current}"
2360 )));
2361 }
2362 SystemContextBoundaryWindow::Closed => {
2363 return Err(CoreBoundaryStageError::unavailable(format!(
2364 "run {run_id} reached a boundary with no open generation"
2365 )));
2366 }
2367 SystemContextBoundaryWindow::Parked { .. }
2368 | SystemContextBoundaryWindow::Resolved { .. }
2369 | SystemContextBoundaryWindow::Consuming { .. } => {
2370 return Err(CoreBoundaryStageError::fault(
2371 "runner re-entered an unresolved model boundary",
2372 ));
2373 }
2374 };
2375 if let Some(request) = request {
2376 let RegisteredSystemContextBoundaryRequest {
2377 request_id,
2378 appends,
2379 } = request;
2380 let state = self
2381 .inner
2382 .lock()
2383 .unwrap_or_else(std::sync::PoisonError::into_inner);
2384 let mut candidate_state = state.clone();
2385 let candidate_result = appends.into_iter().try_for_each(|(append, accepted_at)| {
2386 candidate_state
2387 .stage_active_turn_append(&append, accepted_at)
2388 .map(|_| ())
2389 });
2390 if let Err(error) = candidate_result {
2391 drop(state);
2392 let error = CoreBoundaryStageError::fault(error.to_string());
2393 lifecycle.window = SystemContextBoundaryWindow::Resolved {
2394 run_id: run_id.clone(),
2395 generation,
2396 request_id,
2397 resolution: SystemContextBoundaryResolution::Failed(error.clone()),
2398 };
2399 drop(lifecycle);
2400 self.boundary.notify.notify_waiters();
2401 return Err(error);
2402 }
2403 drop(state);
2404 parked_request_id = request_id;
2405 lifecycle.window = SystemContextBoundaryWindow::Parked {
2406 run_id: run_id.clone(),
2407 generation,
2408 request_id,
2409 candidate_state,
2410 };
2411 } else {
2412 let state = self
2413 .inner
2414 .lock()
2415 .unwrap_or_else(std::sync::PoisonError::into_inner);
2416 let pending = state.pending().to_vec();
2417 drop(state);
2418 lifecycle.window = SystemContextBoundaryWindow::Consuming {
2419 run_id: run_id.clone(),
2420 generation,
2421 request_id: None,
2422 };
2423 return Ok(ModelBoundarySystemContext {
2424 state: self.clone(),
2425 run_id: run_id.clone(),
2426 generation,
2427 request_id: None,
2428 appends: pending,
2429 armed: true,
2430 });
2431 }
2432 }
2433 self.boundary.notify.notify_waiters();
2434
2435 let request_id = parked_request_id;
2436 struct RunnerParkGuard {
2437 boundary: Arc<SystemContextBoundaryCoordinator>,
2438 request_id: u64,
2439 armed: bool,
2440 }
2441 impl Drop for RunnerParkGuard {
2442 fn drop(&mut self) {
2443 if self.armed {
2444 let _ = self.boundary.abort_request(self.request_id);
2445 }
2446 }
2447 }
2448 let mut park_guard = RunnerParkGuard {
2449 boundary: Arc::clone(&self.boundary),
2450 request_id,
2451 armed: true,
2452 };
2453
2454 loop {
2455 let notified = self.boundary.notify.notified();
2456 tokio::pin!(notified);
2457 notified.as_mut().enable();
2458 let poll = {
2459 let mut lifecycle = self.boundary.lock();
2460 if lifecycle.actor_live {
2461 match &lifecycle.window {
2462 SystemContextBoundaryWindow::Parked {
2463 request_id: parked_request_id,
2464 ..
2465 } if *parked_request_id == request_id => Ok(None),
2466 SystemContextBoundaryWindow::Resolved {
2467 run_id: resolved_run_id,
2468 generation,
2469 request_id: resolved_request_id,
2470 resolution,
2471 } if resolved_run_id == run_id && *resolved_request_id == request_id => {
2472 let resolution = resolution.clone();
2473 let generation = *generation;
2474 if let SystemContextBoundaryResolution::Failed(error) = resolution {
2475 Err(error)
2476 } else {
2477 let pending = {
2478 let state = self
2479 .inner
2480 .lock()
2481 .unwrap_or_else(std::sync::PoisonError::into_inner);
2482 state.pending().to_vec()
2483 };
2484 lifecycle.window = SystemContextBoundaryWindow::Consuming {
2485 run_id: run_id.clone(),
2486 generation,
2487 request_id: Some(request_id),
2488 };
2489 Ok(Some((
2490 ModelBoundarySystemContext {
2491 state: self.clone(),
2492 run_id: run_id.clone(),
2493 generation,
2494 request_id: Some(request_id),
2495 appends: pending,
2496 armed: true,
2497 },
2498 resolution,
2499 )))
2500 }
2501 }
2502 _ => Err(CoreBoundaryStageError::stale(format!(
2503 "parked boundary request {request_id} lost exact run/generation authority"
2504 ))),
2505 }
2506 } else {
2507 Err(CoreBoundaryStageError::stale(format!(
2508 "actor incarnation {} was revoked while parked",
2509 self.boundary.incarnation_id
2510 )))
2511 }
2512 };
2513 match poll {
2514 Ok(Some((context, resolution))) => {
2515 park_guard.armed = false;
2516 self.boundary.notify.notify_waiters();
2517 if matches!(resolution, SystemContextBoundaryResolution::Aborted) {
2518 tracing::debug!(
2519 actor_incarnation = %self.boundary.incarnation_id,
2520 run_id = %run_id,
2521 request_id,
2522 "exact model-boundary preparation aborted; consuming ordinary pending context only"
2523 );
2524 }
2525 return Ok(context);
2526 }
2527 Ok(None) => notified.as_mut().await,
2528 Err(error) => {
2529 park_guard.armed = false;
2530 return Err(error);
2531 }
2532 }
2533 }
2534 }
2535
2536 fn finish_model_boundary_consumption(
2537 &self,
2538 run_id: &RunId,
2539 generation: u64,
2540 request_id: Option<u64>,
2541 apply: bool,
2542 ) -> Result<(), CoreBoundaryStageError> {
2543 let mut lifecycle = self.boundary.lock();
2544 if !lifecycle.actor_live {
2545 return Err(CoreBoundaryStageError::stale(format!(
2546 "actor incarnation {} was revoked before model-boundary consumption",
2547 self.boundary.incarnation_id
2548 )));
2549 }
2550 let matches_exact = matches!(
2551 &lifecycle.window,
2552 SystemContextBoundaryWindow::Consuming {
2553 run_id: current_run_id,
2554 generation: current_generation,
2555 request_id: current_request_id,
2556 } if current_run_id == run_id
2557 && *current_generation == generation
2558 && *current_request_id == request_id
2559 );
2560 if !matches_exact {
2561 return Err(CoreBoundaryStageError::stale(format!(
2562 "runner model-boundary witness for run {run_id} generation {generation} is no longer current"
2563 )));
2564 }
2565 if apply {
2566 let mut state = self
2567 .inner
2568 .lock()
2569 .unwrap_or_else(std::sync::PoisonError::into_inner);
2570 state.mark_pending_applied();
2571 }
2572 lifecycle.window = SystemContextBoundaryWindow::Closed;
2573 drop(lifecycle);
2574 self.boundary.notify.notify_waiters();
2575 Ok(())
2576 }
2577
2578 pub fn revoke_boundary_actor(&self) {
2582 self.boundary.revoke_actor();
2583 }
2584
2585 pub fn snapshot(&self) -> SessionSystemContextState {
2586 match self.inner.lock() {
2587 Ok(guard) => guard.clone(),
2588 Err(poisoned) => {
2589 tracing::warn!("system-context state lock poisoned while reading snapshot");
2590 poisoned.into_inner().clone()
2591 }
2592 }
2593 }
2594
2595 pub fn replace_from_generated_restore(
2596 &self,
2597 state: SessionSystemContextState,
2598 ) -> Result<(), serde_json::Error> {
2599 let state = system_context_authority::restore_system_context_state(state)
2600 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
2601 let boundary = self.boundary.lock();
2602 if Self::boundary_reserves_state(&boundary) {
2603 return Err(<serde_json::Error as serde::de::Error>::custom(
2604 "system-context state is reserved by an exact parked boundary",
2605 ));
2606 }
2607 match self.inner.lock() {
2608 Ok(mut guard) => {
2609 *guard = state;
2610 }
2611 Err(poisoned) => {
2612 tracing::warn!("system-context state lock poisoned while restoring state");
2613 *poisoned.into_inner() = state;
2614 }
2615 }
2616 Ok(())
2617 }
2618
2619 pub fn replace_from_generated_restore_if_changed(
2620 &self,
2621 state: SessionSystemContextState,
2622 ) -> Result<bool, serde_json::Error> {
2623 let state = system_context_authority::restore_system_context_state(state)
2624 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
2625 let boundary = self.boundary.lock();
2626 if Self::boundary_reserves_state(&boundary) {
2627 return Err(<serde_json::Error as serde::de::Error>::custom(
2628 "system-context state is reserved by an exact parked boundary",
2629 ));
2630 }
2631 let mut guard = match self.inner.lock() {
2632 Ok(guard) => guard,
2633 Err(poisoned) => {
2634 tracing::warn!(
2635 "system-context state lock poisoned while replacing generated-restored state"
2636 );
2637 poisoned.into_inner()
2638 }
2639 };
2640 if *guard == state {
2641 return Ok(false);
2642 }
2643 *guard = state;
2644 Ok(true)
2645 }
2646
2647 pub fn replace_from_generated_restore_if_current(
2648 &self,
2649 current: &SessionSystemContextState,
2650 replacement: SessionSystemContextState,
2651 ) -> Result<bool, serde_json::Error> {
2652 let replacement = system_context_authority::restore_system_context_state(replacement)
2653 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
2654 let boundary = self.boundary.lock();
2655 if Self::boundary_reserves_state(&boundary) {
2656 return Err(<serde_json::Error as serde::de::Error>::custom(
2657 "system-context state is reserved by an exact parked boundary",
2658 ));
2659 }
2660 let mut guard = match self.inner.lock() {
2661 Ok(guard) => guard,
2662 Err(poisoned) => {
2663 tracing::warn!(
2664 "system-context state lock poisoned while conditionally replacing generated-restored state"
2665 );
2666 poisoned.into_inner()
2667 }
2668 };
2669 if *guard != *current {
2670 return Ok(false);
2671 }
2672 *guard = replacement;
2673 Ok(true)
2674 }
2675
2676 pub fn stage_append_with_snapshot(
2677 &self,
2678 req: &AppendSystemContextRequest,
2679 accepted_at: SystemTime,
2680 ) -> Result<
2681 (
2682 crate::service::AppendSystemContextStatus,
2683 SessionSystemContextState,
2684 SessionSystemContextState,
2685 ),
2686 SystemContextStageError,
2687 > {
2688 let boundary = self.boundary.lock();
2689 if Self::boundary_reserves_state(&boundary) {
2690 return Err(SystemContextStageError::InvalidRequest(
2691 "system-context state is reserved by an exact parked boundary".to_string(),
2692 ));
2693 }
2694 let mut guard = match self.inner.lock() {
2695 Ok(guard) => guard,
2696 Err(poisoned) => {
2697 tracing::warn!("system-context state lock poisoned while staging append");
2698 poisoned.into_inner()
2699 }
2700 };
2701 let snapshot = guard.clone();
2702 let status = guard.stage_append(req, accepted_at)?;
2703 let staged = guard.clone();
2704 Ok((status, snapshot, staged))
2705 }
2706
2707 pub fn stage_active_turn_appends_with_snapshot(
2708 &self,
2709 appends: Vec<(AppendSystemContextRequest, SystemTime)>,
2710 ) -> Result<(SessionSystemContextState, SessionSystemContextState), SystemContextStageError>
2711 {
2712 let boundary = self.boundary.lock();
2713 if Self::boundary_reserves_state(&boundary) {
2714 return Err(SystemContextStageError::InvalidRequest(
2715 "system-context state is reserved by an exact parked boundary".to_string(),
2716 ));
2717 }
2718 let mut guard = match self.inner.lock() {
2719 Ok(guard) => guard,
2720 Err(poisoned) => {
2721 tracing::warn!(
2722 "system-context state lock poisoned while staging active-turn appends"
2723 );
2724 poisoned.into_inner()
2725 }
2726 };
2727 let snapshot = guard.clone();
2728 let mut candidate = snapshot.clone();
2729 for (req, accepted_at) in appends {
2730 candidate.stage_active_turn_append(&req, accepted_at)?;
2731 }
2732 *guard = candidate.clone();
2733 let staged = candidate;
2734 Ok((snapshot, staged))
2735 }
2736
2737 pub fn discard_unapplied_active_turn_pending(&self) -> Result<usize, CoreBoundaryStageError> {
2738 let boundary = self.boundary.lock();
2739 if Self::boundary_reserves_state(&boundary) {
2740 return Err(CoreBoundaryStageError::fault(format!(
2741 "cannot discard active-turn system context while exact actor incarnation {} owns a parked or consuming boundary",
2742 self.boundary.incarnation_id
2743 )));
2744 }
2745 let discarded = match self.inner.lock() {
2746 Ok(mut guard) => guard.discard_unapplied_active_turn_pending(),
2747 Err(poisoned) => {
2748 tracing::warn!(
2749 "system-context state lock poisoned while discarding active-turn context"
2750 );
2751 poisoned
2752 .into_inner()
2753 .discard_unapplied_active_turn_pending()
2754 }
2755 };
2756 Ok(discarded.len())
2757 }
2758
2759 pub fn discard_active_turn_pending_by_keys(
2760 &self,
2761 idempotency_keys: &[String],
2762 ) -> Result<Vec<PendingSystemContextAppend>, CoreBoundaryStageError> {
2763 let boundary = self.boundary.lock();
2764 if Self::boundary_reserves_state(&boundary) {
2765 return Err(CoreBoundaryStageError::fault(format!(
2766 "cannot discard keyed active-turn system context while exact actor incarnation {} owns a parked or consuming boundary",
2767 self.boundary.incarnation_id
2768 )));
2769 }
2770 let discarded = match self.inner.lock() {
2771 Ok(mut guard) => guard.discard_active_turn_pending_by_keys(idempotency_keys),
2772 Err(poisoned) => {
2773 tracing::warn!(
2774 "system-context state lock poisoned while discarding active-turn pending appends"
2775 );
2776 poisoned
2777 .into_inner()
2778 .discard_active_turn_pending_by_keys(idempotency_keys)
2779 }
2780 };
2781 Ok(discarded)
2782 }
2783
2784 pub fn stage_active_turn_append(
2785 &self,
2786 req: &AppendSystemContextRequest,
2787 accepted_at: SystemTime,
2788 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
2789 let boundary = self.boundary.lock();
2790 if Self::boundary_reserves_state(&boundary) {
2791 return Err(SystemContextStageError::InvalidRequest(
2792 "system-context state is reserved by an exact parked boundary".to_string(),
2793 ));
2794 }
2795 match self.inner.lock() {
2796 Ok(mut guard) => guard.stage_active_turn_append(req, accepted_at),
2797 Err(poisoned) => {
2798 tracing::warn!(
2799 "system-context state lock poisoned while staging active-turn context"
2800 );
2801 poisoned
2802 .into_inner()
2803 .stage_active_turn_append(req, accepted_at)
2804 }
2805 }
2806 }
2807}
2808
2809#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
2813#[serde(rename_all = "snake_case")]
2814pub struct SessionSystemContextState {
2815 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2816 pub(crate) pending: Vec<PendingSystemContextAppend>,
2817 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2818 pub(crate) applied: Vec<PendingSystemContextAppend>,
2819 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
2820 pub(crate) seen: std::collections::BTreeMap<String, SeenSystemContextKey>,
2821 #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
2824 pub(crate) active_turn_pending_keys: std::collections::BTreeSet<String>,
2825 #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
2831 pub(crate) active_turn_pending_indices: std::collections::BTreeSet<u64>,
2832}
2833
2834#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
2844#[serde(rename_all = "snake_case")]
2845pub enum SystemContextSource {
2846 #[default]
2848 Normal,
2849 RuntimeSteer,
2852}
2853
2854impl From<SystemContextSource> for session_document::SystemContextSource {
2855 fn from(value: SystemContextSource) -> Self {
2856 match value {
2857 SystemContextSource::Normal => Self::Normal,
2858 SystemContextSource::RuntimeSteer => Self::RuntimeSteer,
2859 }
2860 }
2861}
2862
2863impl SystemContextSource {
2864 #[must_use]
2867 pub fn is_normal(&self) -> bool {
2868 matches!(self, Self::Normal)
2869 }
2870
2871 #[must_use]
2873 pub fn is_runtime_steer(&self) -> bool {
2874 matches!(self, Self::RuntimeSteer)
2875 }
2876}
2877
2878#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2882#[serde(rename_all = "snake_case")]
2883pub struct PendingSystemContextAppend {
2884 pub content: crate::lifecycle::run_primitive::CoreRenderable,
2892 #[serde(default, skip_serializing_if = "Option::is_none")]
2893 pub source: Option<String>,
2894 #[serde(default, skip_serializing_if = "Option::is_none")]
2895 pub idempotency_key: Option<String>,
2896 #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
2898 pub source_kind: SystemContextSource,
2899 #[serde(default, skip_serializing_if = "Option::is_none")]
2907 pub peer_response_terminal: Option<crate::handles::PeerResponseTerminalFact>,
2908 pub accepted_at: SystemTime,
2909}
2910
2911#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2926#[serde(rename_all = "snake_case")]
2927pub enum SessionLifecycleTerminal {
2928 Active,
2930 Archived,
2932}
2933
2934impl SessionLifecycleTerminal {
2935 #[must_use]
2937 pub fn is_archived(self) -> bool {
2938 matches!(self, Self::Archived)
2939 }
2940}
2941
2942impl From<SessionLifecycleTerminal> for session_document::SessionDocumentLifecycle {
2943 fn from(value: SessionLifecycleTerminal) -> Self {
2944 match value {
2945 SessionLifecycleTerminal::Active => Self::Active,
2946 SessionLifecycleTerminal::Archived => Self::Archived,
2947 }
2948 }
2949}
2950
2951impl From<session_document::SessionDocumentLifecycle> for SessionLifecycleTerminal {
2952 fn from(value: session_document::SessionDocumentLifecycle) -> Self {
2953 match value {
2954 session_document::SessionDocumentLifecycle::Active => Self::Active,
2955 session_document::SessionDocumentLifecycle::Archived => Self::Archived,
2956 }
2957 }
2958}
2959
2960#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
2962#[serde(rename_all = "snake_case")]
2963pub struct SessionDeferredTurnState {
2964 #[serde(default, skip_serializing_if = "DeferredFirstTurnPhase::is_inactive")]
2965 pub(crate) first_turn_phase: DeferredFirstTurnPhase,
2966 #[serde(default, skip_serializing_if = "Option::is_none")]
2967 pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
2968 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2969 pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
2970}
2971
2972#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
2974#[serde(rename_all = "snake_case")]
2975pub enum DeferredFirstTurnPhase {
2976 #[default]
2978 Inactive,
2979 Pending,
2981 Consumed,
2983}
2984
2985impl DeferredFirstTurnPhase {
2986 pub fn is_inactive(&self) -> bool {
2987 matches!(self, Self::Inactive)
2988 }
2989}
2990
2991impl From<DeferredFirstTurnPhase> for session_document::SessionFirstTurnPhase {
2992 fn from(value: DeferredFirstTurnPhase) -> Self {
2993 match value {
2994 DeferredFirstTurnPhase::Inactive => Self::Inactive,
2995 DeferredFirstTurnPhase::Pending => Self::Pending,
2996 DeferredFirstTurnPhase::Consumed => Self::Consumed,
2997 }
2998 }
2999}
3000
3001impl From<session_document::SessionFirstTurnPhase> for DeferredFirstTurnPhase {
3002 fn from(value: session_document::SessionFirstTurnPhase) -> Self {
3003 match value {
3004 session_document::SessionFirstTurnPhase::Inactive => Self::Inactive,
3005 session_document::SessionFirstTurnPhase::Pending => Self::Pending,
3006 session_document::SessionFirstTurnPhase::Consumed => Self::Consumed,
3007 }
3008 }
3009}
3010
3011fn is_default_hook_run_overrides(value: &crate::HookRunOverrides) -> bool {
3012 value == &crate::HookRunOverrides::default()
3013}
3014
3015fn is_default_call_timeout_override(value: &crate::CallTimeoutOverride) -> bool {
3016 value == &crate::CallTimeoutOverride::default()
3017}
3018
3019fn is_tool_filter_all(value: &ToolFilter) -> bool {
3020 matches!(value, ToolFilter::All)
3021}
3022
3023fn is_zero(value: &u64) -> bool {
3024 *value == 0
3025}
3026
3027pub fn capability_base_filter_for_image_tool_results(image_tool_results: bool) -> ToolFilter {
3029 if image_tool_results {
3030 ToolFilter::All
3031 } else {
3032 ToolFilter::Deny([VIEW_IMAGE_TOOL_NAME.to_string()].into_iter().collect())
3033 }
3034}
3035
3036#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
3043#[serde(rename_all = "snake_case")]
3044pub struct ToolVisibilityWitness {
3045 #[serde(default, skip_serializing_if = "Option::is_none")]
3046 pub last_seen_provenance: Option<ToolProvenance>,
3047}
3048
3049impl ToolVisibilityWitness {
3050 pub fn has_identity_witness(&self) -> bool {
3051 self.last_seen_provenance.is_some()
3052 }
3053}
3054
3055#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3061#[serde(rename_all = "snake_case")]
3062pub struct DeferredToolLoadAuthority {
3063 pub name: ToolName,
3064 pub witness: ToolVisibilityWitness,
3065}
3066
3067impl DeferredToolLoadAuthority {
3068 pub fn new(name: impl Into<ToolName>, witness: ToolVisibilityWitness) -> Self {
3069 Self {
3070 name: name.into(),
3071 witness,
3072 }
3073 }
3074
3075 pub fn into_parts(self) -> (ToolName, ToolVisibilityWitness) {
3076 (self.name, self.witness)
3077 }
3078}
3079
3080#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
3083#[serde(rename_all = "snake_case")]
3084pub struct WitnessedToolFilter {
3085 pub filter: ToolFilter,
3086 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3087 pub witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3088}
3089
3090impl WitnessedToolFilter {
3091 pub fn new(filter: ToolFilter, witnesses: BTreeMap<ToolName, ToolVisibilityWitness>) -> Self {
3092 Self { filter, witnesses }
3093 }
3094
3095 pub fn into_parts(self) -> (ToolFilter, BTreeMap<ToolName, ToolVisibilityWitness>) {
3096 (self.filter, self.witnesses)
3097 }
3098}
3099
3100#[derive(Debug, Clone, PartialEq, Eq)]
3107pub struct InheritedToolVisibilityAuthority {
3108 filter: ToolFilter,
3109 witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3110}
3111
3112impl InheritedToolVisibilityAuthority {
3113 pub(crate) fn from_generated_composition_authority(
3114 filter: ToolFilter,
3115 witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3116 ) -> Self {
3117 Self { filter, witnesses }
3118 }
3119
3120 pub fn filter(&self) -> &ToolFilter {
3121 &self.filter
3122 }
3123
3124 pub fn witnesses(&self) -> &BTreeMap<ToolName, ToolVisibilityWitness> {
3125 &self.witnesses
3126 }
3127
3128 pub(crate) fn into_initial_visibility_state(self) -> SessionToolVisibilityState {
3129 SessionToolVisibilityState {
3130 inherited_base_filter: self.filter,
3131 filter_witnesses: self.witnesses,
3132 ..Default::default()
3133 }
3134 }
3135}
3136
3137#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
3139#[serde(rename_all = "snake_case")]
3140pub struct SessionToolVisibilityState {
3141 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
3142 pub capability_base_filter: ToolFilter,
3143 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
3144 pub inherited_base_filter: ToolFilter,
3145 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
3146 pub active_filter: ToolFilter,
3147 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
3148 pub staged_filter: ToolFilter,
3149 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
3150 pub active_requested_deferred_names: BTreeSet<ToolName>,
3151 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
3152 pub staged_requested_deferred_names: BTreeSet<ToolName>,
3153 #[serde(default, skip_serializing_if = "is_zero")]
3154 pub active_revision: u64,
3155 #[serde(default, skip_serializing_if = "is_zero")]
3156 pub staged_revision: u64,
3157 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3158 pub requested_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3159 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3160 pub filter_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
3161}
3162
3163impl SessionToolVisibilityState {
3164 #[cfg(test)]
3168 pub(crate) fn projected_boundary_applied(&self) -> Self {
3169 let mut projected = self.clone();
3170 projected.active_filter = self.staged_filter.clone();
3171 projected.active_requested_deferred_names = self.staged_requested_deferred_names.clone();
3172 projected.active_revision = self.staged_revision;
3173 projected
3174 }
3175}
3176
3177#[derive(Debug, Clone, PartialEq, Eq)]
3183pub struct AuthorizedSessionToolVisibilityState {
3184 state: SessionToolVisibilityState,
3185}
3186
3187impl AuthorizedSessionToolVisibilityState {
3188 pub(crate) fn from_generated_authority(state: SessionToolVisibilityState) -> Self {
3189 Self { state }
3190 }
3191
3192 pub fn as_state(&self) -> &SessionToolVisibilityState {
3193 &self.state
3194 }
3195
3196 pub fn into_state(self) -> SessionToolVisibilityState {
3197 self.state
3198 }
3199}
3200
3201#[derive(Debug, Clone, Serialize, Deserialize, Default)]
3204#[serde(rename_all = "snake_case")]
3205pub struct SessionBuildState {
3206 #[serde(
3207 default,
3208 skip_serializing_if = "crate::config::SystemPromptOverride::is_inherit"
3209 )]
3210 pub system_prompt: crate::config::SystemPromptOverride,
3211 #[serde(default, skip_serializing_if = "Option::is_none")]
3212 pub output_schema: Option<crate::OutputSchema>,
3213 #[serde(default, skip_serializing_if = "is_default_hook_run_overrides")]
3214 pub hooks_override: crate::HookRunOverrides,
3215 #[serde(default, skip_serializing_if = "Option::is_none")]
3216 pub budget_limits: Option<crate::BudgetLimits>,
3217 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3218 pub recoverable_tool_defs: Vec<ToolDef>,
3219 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3220 pub silent_comms_intents: Vec<String>,
3221 #[serde(default, skip_serializing_if = "Option::is_none")]
3222 pub max_inline_peer_notifications: Option<i32>,
3223 #[serde(default, skip_serializing_if = "Option::is_none")]
3224 pub app_context: Option<serde_json::Value>,
3225 #[serde(default, skip_serializing_if = "Option::is_none")]
3226 pub additional_instructions: Option<Vec<String>>,
3227 #[serde(default, skip_serializing_if = "Option::is_none")]
3228 pub shell_env: Option<HashMap<String, String>>,
3229 #[serde(default, skip_serializing_if = "Option::is_none")]
3235 pub mob_tool_authority_context: Option<MobToolAuthorityContext>,
3236 #[serde(default, skip_serializing_if = "is_default_call_timeout_override")]
3237 pub call_timeout_override: crate::CallTimeoutOverride,
3238 #[serde(default, skip_serializing_if = "Option::is_none")]
3245 pub assembled_system_prompt: Option<String>,
3246}
3247
3248#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3250#[serde(rename_all = "snake_case")]
3251pub struct PendingDeferredPrompt {
3252 pub prompt: ContentInput,
3253 pub accepted_at: SystemTime,
3254}
3255
3256#[derive(Debug, Clone, Serialize, Deserialize)]
3258#[serde(rename_all = "snake_case")]
3259pub struct PendingToolResultsMessage {
3260 pub results: Vec<ToolResult>,
3261 pub accepted_at: SystemTime,
3262}
3263
3264impl PartialEq for PendingToolResultsMessage {
3265 fn eq(&self, other: &Self) -> bool {
3266 self.accepted_at == other.accepted_at
3267 && serde_json::to_value(&self.results).ok() == serde_json::to_value(&other.results).ok()
3268 }
3269}
3270
3271#[derive(Debug, Clone, Default, PartialEq)]
3273pub struct ConsumedDeferredTurnInputs {
3274 pub(crate) restore_first_turn_pending: bool,
3275 pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
3276 pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
3277}
3278
3279impl ConsumedDeferredTurnInputs {
3280 pub fn is_empty(&self) -> bool {
3281 !self.restore_first_turn_pending
3282 && self.pending_initial_prompt.is_none()
3283 && self.pending_tool_results.is_empty()
3284 }
3285
3286 pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
3287 self.pending_initial_prompt.as_ref()
3288 }
3289
3290 pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
3291 &self.pending_tool_results
3292 }
3293}
3294
3295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3297#[serde(rename_all = "snake_case")]
3298pub struct SeenSystemContextKey {
3299 pub content: crate::lifecycle::run_primitive::CoreRenderable,
3301 #[serde(default, skip_serializing_if = "Option::is_none")]
3302 pub source: Option<String>,
3303 #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
3306 pub source_kind: SystemContextSource,
3307 pub state: SeenSystemContextState,
3308}
3309
3310#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3312#[serde(rename_all = "snake_case")]
3313pub enum SeenSystemContextState {
3314 Pending,
3315 Applied,
3316}
3317
3318impl SessionSystemContextState {
3319 pub fn pending(&self) -> &[PendingSystemContextAppend] {
3320 &self.pending
3321 }
3322
3323 pub fn applied(&self) -> &[PendingSystemContextAppend] {
3324 &self.applied
3325 }
3326
3327 pub fn seen(&self) -> &BTreeMap<String, SeenSystemContextKey> {
3328 &self.seen
3329 }
3330
3331 pub fn active_turn_pending_keys(&self) -> &BTreeSet<String> {
3332 &self.active_turn_pending_keys
3333 }
3334
3335 pub fn pending_len(&self) -> usize {
3336 self.pending.len()
3337 }
3338
3339 pub fn applied_len(&self) -> usize {
3340 self.applied.len()
3341 }
3342
3343 pub fn active_turn_pending_len(&self) -> usize {
3344 if self.active_turn_pending_indices.is_empty() && !self.active_turn_pending_keys.is_empty()
3345 {
3346 return self
3347 .pending
3348 .iter()
3349 .filter(|append| {
3350 append
3351 .idempotency_key
3352 .as_ref()
3353 .is_some_and(|key| self.active_turn_pending_keys.contains(key))
3354 })
3355 .count();
3356 }
3357 self.active_turn_pending_indices.len()
3358 }
3359
3360 pub fn realtime_projection_appends(&self) -> Vec<PendingSystemContextAppend> {
3361 self.applied
3362 .iter()
3363 .chain(self.pending.iter())
3364 .cloned()
3365 .collect()
3366 }
3367
3368 pub fn stage_append(
3370 &mut self,
3371 req: &AppendSystemContextRequest,
3372 accepted_at: SystemTime,
3373 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
3374 system_context_authority::stage_append(self, req, accepted_at, false)
3375 }
3376
3377 fn stage_append_with_generated_authority(
3378 &mut self,
3379 req: &AppendSystemContextRequest,
3380 accepted_at: SystemTime,
3381 active_turn_scoped: bool,
3382 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
3383 system_context_authority::stage_append(self, req, accepted_at, active_turn_scoped)
3384 }
3385
3386 pub fn stage_active_turn_append(
3393 &mut self,
3394 req: &AppendSystemContextRequest,
3395 accepted_at: SystemTime,
3396 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
3397 self.stage_append_with_generated_authority(req, accepted_at, true)
3398 }
3399
3400 pub fn mark_pending_applied(&mut self) {
3402 system_context_authority::mark_pending_applied(self);
3403 }
3404
3405 pub fn discard_unapplied_active_turn_pending(&mut self) -> Vec<PendingSystemContextAppend> {
3408 system_context_authority::discard_unapplied_active_turn_pending(self)
3409 }
3410
3411 pub fn discard_active_turn_pending_by_keys(
3418 &mut self,
3419 idempotency_keys: &[String],
3420 ) -> Vec<PendingSystemContextAppend> {
3421 system_context_authority::discard_active_turn_pending_by_keys(self, idempotency_keys)
3422 }
3423
3424 pub fn restore_from_snapshot(self) -> Result<Self, SystemContextStageError> {
3428 system_context_authority::restore_system_context_state(self)
3429 }
3430
3431 pub fn record_applied_blocks(
3435 &mut self,
3436 appends: &[PendingSystemContextAppend],
3437 current_system_prompt: &str,
3438 ) -> Vec<PendingSystemContextAppend> {
3439 system_context_authority::record_applied_system_context_blocks(
3440 self,
3441 appends,
3442 current_system_prompt,
3443 )
3444 }
3445}
3446
3447const SESSION_DOCUMENT_FIRST_TURN_KEY: &str = "first_turn";
3452
3453fn usize_to_u64(value: usize) -> u64 {
3454 u64::try_from(value).unwrap_or(u64::MAX)
3455}
3456
3457fn validate_deferred_turn_snapshot(
3465 state: SessionDeferredTurnState,
3466) -> Result<SessionDeferredTurnState, session_document::SessionDocumentError> {
3467 let mut authority = session_document::SessionDocumentMachineAuthority::new();
3468 let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
3469 authority.recover_session_first_turn_phase(
3473 key,
3474 state.first_turn_phase.into(),
3475 state.pending_initial_prompt.is_some(),
3476 usize_to_u64(state.pending_tool_results.len()),
3477 )?;
3478 Ok(state)
3479}
3480
3481impl SessionDeferredTurnState {
3482 pub fn first_turn_phase(&self) -> DeferredFirstTurnPhase {
3483 self.first_turn_phase
3484 }
3485
3486 pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
3487 self.pending_initial_prompt.as_ref()
3488 }
3489
3490 pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
3491 &self.pending_tool_results
3492 }
3493
3494 pub fn pending_tool_results_len(&self) -> usize {
3495 self.pending_tool_results.len()
3496 }
3497
3498 pub(crate) fn pending_initial_prompt_mut_for_blob_rewrite(
3499 &mut self,
3500 ) -> Option<&mut PendingDeferredPrompt> {
3501 self.pending_initial_prompt.as_mut()
3502 }
3503
3504 pub(crate) fn pending_tool_results_mut_for_blob_rewrite(
3505 &mut self,
3506 ) -> &mut [PendingToolResultsMessage] {
3507 &mut self.pending_tool_results
3508 }
3509
3510 fn document_authority(
3520 &self,
3521 ) -> (
3522 session_document::SessionDocumentMachineAuthority,
3523 session_document::SessionDocumentKey,
3524 ) {
3525 let mut authority = session_document::SessionDocumentMachineAuthority::new();
3526 let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
3527 if let Err(err) = authority.recover_session_first_turn_phase(
3528 key.clone(),
3529 self.first_turn_phase.into(),
3530 self.pending_initial_prompt.is_some(),
3531 usize_to_u64(self.pending_tool_results.len()),
3532 ) {
3533 tracing::warn!(
3534 error = %err,
3535 "generated session document authority rejected first-turn recovery"
3536 );
3537 }
3538 (authority, key)
3539 }
3540
3541 fn mirror_first_turn_phase(
3544 &mut self,
3545 effects: &[session_document::SessionDocumentEffect],
3546 ) -> Option<bool> {
3547 for effect in effects {
3548 if let session_document::SessionDocumentEffect::SessionFirstTurnPhaseResolved {
3549 phase,
3550 was_pending,
3551 } = effect
3552 {
3553 self.first_turn_phase = (*phase).into();
3554 return Some(*was_pending);
3555 }
3556 }
3557 None
3558 }
3559
3560 pub fn mark_initial_turn_pending(&mut self) {
3562 let (mut authority, key) = self.document_authority();
3563 match authority.mark_session_initial_turn_pending(key) {
3564 Ok(effects) => {
3565 self.mirror_first_turn_phase(&effects);
3566 }
3567 Err(err) => tracing::warn!(
3568 error = %err,
3569 "generated session document authority rejected pending mark"
3570 ),
3571 }
3572 }
3573
3574 pub fn mark_initial_turn_started(&mut self) -> bool {
3578 let (mut authority, key) = self.document_authority();
3579 match authority.start_session_initial_turn(key) {
3580 Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
3581 Err(err) => {
3582 tracing::warn!(
3583 error = %err,
3584 "generated session document authority rejected first-turn start"
3585 );
3586 false
3587 }
3588 }
3589 }
3590
3591 pub fn restore_initial_turn_pending(&mut self) {
3593 let (mut authority, key) = self.document_authority();
3598 match authority.restore_session_consumed_inputs(
3599 key.clone(),
3600 true,
3601 self.pending_initial_prompt.is_some(),
3602 usize_to_u64(self.pending_tool_results.len()),
3603 ) {
3604 Ok(_) => {
3605 if let Some(phase) = authority.session_first_turn_phase_for(&key) {
3608 self.first_turn_phase = phase.into();
3609 }
3610 }
3611 Err(err) => tracing::warn!(
3612 error = %err,
3613 "generated session document authority rejected pending restore"
3614 ),
3615 }
3616 }
3617
3618 pub fn allows_initial_turn_overrides(&self) -> bool {
3620 let (mut authority, key) = self.document_authority();
3621 match authority.resolve_session_first_turn_overrides_allowed(key) {
3622 Ok(effects) => effects
3623 .iter()
3624 .find_map(|effect| {
3625 match effect {
3626 session_document::SessionDocumentEffect::SessionFirstTurnOverridesResolved {
3627 allowed,
3628 } => Some(*allowed),
3629 _ => None,
3630 }
3631 })
3632 .unwrap_or(false),
3633 Err(err) => {
3634 tracing::warn!(
3635 error = %err,
3636 "generated session document authority rejected override resolution"
3637 );
3638 false
3639 }
3640 }
3641 }
3642
3643 pub fn stage_initial_prompt(&mut self, prompt: ContentInput, accepted_at: SystemTime) {
3645 let prompt_has_content = prompt.has_images() || !prompt.text_content().trim().is_empty();
3646 let (mut authority, key) = self.document_authority();
3647 match authority.stage_session_initial_prompt(key, prompt_has_content) {
3648 Ok(effects) => {
3649 let decision = effects.iter().find_map(|effect| {
3650 match effect {
3651 session_document::SessionDocumentEffect::SessionInitialPromptStageResolved {
3652 decision,
3653 } => Some(*decision),
3654 _ => None,
3655 }
3656 });
3657 match decision {
3658 Some(session_document::SessionInitialPromptStageDecision::Store) => {
3659 self.pending_initial_prompt = Some(PendingDeferredPrompt {
3660 prompt,
3661 accepted_at,
3662 });
3663 }
3664 Some(session_document::SessionInitialPromptStageDecision::Clear) => {
3665 self.pending_initial_prompt = None;
3666 }
3667 None => tracing::warn!(
3668 "generated session document authority returned no prompt-stage decision"
3669 ),
3670 }
3671 }
3672 Err(err) => tracing::warn!(
3673 error = %err,
3674 "generated session document authority rejected initial prompt stage"
3675 ),
3676 }
3677 }
3678
3679 pub fn stage_tool_results(
3681 &mut self,
3682 results: Vec<ToolResult>,
3683 accepted_at: SystemTime,
3684 ) -> usize {
3685 let (mut authority, key) = self.document_authority();
3686 let accepted = match authority.stage_session_tool_results(key, usize_to_u64(results.len()))
3687 {
3688 Ok(effects) => effects.iter().find_map(|effect| match effect {
3689 session_document::SessionDocumentEffect::SessionToolResultsStageResolved {
3690 accepted_count,
3691 } => Some(*accepted_count),
3692 _ => None,
3693 }),
3694 Err(err) => {
3695 tracing::warn!(
3696 error = %err,
3697 "generated session document authority rejected tool-results stage"
3698 );
3699 return 0;
3700 }
3701 };
3702 let Some(accepted) = accepted else {
3703 tracing::warn!(
3704 "generated session document authority returned no tool-results decision"
3705 );
3706 return 0;
3707 };
3708 if accepted == 0 {
3709 return 0;
3710 }
3711 let accepted = usize::try_from(accepted).unwrap_or(usize::MAX);
3712 self.pending_tool_results.push(PendingToolResultsMessage {
3713 results,
3714 accepted_at,
3715 });
3716 accepted
3717 }
3718
3719 pub fn has_pending_tool_results(&self) -> bool {
3721 !self.pending_tool_results.is_empty()
3722 }
3723
3724 pub fn consume_for_started_turn(&mut self) -> ConsumedDeferredTurnInputs {
3726 let (mut authority, key) = self.document_authority();
3727 let was_pending = match authority.consume_session_deferred_inputs(key) {
3728 Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
3729 Err(err) => {
3730 tracing::warn!(
3731 error = %err,
3732 "generated session document authority rejected started-turn consumption"
3733 );
3734 return ConsumedDeferredTurnInputs::default();
3735 }
3736 };
3737 ConsumedDeferredTurnInputs {
3738 restore_first_turn_pending: was_pending,
3739 pending_initial_prompt: self.pending_initial_prompt.take(),
3740 pending_tool_results: std::mem::take(&mut self.pending_tool_results),
3741 }
3742 }
3743
3744 pub fn restore_consumed_turn_inputs(&mut self, consumed: ConsumedDeferredTurnInputs) {
3746 if consumed.is_empty() {
3747 return;
3748 }
3749 let (mut authority, key) = self.document_authority();
3750 let effects = match authority.restore_session_consumed_inputs(
3751 key,
3752 consumed.restore_first_turn_pending,
3753 consumed.pending_initial_prompt.is_some(),
3754 usize_to_u64(consumed.pending_tool_results.len()),
3755 ) {
3756 Ok(effects) => effects,
3757 Err(err) => {
3758 tracing::warn!(
3759 error = %err,
3760 "generated session document authority rejected consumed input restore"
3761 );
3762 return;
3763 }
3764 };
3765 let Some((restore_first_turn_pending, restore_initial_prompt, restore_tool_results)) =
3766 effects.iter().find_map(|effect| match effect {
3767 session_document::SessionDocumentEffect::SessionConsumedInputsRestoreResolved {
3768 restore_first_turn_pending,
3769 restore_initial_prompt,
3770 restore_tool_results,
3771 } => Some((
3772 *restore_first_turn_pending,
3773 *restore_initial_prompt,
3774 *restore_tool_results,
3775 )),
3776 _ => None,
3777 })
3778 else {
3779 tracing::warn!(
3780 "generated session document authority returned no consumed-input restore decision"
3781 );
3782 return;
3783 };
3784 if restore_first_turn_pending {
3785 self.restore_initial_turn_pending();
3786 }
3787 if restore_initial_prompt && self.pending_initial_prompt.is_none() {
3788 self.pending_initial_prompt = consumed.pending_initial_prompt;
3789 }
3790 if restore_tool_results {
3791 let mut restored = consumed.pending_tool_results;
3792 restored.extend(std::mem::take(&mut self.pending_tool_results));
3793 self.pending_tool_results = restored;
3794 }
3795 }
3796}
3797
3798#[derive(Debug, Clone, PartialEq, Eq)]
3800pub enum SystemContextStageError {
3801 InvalidRequest(String),
3802 Conflict {
3803 key: String,
3804 existing_text: String,
3805 existing_source: Option<String>,
3806 },
3807}
3808
3809impl std::fmt::Display for SystemContextStageError {
3810 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3811 match self {
3812 Self::InvalidRequest(message) => {
3813 write!(f, "invalid system-context append request: {message}")
3814 }
3815 Self::Conflict { key, .. } => {
3816 write!(
3817 f,
3818 "system-context append conflict for idempotency key `{key}`"
3819 )
3820 }
3821 }
3822 }
3823}
3824
3825impl std::error::Error for SystemContextStageError {}
3826
3827fn render_system_context_block(append: &PendingSystemContextAppend) -> String {
3835 let mut rendered = String::from(SYSTEM_CONTEXT_RENDER_LABEL);
3836 if let Some(source) = &append.source {
3837 rendered.push_str("\nsource: ");
3838 rendered.push_str(source);
3839 }
3840 rendered.push_str("\n\n");
3841 rendered.push_str(append.content.render_text().trim());
3844 rendered
3845}
3846
3847const SYSTEM_CONTEXT_RENDER_LABEL: &str = "[Runtime System Context]";
3852
3853fn render_system_context_blocks_joined(appends: &[PendingSystemContextAppend]) -> String {
3859 appends
3860 .iter()
3861 .map(render_system_context_block)
3862 .collect::<Vec<_>>()
3863 .join(SYSTEM_CONTEXT_SEPARATOR)
3864}
3865
3866fn compose_system_prompt_with_context_tail(base: &str, tail: &str) -> String {
3871 if tail.is_empty() {
3872 return base.to_string();
3873 }
3874 if base.is_empty() {
3875 return tail
3876 .strip_prefix(SYSTEM_CONTEXT_SEPARATOR)
3877 .unwrap_or(tail)
3878 .to_string();
3879 }
3880 format!("{base}{tail}")
3881}
3882
3883fn persisted_prompt_is_admitted_context_append_continuation(
3894 assembled_base: &str,
3895 persisted_content: &str,
3896 persisted_mutation_kind: crate::types::SystemPromptMutationKind,
3897) -> bool {
3898 let content_identical = persisted_content == assembled_base;
3899 let content_extends = persisted_content.starts_with(assembled_base);
3900 let appended_starts_with_separator = content_extends
3901 && persisted_content[assembled_base.len()..].starts_with(SYSTEM_CONTEXT_SEPARATOR);
3902 let mut authority = session_document::SessionDocumentMachineAuthority::new();
3903 match authority.resolve_system_context_persist_append_admission(
3904 true,
3905 content_identical,
3906 content_extends,
3907 appended_starts_with_separator,
3908 persisted_mutation_kind.is_runtime_context_append(),
3909 ) {
3910 Ok(effects) => effects.into_iter().any(|effect| {
3911 matches!(
3912 effect,
3913 session_document::SessionDocumentEffect::SystemContextPersistAppendAdmissionResolved {
3914 admission: session_document::SystemContextPersistAppendAdmission::Admit,
3915 }
3916 )
3917 }),
3918 Err(error) => {
3919 tracing::warn!(
3920 error = %error,
3921 "session document authority refused resume prompt continuation admission; \
3922 falling back to audited rewrite"
3923 );
3924 false
3925 }
3926 }
3927}
3928
3929mod system_context_authority {
3940 use super::{
3941 AppendSystemContextRequest, BTreeSet, PendingSystemContextAppend, SeenSystemContextKey,
3942 SeenSystemContextState, SessionSystemContextState, SystemContextSource,
3943 SystemContextStageError, SystemTime, render_system_context_block, session_document,
3944 usize_to_u64,
3945 };
3946 use crate::service::AppendSystemContextStatus;
3947
3948 fn document_authority() -> session_document::SessionDocumentMachineAuthority {
3949 session_document::SessionDocumentMachineAuthority::new()
3950 }
3951
3952 fn resolve_append_decision(
3954 trimmed_text_byte_count: u64,
3955 idempotency_key_present: bool,
3956 existing_key_matches: bool,
3957 existing_key_conflicts: bool,
3958 active_turn_scoped: bool,
3959 ) -> Result<session_document::SystemContextAppendDecision, SystemContextStageError> {
3960 let mut authority = document_authority();
3961 let effects = authority
3962 .resolve_system_context_append(
3963 trimmed_text_byte_count,
3964 idempotency_key_present,
3965 existing_key_matches,
3966 existing_key_conflicts,
3967 active_turn_scoped,
3968 )
3969 .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
3970 effects
3971 .into_iter()
3972 .find_map(|effect| match effect {
3973 session_document::SessionDocumentEffect::SystemContextAppendResolved {
3974 decision,
3975 ..
3976 } => Some(decision),
3977 _ => None,
3978 })
3979 .ok_or_else(|| {
3980 SystemContextStageError::InvalidRequest(
3981 "generated session document authority returned no append decision".to_string(),
3982 )
3983 })
3984 }
3985
3986 fn pending_apply_item(source_kind: SystemContextSource) -> Option<(bool, bool, bool)> {
3989 let mut authority = document_authority();
3990 match authority.resolve_system_context_pending_apply_item(source_kind.into()) {
3991 Ok(effects) => effects.into_iter().find_map(|effect| {
3992 match effect {
3993 session_document::SessionDocumentEffect::SystemContextPendingApplyItemResolved {
3994 promote_to_applied,
3995 mark_seen_applied,
3996 remove_seen,
3997 } => Some((promote_to_applied, mark_seen_applied, remove_seen)),
3998 _ => None,
3999 }
4000 }),
4001 Err(err) => {
4002 tracing::warn!(
4003 error = %err,
4004 "generated session document authority rejected system-context apply item"
4005 );
4006 None
4007 }
4008 }
4009 }
4010
4011 fn steer_cleanup_discards(source_kind: SystemContextSource) -> bool {
4014 let mut authority = document_authority();
4015 match authority.resolve_system_context_steer_cleanup_item(source_kind.into()) {
4016 Ok(effects) => effects
4017 .into_iter()
4018 .find_map(|effect| {
4019 match effect {
4020 session_document::SessionDocumentEffect::SystemContextSteerCleanupItemResolved {
4021 discard,
4022 } => Some(discard),
4023 _ => None,
4024 }
4025 })
4026 .unwrap_or(false),
4027 Err(err) => {
4028 tracing::warn!(
4029 error = %err,
4030 "generated session document authority rejected system-context steer cleanup item"
4031 );
4032 false
4033 }
4034 }
4035 }
4036
4037 fn discard_pending_where(
4038 state: &mut SessionSystemContextState,
4039 mut should_discard: impl FnMut(&PendingSystemContextAppend, bool) -> bool,
4040 ) -> Vec<PendingSystemContextAppend> {
4041 reconstruct_legacy_active_turn_indices(state);
4042 let active_indices = std::mem::take(&mut state.active_turn_pending_indices);
4043 let pending = std::mem::take(&mut state.pending);
4044 let mut retained = Vec::with_capacity(pending.len());
4045 let mut retained_active_indices = BTreeSet::new();
4046 let mut retained_active_keys = BTreeSet::new();
4047 let mut discarded = Vec::new();
4048
4049 for (index, append) in pending.into_iter().enumerate() {
4050 let is_active_turn = active_indices.contains(&usize_to_u64(index));
4051 if should_discard(&append, is_active_turn) {
4052 discarded.push(append);
4053 continue;
4054 }
4055 if is_active_turn {
4056 retained_active_indices.insert(usize_to_u64(retained.len()));
4057 if let Some(key) = append.idempotency_key.as_ref() {
4058 retained_active_keys.insert(key.clone());
4059 }
4060 }
4061 retained.push(append);
4062 }
4063
4064 state.pending = retained;
4065 state.active_turn_pending_indices = retained_active_indices;
4066 state.active_turn_pending_keys = retained_active_keys;
4067 discarded
4068 }
4069
4070 fn reconstruct_legacy_active_turn_indices(state: &mut SessionSystemContextState) {
4071 if !state.active_turn_pending_indices.is_empty()
4072 || state.active_turn_pending_keys.is_empty()
4073 {
4074 return;
4075 }
4076 state.active_turn_pending_indices = state
4077 .pending
4078 .iter()
4079 .enumerate()
4080 .filter(|(_index, append)| {
4081 append
4082 .idempotency_key
4083 .as_ref()
4084 .is_some_and(|key| state.active_turn_pending_keys.contains(key))
4085 })
4086 .map(|(index, _append)| usize_to_u64(index))
4087 .collect();
4088 }
4089
4090 pub(super) fn restore_system_context_state(
4091 mut state: SessionSystemContextState,
4092 ) -> Result<SessionSystemContextState, SystemContextStageError> {
4093 reconstruct_legacy_active_turn_indices(&mut state);
4097 let active_indices_are_in_bounds = state
4098 .active_turn_pending_indices
4099 .iter()
4100 .all(|index| usize::try_from(*index).is_ok_and(|index| index < state.pending.len()));
4101 let active_keys_have_indexed_pending = state.active_turn_pending_keys.iter().all(|key| {
4102 state.active_turn_pending_indices.iter().any(|index| {
4103 usize::try_from(*index)
4104 .ok()
4105 .and_then(|index| state.pending.get(index))
4106 .and_then(|append| append.idempotency_key.as_ref())
4107 == Some(key)
4108 })
4109 });
4110 let indexed_pending_keys_are_active =
4111 state.active_turn_pending_indices.iter().all(|index| {
4112 usize::try_from(*index)
4113 .ok()
4114 .and_then(|index| state.pending.get(index))
4115 .is_some_and(|append| {
4116 append
4117 .idempotency_key
4118 .as_ref()
4119 .is_none_or(|key| state.active_turn_pending_keys.contains(key))
4120 })
4121 });
4122 let active_turn_membership_is_consistent = active_indices_are_in_bounds
4123 && active_keys_have_indexed_pending
4124 && indexed_pending_keys_are_active;
4125 let seen_keys_match_known_appends = state.seen.iter().all(|(key, seen)| {
4126 state
4127 .pending
4128 .iter()
4129 .chain(state.applied.iter())
4130 .any(|append| {
4131 append.idempotency_key.as_ref() == Some(key)
4132 && seen.content == append.content
4133 && seen.source.as_deref() == append.source.as_deref()
4134 })
4135 });
4136 let mut authority = document_authority();
4137 authority
4138 .restore_system_context_snapshot(
4139 active_turn_membership_is_consistent,
4140 seen_keys_match_known_appends,
4141 )
4142 .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
4143 Ok(state)
4144 }
4145
4146 pub(super) fn stage_append(
4147 state: &mut SessionSystemContextState,
4148 req: &AppendSystemContextRequest,
4149 accepted_at: SystemTime,
4150 active_turn_scoped: bool,
4151 ) -> Result<AppendSystemContextStatus, SystemContextStageError> {
4152 let rendered_text = req.content.render_text();
4156 let rendered_len = rendered_text.trim().len();
4157 let existing = req
4158 .idempotency_key
4159 .as_ref()
4160 .and_then(|key| state.seen.get(key));
4161 let existing_key_matches = existing.is_some_and(|existing| {
4162 existing.content == req.content && existing.source.as_deref() == req.source.as_deref()
4163 });
4164 let existing_key_conflicts = existing.is_some() && !existing_key_matches;
4165 let decision = resolve_append_decision(
4166 usize_to_u64(rendered_len),
4167 req.idempotency_key.is_some(),
4168 existing_key_matches,
4169 existing_key_conflicts,
4170 active_turn_scoped,
4171 )?;
4172
4173 match decision {
4174 session_document::SystemContextAppendDecision::RejectEmpty => {
4175 return Err(SystemContextStageError::InvalidRequest(
4176 "system context text must not be empty".to_string(),
4177 ));
4178 }
4179 session_document::SystemContextAppendDecision::RejectConflict => {
4180 let Some(key) = req.idempotency_key.as_ref() else {
4181 return Err(SystemContextStageError::InvalidRequest(
4182 "generated system-context authority rejected append without a key"
4183 .to_string(),
4184 ));
4185 };
4186 let Some(existing) = existing else {
4187 return Err(SystemContextStageError::InvalidRequest(
4188 "generated system-context authority rejected append without a conflict"
4189 .to_string(),
4190 ));
4191 };
4192 return Err(SystemContextStageError::Conflict {
4193 key: key.clone(),
4194 existing_text: existing.content.render_text(),
4195 existing_source: existing.source.clone(),
4196 });
4197 }
4198 session_document::SystemContextAppendDecision::Duplicate => {
4199 return Ok(AppendSystemContextStatus::Duplicate);
4200 }
4201 session_document::SystemContextAppendDecision::Staged => {}
4202 }
4203
4204 let append = PendingSystemContextAppend {
4205 content: req.content.clone(),
4206 source: req.source.clone(),
4207 idempotency_key: req.idempotency_key.clone(),
4208 source_kind: req.source_kind,
4209 peer_response_terminal: req.peer_response_terminal.clone(),
4213 accepted_at,
4214 };
4215 if let Some(key) = req.idempotency_key.as_ref() {
4216 state.seen.insert(
4217 key.clone(),
4218 SeenSystemContextKey {
4219 content: append.content.clone(),
4220 source: append.source.clone(),
4221 source_kind: append.source_kind,
4222 state: SeenSystemContextState::Pending,
4223 },
4224 );
4225 }
4226 if active_turn_scoped {
4227 state
4228 .active_turn_pending_indices
4229 .insert(usize_to_u64(state.pending.len()));
4230 if let Some(key) = req.idempotency_key.as_ref() {
4231 state.active_turn_pending_keys.insert(key.clone());
4232 }
4233 }
4234 state.pending.push(append);
4235 Ok(AppendSystemContextStatus::Staged)
4236 }
4237
4238 pub(super) fn mark_pending_applied(state: &mut SessionSystemContextState) {
4239 let pending = std::mem::take(&mut state.pending);
4242 let mut seen_to_remove = Vec::new();
4243 for append in &pending {
4244 let Some((promote_to_applied, mark_seen_applied, remove_seen)) =
4245 pending_apply_item(append.source_kind)
4246 else {
4247 continue;
4248 };
4249 if promote_to_applied && !state.applied.contains(append) {
4250 state.applied.push(append.clone());
4251 }
4252 if let Some(key) = append.idempotency_key.as_ref() {
4253 if remove_seen {
4254 seen_to_remove.push(key.clone());
4255 } else if mark_seen_applied && let Some(seen) = state.seen.get_mut(key) {
4256 seen.state = SeenSystemContextState::Applied;
4257 }
4258 }
4259 }
4260 for key in seen_to_remove {
4261 state.seen.remove(&key);
4262 }
4263 state.active_turn_pending_keys.clear();
4264 state.active_turn_pending_indices.clear();
4265 }
4266
4267 pub(super) fn discard_unapplied_active_turn_pending(
4268 state: &mut SessionSystemContextState,
4269 ) -> Vec<PendingSystemContextAppend> {
4270 reconstruct_legacy_active_turn_indices(state);
4271 if state.active_turn_pending_indices.is_empty() {
4272 return Vec::new();
4273 }
4274 let discarded = discard_pending_where(state, |_append, is_active_turn| is_active_turn);
4275
4276 for append in &discarded {
4277 if let Some(key) = append.idempotency_key.as_ref()
4278 && state
4279 .seen
4280 .get(key)
4281 .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
4282 {
4283 state.seen.remove(key);
4284 }
4285 }
4286
4287 discarded
4288 }
4289
4290 pub(super) fn discard_active_turn_pending_by_keys(
4291 state: &mut SessionSystemContextState,
4292 idempotency_keys: &[String],
4293 ) -> Vec<PendingSystemContextAppend> {
4294 reconstruct_legacy_active_turn_indices(state);
4295 if idempotency_keys.is_empty() || state.active_turn_pending_indices.is_empty() {
4296 return Vec::new();
4297 }
4298 let requested_keys: BTreeSet<&str> = idempotency_keys.iter().map(String::as_str).collect();
4299 let discarded = discard_pending_where(state, |append, is_active_turn| {
4300 is_active_turn
4301 && append
4302 .idempotency_key
4303 .as_ref()
4304 .is_some_and(|key| requested_keys.contains(key.as_str()))
4305 });
4306
4307 for append in &discarded {
4308 let Some(key) = append.idempotency_key.as_ref() else {
4309 continue;
4310 };
4311 if state
4312 .seen
4313 .get(key)
4314 .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
4315 {
4316 state.seen.remove(key);
4317 }
4318 }
4319
4320 discarded
4321 }
4322
4323 pub(super) fn discard_transient_runtime_steer_state(
4324 state: &mut SessionSystemContextState,
4325 ) -> usize {
4326 let mut removed = 0usize;
4327
4328 let before_active = state.active_turn_pending_keys.len();
4329 removed += discard_pending_where(state, |append, _is_active_turn| {
4330 steer_cleanup_discards(append.source_kind)
4331 })
4332 .len();
4333
4334 let before_applied = state.applied.len();
4335 state
4336 .applied
4337 .retain(|append| !steer_cleanup_discards(append.source_kind));
4338 removed += before_applied.saturating_sub(state.applied.len());
4339
4340 let before_seen = state.seen.len();
4341 state
4342 .seen
4343 .retain(|_key, seen| !steer_cleanup_discards(seen.source_kind));
4344 removed += before_seen.saturating_sub(state.seen.len());
4345
4346 removed += before_active.saturating_sub(state.active_turn_pending_keys.len());
4347
4348 removed
4349 }
4350
4351 pub(super) fn remove_runtime_steer_blocks_for_rendered(
4352 system_prompt: &str,
4353 runtime_steer_appends: &[PendingSystemContextAppend],
4354 ) -> (String, usize) {
4355 if runtime_steer_appends.is_empty() {
4356 return (system_prompt.to_string(), 0);
4357 }
4358 let steer_blocks: BTreeSet<String> = runtime_steer_appends
4362 .iter()
4363 .map(render_system_context_block)
4364 .collect();
4365 let parts = system_prompt
4366 .split(super::SYSTEM_CONTEXT_SEPARATOR)
4367 .map(str::to_string)
4368 .collect::<Vec<_>>();
4369 let original_len = parts.len();
4370 let retained = parts
4371 .into_iter()
4372 .filter(|part| !steer_blocks.contains(part))
4373 .collect::<Vec<_>>();
4374 let removed = original_len.saturating_sub(retained.len());
4375 (retained.join(super::SYSTEM_CONTEXT_SEPARATOR), removed)
4376 }
4377
4378 pub(super) fn record_applied_system_context_blocks(
4379 state: &mut SessionSystemContextState,
4380 appends: &[PendingSystemContextAppend],
4381 current_system_prompt: &str,
4382 ) -> Vec<PendingSystemContextAppend> {
4383 let mut new_appends: Vec<PendingSystemContextAppend> = Vec::new();
4384 for append in appends {
4385 if append.content.render_text().trim().is_empty() {
4386 continue;
4387 }
4388 let rendered = render_system_context_block(append);
4389 if let Some(key) = append.idempotency_key.as_ref() {
4390 if let Some(existing) = state.seen.get(key)
4391 && !seen_system_context_matches(existing, append)
4392 {
4393 tracing::warn!(
4394 idempotency_key = %key,
4395 "skipping conflicting runtime system-context append"
4396 );
4397 continue;
4398 }
4399 if let Some(existing) = state
4400 .applied
4401 .iter()
4402 .find(|applied| applied.idempotency_key.as_ref() == Some(key))
4403 && !pending_system_context_matches(existing, append)
4404 {
4405 tracing::warn!(
4406 idempotency_key = %key,
4407 "skipping conflicting runtime system-context append"
4408 );
4409 continue;
4410 }
4411 if let Some(existing) = new_appends
4412 .iter()
4413 .find(|pending| pending.idempotency_key.as_ref() == Some(key))
4414 {
4415 if !pending_system_context_matches(existing, append) {
4416 tracing::warn!(
4417 idempotency_key = %key,
4418 "skipping conflicting runtime system-context append"
4419 );
4420 }
4421 continue;
4422 }
4423 if current_system_prompt.contains(&rendered) {
4424 record_applied_append(state, append);
4425 continue;
4426 }
4427 } else if new_appends.contains(append) || current_system_prompt.contains(&rendered) {
4428 continue;
4429 }
4430 record_applied_append(state, append);
4431 new_appends.push(append.clone());
4432 }
4433 new_appends
4434 }
4435
4436 fn record_applied_append(
4437 state: &mut SessionSystemContextState,
4438 append: &PendingSystemContextAppend,
4439 ) {
4440 if let Some(key) = append.idempotency_key.as_ref() {
4441 state.seen.insert(
4442 key.clone(),
4443 SeenSystemContextKey {
4444 content: append.content.clone(),
4445 source: append.source.clone(),
4446 source_kind: append.source_kind,
4447 state: SeenSystemContextState::Applied,
4448 },
4449 );
4450 if state
4451 .applied
4452 .iter()
4453 .any(|applied| applied.idempotency_key.as_ref() == Some(key))
4454 {
4455 return;
4456 }
4457 } else if state.applied.contains(append) {
4458 return;
4459 }
4460 state.applied.push(append.clone());
4461 }
4462
4463 fn seen_system_context_matches(
4464 seen: &SeenSystemContextKey,
4465 append: &PendingSystemContextAppend,
4466 ) -> bool {
4467 seen.content == append.content && seen.source.as_deref() == append.source.as_deref()
4468 }
4469
4470 fn pending_system_context_matches(
4471 existing: &PendingSystemContextAppend,
4472 append: &PendingSystemContextAppend,
4473 ) -> bool {
4474 existing.content == append.content && existing.source.as_deref() == append.source.as_deref()
4475 }
4476}
4477
4478impl Session {
4479 pub fn new() -> Self {
4481 let now = SystemTime::now();
4482 Self {
4483 version: session_version(),
4484 id: SessionId::new(),
4485 messages: Arc::new(Vec::new()),
4486 created_at: now,
4487 updated_at: now,
4488 metadata: serde_json::Map::new(),
4489 transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
4490 usage: Usage::default(),
4491 }
4492 }
4493
4494 pub fn with_id(id: SessionId) -> Self {
4496 let mut session = Self::new();
4497 session.id = id;
4498 session
4499 }
4500
4501 pub fn id(&self) -> &SessionId {
4503 &self.id
4504 }
4505
4506 pub fn version(&self) -> u32 {
4508 self.version
4509 }
4510
4511 pub fn messages(&self) -> &[Message] {
4513 &self.messages
4514 }
4515
4516 #[allow(dead_code)] pub(crate) fn replace_messages_internal(
4523 &mut self,
4524 messages: Vec<Message>,
4525 reason: TranscriptRewriteReason,
4526 ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError> {
4527 if transcript_messages_digest(self.messages()).ok()
4528 == transcript_messages_digest(&messages).ok()
4529 {
4530 return Ok(None);
4531 }
4532 let commit = self.commit_transcript_rewrite(
4533 TranscriptRewriteSelection::MessageRange {
4534 start: 0,
4535 end: self.messages.len(),
4536 },
4537 messages,
4538 reason,
4539 Some("meerkat-core".to_string()),
4540 None,
4541 )?;
4542 Ok(Some(commit))
4543 }
4544
4545 pub(crate) fn replace_messages_for_compaction_internal(
4548 &mut self,
4549 messages: Vec<Message>,
4550 authority: &crate::agent::compact::ValidatedCompactionRewrite,
4551 ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError> {
4552 if transcript_messages_digest(self.messages()).ok()
4553 == transcript_messages_digest(&messages).ok()
4554 {
4555 return Ok(None);
4556 }
4557 if !authority
4558 .authorizes(self.messages(), &messages)
4559 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?
4560 {
4561 return Err(TranscriptEditError::InvalidTranscriptShape(
4562 "validated compaction witness does not authorize this exact transcript rebuild"
4563 .to_string(),
4564 ));
4565 }
4566 let summary_count = messages
4567 .iter()
4568 .filter(|message| {
4569 matches!(message, Message::User(user) if user.transcript_role.is_compaction_summary())
4570 })
4571 .count();
4572 if messages.len() >= self.messages.len() || summary_count != 1 {
4573 return Err(TranscriptEditError::InvalidTranscriptShape(
4574 "validated compaction rewrite must shrink the transcript and carry exactly one CompactionSummary"
4575 .to_string(),
4576 ));
4577 }
4578 let selection =
4579 TranscriptRewriteSelection::validated_compaction(0, self.messages.len(), authority);
4580 let commit = self.commit_transcript_rewrite_authorized(
4581 selection,
4582 messages,
4583 TranscriptRewriteReason::new("compaction"),
4584 Some("meerkat-core".to_string()),
4585 None,
4586 )?;
4587 Ok(Some(commit))
4588 }
4589
4590 pub fn replace_synthetic_notices(
4602 &mut self,
4603 kind: crate::types::SystemNoticeKind,
4604 replacements: Vec<Message>,
4605 ) -> Result<(), TranscriptEditError> {
4606 if !kind.is_synthetic_refresh_projection() {
4607 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
4608 "system notice kind {kind:?} is durable transcript content, not a synthetic refresh projection"
4609 )));
4610 }
4611 for (index, message) in replacements.iter().enumerate() {
4612 let matches_kind = matches!(
4613 message,
4614 Message::SystemNotice(notice)
4615 if notice.kind == kind && notice.is_synthetic_refresh_projection()
4616 );
4617 if !matches_kind {
4618 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
4619 "replacement {index} for synthetic notice kind {kind:?} is not a system notice of that kind"
4620 )));
4621 }
4622 }
4623
4624 let mut refreshed = self
4625 .messages
4626 .iter()
4627 .filter(|message| {
4628 !matches!(
4629 message,
4630 Message::SystemNotice(notice)
4631 if notice.kind == kind && notice.is_synthetic_refresh_projection()
4632 )
4633 })
4634 .cloned()
4635 .collect::<Vec<_>>();
4636 refreshed.extend(replacements);
4637 if transcript_messages_digest(self.messages()).ok()
4638 == transcript_messages_digest(&refreshed).ok()
4639 {
4640 return Ok(());
4641 }
4642
4643 let realtime_state =
4644 self.reconciled_realtime_transcript_metadata_after_rewrite(&refreshed)?;
4645 let updated_at = SystemTime::now();
4646 let history_state = self
4647 .transcript_history_state_after_message_mutation(&refreshed, updated_at)?
4648 .map(serde_json::to_value)
4649 .transpose()
4650 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
4651
4652 self.messages = Arc::new(refreshed);
4653 self.updated_at = updated_at;
4654 if let Some(value) = realtime_state {
4655 self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
4656 }
4657 if let Some(value) = history_state {
4658 self.set_validated_transcript_history_metadata(value);
4659 }
4660 Ok(())
4661 }
4662
4663 pub fn created_at(&self) -> SystemTime {
4665 self.created_at
4666 }
4667
4668 pub fn updated_at(&self) -> SystemTime {
4670 self.updated_at
4671 }
4672
4673 pub fn push(&mut self, message: Message) {
4677 Arc::make_mut(&mut self.messages).push(message);
4678 self.updated_at = SystemTime::now();
4679 self.refresh_transcript_head_after_message_mutation();
4680 }
4681
4682 pub fn push_batch(&mut self, messages: Vec<Message>) {
4686 if messages.is_empty() {
4687 return;
4688 }
4689 let inner = Arc::make_mut(&mut self.messages);
4690 inner.extend(messages);
4691 self.updated_at = SystemTime::now();
4692 self.refresh_transcript_head_after_message_mutation();
4693 }
4694
4695 pub async fn externalize_media(
4706 &mut self,
4707 blob_store: &dyn crate::BlobStore,
4708 start: usize,
4709 ) -> Result<(), crate::blob::BlobStoreError> {
4710 let previous_digest = if self
4711 .metadata
4712 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
4713 {
4714 transcript_messages_digest(self.messages()).ok()
4715 } else {
4716 None
4717 };
4718 let messages = Arc::make_mut(&mut self.messages);
4719 crate::image_content::externalize_messages_from(blob_store, messages, start).await?;
4720 if let Some(previous_digest) = previous_digest
4721 && transcript_messages_digest(self.messages()).ok().as_ref() != Some(&previous_digest)
4722 {
4723 self.refresh_transcript_head_after_message_mutation();
4724 }
4725 Ok(())
4726 }
4727
4728 pub async fn hydrate_realtime_user_images(
4736 &mut self,
4737 blob_store: &dyn crate::BlobStore,
4738 max_decoded_bytes: usize,
4739 ) -> Result<(), crate::image_content::RealtimeUserImageHydrationError> {
4740 self.hydrate_realtime_user_images_with_usage(blob_store, max_decoded_bytes)
4741 .await
4742 .map(|_| ())
4743 }
4744
4745 pub async fn hydrate_realtime_user_images_with_usage(
4748 &mut self,
4749 blob_store: &dyn crate::BlobStore,
4750 max_decoded_bytes: usize,
4751 ) -> Result<usize, crate::image_content::RealtimeUserImageHydrationError> {
4752 let previous_digest = if self
4753 .metadata
4754 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
4755 {
4756 transcript_messages_digest(self.messages()).ok()
4757 } else {
4758 None
4759 };
4760 let messages = Arc::make_mut(&mut self.messages);
4761 let decoded_total =
4762 crate::image_content::hydrate_user_images_for_realtime_projection_with_usage(
4763 blob_store,
4764 messages,
4765 max_decoded_bytes,
4766 )
4767 .await?;
4768 if let Some(previous_digest) = previous_digest
4769 && transcript_messages_digest(self.messages()).ok().as_ref() != Some(&previous_digest)
4770 {
4771 self.refresh_transcript_head_after_message_mutation();
4772 }
4773 Ok(decoded_total)
4774 }
4775
4776 pub fn touch(&mut self) {
4780 self.updated_at = SystemTime::now();
4781 }
4782
4783 pub fn last_n(&self, n: usize) -> &[Message] {
4785 let start = self.messages.len().saturating_sub(n);
4786 &self.messages[start..]
4787 }
4788
4789 pub fn total_tokens(&self) -> u64 {
4791 self.usage.total_tokens()
4792 }
4793
4794 pub fn total_usage(&self) -> Usage {
4796 self.usage.clone()
4797 }
4798
4799 pub fn record_usage(&mut self, turn_usage: Usage) {
4801 self.usage.add(&turn_usage);
4802 self.updated_at = SystemTime::now();
4803 }
4804
4805 pub fn append_external_user_content(&mut self, content: ContentInput) {
4807 self.push(Message::User(UserMessage::with_blocks(
4808 content.into_blocks(),
4809 )));
4810 }
4811
4812 pub fn append_external_assistant_blocks(
4814 &mut self,
4815 blocks: Vec<AssistantBlock>,
4816 stop_reason: StopReason,
4817 usage: Usage,
4818 ) {
4819 if !blocks.is_empty() {
4820 self.push(Message::BlockAssistant(BlockAssistantMessage::new(
4821 blocks,
4822 stop_reason,
4823 )));
4824 }
4825 if usage != Usage::default() {
4826 self.record_usage(usage);
4827 }
4828 }
4829
4830 pub fn append_realtime_transcript_event(
4838 &mut self,
4839 event: RealtimeTranscriptEvent,
4840 ) -> RealtimeTranscriptApplyOutcome {
4841 let mut state = self.realtime_transcript_state();
4842 let commit =
4843 realtime_transcript_revision::apply_realtime_transcript_event(&mut state, event)
4844 .unwrap_or_else(|err| {
4845 fail_closed_generated_restore(
4846 "realtime-transcript",
4847 <serde_json::Error as serde::de::Error>::custom(err),
4848 )
4849 });
4850 self.store_realtime_transcript_state(&state);
4851 self.push_batch(commit.messages);
4852 if commit.usage != Usage::default() {
4853 self.record_usage(commit.usage);
4854 }
4855 commit.outcome
4856 }
4857
4858 #[must_use]
4861 pub fn preflight_realtime_user_content_event(
4862 &self,
4863 event: &RealtimeTranscriptEvent,
4864 ) -> Option<crate::RealtimeUserContentApplyOutcome> {
4865 let state = self.realtime_transcript_state();
4866 realtime_transcript_revision::preflight_realtime_user_content_event(&state, event)
4867 .unwrap_or_else(|err| {
4868 fail_closed_generated_restore(
4869 "realtime-user-content-preflight",
4870 <serde_json::Error as serde::de::Error>::custom(err),
4871 )
4872 })
4873 }
4874
4875 #[must_use]
4894 pub fn in_flight_realtime_assistant_response_ids(&self) -> Vec<String> {
4895 let state = self.realtime_transcript_state();
4896 realtime_transcript_revision::in_flight_realtime_assistant_response_ids(&state)
4897 }
4898
4899 #[must_use]
4902 pub fn realtime_user_content_identities(&self) -> Vec<RealtimeUserContentIdentity> {
4903 let state = self.realtime_transcript_state();
4904 realtime_transcript_revision::realtime_user_content_identities(&state)
4905 }
4906
4907 #[must_use]
4910 pub fn pending_realtime_user_content_blob(
4911 &self,
4912 ) -> Option<crate::PendingRealtimeUserContentBlob> {
4913 let state = self.realtime_transcript_state();
4914 realtime_transcript_revision::pending_realtime_user_content_blob(&state)
4915 }
4916
4917 pub fn stage_pending_realtime_user_content_blob(
4920 &mut self,
4921 pending: crate::PendingRealtimeUserContentBlob,
4922 ) -> Result<
4923 crate::generated::session_document::RealtimeUserContentBlobStageDisposition,
4924 realtime_transcript_revision::RealtimeTranscriptShellError,
4925 > {
4926 let mut state = self.realtime_transcript_state();
4927 let disposition = realtime_transcript_revision::stage_pending_realtime_user_content_blob(
4928 &mut state, pending,
4929 )?;
4930 self.store_realtime_transcript_state(&state);
4931 Ok(disposition)
4932 }
4933
4934 pub fn resolve_pending_realtime_user_content_blob_recovery(
4935 &self,
4936 request: Option<&crate::PendingRealtimeUserContentBlob>,
4937 pending_blob_valid: bool,
4938 ) -> Result<
4939 crate::generated::session_document::RealtimeUserContentBlobRecoveryDisposition,
4940 realtime_transcript_revision::RealtimeTranscriptShellError,
4941 > {
4942 let state = self.realtime_transcript_state();
4943 realtime_transcript_revision::resolve_pending_realtime_user_content_blob_recovery(
4944 &state,
4945 request,
4946 pending_blob_valid,
4947 )
4948 }
4949
4950 pub fn clear_invalid_pending_realtime_user_content_blob(
4953 &mut self,
4954 request: Option<&crate::PendingRealtimeUserContentBlob>,
4955 ) -> Result<(), realtime_transcript_revision::RealtimeTranscriptShellError> {
4956 let mut state = self.realtime_transcript_state();
4957 realtime_transcript_revision::clear_invalid_pending_realtime_user_content_blob(
4958 &mut state, request,
4959 )?;
4960 self.store_realtime_transcript_state(&state);
4961 Ok(())
4962 }
4963
4964 #[must_use]
4968 pub fn realtime_user_content_tombstones(
4969 &self,
4970 ) -> Vec<crate::realtime_transcript::RealtimeUserContentTombstone> {
4971 let state = self.realtime_transcript_state();
4972 realtime_transcript_revision::realtime_user_content_tombstones(&state)
4973 }
4974
4975 fn realtime_transcript_state(&self) -> SessionRealtimeTranscriptState {
4976 match self.try_realtime_transcript_state() {
4977 Ok(Some(state)) => state,
4978 Ok(None) => SessionRealtimeTranscriptState::default(),
4979 Err(err) => fail_closed_generated_restore("realtime-transcript", err),
4980 }
4981 }
4982
4983 fn try_realtime_transcript_state(
4984 &self,
4985 ) -> Result<Option<SessionRealtimeTranscriptState>, serde_json::Error> {
4986 self.metadata
4987 .get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY)
4988 .map(|value| {
4989 let state = serde_json::from_value(value.clone())?;
4990 realtime_transcript_revision::restore_realtime_transcript_state(state)
4991 .map_err(<serde_json::Error as serde::de::Error>::custom)
4992 })
4993 .transpose()
4994 }
4995
4996 fn store_realtime_transcript_state(&mut self, state: &SessionRealtimeTranscriptState) {
4997 match serde_json::to_value(state) {
4998 Ok(value) => self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value),
4999 Err(error) => {
5000 tracing::warn!(error = %error, "failed to serialize realtime transcript state");
5001 }
5002 }
5003 }
5004
5005 fn reconciled_realtime_transcript_metadata_after_rewrite(
5006 &self,
5007 messages: &[Message],
5008 ) -> Result<Option<serde_json::Value>, TranscriptEditError> {
5009 let Some(state) = self
5010 .try_realtime_transcript_state()
5011 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
5012 else {
5013 return Ok(None);
5014 };
5015 let state =
5016 realtime_transcript_revision::reconcile_realtime_transcript_state_after_rewrite(
5017 state, messages,
5018 )
5019 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
5020 serde_json::to_value(state)
5021 .map(Some)
5022 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))
5023 }
5024
5025 fn apply_authorized_system_prompt(
5026 &mut self,
5027 prompt: session_durable_config_authority::AuthorizedSystemPrompt,
5028 ) {
5029 use crate::types::SystemMessage;
5030
5031 let mutation_kind = prompt.mutation_kind();
5036 let (prompt, _replacing_existing) = prompt.into_parts();
5037 let message = SystemMessage::with_mutation_kind(prompt, mutation_kind);
5038 let inner = Arc::make_mut(&mut self.messages);
5039 if let Some(Message::System(_)) = inner.first() {
5041 inner[0] = Message::System(message);
5042 } else {
5043 inner.insert(0, Message::System(message));
5044 }
5045 self.updated_at = SystemTime::now();
5046 self.refresh_transcript_head_after_message_mutation();
5047 }
5048
5049 pub fn set_system_prompt_with_source(
5051 &mut self,
5052 prompt: String,
5053 source: session_durable_config_authority::SessionSystemPromptSource,
5054 ) -> Result<(), session_durable_config_authority::SessionDurableConfigAuthorityError> {
5055 let replacing_existing = matches!(self.messages.first(), Some(Message::System(_)));
5056 let prompt = session_durable_config_authority::authorize_system_prompt_mutation(
5057 prompt,
5058 source,
5059 replacing_existing,
5060 )?;
5061 self.apply_authorized_system_prompt(prompt);
5062 Ok(())
5063 }
5064
5065 pub fn set_system_prompt(&mut self, prompt: String) {
5067 if let Err(err) = self.set_system_prompt_with_source(
5068 prompt,
5069 session_durable_config_authority::SessionSystemPromptSource::DirectMutation,
5070 ) {
5071 tracing::warn!(error = %err, "generated session durable-config authority rejected system prompt mutation");
5072 }
5073 }
5074
5075 pub fn discard_transient_runtime_steer_context(&mut self) -> usize {
5081 let mut removed = 0usize;
5082
5083 let mut state = match self.try_system_context_state() {
5084 Ok(state) => state.unwrap_or_default(),
5085 Err(err) => {
5086 tracing::warn!(
5087 error = %err,
5088 "generated system-context authority rejected runtime steer cleanup state"
5089 );
5090 return removed;
5091 }
5092 };
5093
5094 let runtime_steer_appends = state
5099 .pending
5100 .iter()
5101 .chain(state.applied.iter())
5102 .filter(|append| append.source_kind.is_runtime_steer())
5103 .cloned()
5104 .collect::<Vec<_>>();
5105 if let Some(Message::System(system)) = self.messages.first() {
5106 let (retained_prompt, removed_blocks) =
5107 system_context_authority::remove_runtime_steer_blocks_for_rendered(
5108 &system.content,
5109 &runtime_steer_appends,
5110 );
5111 if removed_blocks > 0 {
5112 removed += removed_blocks;
5113 if let Err(err) = self.set_system_prompt_with_source(
5114 retained_prompt,
5115 session_durable_config_authority::SessionSystemPromptSource::RuntimeSteerCleanup,
5116 ) {
5117 tracing::warn!(
5118 error = %err,
5119 "generated session durable-config authority rejected runtime steer prompt cleanup"
5120 );
5121 }
5122 }
5123 }
5124
5125 removed += system_context_authority::discard_transient_runtime_steer_state(&mut state);
5126
5127 if removed > 0
5128 && let Err(err) = self.set_system_context_state(state)
5129 {
5130 tracing::warn!(
5131 error = %err,
5132 "failed to persist runtime steer context cleanup"
5133 );
5134 }
5135
5136 removed
5137 }
5138
5139 pub fn append_system_context_blocks(&mut self, appends: &[PendingSystemContextAppend]) {
5141 if appends.is_empty() {
5142 return;
5143 }
5144
5145 let current_system_prompt = self
5146 .messages
5147 .first()
5148 .and_then(|message| match message {
5149 Message::System(system) => Some(system.content.as_str()),
5150 _ => None,
5151 })
5152 .unwrap_or_default();
5153 let mut state = match self.try_system_context_state() {
5154 Ok(state) => state.unwrap_or_default(),
5155 Err(err) => {
5156 tracing::warn!(
5157 error = %err,
5158 "generated system-context authority rejected applied context state"
5159 );
5160 return;
5161 }
5162 };
5163 let new_appends = system_context_authority::record_applied_system_context_blocks(
5164 &mut state,
5165 appends,
5166 current_system_prompt,
5167 );
5168 if new_appends.is_empty() {
5169 if let Err(err) = self.set_system_context_state(state) {
5170 tracing::warn!(error = %err, "failed to persist applied system-context state");
5171 }
5172 return;
5173 }
5174
5175 let rendered = render_system_context_blocks_joined(&new_appends);
5176
5177 let next = match self.messages.first() {
5178 Some(Message::System(sys)) if !sys.content.is_empty() => {
5179 format!("{}{}{}", sys.content, SYSTEM_CONTEXT_SEPARATOR, rendered)
5180 }
5181 _ => rendered,
5182 };
5183 if let Err(err) = self.set_system_prompt_with_source(
5184 next,
5185 session_durable_config_authority::SessionSystemPromptSource::RuntimeContextAppend,
5186 ) {
5187 tracing::warn!(
5188 error = %err,
5189 "generated session durable-config authority rejected system-context prompt append"
5190 );
5191 return;
5192 }
5193 if let Err(err) = self.set_system_context_state(state) {
5194 tracing::warn!(error = %err, "failed to persist applied system-context state");
5195 }
5196 }
5197
5198 pub fn reconcile_resumed_system_prompt(
5224 &mut self,
5225 assembled_base: String,
5226 actor: Option<String>,
5227 ) -> Result<ResumedSystemPromptReconciliation, TranscriptEditError> {
5228 let persisted = match self.messages.first() {
5229 Some(Message::System(system)) => Some((system.content.clone(), system.mutation_kind)),
5230 _ => None,
5231 };
5232
5233 let Some((persisted_content, persisted_mutation_kind)) = persisted else {
5234 if assembled_base.is_empty() {
5235 return Ok(ResumedSystemPromptReconciliation::NoChange);
5236 }
5237 self.commit_resume_system_prompt_rewrite(assembled_base, false, actor)?;
5241 return Ok(ResumedSystemPromptReconciliation::RewrittenBase);
5242 };
5243
5244 if persisted_content == assembled_base {
5245 return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
5246 }
5247
5248 if let Some(tail) = self.verified_runtime_context_tail(&persisted_content) {
5257 let expected = compose_system_prompt_with_context_tail(&assembled_base, &tail);
5258 if expected == persisted_content {
5259 return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
5260 }
5261 self.commit_resume_system_prompt_rewrite(expected, true, actor)?;
5262 return Ok(ResumedSystemPromptReconciliation::RewrittenBase);
5263 }
5264
5265 if persisted_prompt_is_admitted_context_append_continuation(
5273 &assembled_base,
5274 &persisted_content,
5275 persisted_mutation_kind,
5276 ) {
5277 return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
5278 }
5279
5280 let dropping_applied_context = persisted_mutation_kind.is_runtime_context_append()
5287 || self
5288 .system_context_state()
5289 .is_some_and(|state| !state.applied.is_empty());
5290 self.commit_resume_system_prompt_rewrite(assembled_base, true, actor)?;
5291 if dropping_applied_context {
5292 tracing::warn!(
5293 session_id = %self.id,
5294 "resume base-prompt refresh dropped an unverifiable runtime system-context tail; \
5295 clearing applied-append records so keyed re-sends can restore the context"
5296 );
5297 self.clear_applied_system_context_records();
5298 }
5299 Ok(ResumedSystemPromptReconciliation::RewrittenBase)
5300 }
5301
5302 fn verified_runtime_context_tail(&self, persisted_content: &str) -> Option<String> {
5312 if let Some(prior_base) = self
5313 .build_state()
5314 .and_then(|state| state.assembled_system_prompt)
5315 {
5316 if persisted_content == prior_base {
5317 return Some(String::new());
5318 }
5319 if let Some(appended) = persisted_content.strip_prefix(prior_base.as_str())
5320 && appended.starts_with(SYSTEM_CONTEXT_SEPARATOR)
5321 {
5322 return Some(appended.to_string());
5323 }
5324 }
5327 let rendered_tail = self
5328 .system_context_state()
5329 .map(|state| render_system_context_blocks_joined(&state.applied))
5330 .unwrap_or_default();
5331 if rendered_tail.is_empty() {
5332 return None;
5333 }
5334 if persisted_content == rendered_tail {
5335 return Some(format!("{SYSTEM_CONTEXT_SEPARATOR}{rendered_tail}"));
5339 }
5340 let with_separator = format!("{SYSTEM_CONTEXT_SEPARATOR}{rendered_tail}");
5341 persisted_content
5342 .ends_with(&with_separator)
5343 .then_some(with_separator)
5344 }
5345
5346 fn commit_resume_system_prompt_rewrite(
5349 &mut self,
5350 content: String,
5351 replacing_existing: bool,
5352 actor: Option<String>,
5353 ) -> Result<(), TranscriptEditError> {
5354 let authorized = session_durable_config_authority::authorize_system_prompt_mutation(
5355 content,
5356 session_durable_config_authority::SessionSystemPromptSource::ExplicitBuild,
5357 replacing_existing,
5358 )
5359 .map_err(|err| {
5360 TranscriptEditError::HistoryStateMalformed(format!(
5361 "generated session durable-config authority rejected resume system prompt refresh: {err}"
5362 ))
5363 })?;
5364 let mutation_kind = authorized.mutation_kind();
5365 let (content, _replacing_existing) = authorized.into_parts();
5366 let replacement = Message::System(crate::types::SystemMessage::with_mutation_kind(
5367 content,
5368 mutation_kind,
5369 ));
5370 let end = usize::from(replacing_existing);
5371 self.commit_transcript_rewrite(
5372 TranscriptRewriteSelection::MessageRange { start: 0, end },
5373 vec![replacement],
5374 TranscriptRewriteReason::new(RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON),
5375 actor,
5376 None,
5377 )?;
5378 Ok(())
5379 }
5380
5381 fn clear_applied_system_context_records(&mut self) {
5385 let mut state = match self.try_system_context_state() {
5386 Ok(Some(state)) => state,
5387 Ok(None) => return,
5388 Err(error) => {
5389 tracing::warn!(
5390 session_id = %self.id,
5391 error = %error,
5392 "failed to read system-context state while clearing orphaned applied records"
5393 );
5394 return;
5395 }
5396 };
5397 if state.applied.is_empty() {
5398 return;
5399 }
5400 let dropped_keys: Vec<String> = state
5401 .applied
5402 .iter()
5403 .filter_map(|append| append.idempotency_key.clone())
5404 .collect();
5405 state.applied.clear();
5406 for key in &dropped_keys {
5407 state.seen.remove(key);
5408 }
5409 if let Err(error) = self.set_system_context_state(state) {
5410 tracing::warn!(
5411 session_id = %self.id,
5412 error = %error,
5413 "failed to persist cleared applied system-context records after resume prompt refresh"
5414 );
5415 }
5416 }
5417
5418 pub fn last_assistant_text(&self) -> Option<String> {
5425 self.messages.iter().rev().find_map(|m| match m {
5426 Message::BlockAssistant(a) => {
5427 let mut buf = String::new();
5428 for block in &a.blocks {
5429 match block {
5430 crate::types::AssistantBlock::Text { text, .. }
5431 | crate::types::AssistantBlock::Transcript { text, .. } => {
5432 buf.push_str(text);
5433 }
5434 _ => {}
5435 }
5436 }
5437 if buf.is_empty() { None } else { Some(buf) }
5438 }
5439 _ => None,
5440 })
5441 }
5442
5443 pub fn tool_call_count(&self) -> usize {
5445 self.messages
5446 .iter()
5447 .filter_map(|m| match m {
5448 Message::BlockAssistant(a) => Some(
5449 a.blocks
5450 .iter()
5451 .filter(|b| matches!(b, crate::types::AssistantBlock::ToolUse { .. }))
5452 .count(),
5453 ),
5454 _ => None,
5455 })
5456 .sum()
5457 }
5458
5459 pub fn metadata(&self) -> &serde_json::Map<String, serde_json::Value> {
5461 &self.metadata
5462 }
5463
5464 fn set_metadata_unchecked(&mut self, key: &str, value: serde_json::Value) {
5465 if self.metadata.get(key) == Some(&value) {
5472 return;
5473 }
5474 self.metadata.insert(key.to_string(), value);
5475 if key == SESSION_TRANSCRIPT_HISTORY_STATE_KEY {
5476 self.metadata
5477 .remove(SESSION_TRANSCRIPT_HISTORY_CHECKPOINT_DIGEST_KEY);
5478 self.transcript_history_metadata_validation =
5479 TranscriptHistoryMetadataValidation::RequiresValidation;
5480 }
5481 self.updated_at = SystemTime::now();
5482 }
5483
5484 fn set_validated_transcript_history_metadata(&mut self, value: serde_json::Value) {
5487 self.metadata
5488 .insert(SESSION_TRANSCRIPT_HISTORY_STATE_KEY.to_string(), value);
5489 self.metadata
5490 .remove(SESSION_TRANSCRIPT_HISTORY_CHECKPOINT_DIGEST_KEY);
5491 self.transcript_history_metadata_validation =
5492 TranscriptHistoryMetadataValidation::Validated;
5493 self.updated_at = SystemTime::now();
5494 }
5495
5496 #[cfg(test)]
5497 pub(crate) fn set_metadata_unchecked_for_test(&mut self, key: &str, value: serde_json::Value) {
5498 self.set_metadata_unchecked(key, value);
5499 }
5500
5501 fn fork_metadata_projection(&self) -> serde_json::Map<String, serde_json::Value> {
5502 let mut metadata = self.metadata.clone();
5503 metadata.retain(|key, _| !is_session_authority_metadata_key(key));
5504 metadata
5505 }
5506
5507 fn remove_metadata_unchecked(&mut self, key: &str) {
5508 let removed = self.metadata.remove(key).is_some();
5509 let mut changed = removed;
5510 if key == SESSION_TRANSCRIPT_HISTORY_STATE_KEY {
5511 changed |= self
5512 .metadata
5513 .remove(SESSION_TRANSCRIPT_HISTORY_CHECKPOINT_DIGEST_KEY)
5514 .is_some();
5515 self.transcript_history_metadata_validation =
5516 TranscriptHistoryMetadataValidation::Validated;
5517 }
5518 if changed {
5519 self.updated_at = SystemTime::now();
5520 }
5521 }
5522
5523 pub fn try_set_metadata(
5525 &mut self,
5526 key: &str,
5527 value: serde_json::Value,
5528 ) -> Result<(), ReservedSessionMetadataKey> {
5529 if is_session_authority_metadata_key(key) {
5530 return Err(ReservedSessionMetadataKey::new(key));
5531 }
5532 self.set_metadata_unchecked(key, value);
5533 Ok(())
5534 }
5535
5536 pub fn set_metadata(&mut self, key: &str, value: serde_json::Value) {
5541 if let Err(err) = self.try_set_metadata(key, value) {
5542 tracing::warn!(error = %err, "rejected raw session metadata mutation");
5543 }
5544 }
5545
5546 pub fn backfill_metadata_if_absent(&mut self, key: &str, value: serde_json::Value) -> bool {
5552 if is_session_authority_metadata_key(key) {
5553 tracing::warn!(
5554 metadata_key = key,
5555 "rejected raw session metadata backfill for authority key"
5556 );
5557 return false;
5558 }
5559 if self.metadata.contains_key(key) {
5560 false
5561 } else {
5562 self.metadata.insert(key.to_string(), value);
5563 true
5564 }
5565 }
5566
5567 pub fn remove_metadata(&mut self, key: &str) {
5569 if is_session_authority_metadata_key(key) {
5570 tracing::warn!(
5571 metadata_key = key,
5572 "rejected raw session metadata removal for authority key"
5573 );
5574 return;
5575 }
5576 if self.metadata.remove(key).is_some() {
5577 self.updated_at = SystemTime::now();
5578 }
5579 }
5580
5581 pub fn set_session_metadata(
5583 &mut self,
5584 metadata: SessionMetadata,
5585 ) -> Result<(), serde_json::Error> {
5586 let metadata =
5587 session_durable_config_authority::authorize_session_metadata_persist(metadata)
5588 .map_err(<serde_json::Error as serde::ser::Error>::custom)?
5589 .into_metadata();
5590 let value = serde_json::to_value(metadata)?;
5591 self.set_metadata_unchecked(SESSION_METADATA_KEY, value);
5592 Ok(())
5593 }
5594
5595 pub fn session_metadata(&self) -> Option<SessionMetadata> {
5600 match self.try_session_metadata() {
5601 Ok(metadata) => metadata,
5602 Err(err) => fail_closed_generated_restore("session-metadata", err),
5603 }
5604 }
5605
5606 pub fn try_session_metadata(&self) -> Result<Option<SessionMetadata>, serde_json::Error> {
5608 try_session_metadata_from_map(&self.metadata)
5609 }
5610
5611 pub fn set_system_context_state(
5613 &mut self,
5614 state: SessionSystemContextState,
5615 ) -> Result<(), serde_json::Error> {
5616 let state = system_context_authority::restore_system_context_state(state)
5617 .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
5618 let value = serde_json::to_value(state)?;
5619 self.set_metadata_unchecked(SESSION_SYSTEM_CONTEXT_STATE_KEY, value);
5620 Ok(())
5621 }
5622
5623 pub fn try_system_context_state(
5625 &self,
5626 ) -> Result<Option<SessionSystemContextState>, serde_json::Error> {
5627 self.metadata
5628 .get(SESSION_SYSTEM_CONTEXT_STATE_KEY)
5629 .map(|value| {
5630 let state = serde_json::from_value(value.clone())?;
5631 system_context_authority::restore_system_context_state(state)
5632 .map_err(<serde_json::Error as serde::de::Error>::custom)
5633 })
5634 .transpose()
5635 }
5636
5637 pub fn system_context_state(&self) -> Option<SessionSystemContextState> {
5643 match self.try_system_context_state() {
5644 Ok(state) => state,
5645 Err(err) => fail_closed_generated_restore("system-context", err),
5646 }
5647 }
5648
5649 pub fn set_deferred_turn_state(
5651 &mut self,
5652 state: SessionDeferredTurnState,
5653 ) -> Result<(), serde_json::Error> {
5654 let state = validate_deferred_turn_snapshot(state)
5655 .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
5656 let value = serde_json::to_value(state)?;
5657 self.set_metadata_unchecked(SESSION_DEFERRED_TURN_STATE_KEY, value);
5658 Ok(())
5659 }
5660
5661 pub fn try_deferred_turn_state(
5663 &self,
5664 ) -> Result<Option<SessionDeferredTurnState>, serde_json::Error> {
5665 self.metadata
5666 .get(SESSION_DEFERRED_TURN_STATE_KEY)
5667 .map(|value| {
5668 let state = serde_json::from_value(value.clone())?;
5669 validate_deferred_turn_snapshot(state)
5670 .map_err(<serde_json::Error as serde::de::Error>::custom)
5671 })
5672 .transpose()
5673 }
5674
5675 pub fn deferred_turn_state(&self) -> Option<SessionDeferredTurnState> {
5681 match self.try_deferred_turn_state() {
5682 Ok(state) => state,
5683 Err(err) => fail_closed_generated_restore("deferred-turn", err),
5684 }
5685 }
5686
5687 pub fn set_lifecycle_terminal(
5696 &mut self,
5697 terminal: SessionLifecycleTerminal,
5698 ) -> Result<(), serde_json::Error> {
5699 let value = serde_json::to_value(terminal)?;
5700 self.set_metadata_unchecked(SESSION_LIFECYCLE_TERMINAL_KEY, value);
5701 Ok(())
5702 }
5703
5704 pub fn try_lifecycle_terminal(
5709 &self,
5710 ) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
5711 try_lifecycle_terminal_from_map(&self.metadata)
5712 }
5713
5714 pub fn lifecycle_terminal(&self) -> Option<SessionLifecycleTerminal> {
5720 match self.try_lifecycle_terminal() {
5721 Ok(state) => state,
5722 Err(err) => fail_closed_generated_restore("session-lifecycle-terminal", err),
5723 }
5724 }
5725
5726 pub fn set_build_state(&mut self, state: SessionBuildState) -> Result<(), serde_json::Error> {
5728 let state = session_durable_config_authority::authorize_session_build_state_persist(state)
5729 .map_err(<serde_json::Error as serde::ser::Error>::custom)?
5730 .into_state();
5731 let value = serde_json::to_value(state)?;
5732 self.set_metadata_unchecked(SESSION_BUILD_STATE_KEY, value);
5733 Ok(())
5734 }
5735
5736 pub fn build_state(&self) -> Option<SessionBuildState> {
5741 match self.try_build_state() {
5742 Ok(state) => state,
5743 Err(err) => fail_closed_generated_restore("session-build-state", err),
5744 }
5745 }
5746
5747 pub fn try_build_state(&self) -> Result<Option<SessionBuildState>, serde_json::Error> {
5749 let Some(value) = self.metadata.get(SESSION_BUILD_STATE_KEY) else {
5750 return Ok(None);
5751 };
5752 let state = serde_json::from_value::<SessionBuildState>(value.clone())?;
5753 session_durable_config_authority::restore_session_build_state(state)
5754 .map(Some)
5755 .map_err(<serde_json::Error as serde::de::Error>::custom)
5756 }
5757
5758 pub fn set_tool_visibility_state(
5760 &mut self,
5761 state: AuthorizedSessionToolVisibilityState,
5762 ) -> Result<(), serde_json::Error> {
5763 let value = serde_json::to_value(state.into_state())?;
5764 self.set_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY, value);
5765 Ok(())
5766 }
5767
5768 #[cfg(test)]
5773 pub(crate) fn clear_tool_visibility_state(&mut self) {
5774 self.remove_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY);
5775 }
5776
5777 pub fn tool_visibility_state(
5779 &self,
5780 ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
5781 self.try_tool_visibility_state()
5782 }
5783
5784 pub fn try_tool_visibility_state(
5787 &self,
5788 ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
5789 self.metadata
5790 .get(SESSION_TOOL_VISIBILITY_STATE_KEY)
5791 .map(|value| serde_json::from_value(value.clone()))
5792 .transpose()
5793 }
5794
5795 pub fn transcript_history_state(
5797 &self,
5798 ) -> Result<Option<TranscriptHistoryState>, serde_json::Error> {
5799 self.metadata
5800 .get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
5801 .map(|value| serde_json::from_value(value.clone()))
5802 .transpose()
5803 }
5804
5805 pub(crate) fn validated_transcript_history_head(
5813 &self,
5814 ) -> Result<Option<&str>, TranscriptEditError> {
5815 self.validate_transcript_history_state()?;
5816 let Some(value) = self.metadata.get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY) else {
5817 return Ok(None);
5818 };
5819 value
5820 .get("head")
5821 .and_then(serde_json::Value::as_str)
5822 .map(Some)
5823 .ok_or_else(|| {
5824 TranscriptEditError::HistoryStateMalformed(
5825 "validated transcript history metadata omitted a string head".to_string(),
5826 )
5827 })
5828 }
5829
5830 pub fn compaction_projection_intents(
5833 &self,
5834 ) -> Result<Vec<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
5835 self.metadata
5836 .get(crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY)
5837 .map(|value| serde_json::from_value(value.clone()))
5838 .transpose()
5839 .map(Option::unwrap_or_default)
5840 }
5841
5842 pub fn validated_compaction_projection_intents(
5850 &self,
5851 ) -> Result<Vec<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
5852 self.validate_transcript_history_state()
5853 .map_err(|error| <serde_json::Error as serde::ser::Error>::custom(error.to_string()))?;
5854 let intents = self.compaction_projection_intents()?;
5855 if intents.is_empty() {
5856 return Ok(intents);
5857 }
5858 let history = self.transcript_history_state()?;
5859 let commits = history
5860 .as_ref()
5861 .map(|history| history.commits.as_slice())
5862 .unwrap_or_default();
5863 let mut unique = std::collections::HashSet::new();
5864 for intent in &intents {
5865 if intent.projection.session_id() != self.id() {
5866 return Err(<serde_json::Error as serde::ser::Error>::custom(
5867 "compaction projection outbox intent has a foreign session id",
5868 ));
5869 }
5870 if !unique.insert(intent.projection.clone()) {
5871 return Err(<serde_json::Error as serde::ser::Error>::custom(
5872 "compaction projection outbox contains a duplicate rewrite identity",
5873 ));
5874 }
5875 let backed = commits.iter().any(|commit| {
5876 intent
5877 .projection
5878 .matches_transcript_rewrite(self.id(), commit)
5879 });
5880 if !backed {
5881 return Err(<serde_json::Error as serde::ser::Error>::custom(format!(
5882 "compaction projection outbox intent {} has no matching TranscriptRewriteCommit",
5883 intent.projection.revision()
5884 )));
5885 }
5886 }
5887 Ok(intents)
5888 }
5889
5890 pub fn add_compaction_projection_intent(
5893 &mut self,
5894 intent: crate::memory::CompactionProjectionIntent,
5895 ) -> Result<(), serde_json::Error> {
5896 if intent.projection.session_id() != self.id() {
5897 return Err(<serde_json::Error as serde::ser::Error>::custom(
5898 "compaction projection intent session does not match snapshot session",
5899 ));
5900 }
5901 self.validate_transcript_history_state()
5902 .map_err(|error| <serde_json::Error as serde::ser::Error>::custom(error.to_string()))?;
5903 let history = self.transcript_history_state()?.ok_or_else(|| {
5904 <serde_json::Error as serde::ser::Error>::custom(
5905 "compaction projection intent requires transcript history state",
5906 )
5907 })?;
5908 let owns_commit = history.commits.iter().any(|commit| {
5909 commit.parent_revision == intent.projection.parent_revision()
5910 && commit.revision == intent.projection.revision()
5911 && intent
5912 .projection
5913 .matches_transcript_rewrite(self.id(), commit)
5914 });
5915 if !owns_commit {
5916 return Err(<serde_json::Error as serde::ser::Error>::custom(
5917 "compaction projection intent is not backed by the session transcript graph",
5918 ));
5919 }
5920 let mut intents = self.validated_compaction_projection_intents()?;
5921 if let Some(existing) = intents
5922 .iter()
5923 .find(|existing| existing.projection == intent.projection)
5924 {
5925 if existing == &intent {
5926 return Ok(());
5927 }
5928 return Err(<serde_json::Error as serde::ser::Error>::custom(
5929 "compaction projection intent conflicts with an existing rewrite identity",
5930 ));
5931 }
5932 intents.push(intent);
5933 self.set_metadata_unchecked(
5934 crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
5935 serde_json::to_value(intents)?,
5936 );
5937 Ok(())
5938 }
5939
5940 pub fn complete_compaction_projection_intent(
5943 &mut self,
5944 projection: &crate::memory::CompactionProjectionId,
5945 ) -> Result<Option<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
5946 let mut intents = self.compaction_projection_intents()?;
5947 let Some(position) = intents
5948 .iter()
5949 .position(|intent| &intent.projection == projection)
5950 else {
5951 return Ok(None);
5952 };
5953 let completed = intents.remove(position);
5954 if intents.is_empty() {
5955 self.remove_metadata_unchecked(
5956 crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
5957 );
5958 } else {
5959 self.set_metadata_unchecked(
5960 crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
5961 serde_json::to_value(intents)?,
5962 );
5963 }
5964 Ok(Some(completed))
5965 }
5966
5967 pub fn validate_transcript_history_state(&self) -> Result<(), TranscriptEditError> {
5969 if self.transcript_history_metadata_validation
5970 == TranscriptHistoryMetadataValidation::Validated
5971 {
5972 return Ok(());
5973 }
5974 let Some(state) = self
5975 .transcript_history_state()
5976 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
5977 else {
5978 return Ok(());
5979 };
5980 validate_transcript_history_state(&state)
5981 }
5982
5983 pub fn clear_transcript_history_state(&mut self) {
5986 self.remove_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY);
5987 }
5988
5989 pub fn try_checkpoint_state(
5995 &self,
5996 ) -> Result<crate::checkpoint::SessionCheckpointState, crate::checkpoint::SessionCheckpointError>
5997 {
5998 let stamp =
5999 match crate::checkpoint::session_checkpoint_metadata_state(&self.id, &self.metadata)? {
6000 crate::checkpoint::SessionCheckpointMetadataState::Stamped(stamp) => stamp,
6001 crate::checkpoint::SessionCheckpointMetadataState::LegacyUnverified {
6002 legacy_runtime_checkpoint,
6003 } => {
6004 return Ok(
6005 crate::checkpoint::SessionCheckpointState::LegacyUnverified {
6006 legacy_runtime_checkpoint,
6007 },
6008 );
6009 }
6010 };
6011 let actual = crate::checkpoint::session_checkpoint_digest(self)?;
6012 if stamp.digest() != &actual {
6013 return Err(crate::checkpoint::SessionCheckpointError::DigestMismatch {
6014 expected: stamp.digest().clone(),
6015 actual,
6016 });
6017 }
6018 crate::checkpoint::record_checkpoint_stamp_verification(self, &actual);
6019 Ok(crate::checkpoint::SessionCheckpointState::Verified(stamp))
6020 }
6021
6022 pub fn try_checkpoint_state_cached(
6037 &self,
6038 ) -> Result<crate::checkpoint::SessionCheckpointState, crate::checkpoint::SessionCheckpointError>
6039 {
6040 let stamp =
6041 match crate::checkpoint::session_checkpoint_metadata_state(&self.id, &self.metadata)? {
6042 crate::checkpoint::SessionCheckpointMetadataState::Stamped(stamp) => stamp,
6043 crate::checkpoint::SessionCheckpointMetadataState::LegacyUnverified {
6044 legacy_runtime_checkpoint,
6045 } => {
6046 return Ok(
6047 crate::checkpoint::SessionCheckpointState::LegacyUnverified {
6048 legacy_runtime_checkpoint,
6049 },
6050 );
6051 }
6052 };
6053 if crate::checkpoint::checkpoint_stamp_verification_is_cached(self, stamp.digest()) {
6054 return Ok(crate::checkpoint::SessionCheckpointState::Verified(stamp));
6055 }
6056 let actual = crate::checkpoint::session_checkpoint_digest(self)?;
6057 if stamp.digest() != &actual {
6058 return Err(crate::checkpoint::SessionCheckpointError::DigestMismatch {
6059 expected: stamp.digest().clone(),
6060 actual,
6061 });
6062 }
6063 crate::checkpoint::record_checkpoint_stamp_verification(self, &actual);
6064 Ok(crate::checkpoint::SessionCheckpointState::Verified(stamp))
6065 }
6066
6067 pub fn install_checkpoint_stamp(
6075 &mut self,
6076 stamp: crate::checkpoint::SessionCheckpointStamp,
6077 ) -> Result<(), crate::checkpoint::SessionCheckpointError> {
6078 stamp.validate_for_session(&self.id)?;
6079 let actual = crate::checkpoint::session_checkpoint_digest(self)?;
6080 if stamp.digest() != &actual {
6081 return Err(crate::checkpoint::SessionCheckpointError::DigestMismatch {
6082 expected: stamp.digest().clone(),
6083 actual,
6084 });
6085 }
6086 let value = serde_json::to_value(&stamp)?;
6087 self.metadata
6088 .remove(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY);
6089 self.metadata
6090 .insert(SESSION_CHECKPOINT_STAMP_KEY.to_string(), value);
6091 crate::checkpoint::record_checkpoint_stamp_verification(self, &actual);
6094 Ok(())
6095 }
6096
6097 pub fn try_has_runtime_checkpoint_provenance(
6099 &self,
6100 ) -> Result<bool, crate::checkpoint::SessionCheckpointError> {
6101 match self.try_checkpoint_state()? {
6102 crate::checkpoint::SessionCheckpointState::Verified(stamp) => Ok(matches!(
6103 stamp.provenance(),
6104 crate::checkpoint::SessionCheckpointProvenance::IntraTurnCheckpoint
6105 )),
6106 crate::checkpoint::SessionCheckpointState::LegacyUnverified { .. } => {
6107 Err(crate::checkpoint::SessionCheckpointError::LegacyCheckpointUnverified)
6108 }
6109 }
6110 }
6111
6112 #[deprecated(
6114 note = "legacy compatibility only; typed writers must install an exact checkpoint stamp"
6115 )]
6116 pub fn set_runtime_checkpoint_provenance(
6117 &mut self,
6118 ) -> Result<(), crate::checkpoint::SessionCheckpointError> {
6119 if matches!(
6120 self.try_checkpoint_state()?,
6121 crate::checkpoint::SessionCheckpointState::Verified(_)
6122 ) {
6123 return Err(
6124 crate::checkpoint::SessionCheckpointError::LegacyProvenanceMutationOnTypedCheckpoint,
6125 );
6126 }
6127 self.set_metadata_unchecked(
6128 SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY,
6129 serde_json::Value::Bool(true),
6130 );
6131 Ok(())
6132 }
6133
6134 #[deprecated(
6136 note = "legacy compatibility only; typed writers must install an exact run-boundary successor"
6137 )]
6138 pub fn clear_runtime_checkpoint_provenance(
6139 &mut self,
6140 ) -> Result<(), crate::checkpoint::SessionCheckpointError> {
6141 if matches!(
6142 self.try_checkpoint_state()?,
6143 crate::checkpoint::SessionCheckpointState::Verified(_)
6144 ) {
6145 return Err(
6146 crate::checkpoint::SessionCheckpointError::LegacyProvenanceMutationOnTypedCheckpoint,
6147 );
6148 }
6149 self.remove_metadata_unchecked(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY);
6150 Ok(())
6151 }
6152
6153 pub fn transcript_revision_body(
6155 &self,
6156 revision: &str,
6157 ) -> Result<Option<TranscriptRevisionBody>, serde_json::Error> {
6158 Ok(self.transcript_history_state()?.and_then(|state| {
6159 state
6160 .revisions
6161 .into_iter()
6162 .find(|body| body.revision == revision)
6163 }))
6164 }
6165
6166 pub fn transcript_revision_messages(
6168 &self,
6169 revision: &str,
6170 ) -> Result<Option<Vec<Message>>, serde_json::Error> {
6171 Ok(self
6172 .transcript_revision_body(revision)?
6173 .map(|body| body.messages))
6174 }
6175
6176 pub fn apply_transcript_history_state(
6178 &mut self,
6179 mut state: TranscriptHistoryState,
6180 ) -> Result<(), TranscriptEditError> {
6181 state.compact_mechanical_revision_bodies()?;
6182 let head_body = state
6183 .revisions
6184 .iter()
6185 .find(|body| body.revision == state.head)
6186 .ok_or_else(|| {
6187 TranscriptEditError::HistoryStateMalformed(format!(
6188 "missing transcript head body {}",
6189 state.head
6190 ))
6191 })?
6192 .clone();
6193 let realtime_state =
6194 self.reconciled_realtime_transcript_metadata_after_rewrite(&head_body.messages)?;
6195 let value = serde_json::to_value(&state)
6196 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
6197 self.set_validated_transcript_history_metadata(value);
6198 if let Some(value) = realtime_state {
6199 self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
6200 }
6201 let mut updated_at = head_body.created_at;
6202 for commit in &state.commits {
6203 if commit.committed_at > updated_at {
6204 updated_at = commit.committed_at;
6205 }
6206 }
6207 self.messages = Arc::new(head_body.messages);
6208 self.updated_at = updated_at;
6209 Ok(())
6210 }
6211
6212 pub fn transcript_revision(&self) -> Result<String, serde_json::Error> {
6215 if let Some(state) = self.transcript_history_state()? {
6216 Ok(state.head)
6217 } else {
6218 transcript_messages_digest(self.messages())
6219 }
6220 }
6221
6222 pub fn transcript_rewrite_generation(&self) -> Result<u64, serde_json::Error> {
6227 Ok(self.transcript_history_state()?.map_or(0, |state| {
6228 u64::try_from(state.commits.len()).unwrap_or(u64::MAX)
6229 }))
6230 }
6231
6232 pub fn commit_transcript_rewrite(
6234 &mut self,
6235 selection: TranscriptRewriteSelection,
6236 replacement: Vec<Message>,
6237 reason: TranscriptRewriteReason,
6238 actor: Option<String>,
6239 expected_parent_revision: Option<String>,
6240 ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
6241 let selection = selection.into_current_edit_semantic();
6242 if selection.semantic() == TranscriptRewriteSemantic::Compaction {
6243 return Err(TranscriptEditError::InvalidTranscriptShape(
6244 "typed compaction rewrites require a core-validated compaction witness".to_string(),
6245 ));
6246 }
6247 self.commit_transcript_rewrite_authorized(
6248 selection,
6249 replacement,
6250 reason,
6251 actor,
6252 expected_parent_revision,
6253 )
6254 }
6255
6256 fn commit_transcript_rewrite_authorized(
6257 &mut self,
6258 selection: TranscriptRewriteSelection,
6259 replacement: Vec<Message>,
6260 reason: TranscriptRewriteReason,
6261 actor: Option<String>,
6262 expected_parent_revision: Option<String>,
6263 ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
6264 let parent_revision = self
6265 .transcript_revision()
6266 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
6267 if let Some(expected) = expected_parent_revision
6268 && expected != parent_revision
6269 {
6270 return Err(TranscriptEditError::RevisionConflict {
6271 expected,
6272 actual: parent_revision,
6273 });
6274 }
6275
6276 let (start, end) = selection.bounds();
6277 let message_count = self.messages.len();
6278 if start > end || end > message_count {
6279 return Err(TranscriptEditError::InvalidRewriteRange {
6280 start,
6281 end,
6282 message_count,
6283 });
6284 }
6285
6286 let replacement_len = replacement.len();
6287 let mut rewritten = Vec::with_capacity(
6288 start
6289 .saturating_add(replacement_len)
6290 .saturating_add(message_count.saturating_sub(end)),
6291 );
6292 rewritten.extend_from_slice(&self.messages[..start]);
6293 rewritten.extend(replacement);
6294 rewritten.extend_from_slice(&self.messages[end..]);
6295 validate_transcript_tool_result_shape(&rewritten)?;
6296
6297 let original_span_digest = transcript_messages_digest(&self.messages[start..end])
6298 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
6299 let replacement_digest =
6300 transcript_messages_digest(&rewritten[start..start + replacement_len])
6301 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
6302 let revision = transcript_messages_digest(&rewritten)
6303 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
6304 if revision == parent_revision {
6305 return Err(TranscriptEditError::NoOpRewrite { revision });
6306 }
6307 let realtime_state =
6308 self.reconciled_realtime_transcript_metadata_after_rewrite(&rewritten)?;
6309
6310 let commit = TranscriptRewriteCommit {
6311 parent_revision,
6312 revision: revision.clone(),
6313 selection,
6314 original_span_digest,
6315 replacement_digest,
6316 messages_before: message_count,
6317 messages_after: rewritten.len(),
6318 reason,
6319 actor,
6320 committed_at: SystemTime::now(),
6321 };
6322
6323 let mut state = self
6324 .transcript_history_state()
6325 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
6326 .unwrap_or_else(|| TranscriptHistoryState {
6327 head: commit.parent_revision.clone(),
6328 commits: Vec::new(),
6329 revisions: Vec::new(),
6330 digest_format: TRANSCRIPT_DIGEST_FORMAT_CURRENT,
6331 });
6332 if !state
6333 .revisions
6334 .iter()
6335 .any(|body| body.revision == commit.parent_revision)
6336 {
6337 state.revisions.push(TranscriptRevisionBody {
6338 revision: commit.parent_revision.clone(),
6339 parent_revision: None,
6340 messages: self.messages().to_vec(),
6341 created_at: self.updated_at,
6342 });
6343 }
6344 if !state
6345 .revisions
6346 .iter()
6347 .any(|body| body.revision == commit.revision)
6348 {
6349 state.revisions.push(TranscriptRevisionBody {
6350 revision: commit.revision.clone(),
6351 parent_revision: Some(commit.parent_revision.clone()),
6352 messages: rewritten.clone(),
6353 created_at: commit.committed_at,
6354 });
6355 }
6356 state.head = revision;
6357 state.commits.push(commit.clone());
6358 state.compact_mechanical_revision_bodies()?;
6359 let value = serde_json::to_value(state)
6360 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
6361 self.set_validated_transcript_history_metadata(value);
6362 if let Some(value) = realtime_state {
6363 self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
6364 }
6365
6366 self.messages = Arc::new(rewritten);
6367 self.updated_at = SystemTime::now();
6368 Ok(commit)
6369 }
6370
6371 fn transcript_history_state_after_message_mutation(
6372 &self,
6373 messages: &[Message],
6374 created_at: SystemTime,
6375 ) -> Result<Option<TranscriptHistoryState>, TranscriptEditError> {
6376 if !self
6377 .metadata
6378 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
6379 {
6380 return Ok(None);
6381 }
6382 let mut state = self
6383 .transcript_history_state()
6384 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?
6385 .ok_or_else(|| {
6386 TranscriptEditError::HistoryStateMalformed(
6387 "transcript history metadata key decoded without state".to_string(),
6388 )
6389 })?;
6390 state.compact_mechanical_revision_bodies()?;
6391 let head = transcript_messages_digest(messages)
6392 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
6393 if !state.revisions.iter().any(|body| body.revision == head) {
6394 state.revisions.push(TranscriptRevisionBody {
6395 revision: head.clone(),
6396 parent_revision: state.commits.last().map(|commit| commit.revision.clone()),
6397 messages: messages.to_vec(),
6398 created_at,
6399 });
6400 }
6401 state.head = head;
6402 state.compact_mechanical_revision_bodies()?;
6403 Ok(Some(state))
6404 }
6405
6406 fn refresh_transcript_head_after_message_mutation(&mut self) {
6407 match self
6408 .transcript_history_state_after_message_mutation(self.messages(), SystemTime::now())
6409 {
6410 Ok(Some(state)) => match serde_json::to_value(state) {
6411 Ok(value) => {
6412 self.set_validated_transcript_history_metadata(value);
6413 }
6414 Err(error) => {
6415 tracing::warn!(
6416 session_id = %self.id,
6417 error = %error,
6418 "failed to serialize transcript history state after message mutation"
6419 );
6420 }
6421 },
6422 Ok(None) => {}
6423 Err(error) => {
6424 tracing::warn!(
6425 session_id = %self.id,
6426 error = %error,
6427 "transcript history state failed validation after message mutation"
6428 );
6429 }
6430 }
6431 }
6432
6433 pub fn set_mob_tool_authority_context(
6441 &mut self,
6442 authority_context: Option<MobToolAuthorityContext>,
6443 ) -> Result<(), serde_json::Error> {
6444 if let Some(authority_context) = authority_context.as_ref()
6445 && !authority_context.is_generated_authority_context()
6446 {
6447 return Err(<serde_json::Error as serde::de::Error>::custom(
6448 "mob authority context was not minted by generated authority",
6449 ));
6450 }
6451 let mut build_state = self.build_state().ok_or_else(|| {
6452 <serde_json::Error as serde::de::Error>::custom(format!(
6453 "session {} is missing session build state",
6454 self.id
6455 ))
6456 })?;
6457 build_state.mob_tool_authority_context = authority_context;
6458 self.set_build_state(build_state)
6459 }
6460
6461 pub fn mob_tool_authority_context(&self) -> Option<MobToolAuthorityContext> {
6466 self.build_state()
6467 .and_then(|state| state.mob_tool_authority_context)
6468 .filter(MobToolAuthorityContext::is_generated_authority_context)
6469 }
6470
6471 pub fn fork_at(&self, index: usize) -> Self {
6476 let now = SystemTime::now();
6477 let truncated = self.messages[..index.min(self.messages.len())].to_vec();
6478 Self {
6479 version: session_version(),
6480 id: SessionId::new(),
6481 messages: Arc::new(truncated),
6482 created_at: now,
6483 updated_at: now,
6484 metadata: self.fork_metadata_projection(),
6485 transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
6486 usage: self.usage.clone(),
6487 }
6488 }
6489
6490 pub fn fork_replacing(
6497 &self,
6498 message_index: usize,
6499 replacement: TranscriptReplacement,
6500 ) -> Result<Self, TranscriptEditError> {
6501 let Some(original) = self.messages.get(message_index) else {
6502 return Err(TranscriptEditError::MessageIndexOutOfBounds {
6503 message_index,
6504 message_count: self.messages.len(),
6505 });
6506 };
6507
6508 let replacement_message = match replacement {
6509 TranscriptReplacement::Message { message } => message,
6510 TranscriptReplacement::UserContentBlock { block_index, block } => {
6511 let Message::User(user) = original else {
6512 return Err(TranscriptEditError::MessageRoleMismatch {
6513 message_index,
6514 expected: "user",
6515 actual: message_role_name(original),
6516 });
6517 };
6518 if block_index >= user.content.len() {
6519 return Err(TranscriptEditError::BlockIndexOutOfBounds {
6520 block_kind: "user content block",
6521 block_index,
6522 block_count: user.content.len(),
6523 });
6524 }
6525 let mut edited = user.clone();
6526 edited.content[block_index] = block;
6527 Message::User(edited)
6528 }
6529 TranscriptReplacement::AssistantBlock { block_index, block } => {
6530 let Message::BlockAssistant(assistant) = original else {
6531 return Err(TranscriptEditError::MessageRoleMismatch {
6532 message_index,
6533 expected: "block_assistant",
6534 actual: message_role_name(original),
6535 });
6536 };
6537 if block_index >= assistant.blocks.len() {
6538 return Err(TranscriptEditError::BlockIndexOutOfBounds {
6539 block_kind: "assistant block",
6540 block_index,
6541 block_count: assistant.blocks.len(),
6542 });
6543 }
6544 let mut edited = assistant.clone();
6545 edited.blocks[block_index] = block;
6546 Message::BlockAssistant(edited)
6547 }
6548 TranscriptReplacement::ToolResultContentBlock {
6549 result_index,
6550 block_index,
6551 block,
6552 } => {
6553 let Message::ToolResults {
6554 results,
6555 created_at,
6556 } = original
6557 else {
6558 return Err(TranscriptEditError::MessageRoleMismatch {
6559 message_index,
6560 expected: "tool_results",
6561 actual: message_role_name(original),
6562 });
6563 };
6564 let Some(result) = results.get(result_index) else {
6565 return Err(TranscriptEditError::BlockIndexOutOfBounds {
6566 block_kind: "tool result",
6567 block_index: result_index,
6568 block_count: results.len(),
6569 });
6570 };
6571 if block_index >= result.content.len() {
6572 return Err(TranscriptEditError::BlockIndexOutOfBounds {
6573 block_kind: "tool result content block",
6574 block_index,
6575 block_count: result.content.len(),
6576 });
6577 }
6578 let mut edited_results = results.clone();
6579 edited_results[result_index].content[block_index] = block;
6580 Message::ToolResults {
6581 results: edited_results,
6582 created_at: *created_at,
6583 }
6584 }
6585 };
6586
6587 let mut forked = self.fork_at(message_index);
6588 forked.push(replacement_message);
6589 Ok(forked)
6590 }
6591
6592 pub fn fork(&self) -> Self {
6597 let now = SystemTime::now();
6598 Self {
6599 version: session_version(),
6600 id: SessionId::new(),
6601 messages: Arc::clone(&self.messages),
6602 created_at: now,
6603 updated_at: now,
6604 metadata: self.fork_metadata_projection(),
6605 transcript_history_metadata_validation: TranscriptHistoryMetadataValidation::Validated,
6606 usage: self.usage.clone(),
6607 }
6608 }
6609}
6610
6611impl Default for Session {
6612 fn default() -> Self {
6613 Self::new()
6614 }
6615}
6616
6617#[derive(Debug, Clone, Serialize, Deserialize)]
6619#[serde(rename_all = "snake_case")]
6620pub struct SessionMeta {
6621 pub id: SessionId,
6622 pub created_at: SystemTime,
6623 pub updated_at: SystemTime,
6624 pub message_count: usize,
6625 pub total_tokens: u64,
6626 #[serde(default)]
6627 pub metadata: serde_json::Map<String, serde_json::Value>,
6628}
6629
6630#[derive(Debug, Clone, Serialize, Deserialize)]
6632#[serde(rename_all = "snake_case")]
6633pub struct SessionMetadata {
6634 pub schema_version: u32,
6641 pub model: String,
6642 pub max_tokens: u32,
6643 #[serde(default = "crate::config::default_structured_output_retries")]
6644 pub structured_output_retries: u32,
6645 pub provider: Provider,
6646 #[serde(default, skip_serializing_if = "Option::is_none")]
6647 pub self_hosted_server_id: Option<String>,
6648 #[serde(default, skip_serializing_if = "Option::is_none")]
6651 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
6652 pub tooling: SessionTooling,
6653 #[serde(default)]
6654 pub keep_alive: bool,
6655 pub comms_name: Option<String>,
6656 #[serde(default, skip_serializing_if = "Option::is_none")]
6658 pub peer_meta: Option<PeerMeta>,
6659 #[serde(default, skip_serializing_if = "Option::is_none")]
6665 pub realm_id: Option<crate::RealmId>,
6666 #[serde(default, skip_serializing_if = "Option::is_none")]
6668 pub instance_id: Option<String>,
6669 #[serde(default, skip_serializing_if = "Option::is_none")]
6671 pub backend: Option<String>,
6672 #[serde(default, skip_serializing_if = "Option::is_none")]
6674 pub config_generation: Option<u64>,
6675 #[serde(default, skip_serializing_if = "Option::is_none")]
6685 pub auth_binding: Option<crate::AuthBindingRef>,
6686 #[serde(default, skip_serializing_if = "Option::is_none")]
6699 pub mob_member_binding: Option<crate::MobMemberBinding>,
6700}
6701
6702#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
6704#[serde(rename_all = "snake_case")]
6705pub struct SessionLlmIdentity {
6706 pub model: String,
6707 pub provider: Provider,
6708 #[serde(default, skip_serializing_if = "Option::is_none")]
6709 pub self_hosted_server_id: Option<String>,
6710 #[serde(default, skip_serializing_if = "Option::is_none")]
6712 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
6713 #[serde(default, skip_serializing_if = "Option::is_none")]
6726 pub auth_binding: Option<crate::AuthBindingRef>,
6727}
6728
6729pub struct SessionLlmIdentityOverride<'a> {
6737 pub model: Option<&'a str>,
6738 pub provider: Option<Provider>,
6739 pub self_hosted_server_id: Option<&'a str>,
6743 pub provider_params:
6744 Option<TurnMetadataOverride<&'a crate::lifecycle::run_primitive::ProviderParamsOverride>>,
6745 pub auth_binding: Option<TurnMetadataOverride<&'a crate::AuthBindingRef>>,
6746}
6747
6748#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
6749pub enum SessionLlmIdentityOverrideError {
6750 #[error("provider override requires model on an existing session")]
6751 ProviderRequiresModel,
6752 #[error("{0}")]
6753 ProviderModelMismatch(String),
6754 #[error("self-hosted provider requires a registered model alias; '{model}' is not configured")]
6755 MissingSelfHostedAlias { model: String },
6756 #[error("self_hosted_server_id requires provider 'self_hosted'")]
6757 SelfHostedServerRequiresSelfHostedProvider,
6758 #[error("self_hosted_server_id must not be empty")]
6759 EmptySelfHostedServerId,
6760 #[error(
6761 "self-hosted model '{model}' is configured on server '{configured}', not requested server '{requested}'"
6762 )]
6763 SelfHostedServerMismatch {
6764 model: String,
6765 requested: String,
6766 configured: String,
6767 },
6768}
6769
6770pub fn resolve_session_llm_identity_override(
6778 current: &SessionLlmIdentity,
6779 registry: &crate::ModelRegistry,
6780 overrides: SessionLlmIdentityOverride<'_>,
6781) -> Result<SessionLlmIdentity, SessionLlmIdentityOverrideError> {
6782 if overrides.provider.is_some() && overrides.model.is_none() {
6783 return Err(SessionLlmIdentityOverrideError::ProviderRequiresModel);
6784 }
6785
6786 let model = overrides
6787 .model
6788 .map(str::to_string)
6789 .unwrap_or_else(|| current.model.clone());
6790 let provider = if let Some(provider) = overrides.provider {
6791 provider
6792 } else if overrides.model.is_some() {
6793 registry
6794 .entry(&model)
6795 .map_or(current.provider, |entry| entry.provider)
6796 } else {
6797 current.provider
6798 };
6799
6800 if (overrides.model.is_some() || overrides.provider.is_some())
6801 && let Some(reason) = registry.provider_override_mismatch_reason(provider, &model)
6802 {
6803 return Err(SessionLlmIdentityOverrideError::ProviderModelMismatch(
6804 reason,
6805 ));
6806 }
6807
6808 let provider_params = match overrides.provider_params {
6809 Some(TurnMetadataOverride::Clear) => None,
6810 Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
6811 None => current.provider_params.clone(),
6812 };
6813 if overrides.self_hosted_server_id.is_some() && provider != Provider::SelfHosted {
6814 return Err(SessionLlmIdentityOverrideError::SelfHostedServerRequiresSelfHostedProvider);
6815 }
6816 let self_hosted_server_id = if provider == Provider::SelfHosted {
6817 if let Some(requested_server_id) = overrides.self_hosted_server_id {
6818 if requested_server_id.trim().is_empty() {
6819 return Err(SessionLlmIdentityOverrideError::EmptySelfHostedServerId);
6820 }
6821 let entry = registry
6822 .entry_for_provider(Provider::SelfHosted, &model)
6823 .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
6824 model: model.clone(),
6825 })?;
6826 let configured_server_id = entry
6827 .self_hosted
6828 .as_ref()
6829 .map(|server| server.server_id.as_str())
6830 .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
6831 model: model.clone(),
6832 })?;
6833 if configured_server_id != requested_server_id {
6834 return Err(SessionLlmIdentityOverrideError::SelfHostedServerMismatch {
6835 model,
6836 requested: requested_server_id.to_string(),
6837 configured: configured_server_id.to_string(),
6838 });
6839 }
6840 Some(requested_server_id.to_string())
6841 } else if overrides.model.is_none() {
6842 current.self_hosted_server_id.clone().or_else(|| {
6843 registry
6844 .entry_for_provider(Provider::SelfHosted, &model)
6845 .and_then(|entry| entry.self_hosted.as_ref())
6846 .map(|server| server.server_id.clone())
6847 })
6848 } else {
6849 let entry = registry
6850 .entry_for_provider(Provider::SelfHosted, &model)
6851 .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
6852 model: model.clone(),
6853 })?;
6854 entry
6855 .self_hosted
6856 .as_ref()
6857 .map(|server| server.server_id.clone())
6858 }
6859 } else {
6860 None
6861 };
6862
6863 let auth_binding = match overrides.auth_binding {
6864 Some(TurnMetadataOverride::Clear) => None,
6865 Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
6866 None if provider != current.provider => None,
6869 None => current.auth_binding.clone(),
6870 };
6871
6872 Ok(SessionLlmIdentity {
6873 model,
6874 provider,
6875 self_hosted_server_id,
6876 provider_params,
6877 auth_binding,
6878 })
6879}
6880
6881#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
6888#[serde(rename_all = "snake_case")]
6889pub struct SessionLlmRequestPolicy {
6890 pub model: String,
6891 #[serde(default, skip_serializing_if = "Option::is_none")]
6893 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
6894 #[serde(default, skip_serializing_if = "Option::is_none")]
6896 pub provider_tool_defaults: Option<crate::lifecycle::run_primitive::ProviderTag>,
6897}
6898
6899impl SessionMetadata {
6900 pub fn llm_identity(&self) -> SessionLlmIdentity {
6902 SessionLlmIdentity {
6903 model: self.model.clone(),
6904 provider: self.provider,
6905 self_hosted_server_id: self.self_hosted_server_id.clone(),
6906 provider_params: self.provider_params.clone(),
6907 auth_binding: self.auth_binding.clone(),
6908 }
6909 }
6910
6911 pub fn apply_llm_identity(&mut self, identity: &SessionLlmIdentity) {
6913 self.model = identity.model.clone();
6914 self.provider = identity.provider;
6915 self.self_hosted_server_id = identity.self_hosted_server_id.clone();
6916 self.provider_params = identity.provider_params.clone();
6917 self.auth_binding = identity.auth_binding.clone();
6918 }
6919}
6920
6921pub const SESSION_METADATA_KEY: &str = "session_metadata";
6923
6924#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
6932#[serde(rename_all = "snake_case")]
6933pub enum ToolCategoryOverride {
6934 #[default]
6936 Inherit,
6937 Enable,
6939 Disable,
6941}
6942
6943impl ToolCategoryOverride {
6944 #[must_use]
6950 pub fn resolve(self, runtime_default: bool) -> bool {
6951 match self {
6952 Self::Enable => true,
6953 Self::Disable => false,
6954 Self::Inherit => runtime_default,
6955 }
6956 }
6957
6958 #[must_use]
6964 pub fn to_override(self) -> Option<bool> {
6965 match self {
6966 Self::Enable => Some(true),
6967 Self::Disable => Some(false),
6968 Self::Inherit => None,
6969 }
6970 }
6971
6972 #[must_use]
6980 pub fn from_effective(enabled: bool) -> Self {
6981 if enabled { Self::Enable } else { Self::Disable }
6982 }
6983
6984 #[must_use]
6994 pub fn from_override(value: Option<bool>) -> Self {
6995 match value {
6996 Some(true) => Self::Enable,
6997 Some(false) => Self::Disable,
6998 None => Self::Inherit,
6999 }
7000 }
7001}
7002
7003#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
7010#[serde(rename_all = "snake_case")]
7011pub struct SessionTooling {
7012 #[serde(default)]
7013 pub builtins: ToolCategoryOverride,
7014 #[serde(default)]
7015 pub shell: ToolCategoryOverride,
7016 #[serde(default)]
7017 pub comms: ToolCategoryOverride,
7018 #[serde(default)]
7020 pub mob: ToolCategoryOverride,
7021 #[serde(default)]
7023 pub memory: ToolCategoryOverride,
7024 #[serde(default)]
7026 pub schedule: ToolCategoryOverride,
7027 #[serde(default)]
7029 pub workgraph: ToolCategoryOverride,
7030 #[serde(default)]
7032 pub image_generation: ToolCategoryOverride,
7033 #[serde(default)]
7035 pub web_search: ToolCategoryOverride,
7036 #[serde(default, skip_serializing_if = "Option::is_none")]
7046 pub tool_access_policy: Option<crate::ops::ToolAccessPolicy>,
7047 #[serde(default, skip_serializing_if = "Option::is_none")]
7049 pub active_skills: Option<Vec<crate::skills::SkillKey>>,
7050}
7051
7052impl From<&Session> for SessionMeta {
7053 fn from(session: &Session) -> Self {
7054 Self {
7055 id: session.id.clone(),
7056 created_at: session.created_at,
7057 updated_at: session.updated_at,
7058 message_count: session.messages.len(),
7059 total_tokens: session.total_tokens(),
7060 metadata: session.metadata.clone(),
7061 }
7062 }
7063}
7064
7065pub fn try_session_metadata_from_map(
7074 metadata: &serde_json::Map<String, serde_json::Value>,
7075) -> Result<Option<SessionMetadata>, serde_json::Error> {
7076 let Some(value) = metadata.get(SESSION_METADATA_KEY) else {
7077 return Ok(None);
7078 };
7079 let mut metadata = serde_json::from_value::<SessionMetadata>(value.clone())?;
7080 metadata.schema_version =
7081 session_persistence_version_authority::restore_session_metadata_schema_version(
7082 metadata.schema_version,
7083 )
7084 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
7085 session_durable_config_authority::restore_session_metadata(metadata)
7086 .map(Some)
7087 .map_err(<serde_json::Error as serde::de::Error>::custom)
7088}
7089
7090pub fn try_lifecycle_terminal_from_map(
7097 metadata: &serde_json::Map<String, serde_json::Value>,
7098) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
7099 match metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY) {
7100 Some(value) => serde_json::from_value(value.clone()).map(Some),
7101 None => Ok(None),
7102 }
7103}
7104
7105#[derive(Debug, Clone)]
7113pub struct PersistedSessionMetadataView {
7114 pub session_id: SessionId,
7115 pub session_metadata: Option<SessionMetadata>,
7116 pub lifecycle_terminal: Option<SessionLifecycleTerminal>,
7117}
7118
7119impl PersistedSessionMetadataView {
7120 pub fn try_from_metadata_map(
7126 session_id: SessionId,
7127 metadata: &serde_json::Map<String, serde_json::Value>,
7128 ) -> Result<Self, serde_json::Error> {
7129 Ok(Self {
7130 session_id,
7131 session_metadata: try_session_metadata_from_map(metadata)?,
7132 lifecycle_terminal: try_lifecycle_terminal_from_map(metadata)?,
7133 })
7134 }
7135
7136 pub fn try_from_session(session: &Session) -> Result<Self, serde_json::Error> {
7138 Ok(Self {
7139 session_id: session.id().clone(),
7140 session_metadata: session.try_session_metadata()?,
7141 lifecycle_terminal: session.try_lifecycle_terminal()?,
7142 })
7143 }
7144
7145 pub fn mob_member_binding(&self) -> Option<&crate::MobMemberBinding> {
7148 self.session_metadata.as_ref()?.mob_member_binding.as_ref()
7149 }
7150}
7151
7152#[cfg(test)]
7153#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
7154mod tests {
7155 use super::*;
7156 use crate::realtime_transcript::RealtimeTranscriptRole;
7157 use crate::types::{
7158 AssistantBlock, BlockAssistantMessage, ContentBlock, StopReason, SystemMessage, Usage,
7159 UserMessage,
7160 };
7161 use std::sync::Arc;
7162
7163 fn exact_boundary_append(key: &str, text: &str) -> PendingSystemContextAppend {
7164 PendingSystemContextAppend {
7165 content: crate::lifecycle::CoreRenderable::text(text.to_string()),
7166 source: Some("test:exact-boundary".to_string()),
7167 idempotency_key: Some(key.to_string()),
7168 source_kind: SystemContextSource::RuntimeSteer,
7169 accepted_at: SystemTime::now(),
7170 peer_response_terminal: None,
7171 }
7172 }
7173
7174 async fn wait_for_exact_boundary_request(handle: &SystemContextStateHandle) {
7175 for _ in 0..1_000 {
7176 let registered = matches!(
7177 &handle.boundary.lock().window,
7178 SystemContextBoundaryWindow::Open {
7179 request: Some(_),
7180 ..
7181 }
7182 );
7183 if registered {
7184 return;
7185 }
7186 tokio::task::yield_now().await;
7187 }
7188 panic!("exact boundary request did not register");
7189 }
7190
7191 fn assert_send<T: Send>() {}
7192
7193 #[test]
7194 fn prepared_boundary_authority_is_send_for_owned_commit_handoff() {
7195 assert_send::<PreparedSystemContextBoundary>();
7196 assert_send::<crate::lifecycle::CoreBoundaryStageOutput>();
7197 assert_send::<ModelBoundarySystemContext>();
7198 }
7199
7200 #[tokio::test]
7201 async fn exact_boundary_runner_first_is_typed_unavailable() {
7202 let state = SystemContextStateHandle::new(Default::default()).expect("state");
7203 let run_id = RunId::new();
7204 let _run = state
7205 .begin_boundary_run(run_id.clone())
7206 .expect("open boundary");
7207
7208 let consuming = state
7209 .take_pending_at_exact_boundary(&run_id)
7210 .await
7211 .expect("runner claims open boundary");
7212 assert!(consuming.appends().is_empty());
7213 assert!(
7214 consuming
7215 .consume()
7216 .expect("runner consumes open boundary")
7217 .is_empty()
7218 );
7219 let error = state
7220 .prepare_active_turn_boundary(
7221 &run_id,
7222 vec![exact_boundary_append("runner-first", "too late")],
7223 )
7224 .await
7225 .expect_err("consumed generation cannot mint a preparation");
7226 assert!(matches!(error, CoreBoundaryStageError::Unavailable { .. }));
7227 }
7228
7229 #[tokio::test]
7230 async fn exact_boundary_wrong_run_is_stale_without_claiming_or_mutating_window() {
7231 let state = SystemContextStateHandle::new(Default::default()).expect("state");
7232 let active_run_id = RunId::new();
7233 let wrong_run_id = RunId::new();
7234 let _run = state
7235 .begin_boundary_run(active_run_id.clone())
7236 .expect("open boundary");
7237
7238 let error = state
7239 .prepare_active_turn_boundary(
7240 &wrong_run_id,
7241 vec![exact_boundary_append("wrong-run", "must not stage")],
7242 )
7243 .await
7244 .expect_err("a different run cannot claim the active window");
7245 assert!(matches!(error, CoreBoundaryStageError::Stale { .. }));
7246 assert!(state.snapshot().seen().is_empty());
7247
7248 let consuming = state
7249 .take_pending_at_exact_boundary(&active_run_id)
7250 .await
7251 .expect("the correct run still owns its unclaimed boundary");
7252 assert!(
7253 consuming
7254 .consume()
7255 .expect("consume unchanged active window")
7256 .is_empty()
7257 );
7258 }
7259
7260 #[tokio::test]
7261 async fn exact_boundary_conflicting_batch_is_atomic_and_leaves_window_unclaimed() {
7262 let state = SystemContextStateHandle::new(Default::default()).expect("state");
7263 let run_id = RunId::new();
7264 let _run = state
7265 .begin_boundary_run(run_id.clone())
7266 .expect("open boundary");
7267
7268 let error = state
7269 .prepare_active_turn_boundary(
7270 &run_id,
7271 vec![
7272 exact_boundary_append("conflict", "first"),
7273 exact_boundary_append("conflict", "different"),
7274 ],
7275 )
7276 .await
7277 .expect_err("a conflicting batch must fail before registration");
7278 assert!(matches!(error, CoreBoundaryStageError::Fault { .. }));
7279 assert!(state.snapshot().seen().is_empty());
7280
7281 let consuming = state
7282 .take_pending_at_exact_boundary(&run_id)
7283 .await
7284 .expect("failed validation must leave the exact window unclaimed");
7285 assert!(
7286 consuming
7287 .consume()
7288 .expect("consume unchanged active window")
7289 .is_empty()
7290 );
7291 }
7292
7293 #[tokio::test]
7294 async fn exact_boundary_prepare_first_parks_until_commit() {
7295 let state = SystemContextStateHandle::new(Default::default()).expect("state");
7296 let run_id = RunId::new();
7297 let _run = state
7298 .begin_boundary_run(run_id.clone())
7299 .expect("open boundary");
7300
7301 let prepare_state = state.clone();
7302 let prepare_run_id = run_id.clone();
7303 let prepare = tokio::spawn(async move {
7304 prepare_state
7305 .prepare_active_turn_boundary(
7306 &prepare_run_id,
7307 vec![exact_boundary_append("prepare-first", "parked context")],
7308 )
7309 .await
7310 });
7311 wait_for_exact_boundary_request(&state).await;
7312
7313 let runner_state = state.clone();
7314 let runner_run_id = run_id.clone();
7315 let runner = tokio::spawn(async move {
7316 runner_state
7317 .take_pending_at_exact_boundary(&runner_run_id)
7318 .await
7319 });
7320 let prepared = prepare
7321 .await
7322 .expect("prepare task")
7323 .expect("prepare must return only after park");
7324 assert_eq!(prepared.expected_run_id(), &run_id);
7325 assert!(prepared.boundary_generation() > 0);
7326 assert!(state.snapshot().pending().is_empty());
7327 assert!(
7328 !runner.is_finished(),
7329 "runner must remain parked before commit"
7330 );
7331
7332 prepared
7333 .into_stage_output(None)
7334 .commit()
7335 .expect("exact commit");
7336 let consuming = runner
7337 .await
7338 .expect("runner task")
7339 .expect("runner resumes after commit");
7340 assert_eq!(state.snapshot().pending().len(), 1);
7341 assert!(state.snapshot().applied().is_empty());
7342 let consumed = consuming.consume().expect("consume at model call seam");
7343 assert_eq!(consumed.len(), 1);
7344 assert_eq!(consumed[0].content.render_text(), "parked context");
7345 assert!(state.snapshot().pending().is_empty());
7346 assert!(state.snapshot().applied().is_empty());
7347 }
7348
7349 #[tokio::test]
7350 async fn exact_boundary_preprocessing_drop_does_not_claim_model_consumption() {
7351 let state = SystemContextStateHandle::new(Default::default()).expect("state");
7352 let run_id = RunId::new();
7353 let _run = state
7354 .begin_boundary_run(run_id.clone())
7355 .expect("open boundary");
7356 let prepare_state = state.clone();
7357 let prepare_run_id = run_id.clone();
7358 let prepare = tokio::spawn(async move {
7359 prepare_state
7360 .prepare_active_turn_boundary(
7361 &prepare_run_id,
7362 vec![exact_boundary_append(
7363 "preprocess-drop",
7364 "retryable context",
7365 )],
7366 )
7367 .await
7368 });
7369 wait_for_exact_boundary_request(&state).await;
7370 let runner_state = state.clone();
7371 let runner_run_id = run_id.clone();
7372 let runner = tokio::spawn(async move {
7373 runner_state
7374 .take_pending_at_exact_boundary(&runner_run_id)
7375 .await
7376 });
7377 prepare
7378 .await
7379 .expect("prepare task")
7380 .expect("prepare parks")
7381 .into_stage_output(None)
7382 .commit()
7383 .expect("publish pending candidate");
7384
7385 let consuming = runner
7386 .await
7387 .expect("runner task")
7388 .expect("runner enters preprocessing");
7389 assert_eq!(consuming.appends().len(), 1);
7390 drop(consuming);
7391
7392 let snapshot = state.snapshot();
7393 assert_eq!(snapshot.pending().len(), 1);
7394 assert!(snapshot.applied().is_empty());
7395 assert!(snapshot.seen().contains_key("preprocess-drop"));
7396
7397 assert_eq!(
7402 state
7403 .discard_unapplied_active_turn_pending()
7404 .expect("closed consuming window permits active-turn cleanup"),
7405 1
7406 );
7407 assert!(state.snapshot().pending().is_empty());
7408 }
7409
7410 #[tokio::test]
7411 async fn exact_boundary_drop_aborts_and_preserves_ordinary_pending() {
7412 let state = SystemContextStateHandle::new(Default::default()).expect("state");
7413 state
7414 .stage_append_with_snapshot(
7415 &AppendSystemContextRequest {
7416 content: crate::lifecycle::CoreRenderable::text("ordinary"),
7417 source: Some("test:ordinary".to_string()),
7418 idempotency_key: Some("ordinary".to_string()),
7419 source_kind: SystemContextSource::Normal,
7420 peer_response_terminal: None,
7421 },
7422 SystemTime::now(),
7423 )
7424 .expect("ordinary append");
7425 let run_id = RunId::new();
7426 let _run = state
7427 .begin_boundary_run(run_id.clone())
7428 .expect("open boundary");
7429 let prepare_state = state.clone();
7430 let prepare_run_id = run_id.clone();
7431 let prepare = tokio::spawn(async move {
7432 prepare_state
7433 .prepare_active_turn_boundary(
7434 &prepare_run_id,
7435 vec![exact_boundary_append("drop-abort", "must not publish")],
7436 )
7437 .await
7438 });
7439 wait_for_exact_boundary_request(&state).await;
7440 let runner_state = state.clone();
7441 let runner_run_id = run_id.clone();
7442 let runner = tokio::spawn(async move {
7443 runner_state
7444 .take_pending_at_exact_boundary(&runner_run_id)
7445 .await
7446 });
7447 let prepared = prepare.await.expect("prepare task").expect("parked");
7448 drop(prepared);
7449 let consuming = runner
7450 .await
7451 .expect("runner task")
7452 .expect("drop abort wakes runner");
7453 let consumed = consuming
7454 .consume()
7455 .expect("consume ordinary pending context");
7456 assert_eq!(consumed.len(), 1);
7457 assert_eq!(consumed[0].content.render_text(), "ordinary");
7458 assert!(!state.snapshot().seen().contains_key("drop-abort"));
7459 }
7460
7461 #[tokio::test]
7462 async fn exact_boundary_duplicate_prepare_cannot_overwrite_generation() {
7463 let state = SystemContextStateHandle::new(Default::default()).expect("state");
7464 let run_id = RunId::new();
7465 let _run = state
7466 .begin_boundary_run(run_id.clone())
7467 .expect("open boundary");
7468 let first_state = state.clone();
7469 let first_run_id = run_id.clone();
7470 let first = tokio::spawn(async move {
7471 first_state
7472 .prepare_active_turn_boundary(
7473 &first_run_id,
7474 vec![exact_boundary_append("first", "first")],
7475 )
7476 .await
7477 });
7478 wait_for_exact_boundary_request(&state).await;
7479 let duplicate = state
7480 .prepare_active_turn_boundary(&run_id, vec![exact_boundary_append("second", "second")])
7481 .await
7482 .expect_err("duplicate preparation must fail closed");
7483 assert!(duplicate.is_unavailable());
7484
7485 let runner_state = state.clone();
7486 let runner_run_id = run_id.clone();
7487 let runner = tokio::spawn(async move {
7488 runner_state
7489 .take_pending_at_exact_boundary(&runner_run_id)
7490 .await
7491 });
7492 let prepared = first.await.expect("first task").expect("first parks");
7493 prepared
7494 .into_stage_output(None)
7495 .abort()
7496 .expect("explicit abort");
7497 runner
7498 .await
7499 .expect("runner task")
7500 .expect("runner resumes after abort")
7501 .consume()
7502 .expect("consume ordinary pending after abort");
7503 assert!(!state.snapshot().seen().contains_key("second"));
7504 }
7505
7506 #[tokio::test]
7507 async fn exact_boundary_concurrent_conflict_surfaces_fault_to_runner_and_preparer() {
7508 let state = SystemContextStateHandle::new(Default::default()).expect("state");
7509 let run_id = RunId::new();
7510 let _run = state
7511 .begin_boundary_run(run_id.clone())
7512 .expect("open boundary");
7513 let prepare_state = state.clone();
7514 let prepare_run_id = run_id.clone();
7515 let prepare = tokio::spawn(async move {
7516 prepare_state
7517 .prepare_active_turn_boundary(
7518 &prepare_run_id,
7519 vec![exact_boundary_append("shared-key", "prepared context")],
7520 )
7521 .await
7522 });
7523 wait_for_exact_boundary_request(&state).await;
7524
7525 state
7526 .stage_append_with_snapshot(
7527 &AppendSystemContextRequest {
7528 content: crate::lifecycle::CoreRenderable::text("ordinary conflict"),
7529 source: Some("test:exact-boundary".to_string()),
7530 idempotency_key: Some("shared-key".to_string()),
7531 source_kind: SystemContextSource::Normal,
7532 peer_response_terminal: None,
7533 },
7534 SystemTime::now(),
7535 )
7536 .expect("ordinary mutation remains legal before the runner parks");
7537
7538 let runner_error = state
7539 .take_pending_at_exact_boundary(&run_id)
7540 .await
7541 .err()
7542 .expect("runner must surface candidate recomputation conflict");
7543 assert!(matches!(runner_error, CoreBoundaryStageError::Fault { .. }));
7544 let prepare_error = prepare
7545 .await
7546 .expect("prepare task")
7547 .expect_err("preparer must receive the same typed failure class");
7548 assert!(matches!(
7549 prepare_error,
7550 CoreBoundaryStageError::Fault { .. }
7551 ));
7552
7553 let snapshot = state.snapshot();
7554 assert_eq!(snapshot.pending().len(), 1);
7555 assert_eq!(
7556 snapshot.pending()[0].content.render_text(),
7557 "ordinary conflict"
7558 );
7559 assert!(snapshot.applied().is_empty());
7560 }
7561
7562 #[tokio::test]
7563 async fn exact_boundary_nonconflicting_open_mutation_is_preserved_in_candidate() {
7564 let state = SystemContextStateHandle::new(Default::default()).expect("state");
7565 let run_id = RunId::new();
7566 let _run = state
7567 .begin_boundary_run(run_id.clone())
7568 .expect("open boundary");
7569 let prepare_state = state.clone();
7570 let prepare_run_id = run_id.clone();
7571 let prepare = tokio::spawn(async move {
7572 prepare_state
7573 .prepare_active_turn_boundary(
7574 &prepare_run_id,
7575 vec![exact_boundary_append("prepared", "prepared context")],
7576 )
7577 .await
7578 });
7579 wait_for_exact_boundary_request(&state).await;
7580
7581 state
7582 .stage_append_with_snapshot(
7583 &AppendSystemContextRequest {
7584 content: crate::lifecycle::CoreRenderable::text("ordinary context"),
7585 source: Some("test:ordinary".to_string()),
7586 idempotency_key: Some("ordinary".to_string()),
7587 source_kind: SystemContextSource::Normal,
7588 peer_response_terminal: None,
7589 },
7590 SystemTime::now(),
7591 )
7592 .expect("nonconflicting mutation remains legal before park");
7593
7594 let runner_state = state.clone();
7595 let runner_run_id = run_id.clone();
7596 let runner = tokio::spawn(async move {
7597 runner_state
7598 .take_pending_at_exact_boundary(&runner_run_id)
7599 .await
7600 });
7601 let prepared = prepare.await.expect("prepare task").expect("parked");
7602 assert_eq!(prepared.candidate_state().pending().len(), 2);
7603 prepared
7604 .into_stage_output(None)
7605 .commit()
7606 .expect("commit exact candidate");
7607 let consuming = runner
7608 .await
7609 .expect("runner task")
7610 .expect("runner resumes after commit");
7611 let consumed = consuming.consume().expect("consume exact candidate");
7612 assert_eq!(consumed.len(), 2);
7613 assert!(
7614 consumed
7615 .iter()
7616 .any(|append| append.content.render_text() == "ordinary context")
7617 );
7618 assert!(
7619 consumed
7620 .iter()
7621 .any(|append| append.content.render_text() == "prepared context")
7622 );
7623 }
7624
7625 #[tokio::test]
7626 async fn exact_boundary_actor_replacement_rejects_old_commit() {
7627 let actor_a = SystemContextStateHandle::new(Default::default()).expect("actor A");
7628 let run_id = RunId::new();
7629 let _run_a = actor_a
7630 .begin_boundary_run(run_id.clone())
7631 .expect("open A boundary");
7632 let prepare_state = actor_a.clone();
7633 let prepare_run_id = run_id.clone();
7634 let prepare = tokio::spawn(async move {
7635 prepare_state
7636 .prepare_active_turn_boundary(
7637 &prepare_run_id,
7638 vec![exact_boundary_append("actor-a", "stale A")],
7639 )
7640 .await
7641 });
7642 wait_for_exact_boundary_request(&actor_a).await;
7643 let runner_state = actor_a.clone();
7644 let runner_run_id = run_id.clone();
7645 let runner = tokio::spawn(async move {
7646 runner_state
7647 .take_pending_at_exact_boundary(&runner_run_id)
7648 .await
7649 });
7650 let prepared_a = prepare.await.expect("prepare task").expect("A parked");
7651
7652 actor_a.revoke_boundary_actor();
7653 let actor_b = SystemContextStateHandle::new(Default::default()).expect("actor B");
7654 let _run_b = actor_b
7655 .begin_boundary_run(run_id.clone())
7656 .expect("replacement opens independently");
7657 let error = prepared_a
7658 .into_stage_output(None)
7659 .commit()
7660 .expect_err("A cannot commit after replacement revoke");
7661 assert!(matches!(error, CoreBoundaryStageError::Stale { .. }));
7662 assert!(runner.await.expect("A runner task").is_err());
7663 assert!(actor_b.snapshot().seen().is_empty());
7664 }
7665
7666 #[tokio::test]
7667 async fn exact_boundary_hard_interrupt_and_concurrent_append_fail_closed() {
7668 let state = SystemContextStateHandle::new(Default::default()).expect("state");
7669 let run_id = RunId::new();
7670 let _run = state
7671 .begin_boundary_run(run_id.clone())
7672 .expect("open boundary");
7673 let prepare_state = state.clone();
7674 let prepare_run_id = run_id.clone();
7675 let prepare = tokio::spawn(async move {
7676 prepare_state
7677 .prepare_active_turn_boundary(
7678 &prepare_run_id,
7679 vec![exact_boundary_append("interrupt", "stale")],
7680 )
7681 .await
7682 });
7683 wait_for_exact_boundary_request(&state).await;
7684 let runner_state = state.clone();
7685 let runner_run_id = run_id.clone();
7686 let runner = tokio::spawn(async move {
7687 runner_state
7688 .take_pending_at_exact_boundary(&runner_run_id)
7689 .await
7690 });
7691 let prepared = prepare.await.expect("prepare task").expect("parked");
7692 let concurrent = state.stage_append_with_snapshot(
7693 &AppendSystemContextRequest {
7694 content: crate::lifecycle::CoreRenderable::text("concurrent"),
7695 source: Some("test:concurrent".to_string()),
7696 idempotency_key: Some("concurrent".to_string()),
7697 source_kind: SystemContextSource::Normal,
7698 peer_response_terminal: None,
7699 },
7700 SystemTime::now(),
7701 );
7702 assert!(
7703 concurrent.is_err(),
7704 "parked candidate must not be overwritten"
7705 );
7706 let discard_error = state
7707 .discard_unapplied_active_turn_pending()
7708 .expect_err("parked authority must reject cleanup, not report an empty success");
7709 assert!(matches!(
7710 discard_error,
7711 CoreBoundaryStageError::Fault { .. }
7712 ));
7713 let keyed_discard_error = state
7714 .discard_active_turn_pending_by_keys(&["interrupt".to_string()])
7715 .expect_err("parked authority must reject keyed rollback");
7716 assert!(matches!(
7717 keyed_discard_error,
7718 CoreBoundaryStageError::Fault { .. }
7719 ));
7720
7721 runner.abort();
7722 let _ = runner.await;
7723 let error = prepared
7724 .into_stage_output(None)
7725 .commit()
7726 .expect_err("hard-interrupted parked request cannot commit later");
7727 assert!(matches!(error, CoreBoundaryStageError::Stale { .. }));
7728 assert!(state.snapshot().seen().is_empty());
7729 }
7730
7731 #[tokio::test]
7732 async fn exact_boundary_run_exit_wakes_prepare_before_parking() {
7733 let state = SystemContextStateHandle::new(Default::default()).expect("state");
7734 let run_id = RunId::new();
7735 let run = state
7736 .begin_boundary_run(run_id.clone())
7737 .expect("open boundary");
7738 let prepare_state = state.clone();
7739 let prepare_run_id = run_id.clone();
7740 let prepare = tokio::spawn(async move {
7741 prepare_state
7742 .prepare_active_turn_boundary(
7743 &prepare_run_id,
7744 vec![exact_boundary_append("run-exit", "never parks")],
7745 )
7746 .await
7747 });
7748 wait_for_exact_boundary_request(&state).await;
7749 drop(run);
7750 let error = prepare
7751 .await
7752 .expect("prepare task")
7753 .expect_err("run exit must release preparer");
7754 assert!(matches!(
7755 error,
7756 CoreBoundaryStageError::Unavailable { .. } | CoreBoundaryStageError::Stale { .. }
7757 ));
7758 }
7759
7760 fn block_assistant_text(message: &BlockAssistantMessage) -> String {
7761 message
7762 .blocks
7763 .iter()
7764 .filter_map(|block| match block {
7765 AssistantBlock::Text { text, .. } => Some(text.as_str()),
7766 _ => None,
7767 })
7768 .collect()
7769 }
7770
7771 fn append_staged_user_image(
7776 session: &mut Session,
7777 event: &RealtimeTranscriptEvent,
7778 ) -> RealtimeTranscriptApplyOutcome {
7779 let RealtimeTranscriptEvent::UserContentFinal {
7780 idempotency_key,
7781 item_id,
7782 previous_item_id,
7783 content_index,
7784 content,
7785 } = event
7786 else {
7787 panic!("test helper requires user content final")
7788 };
7789 let [ContentBlock::Image { media_type, data }] = content.as_slice() else {
7790 panic!("test helper requires exactly one image")
7791 };
7792 let media_type = crate::image_generation::MediaType::canonical_str(media_type);
7793 let blob_id = match data {
7794 crate::types::ImageData::Inline { data } => {
7795 crate::blob::content_blob_id(&media_type, data)
7796 }
7797 crate::types::ImageData::Blob { blob_id } => blob_id.clone(),
7798 };
7799 let pending = crate::PendingRealtimeUserContentBlob {
7800 idempotency_key: idempotency_key.clone(),
7801 item_id: item_id.clone(),
7802 previous_item_id: previous_item_id.clone(),
7803 content_index: *content_index,
7804 blob_id,
7805 media_type,
7806 };
7807 assert_eq!(
7808 session
7809 .stage_pending_realtime_user_content_blob(pending.clone())
7810 .expect("test pending anchor should stage"),
7811 crate::generated::session_document::RealtimeUserContentBlobStageDisposition::StageNew
7812 );
7813 session.append_realtime_transcript_event(pending.canonical_event())
7814 }
7815
7816 #[test]
7817 fn transcript_digest_is_content_addressed() {
7818 let base_time = crate::types::message_timestamp_now();
7819 let stamped = vec![
7820 Message::User(UserMessage::text("turn one".to_string())),
7821 Message::BlockAssistant(BlockAssistantMessage {
7822 blocks: vec![AssistantBlock::Text {
7823 text: "answer one".to_string(),
7824 meta: None,
7825 }],
7826 stop_reason: StopReason::EndTurn,
7827 identity: crate::types::TranscriptMessageIdentity {
7828 interaction_id: None,
7829 run_id: Some(crate::lifecycle::RunId::new()),
7830 objective_id: None,
7831 },
7832 created_at: base_time,
7833 }),
7834 ];
7835 let mut restamped = stamped.clone();
7836 for message in &mut restamped {
7837 match message {
7838 Message::User(user) => {
7839 user.created_at = base_time + chrono::Duration::hours(2);
7840 }
7841 Message::BlockAssistant(assistant) => {
7842 assistant.identity = crate::types::TranscriptMessageIdentity {
7843 interaction_id: None,
7844 run_id: Some(crate::lifecycle::RunId::new()),
7845 objective_id: None,
7846 };
7847 assistant.created_at = base_time + chrono::Duration::hours(2);
7848 }
7849 _ => {}
7850 }
7851 }
7852 assert_eq!(
7853 transcript_messages_digest(&stamped).expect("digest"),
7854 transcript_messages_digest(&restamped).expect("digest"),
7855 "bookkeeping variance must not fork the transcript revision"
7856 );
7857
7858 let mut content_changed = stamped.clone();
7859 if let Message::User(user) = &mut content_changed[0] {
7860 user.content = vec![ContentBlock::Text {
7861 text: "a different turn".to_string(),
7862 }];
7863 }
7864 assert_ne!(
7865 transcript_messages_digest(&stamped).expect("digest"),
7866 transcript_messages_digest(&content_changed).expect("digest"),
7867 "content changes must fork the transcript revision"
7868 );
7869 }
7870
7871 #[test]
7872 fn public_generic_rewrite_api_rejects_typed_compaction_semantic() {
7873 let mut session = Session::new();
7874 session.push(Message::User(UserMessage::text("old context")));
7875 let error = session
7876 .commit_transcript_rewrite(
7877 TranscriptRewriteSelection::typed_compaction_for_test(0, 1),
7878 vec![Message::User(UserMessage::compaction_summary("summary"))],
7879 TranscriptRewriteReason::new("anything"),
7880 None,
7881 None,
7882 )
7883 .unwrap_err();
7884 assert!(matches!(
7885 error,
7886 TranscriptEditError::InvalidTranscriptShape(_)
7887 ));
7888 assert_eq!(session.messages().len(), 1);
7889 }
7890
7891 #[test]
7892 fn compaction_witness_authorizes_only_the_exact_validated_rebuild() {
7893 let mut session = Session::new();
7894 session.push(Message::User(UserMessage::text("old context one")));
7895 session.push(Message::User(UserMessage::text("old context two")));
7896 let validated = vec![Message::User(UserMessage::compaction_summary(
7897 "validated summary",
7898 ))];
7899 let authority = crate::agent::compact::ValidatedCompactionRewrite::for_test(
7900 session.messages(),
7901 &validated,
7902 )
7903 .unwrap();
7904 let error = session
7905 .replace_messages_for_compaction_internal(
7906 vec![Message::User(UserMessage::compaction_summary(
7907 "substituted summary",
7908 ))],
7909 &authority,
7910 )
7911 .unwrap_err();
7912 assert!(matches!(
7913 error,
7914 TranscriptEditError::InvalidTranscriptShape(_)
7915 ));
7916 assert_eq!(session.messages().len(), 2);
7917 }
7918
7919 #[test]
7920 fn semantic_marker_prevents_new_generic_compaction_forgery_and_heals_prior_data() {
7921 let mut session = Session::new();
7922 session.push(Message::User(UserMessage::text("old context one")));
7923 session.push(Message::User(UserMessage::text("old context two")));
7924 session
7925 .commit_transcript_rewrite(
7926 TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
7927 vec![Message::User(UserMessage::compaction_summary("summary"))],
7928 TranscriptRewriteReason::new("compaction"),
7929 None,
7930 None,
7931 )
7932 .unwrap();
7933 let session: Session =
7934 serde_json::from_value(serde_json::to_value(&session).unwrap()).unwrap();
7935 let history = session.transcript_history_state().unwrap().unwrap();
7936 assert_eq!(
7937 history.commits[0].selection.semantic(),
7938 TranscriptRewriteSemantic::Edit,
7939 "new generic rewrites retain an explicit typed edit marker after roundtrip"
7940 );
7941 assert_eq!(history.commits[0].reason.kind, "compaction");
7942
7943 let mut legacy = history;
7944 legacy.commits[0].selection = TranscriptRewriteSelection::MessageRange { start: 0, end: 2 };
7945 let legacy: TranscriptHistoryState =
7946 serde_json::from_value(serde_json::to_value(legacy).unwrap()).unwrap();
7947 assert_eq!(
7948 legacy.commits[0].selection.semantic(),
7949 TranscriptRewriteSemantic::Compaction,
7950 "marker-absent prior data derives compaction from typed transcript evidence"
7951 );
7952
7953 let mut ordinary = Session::new();
7954 ordinary.push(Message::User(UserMessage::text("ordinary old one")));
7955 ordinary.push(Message::User(UserMessage::text("ordinary old two")));
7956 ordinary
7957 .commit_transcript_rewrite(
7958 TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
7959 vec![Message::User(UserMessage::text("ordinary replacement"))],
7960 TranscriptRewriteReason::new("compaction"),
7961 None,
7962 None,
7963 )
7964 .unwrap();
7965 let history = ordinary.transcript_history_state().unwrap().unwrap();
7966 assert_eq!(
7967 history.commits[0].selection.semantic(),
7968 TranscriptRewriteSemantic::Edit,
7969 "free-form reason must not upgrade an ordinary edit"
7970 );
7971 }
7972
7973 fn legacy_rewrite_fixture() -> (TranscriptRewriteCommit, Vec<Message>, Vec<Message>) {
7974 let parent_messages = vec![
7975 Message::User(UserMessage::text("before rewrite".to_string())),
7976 Message::User(UserMessage::text("retained tail".to_string())),
7977 ];
7978 let revision_messages = vec![
7979 Message::User(UserMessage::text("after rewrite".to_string())),
7980 Message::User(UserMessage::text("retained tail".to_string())),
7981 ];
7982 let parent_revision =
7985 legacy_transcript_messages_digest(&parent_messages).expect("legacy parent digest");
7986 let revision =
7987 legacy_transcript_messages_digest(&revision_messages).expect("legacy revision digest");
7988 let commit = TranscriptRewriteCommit {
7989 parent_revision,
7990 revision,
7991 selection: TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7992 original_span_digest: legacy_transcript_messages_digest(&parent_messages[0..1])
7993 .expect("legacy span digest"),
7994 replacement_digest: legacy_transcript_messages_digest(&revision_messages[0..1])
7995 .expect("legacy replacement digest"),
7996 messages_before: 2,
7997 messages_after: 2,
7998 reason: TranscriptRewriteReason::new("compaction"),
7999 actor: Some("legacy-test".to_string()),
8000 committed_at: SystemTime::now(),
8001 };
8002 (commit, parent_messages, revision_messages)
8003 }
8004
8005 #[test]
8006 fn legacy_transcript_history_state_heals_to_content_addressed_on_parse() {
8007 let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
8008 let state = TranscriptHistoryState {
8009 head: commit.revision.clone(),
8010 digest_format: 0,
8011 commits: vec![commit.clone()],
8012 revisions: vec![
8013 TranscriptRevisionBody {
8014 revision: commit.parent_revision.clone(),
8015 parent_revision: None,
8016 messages: parent_messages.clone(),
8017 created_at: SystemTime::now(),
8018 },
8019 TranscriptRevisionBody {
8020 revision: commit.revision.clone(),
8021 parent_revision: Some(commit.parent_revision),
8022 messages: revision_messages.clone(),
8023 created_at: SystemTime::now(),
8024 },
8025 ],
8026 };
8027 let value = serde_json::to_value(&state).expect("serialize legacy state");
8028 let healed: TranscriptHistoryState =
8029 serde_json::from_value(value).expect("parse legacy state");
8030
8031 let content_parent =
8032 transcript_messages_digest(&parent_messages).expect("content parent digest");
8033 let content_revision =
8034 transcript_messages_digest(&revision_messages).expect("content revision digest");
8035 assert_eq!(healed.head, content_revision, "head must re-derive");
8036 assert_eq!(healed.commits[0].parent_revision, content_parent);
8037 assert_eq!(healed.commits[0].revision, content_revision);
8038 assert_eq!(healed.revisions[0].revision, content_parent);
8039 assert_eq!(healed.revisions[1].revision, content_revision);
8040 assert_eq!(
8041 healed.revisions[1].parent_revision.as_deref(),
8042 Some(content_parent.as_str())
8043 );
8044 validate_transcript_history_state(&healed).expect("healed graph must validate");
8045
8046 let mut session = Session::new();
8049 session
8050 .apply_transcript_history_state(healed)
8051 .expect("apply healed graph");
8052 session
8053 .commit_transcript_rewrite(
8054 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8055 vec![Message::User(UserMessage::text(
8056 "rewritten again".to_string(),
8057 ))],
8058 TranscriptRewriteReason::new("unit-test"),
8059 None,
8060 None,
8061 )
8062 .expect("extend healed graph with a new rewrite");
8063 session
8064 .validate_transcript_history_state()
8065 .expect("extended graph must validate");
8066 }
8067
8068 #[test]
8069 fn legacy_transcript_rewrite_record_heals_on_parse() {
8070 let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
8071 let record_value = serde_json::json!({
8072 "commit": commit,
8073 "parent_body": TranscriptRevisionBody {
8074 revision: commit.parent_revision.clone(),
8075 parent_revision: None,
8076 messages: parent_messages,
8077 created_at: SystemTime::now(),
8078 },
8079 "revision_body": TranscriptRevisionBody {
8080 revision: commit.revision.clone(),
8081 parent_revision: Some(commit.parent_revision),
8082 messages: revision_messages.clone(),
8083 created_at: SystemTime::now(),
8084 },
8085 });
8086 let healed: TranscriptRewriteRecord =
8087 serde_json::from_value(record_value).expect("parse legacy record");
8088 assert_eq!(
8089 healed.commit.revision,
8090 transcript_messages_digest(&revision_messages).expect("content digest")
8091 );
8092 TranscriptRewriteRecord::new(healed.commit, healed.parent_body, healed.revision_body)
8094 .expect("healed record must validate");
8095 }
8096
8097 #[test]
8098 fn corrupt_transcript_history_strings_stay_untouched_and_fail_validation() {
8099 let (commit, parent_messages, _revision_messages) = legacy_rewrite_fixture();
8100 let bogus = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
8101 let state = TranscriptHistoryState {
8102 digest_format: 0,
8103 head: bogus.to_string(),
8104 commits: Vec::new(),
8105 revisions: vec![TranscriptRevisionBody {
8106 revision: bogus.to_string(),
8107 parent_revision: None,
8108 messages: parent_messages,
8109 created_at: SystemTime::now(),
8110 }],
8111 };
8112 let _ = commit;
8113 let value = serde_json::to_value(&state).expect("serialize corrupt state");
8114 let parsed: TranscriptHistoryState =
8115 serde_json::from_value(value).expect("corrupt strings still parse");
8116 assert_eq!(
8117 parsed.head, bogus,
8118 "unverifiable strings must not be rewritten"
8119 );
8120 assert!(
8121 validate_transcript_history_state(&parsed).is_err(),
8122 "corrupt graph must keep failing validation"
8123 );
8124 }
8125
8126 #[test]
8130 fn replace_synthetic_notices_leaves_only_replacements_of_kind() {
8131 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
8132
8133 let mut session = Session::new();
8134 session.push(Message::User(UserMessage::text("hello".to_string())));
8135 session.push(Message::SystemNotice(SystemNoticeMessage::new(
8136 SystemNoticeKind::McpPending,
8137 "stale one",
8138 )));
8139 session.push(Message::SystemNotice(SystemNoticeMessage::new(
8140 SystemNoticeKind::McpPending,
8141 "stale two",
8142 )));
8143 session.push(Message::SystemNotice(SystemNoticeMessage::new(
8145 SystemNoticeKind::BackgroundJob,
8146 "other-kind",
8147 )));
8148
8149 session
8150 .replace_synthetic_notices(
8151 SystemNoticeKind::McpPending,
8152 vec![Message::SystemNotice(SystemNoticeMessage::new(
8153 SystemNoticeKind::McpPending,
8154 "fresh",
8155 ))],
8156 )
8157 .expect("notice refresh succeeds");
8158
8159 let mcp_pending: Vec<&SystemNoticeMessage> = session
8160 .messages()
8161 .iter()
8162 .filter_map(|message| match message {
8163 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending => {
8164 Some(notice)
8165 }
8166 _ => None,
8167 })
8168 .collect();
8169 assert_eq!(mcp_pending.len(), 1, "exactly one notice of the kind");
8170 assert_eq!(mcp_pending[0].body.as_deref(), Some("fresh"));
8171 assert!(
8172 session.messages().iter().any(|message| matches!(
8173 message,
8174 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::BackgroundJob
8175 )),
8176 "other-kind notices are untouched"
8177 );
8178
8179 session
8181 .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
8182 .expect("pure strip succeeds");
8183 assert!(
8184 !session.messages().iter().any(|message| matches!(
8185 message,
8186 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending
8187 )),
8188 "empty replacement clears the kind"
8189 );
8190 }
8191
8192 #[test]
8193 fn ordinary_appends_after_rewrite_coalesce_mechanical_revision_bodies() {
8194 let mut session = Session::new();
8195 for message in 0..133 {
8196 session.push(Message::User(UserMessage::text(format!(
8197 "seed message {message}"
8198 ))));
8199 }
8200 let parent = session.transcript_revision().expect("parent revision");
8201 session
8202 .commit_transcript_rewrite(
8203 TranscriptRewriteSelection::MessageRange {
8204 start: 132,
8205 end: 133,
8206 },
8207 vec![Message::User(UserMessage::text("edited question"))],
8208 TranscriptRewriteReason::new("unit-test-edit"),
8209 Some("unit-test".to_string()),
8210 Some(parent),
8211 )
8212 .expect("rewrite should commit");
8213
8214 for turn in 0..762 {
8215 session.push(Message::User(UserMessage::text(format!("turn {turn}"))));
8216 }
8217
8218 let state = session
8219 .transcript_history_state()
8220 .expect("history state should decode")
8221 .expect("rewrite should create history state");
8222 assert_eq!(session.messages().len(), 895);
8223 assert_eq!(state.commits.len(), 1, "ordinary appends are not rewrites");
8224 assert_eq!(
8225 state.revisions.len(),
8226 3,
8227 "one real rewrite retains its two audited endpoints plus one live head"
8228 );
8229 let retained_message_entries = state
8230 .revisions
8231 .iter()
8232 .map(|body| body.messages.len())
8233 .sum::<usize>();
8234 assert!(retained_message_entries <= 3 * session.messages().len());
8235
8236 let live_bytes = serde_json::to_vec(session.messages())
8237 .expect("live transcript should serialize")
8238 .len();
8239 let snapshot_bytes = serde_json::to_vec(&session)
8240 .expect("session snapshot should serialize")
8241 .len();
8242 assert!(
8243 snapshot_bytes <= live_bytes.saturating_mul(5).saturating_add(64 * 1024),
8244 "snapshot must remain linear in the live transcript: {snapshot_bytes} bytes for {live_bytes} live bytes"
8245 );
8246 }
8247
8248 #[test]
8249 fn repeated_synthetic_notice_refreshes_do_not_mint_rewrite_commits() {
8250 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
8251
8252 let mut session = Session::new();
8253 session.push(Message::User(UserMessage::text("before".to_string())));
8254 session
8255 .commit_transcript_rewrite(
8256 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8257 vec![Message::User(UserMessage::text("after".to_string()))],
8258 TranscriptRewriteReason::new("unit-test-edit"),
8259 Some("unit-test".to_string()),
8260 None,
8261 )
8262 .expect("seed rewrite");
8263
8264 for refresh in 0..64 {
8265 session
8266 .replace_synthetic_notices(
8267 SystemNoticeKind::McpPending,
8268 vec![Message::SystemNotice(SystemNoticeMessage::new(
8269 SystemNoticeKind::McpPending,
8270 format!("refresh {refresh}"),
8271 ))],
8272 )
8273 .expect("mechanical refresh");
8274 }
8275
8276 let state = session
8277 .transcript_history_state()
8278 .expect("history state")
8279 .expect("seed rewrite history");
8280 assert_eq!(state.commits.len(), 1);
8281 assert_eq!(session.transcript_rewrite_generation().unwrap(), 1);
8282 assert_eq!(state.revisions.len(), 3);
8283 }
8284
8285 #[test]
8286 fn legacy_append_head_chain_compacts_during_session_restore() {
8287 let mut session = Session::new();
8288 session.push(Message::User(UserMessage::text("seed".to_string())));
8289 session
8290 .commit_transcript_rewrite(
8291 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8292 vec![Message::User(UserMessage::text(
8293 "rewritten seed".to_string(),
8294 ))],
8295 TranscriptRewriteReason::new("unit-test-edit"),
8296 Some("unit-test".to_string()),
8297 None,
8298 )
8299 .expect("seed rewrite");
8300
8301 let mut legacy = session
8302 .transcript_history_state()
8303 .expect("history state")
8304 .expect("seed history");
8305 let mut messages = session.messages().to_vec();
8306 let mut previous_head = legacy.head.clone();
8307 for append in 0..32 {
8308 messages.push(Message::User(UserMessage::text(format!(
8309 "legacy append {append}"
8310 ))));
8311 let revision = transcript_messages_digest(&messages).expect("revision digest");
8312 legacy.revisions.push(TranscriptRevisionBody {
8313 revision: revision.clone(),
8314 parent_revision: Some(previous_head),
8315 messages: messages.clone(),
8316 created_at: SystemTime::now(),
8317 });
8318 previous_head = revision;
8319 }
8320 legacy.head = previous_head;
8321 assert_eq!(legacy.revisions.len(), 34, "fixture matches old shape");
8322
8323 let mut envelope = serde_json::to_value(&session).expect("base envelope");
8324 envelope["messages"] = serde_json::to_value(&messages).expect("legacy live messages");
8325 envelope["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY] =
8326 serde_json::to_value(&legacy).expect("legacy unbounded history");
8327 for body in envelope["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["revisions"]
8328 .as_array_mut()
8329 .expect("legacy revisions")
8330 {
8331 body.as_object_mut()
8332 .expect("legacy revision body")
8333 .remove("parent_revision");
8334 }
8335 let raw = serde_json::to_vec(&envelope).expect("raw legacy bytes");
8336
8337 let restored: Session = serde_json::from_slice(&raw).expect("legacy restore");
8338 let compact = restored
8339 .transcript_history_state()
8340 .expect("compacted state")
8341 .expect("history retained");
8342 assert_eq!(compact.commits, legacy.commits);
8343 assert_eq!(compact.revisions.len(), 3);
8344 validate_transcript_history_state(&compact).expect("compacted history remains valid");
8345 let repaired = serde_json::to_vec(&restored).expect("repaired snapshot");
8346 assert!(
8347 repaired.len() * 4 < raw.len(),
8348 "repair should shed old bodies"
8349 );
8350 }
8351
8352 #[test]
8353 fn snapshot_compaction_does_not_launder_corrupt_old_body() {
8354 let mut session = Session::new();
8355 session.push(Message::User(UserMessage::text("seed".to_string())));
8356 session
8357 .commit_transcript_rewrite(
8358 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8359 vec![Message::User(UserMessage::text("rewritten".to_string()))],
8360 TranscriptRewriteReason::new("unit-test-edit"),
8361 Some("unit-test".to_string()),
8362 None,
8363 )
8364 .expect("seed rewrite");
8365 let mut state = session
8366 .transcript_history_state()
8367 .expect("state")
8368 .expect("history");
8369 state.revisions.push(TranscriptRevisionBody {
8370 revision: "sha256:corrupt-old-body".to_string(),
8371 parent_revision: Some(state.head.clone()),
8372 messages: vec![Message::User(UserMessage::text("tampered".to_string()))],
8373 created_at: SystemTime::now(),
8374 });
8375 session.set_metadata_unchecked_for_test(
8376 SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
8377 serde_json::to_value(state).expect("corrupt history value"),
8378 );
8379
8380 assert!(
8381 serde_json::to_vec(&session).is_err(),
8382 "serialization must fail before pruning a corrupt old body"
8383 );
8384 }
8385
8386 #[test]
8387 fn unchecked_valid_history_is_validated_and_compacted_at_snapshot_boundary() {
8388 let mut session = Session::new();
8389 session.push(Message::User(UserMessage::text("seed".to_string())));
8390 session
8391 .commit_transcript_rewrite(
8392 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8393 vec![Message::User(UserMessage::text("rewritten".to_string()))],
8394 TranscriptRewriteReason::new("unit-test-edit"),
8395 Some("unit-test".to_string()),
8396 None,
8397 )
8398 .expect("seed rewrite");
8399 let mut state = session
8400 .transcript_history_state()
8401 .expect("state")
8402 .expect("history");
8403 let mut messages = session.messages().to_vec();
8404 let mut parent = state.head.clone();
8405 for index in 0..8 {
8406 messages.push(Message::User(UserMessage::text(format!(
8407 "legacy append {index}"
8408 ))));
8409 let revision = transcript_messages_digest(&messages).expect("revision digest");
8410 state.revisions.push(TranscriptRevisionBody {
8411 revision: revision.clone(),
8412 parent_revision: Some(parent),
8413 messages: messages.clone(),
8414 created_at: SystemTime::now(),
8415 });
8416 parent = revision;
8417 }
8418 state.head = parent;
8419 session.messages = Arc::new(messages);
8420 session.set_metadata_unchecked_for_test(
8421 SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
8422 serde_json::to_value(state).expect("uncompacted history"),
8423 );
8424 assert_eq!(
8425 session.transcript_history_metadata_validation,
8426 TranscriptHistoryMetadataValidation::RequiresValidation
8427 );
8428
8429 let snapshot = serde_json::to_vec(&session)
8430 .expect("valid unchecked history should serialize after validation");
8431 let snapshot: serde_json::Value = serde_json::from_slice(&snapshot).expect("snapshot JSON");
8432 let compact: TranscriptHistoryState = serde_json::from_value(
8433 snapshot["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY].clone(),
8434 )
8435 .expect("compacted history");
8436
8437 assert_eq!(
8438 compact.revisions.len(),
8439 3,
8440 "snapshot boundary should retain two audited endpoints plus the live head"
8441 );
8442 validate_transcript_history_state(&compact).expect("compacted history remains valid");
8443 }
8444
8445 #[test]
8446 fn transcript_history_rejects_stale_branch_after_digest_recurrence() {
8447 let mut restored = Session::new();
8448 restored.push(Message::User(UserMessage::text("A".to_string())));
8449 restored
8450 .commit_transcript_rewrite(
8451 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8452 vec![Message::User(UserMessage::text("B".to_string()))],
8453 TranscriptRewriteReason::new("to-b"),
8454 Some("unit-test".to_string()),
8455 None,
8456 )
8457 .expect("A to B");
8458 let mut stale_branch = restored.clone();
8459 restored
8460 .commit_transcript_rewrite(
8461 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8462 vec![Message::User(UserMessage::text("A".to_string()))],
8463 TranscriptRewriteReason::new("restore-a"),
8464 Some("unit-test".to_string()),
8465 None,
8466 )
8467 .expect("B back to A");
8468 stale_branch
8469 .commit_transcript_rewrite(
8470 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8471 vec![Message::User(UserMessage::text("C".to_string()))],
8472 TranscriptRewriteReason::new("stale-b-to-c"),
8473 Some("unit-test".to_string()),
8474 None,
8475 )
8476 .expect("stale B to C is locally valid");
8477
8478 let stale_state = stale_branch
8479 .transcript_history_state()
8480 .expect("stale state")
8481 .expect("stale history");
8482 let stale_commit = stale_state.commits.last().expect("stale commit").clone();
8483 let stale_body = stale_state
8484 .revisions
8485 .iter()
8486 .find(|body| body.revision == stale_commit.revision)
8487 .expect("stale revision body")
8488 .clone();
8489 let mut forged = restored
8490 .transcript_history_state()
8491 .expect("restored state")
8492 .expect("restored history");
8493 forged.commits.push(stale_commit);
8494 forged.revisions.push(stale_body);
8495 forged.head = forged
8496 .commits
8497 .last()
8498 .expect("forged commit")
8499 .revision
8500 .clone();
8501
8502 assert!(
8503 validate_transcript_history_state(&forged).is_err(),
8504 "an old B<-A body edge cannot authorize stale B->C after B->A restored A"
8505 );
8506 }
8507
8508 #[test]
8509 fn transcript_history_rejects_orphan_head_parent_cycle() {
8510 let mut session = Session::new();
8511 session.push(Message::User(UserMessage::text("P".to_string())));
8512 session
8513 .commit_transcript_rewrite(
8514 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8515 vec![Message::User(UserMessage::text("Q".to_string()))],
8516 TranscriptRewriteReason::new("valid"),
8517 Some("unit-test".to_string()),
8518 None,
8519 )
8520 .expect("valid seed rewrite");
8521 let mut state = session
8522 .transcript_history_state()
8523 .expect("state")
8524 .expect("history");
8525 let x_messages = vec![Message::User(UserMessage::text("X".to_string()))];
8526 let y_messages = vec![Message::User(UserMessage::text("Y".to_string()))];
8527 let x = transcript_messages_digest(&x_messages).expect("X digest");
8528 let y = transcript_messages_digest(&y_messages).expect("Y digest");
8529 state.revisions.push(TranscriptRevisionBody {
8530 revision: x.clone(),
8531 parent_revision: Some(y.clone()),
8532 messages: x_messages,
8533 created_at: SystemTime::now(),
8534 });
8535 state.revisions.push(TranscriptRevisionBody {
8536 revision: y,
8537 parent_revision: Some(x.clone()),
8538 messages: y_messages,
8539 created_at: SystemTime::now(),
8540 });
8541 state.head = x;
8542 session.set_metadata_unchecked_for_test(
8543 SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
8544 serde_json::to_value(state).expect("cyclic state"),
8545 );
8546
8547 assert!(
8548 serde_json::to_vec(&session).is_err(),
8549 "cyclic orphan head lineage must fail instead of looping"
8550 );
8551 }
8552
8553 #[test]
8554 fn mechanical_append_can_recur_to_an_audited_digest_without_mutating_its_body() {
8555 let a = Message::User(UserMessage::text("A".to_string()));
8556 let b = Message::User(UserMessage::text("B".to_string()));
8557 let mut session = Session::new();
8558 session.push(Message::User(UserMessage::text("X".to_string())));
8559 let first = session
8560 .commit_transcript_rewrite(
8561 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8562 vec![a.clone(), b.clone()],
8563 TranscriptRewriteReason::new("to-a-b"),
8564 Some("unit-test".to_string()),
8565 None,
8566 )
8567 .expect("X to [A,B]");
8568 let h_parent = session
8569 .transcript_revision_body(&first.revision)
8570 .expect("H body")
8571 .expect("H retained")
8572 .parent_revision;
8573 session
8574 .commit_transcript_rewrite(
8575 TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
8576 vec![a],
8577 TranscriptRewriteReason::new("to-a"),
8578 Some("unit-test".to_string()),
8579 None,
8580 )
8581 .expect("[A,B] to [A]");
8582
8583 session.push(b);
8584
8585 let state = session
8586 .transcript_history_state()
8587 .expect("state")
8588 .expect("history");
8589 assert_eq!(state.head, first.revision);
8590 assert_eq!(session.transcript_revision().unwrap(), first.revision);
8591 assert_eq!(
8592 state
8593 .revisions
8594 .iter()
8595 .find(|body| body.revision == first.revision)
8596 .expect("recurred H body")
8597 .parent_revision,
8598 h_parent,
8599 "reusing an audited digest must not rewrite its occurrence metadata"
8600 );
8601 validate_transcript_history_state(&state).expect("recurred mechanical head is valid");
8602 }
8603
8604 #[test]
8608 fn replace_synthetic_notices_rejects_mismatched_kind_without_mutation() {
8609 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
8610
8611 let mut session = Session::new();
8612 session.push(Message::SystemNotice(SystemNoticeMessage::new(
8613 SystemNoticeKind::McpPending,
8614 "stale",
8615 )));
8616 let before = session.messages().to_vec();
8617
8618 let err = session
8619 .replace_synthetic_notices(
8620 SystemNoticeKind::McpPending,
8621 vec![Message::User(UserMessage::text("not a notice".to_string()))],
8622 )
8623 .expect_err("mismatched replacement must fail typed");
8624 assert!(
8625 matches!(err, TranscriptEditError::InvalidTranscriptShape(_)),
8626 "expected InvalidTranscriptShape, got {err:?}"
8627 );
8628 assert_eq!(
8629 session.messages(),
8630 before.as_slice(),
8631 "fault must leave the transcript unchanged (no partial strip)"
8632 );
8633 }
8634
8635 #[test]
8636 fn replace_synthetic_notices_rejects_malformed_history_atomically() {
8637 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
8638
8639 let mut session = Session::new();
8640 session.push(Message::User(UserMessage::text("before".to_string())));
8641 session
8642 .commit_transcript_rewrite(
8643 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8644 vec![Message::User(UserMessage::text("after".to_string()))],
8645 TranscriptRewriteReason::new("unit-test-edit"),
8646 Some("unit-test".to_string()),
8647 None,
8648 )
8649 .expect("seed rewrite");
8650 session.push(Message::SystemNotice(SystemNoticeMessage::new(
8651 SystemNoticeKind::McpPending,
8652 "stale",
8653 )));
8654 let mut state = session
8655 .transcript_history_state()
8656 .expect("state")
8657 .expect("history");
8658 state.revisions[0].messages[0] = Message::User(UserMessage::text("tampered".to_string()));
8659 session.set_metadata_unchecked_for_test(
8660 SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
8661 serde_json::to_value(state).expect("corrupt state"),
8662 );
8663 let before_messages = session.messages.clone();
8664 let before_metadata = session.metadata.clone();
8665 let before_updated_at = session.updated_at;
8666
8667 assert!(
8668 session
8669 .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
8670 .is_err()
8671 );
8672 assert_eq!(session.messages, before_messages);
8673 assert_eq!(session.metadata, before_metadata);
8674 assert_eq!(session.updated_at, before_updated_at);
8675 }
8676
8677 #[test]
8678 fn replace_synthetic_notices_rejects_durable_notice_kinds() {
8679 use crate::types::SystemNoticeKind;
8680
8681 let mut session = Session::new();
8682 let before = session.messages().to_vec();
8683 assert!(
8684 session
8685 .replace_synthetic_notices(SystemNoticeKind::Comms, Vec::new())
8686 .is_err()
8687 );
8688 assert_eq!(session.messages(), before);
8689 }
8690
8691 #[test]
8692 fn replace_synthetic_notices_preserves_persisted_mcp_pending_notice() {
8693 use crate::types::{SystemNoticeBlock, SystemNoticeKind, SystemNoticeMessage};
8694
8695 let mut session = Session::new();
8696 session.push(Message::SystemNotice(SystemNoticeMessage::with_block(
8697 SystemNoticeKind::McpPending,
8698 Some("persisted pending fact".to_string()),
8699 SystemNoticeBlock::Mcp {
8700 server_id: Some("server".to_string()),
8701 operation: None,
8702 phase: None,
8703 persisted: true,
8704 detail: None,
8705 pending_sources: Vec::new(),
8706 },
8707 )));
8708 let before = session.messages().to_vec();
8709
8710 session
8711 .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
8712 .expect("synthetic refresh must coexist with a durable notice of the same kind");
8713 assert_eq!(session.messages(), before);
8714 }
8715
8716 #[test]
8717 fn replace_synthetic_notices_replaces_projection_beside_persisted_mcp_fact() {
8718 use crate::types::{SystemNoticeBlock, SystemNoticeKind, SystemNoticeMessage};
8719
8720 let durable = Message::SystemNotice(SystemNoticeMessage::with_block(
8721 SystemNoticeKind::McpPending,
8722 Some("persisted pending fact".to_string()),
8723 SystemNoticeBlock::Mcp {
8724 server_id: Some("server".to_string()),
8725 operation: None,
8726 phase: None,
8727 persisted: true,
8728 detail: None,
8729 pending_sources: Vec::new(),
8730 },
8731 ));
8732 let stale = Message::SystemNotice(SystemNoticeMessage::new(
8733 SystemNoticeKind::McpPending,
8734 "stale synthetic projection",
8735 ));
8736 let fresh = Message::SystemNotice(SystemNoticeMessage::new(
8737 SystemNoticeKind::McpPending,
8738 "fresh synthetic projection",
8739 ));
8740 let mut session = Session::new();
8741 session.push(durable.clone());
8742 session.push(stale);
8743
8744 session
8745 .replace_synthetic_notices(SystemNoticeKind::McpPending, vec![fresh.clone()])
8746 .expect("synthetic refresh beside durable fact");
8747
8748 assert_eq!(session.messages(), &[durable, fresh]);
8749 }
8750
8751 #[test]
8752 fn transcript_rewrite_preserves_full_assistant_block_trace() {
8753 let mut session = Session::new();
8754 session.push(Message::User(UserMessage::text(
8755 "run the trace".to_string(),
8756 )));
8757 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
8758 vec![AssistantBlock::Text {
8759 text: "original assistant trace".to_string(),
8760 meta: None,
8761 }],
8762 StopReason::EndTurn,
8763 )));
8764
8765 let parent_revision = session.transcript_revision().expect("parent revision");
8766 let replacement = vec![
8767 Message::BlockAssistant(BlockAssistantMessage::new(
8768 vec![
8769 AssistantBlock::Text {
8770 text: "compacted assistant trace".to_string(),
8771 meta: None,
8772 },
8773 AssistantBlock::ToolUse {
8774 id: "toolu_trace".to_string(),
8775 name: "trace_probe".to_string(),
8776 args: serde_json::value::RawValue::from_string(
8777 r#"{"path":"N-3"}"#.to_string(),
8778 )
8779 .expect("valid tool args"),
8780 meta: None,
8781 },
8782 ],
8783 StopReason::ToolUse,
8784 )),
8785 Message::tool_results(vec![ToolResult::new(
8786 "toolu_trace".to_string(),
8787 "trace complete".to_string(),
8788 false,
8789 )]),
8790 ];
8791
8792 let commit = session
8793 .commit_transcript_rewrite(
8794 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8795 replacement,
8796 TranscriptRewriteReason::new("compaction"),
8797 Some("unit-test".to_string()),
8798 Some(parent_revision.clone()),
8799 )
8800 .expect("rewrite should commit");
8801
8802 assert_eq!(commit.parent_revision, parent_revision);
8803 let current = session
8804 .transcript_revision_messages(&commit.revision)
8805 .expect("history state should decode")
8806 .expect("current revision should be retained");
8807 let Message::BlockAssistant(assistant) = ¤t[1] else {
8808 panic!("replacement should remain a block assistant message");
8809 };
8810 assert!(assistant.blocks.iter().any(|block| matches!(
8811 block,
8812 AssistantBlock::ToolUse { name, args, .. }
8813 if name == "trace_probe" && args.get().contains("\"N-3\"")
8814 )));
8815
8816 let parent = session
8817 .transcript_revision_messages(&parent_revision)
8818 .expect("history state should decode")
8819 .expect("parent revision should remain retained");
8820 assert!(matches!(
8821 &parent[1],
8822 Message::BlockAssistant(assistant)
8823 if block_assistant_text(assistant).contains("original assistant trace")
8824 ));
8825 }
8826
8827 #[test]
8828 fn transcript_rewrite_rejects_trailing_block_assistant_tool_call() {
8829 let mut session = Session::new();
8830 session.push(Message::User(UserMessage::text("question".to_string())));
8831 session.push(Message::BlockAssistant(BlockAssistantMessage {
8832 blocks: vec![AssistantBlock::Text {
8833 text: "plain answer".to_string(),
8834 meta: None,
8835 }],
8836 stop_reason: StopReason::EndTurn,
8837 identity: crate::types::TranscriptMessageIdentity::default(),
8838 created_at: crate::types::message_timestamp_now(),
8839 }));
8840 let parent_revision = session.transcript_revision().expect("parent revision");
8841
8842 let err = session
8843 .commit_transcript_rewrite(
8844 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8845 vec![Message::BlockAssistant(BlockAssistantMessage::new(
8846 vec![AssistantBlock::ToolUse {
8847 id: "toolu_1".to_string(),
8848 name: "lookup".to_string(),
8849 args: serde_json::value::RawValue::from_string("{}".to_string())
8850 .expect("valid args"),
8851 meta: None,
8852 }],
8853 StopReason::ToolUse,
8854 ))],
8855 TranscriptRewriteReason::new("compaction"),
8856 Some("unit-test".to_string()),
8857 Some(parent_revision),
8858 )
8859 .expect_err("rewrite should reject trailing unresolved block-assistant tool call");
8860 assert!(matches!(
8861 err,
8862 TranscriptEditError::InvalidTranscriptShape(_)
8863 ));
8864 }
8865
8866 #[test]
8867 fn transcript_rewrite_rejects_no_op_self_edge() {
8868 let mut session = Session::new();
8869 session.push(Message::User(UserMessage::text(
8870 "keep this exact transcript".to_string(),
8871 )));
8872 session.push(Message::BlockAssistant(BlockAssistantMessage {
8873 blocks: vec![AssistantBlock::Text {
8874 text: "unchanged".to_string(),
8875 meta: None,
8876 }],
8877 stop_reason: StopReason::EndTurn,
8878 identity: crate::types::TranscriptMessageIdentity::default(),
8879 created_at: crate::types::message_timestamp_now(),
8880 }));
8881
8882 let parent_revision = session.transcript_revision().expect("parent revision");
8883 let err = session
8884 .commit_transcript_rewrite(
8885 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8886 vec![session.messages()[1].clone()],
8887 TranscriptRewriteReason::new("retry"),
8888 Some("unit-test".to_string()),
8889 Some(parent_revision.clone()),
8890 )
8891 .expect_err("same-content rewrite should not emit a self-edge commit");
8892
8893 assert!(matches!(
8894 err,
8895 TranscriptEditError::NoOpRewrite { revision } if revision == parent_revision
8896 ));
8897 assert!(
8898 session
8899 .transcript_history_state()
8900 .expect("history state should decode")
8901 .is_none()
8902 );
8903 }
8904
8905 #[test]
8906 fn transcript_rewrite_run_boundary_guard_accepts_rewrite_then_append() {
8907 let mut original = Session::new();
8908 original.push(Message::User(UserMessage::text("question".to_string())));
8909 original.push(Message::BlockAssistant(BlockAssistantMessage {
8910 blocks: vec![AssistantBlock::Text {
8911 text: "verbose answer".to_string(),
8912 meta: None,
8913 }],
8914 stop_reason: StopReason::EndTurn,
8915 identity: crate::types::TranscriptMessageIdentity::default(),
8916 created_at: crate::types::message_timestamp_now(),
8917 }));
8918
8919 let parent_revision = original.transcript_revision().expect("parent revision");
8920 let mut incoming = original.clone();
8921 incoming
8922 .commit_transcript_rewrite(
8923 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8924 vec![Message::BlockAssistant(BlockAssistantMessage {
8925 blocks: vec![AssistantBlock::Text {
8926 text: "compact answer".to_string(),
8927 meta: None,
8928 }],
8929 stop_reason: StopReason::EndTurn,
8930 identity: crate::types::TranscriptMessageIdentity::default(),
8931 created_at: crate::types::message_timestamp_now(),
8932 })],
8933 TranscriptRewriteReason::new("compaction"),
8934 Some("unit-test".to_string()),
8935 Some(parent_revision),
8936 )
8937 .expect("rewrite should commit");
8938 incoming.push(Message::User(UserMessage::text("follow-up".to_string())));
8939 incoming.push(Message::BlockAssistant(BlockAssistantMessage {
8940 blocks: vec![AssistantBlock::Text {
8941 text: "follow-up answer".to_string(),
8942 meta: None,
8943 }],
8944 stop_reason: StopReason::EndTurn,
8945 identity: crate::types::TranscriptMessageIdentity::default(),
8946 created_at: crate::types::message_timestamp_now(),
8947 }));
8948
8949 crate::session_store::run_boundary_snapshot_save_guard(&incoming, Some(&original))
8950 .expect("rewrite plus appended turn should be a valid run-boundary commit");
8951 }
8952
8953 #[test]
8954 fn transcript_rewrite_rejects_orphaned_tool_results() {
8955 let mut session = Session::new();
8956 session.push(Message::User(UserMessage::text("use a tool".to_string())));
8957 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
8958 vec![AssistantBlock::ToolUse {
8959 id: "toolu_1".to_string(),
8960 name: "lookup".to_string(),
8961 args: serde_json::value::RawValue::from_string("{}".to_string())
8962 .expect("valid args"),
8963 meta: None,
8964 }],
8965 StopReason::ToolUse,
8966 )));
8967 session.push(Message::tool_results(vec![ToolResult::new(
8968 "toolu_1".to_string(),
8969 "done".to_string(),
8970 false,
8971 )]));
8972 let parent_revision = session.transcript_revision().expect("parent revision");
8973
8974 let err = session
8975 .commit_transcript_rewrite(
8976 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8977 vec![Message::BlockAssistant(BlockAssistantMessage {
8978 blocks: vec![AssistantBlock::Text {
8979 text: "no tool after all".to_string(),
8980 meta: None,
8981 }],
8982 stop_reason: StopReason::EndTurn,
8983 identity: crate::types::TranscriptMessageIdentity::default(),
8984 created_at: crate::types::message_timestamp_now(),
8985 })],
8986 TranscriptRewriteReason::new("compaction"),
8987 Some("unit-test".to_string()),
8988 Some(parent_revision),
8989 )
8990 .expect_err("rewrite should reject stranded tool results");
8991 assert!(matches!(
8992 err,
8993 TranscriptEditError::InvalidTranscriptShape(_)
8994 ));
8995 }
8996
8997 #[test]
8998 fn transcript_rewrite_rejects_trailing_assistant_tool_call() {
8999 let mut session = Session::new();
9000 session.push(Message::User(UserMessage::text("question".to_string())));
9001 session.push(Message::BlockAssistant(BlockAssistantMessage {
9002 blocks: vec![AssistantBlock::Text {
9003 text: "plain answer".to_string(),
9004 meta: None,
9005 }],
9006 stop_reason: StopReason::EndTurn,
9007 identity: crate::types::TranscriptMessageIdentity::default(),
9008 created_at: crate::types::message_timestamp_now(),
9009 }));
9010 let parent_revision = session.transcript_revision().expect("parent revision");
9011
9012 let err = session
9013 .commit_transcript_rewrite(
9014 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9015 vec![Message::BlockAssistant(BlockAssistantMessage {
9016 blocks: vec![AssistantBlock::ToolUse {
9017 id: "toolu_1".to_string(),
9018 name: "lookup".to_string(),
9019 args: serde_json::value::RawValue::from_string("{}".to_string())
9020 .expect("valid args"),
9021 meta: None,
9022 }],
9023 stop_reason: StopReason::ToolUse,
9024 identity: crate::types::TranscriptMessageIdentity::default(),
9025 created_at: crate::types::message_timestamp_now(),
9026 })],
9027 TranscriptRewriteReason::new("compaction"),
9028 Some("unit-test".to_string()),
9029 Some(parent_revision),
9030 )
9031 .expect_err("rewrite should reject trailing unresolved tool call");
9032 assert!(matches!(
9033 err,
9034 TranscriptEditError::InvalidTranscriptShape(_)
9035 ));
9036 }
9037
9038 #[test]
9039 fn transcript_rewrite_rejects_duplicate_tool_results() {
9040 let mut session = Session::new();
9041 session.push(Message::User(UserMessage::text("use a tool".to_string())));
9042 session.push(Message::BlockAssistant(BlockAssistantMessage {
9043 blocks: vec![AssistantBlock::Text {
9044 text: "plain answer".to_string(),
9045 meta: None,
9046 }],
9047 stop_reason: StopReason::EndTurn,
9048 identity: crate::types::TranscriptMessageIdentity::default(),
9049 created_at: crate::types::message_timestamp_now(),
9050 }));
9051 let parent_revision = session.transcript_revision().expect("parent revision");
9052
9053 let err = session
9054 .commit_transcript_rewrite(
9055 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9056 vec![
9057 Message::BlockAssistant(BlockAssistantMessage::new(
9058 vec![AssistantBlock::ToolUse {
9059 id: "toolu_1".to_string(),
9060 name: "lookup".to_string(),
9061 args: serde_json::value::RawValue::from_string("{}".to_string())
9062 .expect("valid args"),
9063 meta: None,
9064 }],
9065 StopReason::ToolUse,
9066 )),
9067 Message::tool_results(vec![
9068 ToolResult::new("toolu_1".to_string(), "one".to_string(), false),
9069 ToolResult::new("toolu_1".to_string(), "two".to_string(), false),
9070 ]),
9071 ],
9072 TranscriptRewriteReason::new("compaction"),
9073 Some("unit-test".to_string()),
9074 Some(parent_revision),
9075 )
9076 .expect_err("rewrite should reject duplicate tool results");
9077 assert!(matches!(
9078 err,
9079 TranscriptEditError::InvalidTranscriptShape(_)
9080 ));
9081 }
9082
9083 #[test]
9084 fn transcript_rewrite_record_rejects_prefix_or_suffix_tampering() {
9085 let mut session = Session::new();
9086 session.push(Message::System(SystemMessage::new("keep prefix")));
9087 session.push(Message::BlockAssistant(BlockAssistantMessage {
9088 blocks: vec![AssistantBlock::Text {
9089 text: "verbose answer".to_string(),
9090 meta: None,
9091 }],
9092 stop_reason: StopReason::EndTurn,
9093 identity: crate::types::TranscriptMessageIdentity::default(),
9094 created_at: crate::types::message_timestamp_now(),
9095 }));
9096 session.push(Message::User(UserMessage::text("keep suffix".to_string())));
9097
9098 let parent_revision = session.transcript_revision().expect("parent revision");
9099 let commit = session
9100 .commit_transcript_rewrite(
9101 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9102 vec![Message::BlockAssistant(BlockAssistantMessage {
9103 blocks: vec![AssistantBlock::Text {
9104 text: "compact answer".to_string(),
9105 meta: None,
9106 }],
9107 stop_reason: StopReason::EndTurn,
9108 identity: crate::types::TranscriptMessageIdentity::default(),
9109 created_at: crate::types::message_timestamp_now(),
9110 })],
9111 TranscriptRewriteReason::new("compaction"),
9112 Some("unit-test".to_string()),
9113 Some(parent_revision),
9114 )
9115 .expect("rewrite should commit");
9116 let state = session
9117 .transcript_history_state()
9118 .expect("history state should decode")
9119 .expect("history state should exist");
9120 let parent_body = state
9121 .revisions
9122 .iter()
9123 .find(|body| body.revision == commit.parent_revision)
9124 .expect("parent body retained")
9125 .clone();
9126 let revision_body = state
9127 .revisions
9128 .iter()
9129 .find(|body| body.revision == commit.revision)
9130 .expect("revision body retained")
9131 .clone();
9132
9133 let mut forged_body = revision_body;
9134 forged_body.messages[0] = Message::System(SystemMessage::new("tampered prefix"));
9135 forged_body.revision =
9136 transcript_messages_digest(&forged_body.messages).expect("forged digest");
9137 let mut forged_commit = commit;
9138 forged_commit.revision = forged_body.revision.clone();
9139 let err = TranscriptRewriteRecord::new(forged_commit, parent_body, forged_body)
9140 .expect_err("record validation must reject changes outside selected span");
9141 assert!(
9142 err.to_string().contains("before the selected span"),
9143 "unexpected error: {err}"
9144 );
9145 }
9146
9147 #[test]
9148 fn transcript_rewrite_replay_allows_normal_turn_revisions_between_rewrites() {
9149 let mut session = Session::new();
9150 session.push(Message::User(UserMessage::text("first".to_string())));
9151 session.push(Message::BlockAssistant(BlockAssistantMessage {
9152 blocks: vec![AssistantBlock::Text {
9153 text: "verbose first answer".to_string(),
9154 meta: None,
9155 }],
9156 stop_reason: StopReason::EndTurn,
9157 identity: crate::types::TranscriptMessageIdentity::default(),
9158 created_at: crate::types::message_timestamp_now(),
9159 }));
9160
9161 let first_parent = session.transcript_revision().expect("first parent");
9162 let first_commit = session
9163 .commit_transcript_rewrite(
9164 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9165 vec![Message::BlockAssistant(BlockAssistantMessage {
9166 blocks: vec![AssistantBlock::Text {
9167 text: "compact first answer".to_string(),
9168 meta: None,
9169 }],
9170 stop_reason: StopReason::EndTurn,
9171 identity: crate::types::TranscriptMessageIdentity::default(),
9172 created_at: crate::types::message_timestamp_now(),
9173 })],
9174 TranscriptRewriteReason::new("compaction"),
9175 Some("unit-test".to_string()),
9176 Some(first_parent),
9177 )
9178 .expect("first rewrite");
9179
9180 session.push(Message::User(UserMessage::text("normal turn".to_string())));
9181 session.push(Message::BlockAssistant(BlockAssistantMessage {
9182 blocks: vec![AssistantBlock::Text {
9183 text: "verbose second answer".to_string(),
9184 meta: None,
9185 }],
9186 stop_reason: StopReason::EndTurn,
9187 identity: crate::types::TranscriptMessageIdentity::default(),
9188 created_at: crate::types::message_timestamp_now(),
9189 }));
9190 let bridge_parent = session
9191 .transcript_revision()
9192 .expect("normal turn should advance transcript head");
9193 assert_ne!(bridge_parent, first_commit.revision);
9194 validate_transcript_history_state(
9195 &session
9196 .transcript_history_state()
9197 .expect("history state should decode")
9198 .expect("history state should exist"),
9199 )
9200 .expect("normal turn head may legitimately differ from last rewrite commit");
9201
9202 let second_commit = session
9203 .commit_transcript_rewrite(
9204 TranscriptRewriteSelection::MessageRange { start: 3, end: 4 },
9205 vec![Message::BlockAssistant(BlockAssistantMessage {
9206 blocks: vec![AssistantBlock::Text {
9207 text: "compact second answer".to_string(),
9208 meta: None,
9209 }],
9210 stop_reason: StopReason::EndTurn,
9211 identity: crate::types::TranscriptMessageIdentity::default(),
9212 created_at: crate::types::message_timestamp_now(),
9213 })],
9214 TranscriptRewriteReason::new("compaction"),
9215 Some("unit-test".to_string()),
9216 Some(bridge_parent.clone()),
9217 )
9218 .expect("second rewrite");
9219
9220 let state = session
9221 .transcript_history_state()
9222 .expect("history state should decode")
9223 .expect("history state should exist");
9224 let records = state.commits.iter().map(|commit| {
9225 let parent_body = state
9226 .revisions
9227 .iter()
9228 .find(|body| body.revision == commit.parent_revision)
9229 .expect("parent body retained")
9230 .clone();
9231 let revision_body = state
9232 .revisions
9233 .iter()
9234 .find(|body| body.revision == commit.revision)
9235 .expect("revision body retained")
9236 .clone();
9237 TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
9238 .expect("record should validate")
9239 });
9240
9241 let replayed = TranscriptHistoryState::from_rewrite_records(records)
9242 .expect("rewrite replay should accept normal-turn bridge revisions")
9243 .expect("rewrite records should exist");
9244 assert_eq!(replayed.head, second_commit.revision);
9245 assert!(
9246 replayed
9247 .revisions
9248 .iter()
9249 .any(|body| body.revision == bridge_parent)
9250 );
9251 }
9252
9253 #[test]
9254 fn transcript_rewrite_replay_rejects_branched_rewrite_records() {
9255 let mut base = Session::new();
9256 base.push(Message::User(UserMessage::text("question".to_string())));
9257 base.push(Message::BlockAssistant(BlockAssistantMessage {
9258 blocks: vec![AssistantBlock::Text {
9259 text: "verbose answer".to_string(),
9260 meta: None,
9261 }],
9262 stop_reason: StopReason::EndTurn,
9263 identity: crate::types::TranscriptMessageIdentity::default(),
9264 created_at: crate::types::message_timestamp_now(),
9265 }));
9266 let parent = base.transcript_revision().expect("parent revision");
9267
9268 let mut first = base.clone();
9269 let first_commit = first
9270 .commit_transcript_rewrite(
9271 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9272 vec![Message::BlockAssistant(BlockAssistantMessage {
9273 blocks: vec![AssistantBlock::Text {
9274 text: "first compact answer".to_string(),
9275 meta: None,
9276 }],
9277 stop_reason: StopReason::EndTurn,
9278 identity: crate::types::TranscriptMessageIdentity::default(),
9279 created_at: crate::types::message_timestamp_now(),
9280 })],
9281 TranscriptRewriteReason::new("compaction"),
9282 Some("unit-test".to_string()),
9283 Some(parent.clone()),
9284 )
9285 .expect("first rewrite");
9286 let first_state = first
9287 .transcript_history_state()
9288 .expect("first state decodes")
9289 .expect("first state exists");
9290
9291 let mut second = base;
9292 let second_commit = second
9293 .commit_transcript_rewrite(
9294 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9295 vec![Message::BlockAssistant(BlockAssistantMessage {
9296 blocks: vec![AssistantBlock::Text {
9297 text: "second compact answer".to_string(),
9298 meta: None,
9299 }],
9300 stop_reason: StopReason::EndTurn,
9301 identity: crate::types::TranscriptMessageIdentity::default(),
9302 created_at: crate::types::message_timestamp_now(),
9303 })],
9304 TranscriptRewriteReason::new("compaction"),
9305 Some("unit-test".to_string()),
9306 Some(parent),
9307 )
9308 .expect("second rewrite");
9309 let second_state = second
9310 .transcript_history_state()
9311 .expect("second state decodes")
9312 .expect("second state exists");
9313
9314 let record = |state: &TranscriptHistoryState, commit: &TranscriptRewriteCommit| {
9315 let parent_body = state
9316 .revisions
9317 .iter()
9318 .find(|body| body.revision == commit.parent_revision)
9319 .expect("parent body retained")
9320 .clone();
9321 let revision_body = state
9322 .revisions
9323 .iter()
9324 .find(|body| body.revision == commit.revision)
9325 .expect("revision body retained")
9326 .clone();
9327 TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
9328 .expect("record should validate")
9329 };
9330
9331 let err = TranscriptHistoryState::from_rewrite_records(vec![
9332 record(&first_state, &first_commit),
9333 record(&second_state, &second_commit),
9334 ])
9335 .expect_err("branched rewrite records must not replay as a linear source history");
9336 assert!(
9337 err.to_string().contains("does not extend transcript head"),
9338 "unexpected error: {err}"
9339 );
9340 }
9341
9342 #[test]
9343 fn internal_message_rewrites_refresh_transcript_history_head() {
9344 let mut session = Session::new();
9345 session.push(Message::User(UserMessage::text("question".to_string())));
9346 session.push(Message::BlockAssistant(BlockAssistantMessage {
9347 blocks: vec![AssistantBlock::Text {
9348 text: "verbose answer".to_string(),
9349 meta: None,
9350 }],
9351 stop_reason: StopReason::EndTurn,
9352 identity: crate::types::TranscriptMessageIdentity::default(),
9353 created_at: crate::types::message_timestamp_now(),
9354 }));
9355
9356 let parent = session.transcript_revision().expect("parent revision");
9357 session
9358 .commit_transcript_rewrite(
9359 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9360 vec![Message::BlockAssistant(BlockAssistantMessage {
9361 blocks: vec![AssistantBlock::Text {
9362 text: "compact answer".to_string(),
9363 meta: None,
9364 }],
9365 stop_reason: StopReason::EndTurn,
9366 identity: crate::types::TranscriptMessageIdentity::default(),
9367 created_at: crate::types::message_timestamp_now(),
9368 })],
9369 TranscriptRewriteReason::new("compaction"),
9370 Some("unit-test".to_string()),
9371 Some(parent),
9372 )
9373 .expect("rewrite should commit");
9374
9375 session.push(Message::User(UserMessage::text(
9376 "notice-bearing turn".to_string(),
9377 )));
9378 let retained = session
9379 .messages()
9380 .iter()
9381 .filter(|message| {
9382 !matches!(
9383 message,
9384 Message::User(user)
9385 if user.content.iter().any(|block| matches!(
9386 block,
9387 ContentBlock::Text { text } if text.contains("notice-bearing")
9388 ))
9389 )
9390 })
9391 .cloned()
9392 .collect();
9393 session
9394 .replace_messages_internal(
9395 retained,
9396 TranscriptRewriteReason::new("synthetic_notice_cleanup"),
9397 )
9398 .expect("retain should commit internal rewrite");
9399 let retained_digest =
9400 transcript_messages_digest(session.messages()).expect("retained digest");
9401 assert_eq!(
9402 session.transcript_revision().expect("retained head"),
9403 retained_digest
9404 );
9405
9406 session
9407 .replace_messages_internal(
9408 vec![
9409 Message::User(UserMessage::text("compacted question".to_string())),
9410 Message::BlockAssistant(BlockAssistantMessage {
9411 blocks: vec![AssistantBlock::Text {
9412 text: "compacted answer".to_string(),
9413 meta: None,
9414 }],
9415 stop_reason: StopReason::EndTurn,
9416 identity: crate::types::TranscriptMessageIdentity::default(),
9417 created_at: crate::types::message_timestamp_now(),
9418 }),
9419 ],
9420 TranscriptRewriteReason::new("compaction"),
9421 )
9422 .expect("replace should commit internal rewrite");
9423 let replaced_digest =
9424 transcript_messages_digest(session.messages()).expect("replaced digest");
9425 assert_eq!(
9426 session.transcript_revision().expect("replaced head"),
9427 replaced_digest
9428 );
9429 let state = session
9430 .transcript_history_state()
9431 .expect("history state should decode")
9432 .expect("history state should exist");
9433 assert!(
9434 state
9435 .revisions
9436 .iter()
9437 .any(|body| body.revision == replaced_digest)
9438 );
9439 validate_transcript_history_state(&state).expect("history state remains valid");
9440 }
9441
9442 #[test]
9443 fn set_system_prompt_refreshes_transcript_history_head_after_rewrite() {
9444 let mut session = Session::new();
9445 session.push(Message::User(UserMessage::text("question".to_string())));
9446 session.push(Message::BlockAssistant(BlockAssistantMessage {
9447 blocks: vec![AssistantBlock::Text {
9448 text: "verbose answer".to_string(),
9449 meta: None,
9450 }],
9451 stop_reason: StopReason::EndTurn,
9452 identity: crate::types::TranscriptMessageIdentity::default(),
9453 created_at: crate::types::message_timestamp_now(),
9454 }));
9455
9456 let parent = session.transcript_revision().expect("parent revision");
9457 let rewrite = session
9458 .commit_transcript_rewrite(
9459 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9460 vec![Message::BlockAssistant(BlockAssistantMessage {
9461 blocks: vec![AssistantBlock::Text {
9462 text: "compact answer".to_string(),
9463 meta: None,
9464 }],
9465 stop_reason: StopReason::EndTurn,
9466 identity: crate::types::TranscriptMessageIdentity::default(),
9467 created_at: crate::types::message_timestamp_now(),
9468 })],
9469 TranscriptRewriteReason::new("compaction"),
9470 Some("unit-test".to_string()),
9471 Some(parent),
9472 )
9473 .expect("rewrite should commit");
9474
9475 session.set_system_prompt("durable system prompt".to_string());
9476
9477 let head = session
9478 .transcript_revision()
9479 .expect("system prompt should refresh transcript head");
9480 assert_ne!(head, rewrite.revision);
9481 assert_eq!(
9482 head,
9483 transcript_messages_digest(session.messages()).expect("current digest")
9484 );
9485 let head_messages = session
9486 .transcript_revision_messages(&head)
9487 .expect("history state should decode")
9488 .expect("refreshed head body should be retained");
9489 assert_eq!(
9490 serde_json::to_value(&head_messages).expect("head serializes"),
9491 serde_json::to_value(session.messages()).expect("session serializes")
9492 );
9493 validate_transcript_history_state(
9494 &session
9495 .transcript_history_state()
9496 .expect("history state should decode")
9497 .expect("history state should exist"),
9498 )
9499 .expect("history state remains valid after system prompt update");
9500 }
9501
9502 #[test]
9503 fn apply_transcript_history_state_uses_latest_commit_time_for_restored_head() {
9504 let mut session = Session::new();
9505 session.push(Message::User(UserMessage::text("question".to_string())));
9506 session.push(Message::BlockAssistant(BlockAssistantMessage {
9507 blocks: vec![AssistantBlock::Text {
9508 text: "verbose answer".to_string(),
9509 meta: None,
9510 }],
9511 stop_reason: StopReason::EndTurn,
9512 identity: crate::types::TranscriptMessageIdentity::default(),
9513 created_at: crate::types::message_timestamp_now(),
9514 }));
9515 let original_messages = session.messages().to_vec();
9516 let parent = session.transcript_revision().expect("parent revision");
9517 let compact = session
9518 .commit_transcript_rewrite(
9519 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
9520 vec![Message::BlockAssistant(BlockAssistantMessage {
9521 blocks: vec![AssistantBlock::Text {
9522 text: "compact answer".to_string(),
9523 meta: None,
9524 }],
9525 stop_reason: StopReason::EndTurn,
9526 identity: crate::types::TranscriptMessageIdentity::default(),
9527 created_at: crate::types::message_timestamp_now(),
9528 })],
9529 TranscriptRewriteReason::new("compaction"),
9530 Some("unit-test".to_string()),
9531 Some(parent.clone()),
9532 )
9533 .expect("rewrite should commit");
9534
9535 std::thread::sleep(std::time::Duration::from_millis(2));
9536 let restore = session
9537 .commit_transcript_rewrite(
9538 TranscriptRewriteSelection::MessageRange {
9539 start: 0,
9540 end: session.messages().len(),
9541 },
9542 original_messages.clone(),
9543 TranscriptRewriteReason::new("restore"),
9544 Some("unit-test".to_string()),
9545 Some(compact.revision),
9546 )
9547 .expect("restore should commit");
9548 assert_eq!(restore.revision, parent);
9549
9550 let state = session
9551 .transcript_history_state()
9552 .expect("history state should decode")
9553 .expect("history state should exist");
9554 let restored_body_created_at = state
9555 .revisions
9556 .iter()
9557 .find(|body| body.revision == restore.revision)
9558 .expect("restored body should be retained")
9559 .created_at;
9560 assert!(
9561 restored_body_created_at < restore.committed_at,
9562 "test requires restore commit to be newer than retained body"
9563 );
9564
9565 let mut replayed = Session::new();
9566 replayed
9567 .apply_transcript_history_state(state)
9568 .expect("replay should materialize restored head");
9569 assert_eq!(
9570 serde_json::to_value(replayed.messages()).expect("replayed serializes"),
9571 serde_json::to_value(&original_messages).expect("original serializes")
9572 );
9573 assert_eq!(replayed.updated_at(), restore.committed_at);
9574 }
9575
9576 #[test]
9577 fn test_session_new() {
9578 let session = Session::new();
9579 assert_eq!(session.version(), SESSION_VERSION);
9580 assert!(session.messages().is_empty());
9581 assert!(session.created_at() <= session.updated_at());
9582 }
9583
9584 #[test]
9585 fn llm_identity_model_override_switches_to_catalog_provider() {
9586 let registry = crate::ModelRegistry::from_config(
9587 &crate::Config::default(),
9588 *crate::model_profile::test_catalog::TEST_CATALOG,
9589 )
9590 .unwrap();
9591 let current = SessionLlmIdentity {
9592 model: "test-anthropic-default".to_string(),
9593 provider: Provider::Anthropic,
9594 self_hosted_server_id: None,
9595 provider_params: None,
9596 auth_binding: Some(crate::AuthBindingRef {
9597 realm: crate::RealmId::parse("tenant_a").unwrap(),
9598 binding: crate::BindingId::parse("anthropic_default").unwrap(),
9599 profile: None,
9600 origin: crate::BindingOrigin::Configured,
9601 }),
9602 };
9603
9604 let resolved = resolve_session_llm_identity_override(
9605 ¤t,
9606 ®istry,
9607 SessionLlmIdentityOverride {
9608 model: Some("test-openai-default"),
9609 provider: None,
9610 self_hosted_server_id: None,
9611 provider_params: None,
9612 auth_binding: None,
9613 },
9614 )
9615 .unwrap();
9616
9617 assert_eq!(resolved.model, "test-openai-default");
9618 assert_eq!(resolved.provider, Provider::OpenAI);
9619 assert!(
9620 resolved.auth_binding.is_none(),
9621 "provider switches must not inherit a binding from the previous provider"
9622 );
9623 }
9624
9625 #[test]
9626 fn llm_identity_model_override_keeps_uncatalogued_model_on_current_provider() {
9627 let registry = crate::ModelRegistry::from_config(
9628 &crate::Config::default(),
9629 *crate::model_profile::test_catalog::TEST_CATALOG,
9630 )
9631 .unwrap();
9632 let current = SessionLlmIdentity {
9633 model: "custom-model".to_string(),
9634 provider: Provider::Anthropic,
9635 self_hosted_server_id: None,
9636 provider_params: None,
9637 auth_binding: None,
9638 };
9639
9640 let resolved = resolve_session_llm_identity_override(
9641 ¤t,
9642 ®istry,
9643 SessionLlmIdentityOverride {
9644 model: Some("uncatalogued-custom-model"),
9645 provider: None,
9646 self_hosted_server_id: None,
9647 provider_params: None,
9648 auth_binding: None,
9649 },
9650 )
9651 .unwrap();
9652
9653 assert_eq!(resolved.model, "uncatalogued-custom-model");
9654 assert_eq!(resolved.provider, Provider::Anthropic);
9655 }
9656
9657 fn self_hosted_registry_with_shared_remote_model() -> crate::ModelRegistry {
9658 use crate::config::{
9659 SelfHostedApiStyle, SelfHostedModelConfig, SelfHostedServerConfig, SelfHostedTransport,
9660 };
9661 use crate::model_profile::catalog::ModelTier;
9662
9663 let mut config = crate::Config::default();
9664 for server_id in ["local-a", "local-b"] {
9665 config.self_hosted.servers.insert(
9666 server_id.to_string(),
9667 SelfHostedServerConfig {
9668 transport: SelfHostedTransport::OpenAiCompatible,
9669 base_url: format!("http://{server_id}.test"),
9670 api_style: SelfHostedApiStyle::Responses,
9671 },
9672 );
9673 config.self_hosted.models.insert(
9674 format!("shared-local-{server_id}"),
9675 SelfHostedModelConfig {
9676 server: server_id.to_string(),
9677 remote_model: "shared-local-model".to_string(),
9678 display_name: "Shared local model".to_string(),
9679 family: "shared-local".to_string(),
9680 tier: ModelTier::Supported,
9681 ..Default::default()
9682 },
9683 );
9684 }
9685 config.self_hosted.default_model = Some("shared-local-local-a".to_string());
9686 crate::ModelRegistry::from_config(
9687 &config,
9688 *crate::model_profile::test_catalog::TEST_CATALOG,
9689 )
9690 .expect("shared local registry")
9691 }
9692
9693 #[test]
9694 fn llm_identity_override_preserves_exact_self_hosted_server_route() {
9695 let registry = self_hosted_registry_with_shared_remote_model();
9696 let current = SessionLlmIdentity {
9697 model: "shared-local-local-a".to_string(),
9698 provider: Provider::SelfHosted,
9699 self_hosted_server_id: Some("local-a".to_string()),
9700 provider_params: None,
9701 auth_binding: None,
9702 };
9703
9704 let resolved = resolve_session_llm_identity_override(
9705 ¤t,
9706 ®istry,
9707 SessionLlmIdentityOverride {
9708 model: Some("shared-local-local-b"),
9709 provider: Some(Provider::SelfHosted),
9710 self_hosted_server_id: Some("local-b"),
9711 provider_params: None,
9712 auth_binding: None,
9713 },
9714 )
9715 .expect("exact configured local route should resolve");
9716
9717 assert_eq!(resolved.model, "shared-local-local-b");
9718 assert_eq!(resolved.provider, Provider::SelfHosted);
9719 assert_eq!(resolved.self_hosted_server_id.as_deref(), Some("local-b"));
9720 }
9721
9722 #[test]
9723 fn llm_identity_override_rejects_self_hosted_server_model_mismatch() {
9724 let registry = self_hosted_registry_with_shared_remote_model();
9725 let current = SessionLlmIdentity {
9726 model: "shared-local-local-a".to_string(),
9727 provider: Provider::SelfHosted,
9728 self_hosted_server_id: Some("local-a".to_string()),
9729 provider_params: None,
9730 auth_binding: None,
9731 };
9732
9733 let error = resolve_session_llm_identity_override(
9734 ¤t,
9735 ®istry,
9736 SessionLlmIdentityOverride {
9737 model: Some("shared-local-local-b"),
9738 provider: Some(Provider::SelfHosted),
9739 self_hosted_server_id: Some("local-a"),
9740 provider_params: None,
9741 auth_binding: None,
9742 },
9743 )
9744 .expect_err("server id must match the requested model alias route");
9745
9746 assert!(matches!(
9747 error,
9748 SessionLlmIdentityOverrideError::SelfHostedServerMismatch {
9749 requested,
9750 configured,
9751 ..
9752 } if requested == "local-a" && configured == "local-b"
9753 ));
9754 }
9755
9756 #[test]
9757 fn realtime_transcript_append_is_idempotent_by_provider_item_and_delta_id() {
9758 let mut session = Session::new();
9759
9760 let user = RealtimeTranscriptEvent::UserTranscriptFinal {
9761 item_id: "item_user".to_string(),
9762 previous_item_id: None,
9763 content_index: 0,
9764 text: "hello".to_string(),
9765 };
9766 assert!(
9767 !session
9768 .append_realtime_transcript_event(user.clone())
9769 .is_inert()
9770 );
9771 assert!(session.append_realtime_transcript_event(user).is_inert());
9772
9773 let delta = RealtimeTranscriptEvent::AssistantTextDelta {
9774 response_id: "resp_assistant".to_string(),
9775 delta_id: "evt_delta_1".to_string(),
9776 item_id: "item_assistant".to_string(),
9777 previous_item_id: Some("item_user".to_string()),
9778 content_index: 0,
9779 delta: "hi".to_string(),
9780 };
9781 assert!(
9782 session
9783 .append_realtime_transcript_event(delta.clone())
9784 .is_inert()
9785 );
9786 assert!(session.append_realtime_transcript_event(delta).is_inert());
9787
9788 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
9789 response_id: "resp_assistant".to_string(),
9790 stop_reason: StopReason::EndTurn,
9791 usage: Usage::default(),
9792 };
9793 assert!(
9794 !session
9795 .append_realtime_transcript_event(terminal.clone())
9796 .is_inert()
9797 );
9798 assert!(
9799 session
9800 .append_realtime_transcript_event(terminal)
9801 .is_inert()
9802 );
9803
9804 assert_eq!(session.messages().len(), 2);
9805 assert!(matches!(
9806 &session.messages()[0],
9807 Message::User(user) if user.text_content() == "hello"
9808 ));
9809 assert!(matches!(
9810 &session.messages()[1],
9811 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "hi"
9812 ));
9813 }
9814
9815 #[test]
9816 fn realtime_user_image_materializes_once_and_unblocks_causal_assistant() {
9817 let mut session = Session::new();
9818 let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
9819 let image = RealtimeTranscriptEvent::UserContentFinal {
9820 idempotency_key: "image-request-1".to_string(),
9821 item_id: "item_image".to_string(),
9822 previous_item_id: None,
9823 content_index: 0,
9824 content: vec![ContentBlock::Image {
9825 media_type: "image/png".to_string(),
9826 data: crate::types::ImageData::Inline {
9827 data: image_data.clone(),
9828 },
9829 }],
9830 };
9831
9832 assert!(
9833 !append_staged_user_image(&mut session, &image).is_inert(),
9834 "first image final must materialize canonical user content"
9835 );
9836 let replay = session
9837 .preflight_realtime_user_content_event(&image)
9838 .expect("exact retry should preflight as committed");
9839 assert!(matches!(
9840 replay,
9841 crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(_)
9842 ));
9843
9844 let staged_state = session
9845 .metadata
9846 .get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY)
9847 .expect("realtime state must be persisted");
9848 assert!(
9849 !staged_state.to_string().contains(&image_data),
9850 "materialized image bytes must not remain duplicated in transcript metadata"
9851 );
9852
9853 assert!(
9854 session
9855 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
9856 response_id: "resp_image".to_string(),
9857 delta_id: "delta_image".to_string(),
9858 item_id: "item_assistant".to_string(),
9859 previous_item_id: Some("item_image".to_string()),
9860 content_index: 0,
9861 delta: "I see red.".to_string(),
9862 })
9863 .is_inert()
9864 );
9865 assert!(
9866 !session
9867 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
9868 response_id: "resp_image".to_string(),
9869 stop_reason: StopReason::EndTurn,
9870 usage: Usage::default(),
9871 },)
9872 .is_inert(),
9873 "materialized image predecessor must unblock the assistant response"
9874 );
9875
9876 assert_eq!(session.messages().len(), 2);
9877 assert!(matches!(
9878 &session.messages()[0],
9879 Message::User(user)
9880 if matches!(
9881 user.content.as_slice(),
9882 [ContentBlock::Image {
9883 media_type,
9884 data: crate::types::ImageData::Blob { blob_id },
9885 }] if media_type == "image/png"
9886 && blob_id == &crate::blob::content_blob_id("image/png", &image_data)
9887 )
9888 ));
9889 assert!(matches!(
9890 &session.messages()[1],
9891 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "I see red."
9892 ));
9893 }
9894
9895 #[test]
9896 fn realtime_user_image_identity_is_durable_canonical_and_conflict_safe() {
9897 let mut session = Session::new();
9898 let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
9899 let initial = RealtimeTranscriptEvent::UserContentFinal {
9900 idempotency_key: "stable-image-key".to_string(),
9901 item_id: "canonical-image-item".to_string(),
9902 previous_item_id: None,
9903 content_index: 0,
9904 content: vec![ContentBlock::Image {
9905 media_type: " image/PNG; charset=binary ".to_string(),
9906 data: crate::types::ImageData::Inline { data: data.clone() },
9907 }],
9908 };
9909 let committed = append_staged_user_image(&mut session, &initial);
9910 let Some(crate::RealtimeUserContentApplyOutcome::Committed(identity)) =
9911 committed.user_content
9912 else {
9913 panic!("first image must commit its durable identity");
9914 };
9915 assert_eq!(identity.item_id, "canonical-image-item");
9916 assert_eq!(identity.media_type, "image/png");
9917
9918 let encoded = serde_json::to_string(&session).expect("session should serialize");
9919 let restored: Session =
9920 serde_json::from_str(&encoded).expect("committed identity should restore");
9921
9922 let replay_event = RealtimeTranscriptEvent::UserContentFinal {
9923 idempotency_key: "stable-image-key".to_string(),
9924 item_id: "ignored-retry-item".to_string(),
9925 previous_item_id: None,
9926 content_index: 0,
9927 content: vec![ContentBlock::Image {
9928 media_type: "image/png".to_string(),
9929 data: crate::types::ImageData::Inline { data: data.clone() },
9930 }],
9931 };
9932 let replay = restored
9933 .preflight_realtime_user_content_event(&replay_event)
9934 .expect("exact retry should preflight");
9935 assert!(matches!(
9936 replay,
9937 crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(
9938 crate::RealtimeUserContentIdentity { ref item_id, .. }
9939 ) if item_id == "canonical-image-item"
9940 ));
9941
9942 let conflict = restored
9943 .preflight_realtime_user_content_event(&RealtimeTranscriptEvent::UserContentFinal {
9944 idempotency_key: "stable-image-key".to_string(),
9945 item_id: "conflicting-item".to_string(),
9946 previous_item_id: None,
9947 content_index: 0,
9948 content: vec![ContentBlock::Image {
9949 media_type: "image/png".to_string(),
9950 data: crate::types::ImageData::Inline {
9951 data: "different-payload".to_string(),
9952 },
9953 }],
9954 })
9955 .expect("conflicting retry should preflight");
9956 assert!(matches!(
9957 conflict,
9958 crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. }
9959 ));
9960
9961 let item_collision = restored
9962 .preflight_realtime_user_content_event(&RealtimeTranscriptEvent::UserContentFinal {
9963 idempotency_key: "another-key".to_string(),
9964 item_id: "canonical-image-item".to_string(),
9965 previous_item_id: None,
9966 content_index: 0,
9967 content: vec![ContentBlock::Image {
9968 media_type: "image/png".to_string(),
9969 data: crate::types::ImageData::Inline { data },
9970 }],
9971 })
9972 .expect("item collision should preflight");
9973 assert!(matches!(
9974 item_collision,
9975 crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. }
9976 ));
9977 assert_eq!(restored.messages().len(), 1);
9978 serde_json::to_string(&restored).expect("rejections must not corrupt durable state");
9979 }
9980
9981 #[test]
9982 fn realtime_user_image_reducer_never_receipts_without_pending_blob_proof() {
9983 for data in [
9984 crate::types::ImageData::Inline {
9985 data: "iVBORw0KGgo=".to_string(),
9986 },
9987 crate::types::ImageData::Blob {
9988 blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgo="),
9989 },
9990 ] {
9991 let mut session = Session::new();
9992 let outcome = session.append_realtime_transcript_event(
9993 RealtimeTranscriptEvent::UserContentFinal {
9994 idempotency_key: "unstaged-image-key".to_string(),
9995 item_id: "unstaged-image-item".to_string(),
9996 previous_item_id: None,
9997 content_index: 0,
9998 content: vec![ContentBlock::Image {
9999 media_type: "image/png".to_string(),
10000 data,
10001 }],
10002 },
10003 );
10004 assert!(matches!(
10005 outcome.user_content,
10006 Some(crate::RealtimeUserContentApplyOutcome::RejectedInvalidIdentity { .. })
10007 ));
10008 assert!(session.messages().is_empty());
10009 assert!(session.realtime_user_content_identities().is_empty());
10010 }
10011 }
10012
10013 #[test]
10014 fn realtime_user_image_pending_slot_is_generated_bounded_and_recovery_typed() {
10015 use crate::generated::session_document::{
10016 RealtimeUserContentBlobRecoveryDisposition, RealtimeUserContentBlobStageDisposition,
10017 };
10018 let mut session = Session::new();
10019 let pending = crate::PendingRealtimeUserContentBlob {
10020 idempotency_key: "pending-key-a".to_string(),
10021 item_id: "pending-item-a".to_string(),
10022 previous_item_id: None,
10023 content_index: 0,
10024 blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgo="),
10025 media_type: "image/png".to_string(),
10026 };
10027 let different = crate::PendingRealtimeUserContentBlob {
10028 idempotency_key: "pending-key-b".to_string(),
10029 item_id: "pending-item-b".to_string(),
10030 previous_item_id: None,
10031 content_index: 0,
10032 blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgoB"),
10033 media_type: "image/png".to_string(),
10034 };
10035 assert_eq!(
10036 session
10037 .stage_pending_realtime_user_content_blob(pending.clone())
10038 .expect("empty slot stages"),
10039 RealtimeUserContentBlobStageDisposition::StageNew
10040 );
10041 assert_eq!(
10042 session
10043 .stage_pending_realtime_user_content_blob(pending.clone())
10044 .expect("exact stage retry is idempotent"),
10045 RealtimeUserContentBlobStageDisposition::ReuseExact
10046 );
10047 assert_eq!(
10048 session
10049 .stage_pending_realtime_user_content_blob(different.clone())
10050 .expect("occupied decision is typed"),
10051 RealtimeUserContentBlobStageDisposition::RejectOccupied
10052 );
10053 assert_eq!(
10054 session.pending_realtime_user_content_blob(),
10055 Some(pending.clone())
10056 );
10057 assert_eq!(
10058 session
10059 .resolve_pending_realtime_user_content_blob_recovery(Some(&pending), false)
10060 .expect("exact recovery decision"),
10061 RealtimeUserContentBlobRecoveryDisposition::RetryExact
10062 );
10063 assert_eq!(
10064 session
10065 .resolve_pending_realtime_user_content_blob_recovery(Some(&different), true)
10066 .expect("verified older recovery decision"),
10067 RealtimeUserContentBlobRecoveryDisposition::CommitVerifiedBeforeCurrent
10068 );
10069 assert_eq!(
10070 session
10071 .resolve_pending_realtime_user_content_blob_recovery(Some(&different), false)
10072 .expect("invalid older recovery decision"),
10073 RealtimeUserContentBlobRecoveryDisposition::ClearInvalidBeforeCurrent
10074 );
10075 session
10076 .clear_invalid_pending_realtime_user_content_blob(Some(&different))
10077 .expect("generated clear-invalid disposition authorizes clear");
10078 assert!(session.pending_realtime_user_content_blob().is_none());
10079 }
10080
10081 #[test]
10082 fn transcript_rewrite_tombstones_removed_image_key_and_accepts_new_key() {
10083 let mut session = Session::new();
10084 let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
10085 let original = RealtimeTranscriptEvent::UserContentFinal {
10086 idempotency_key: "removed-image-key".to_string(),
10087 item_id: "removed-image-item".to_string(),
10088 previous_item_id: None,
10089 content_index: 0,
10090 content: vec![ContentBlock::Image {
10091 media_type: "image/png".to_string(),
10092 data: crate::types::ImageData::Inline { data: data.clone() },
10093 }],
10094 };
10095 assert!(matches!(
10096 append_staged_user_image(&mut session, &original).user_content,
10097 Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
10098 ));
10099
10100 let parent = session.transcript_revision().expect("parent revision");
10101 session
10102 .commit_transcript_rewrite(
10103 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
10104 vec![Message::User(UserMessage::text("image removed"))],
10105 TranscriptRewriteReason::new("remove-image"),
10106 None,
10107 Some(parent),
10108 )
10109 .expect("rewrite should tombstone removed image identity");
10110
10111 assert!(session.realtime_user_content_identities().is_empty());
10112 assert_eq!(
10113 session.realtime_user_content_tombstones(),
10114 vec![crate::RealtimeUserContentTombstone {
10115 idempotency_key: "removed-image-key".to_string(),
10116 }]
10117 );
10118 assert!(matches!(
10119 session.preflight_realtime_user_content_event(&original),
10120 Some(crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. })
10121 ));
10122 assert!(matches!(
10123 session
10124 .append_realtime_transcript_event(original)
10125 .user_content,
10126 Some(crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. })
10127 ));
10128 assert_eq!(
10129 session.messages().len(),
10130 1,
10131 "stale retry emits no receipt content"
10132 );
10133
10134 let new_image = RealtimeTranscriptEvent::UserContentFinal {
10135 idempotency_key: "new-image-key".to_string(),
10136 item_id: "new-image-item".to_string(),
10137 previous_item_id: None,
10138 content_index: 0,
10139 content: vec![ContentBlock::Image {
10140 media_type: "image/png".to_string(),
10141 data: crate::types::ImageData::Inline { data },
10142 }],
10143 };
10144 assert!(matches!(
10145 append_staged_user_image(&mut session, &new_image).user_content,
10146 Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
10147 ));
10148 assert_eq!(session.messages().len(), 2);
10149
10150 let restored: Session = serde_json::from_str(
10151 &serde_json::to_string(&session).expect("serialize rewritten session"),
10152 )
10153 .expect("cold restore rewritten session");
10154 assert_eq!(restored.realtime_user_content_identities().len(), 1);
10155 assert_eq!(restored.realtime_user_content_tombstones().len(), 1);
10156 }
10157
10158 #[test]
10159 fn transcript_rewrite_retains_only_canonical_image_occurrence_for_exact_replay() {
10160 let mut session = Session::new();
10161 let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
10162 let original = RealtimeTranscriptEvent::UserContentFinal {
10163 idempotency_key: "retained-image-key".to_string(),
10164 item_id: "retained-image-item".to_string(),
10165 previous_item_id: None,
10166 content_index: 0,
10167 content: vec![ContentBlock::Image {
10168 media_type: "image/png".to_string(),
10169 data: crate::types::ImageData::Inline { data },
10170 }],
10171 };
10172 assert!(matches!(
10173 append_staged_user_image(&mut session, &original).user_content,
10174 Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
10175 ));
10176 let retained_message = session.messages()[0].clone();
10177 let parent = session.transcript_revision().expect("parent revision");
10178 session
10179 .commit_transcript_rewrite(
10180 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
10181 vec![
10182 retained_message,
10183 Message::User(UserMessage::text("new canonical neighbor")),
10184 ],
10185 TranscriptRewriteReason::new("retain-image"),
10186 None,
10187 Some(parent),
10188 )
10189 .expect("rewrite retaining exact inline image should reconcile");
10190
10191 assert!(session.realtime_user_content_tombstones().is_empty());
10192 let replay = session
10193 .preflight_realtime_user_content_event(&original)
10194 .expect("retained image should preflight as exact replay");
10195 assert!(matches!(
10196 replay,
10197 crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(_)
10198 ));
10199 assert_eq!(session.messages().len(), 2);
10200 }
10201
10202 #[test]
10203 fn transcript_rewrite_rejects_atomically_while_image_blob_anchor_is_pending() {
10204 let mut session = Session::new();
10205 session.push(Message::User(UserMessage::text("before rewrite")));
10206 let pending = crate::PendingRealtimeUserContentBlob {
10207 idempotency_key: "pending-rewrite-key".to_string(),
10208 item_id: "pending-rewrite-item".to_string(),
10209 previous_item_id: None,
10210 content_index: 0,
10211 blob_id: crate::blob::content_blob_id("image/png", "pending-bytes"),
10212 media_type: "image/png".to_string(),
10213 };
10214 session
10215 .stage_pending_realtime_user_content_blob(pending.clone())
10216 .expect("stage durable pending anchor");
10217 let parent = session.transcript_revision().expect("parent revision");
10218 let error = session
10219 .commit_transcript_rewrite(
10220 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
10221 vec![Message::User(UserMessage::text("after rewrite"))],
10222 TranscriptRewriteReason::new("blocked-pending-image"),
10223 None,
10224 Some(parent),
10225 )
10226 .expect_err("rewrite must not cross an unresolved image anchor");
10227 assert!(
10228 error
10229 .to_string()
10230 .contains("history_rewrite_pending_user_content_blob")
10231 );
10232 assert!(matches!(
10233 &session.messages()[0],
10234 Message::User(user) if user.text_content() == "before rewrite"
10235 ));
10236 assert_eq!(session.pending_realtime_user_content_blob(), Some(pending));
10237 }
10238
10239 #[test]
10240 fn realtime_user_image_rejects_noncanonical_blob_and_multiblock_shape() {
10241 let mut session = Session::new();
10242 for (key, content) in [
10243 (
10244 "invalid-blob",
10245 vec![ContentBlock::Image {
10246 media_type: "image/png".to_string(),
10247 data: crate::types::ImageData::Blob {
10248 blob_id: crate::BlobId::new("sha256:not-a-digest"),
10249 },
10250 }],
10251 ),
10252 (
10253 "multi-block",
10254 vec![
10255 ContentBlock::Image {
10256 media_type: "image/png".to_string(),
10257 data: crate::types::ImageData::Inline {
10258 data: "payload".to_string(),
10259 },
10260 },
10261 ContentBlock::Text {
10262 text: "smuggled".to_string(),
10263 },
10264 ],
10265 ),
10266 ] {
10267 let outcome = session.append_realtime_transcript_event(
10268 RealtimeTranscriptEvent::UserContentFinal {
10269 idempotency_key: key.to_string(),
10270 item_id: format!("item-{key}"),
10271 previous_item_id: None,
10272 content_index: 0,
10273 content,
10274 },
10275 );
10276 assert!(matches!(
10277 outcome.user_content,
10278 Some(crate::RealtimeUserContentApplyOutcome::RejectedInvalidIdentity { .. })
10279 ));
10280 }
10281 assert!(session.messages().is_empty());
10282 let encoded = serde_json::to_string(&session).expect("session should serialize");
10283 serde_json::from_str::<Session>(&encoded).expect("rejections must leave restorable state");
10284 }
10285
10286 #[test]
10287 fn realtime_restore_rejects_malformed_causal_graphs_and_accepts_waiting_dag() {
10288 fn restore(
10289 items: serde_json::Value,
10290 first_seen_order: Vec<&str>,
10291 ) -> Result<
10292 crate::realtime_transcript_revision::SessionRealtimeTranscriptState,
10293 crate::realtime_transcript_revision::RealtimeTranscriptShellError,
10294 > {
10295 let state = serde_json::from_value(serde_json::json!({
10296 "items": items,
10297 "first_seen_order": first_seen_order,
10298 }))
10299 .expect("test state shape should deserialize");
10300 crate::realtime_transcript_revision::restore_realtime_transcript_state(state)
10301 }
10302
10303 assert!(
10304 restore(
10305 serde_json::json!({
10306 "child": { "role": "user", "previous_item_id": "missing" }
10307 }),
10308 vec!["child"],
10309 )
10310 .is_ok(),
10311 "an unmaterialized out-of-order item must survive cold restore until its predecessor arrives"
10312 );
10313 assert!(
10314 restore(
10315 serde_json::json!({
10316 "child": {
10317 "role": "user",
10318 "previous_item_id": "missing",
10319 "ready": true,
10320 "materialized": true
10321 }
10322 }),
10323 vec!["child"],
10324 )
10325 .is_err(),
10326 "a materialized item cannot reference a missing predecessor"
10327 );
10328 assert!(
10329 restore(
10330 serde_json::json!({
10331 "self": { "role": "user", "previous_item_id": "self" }
10332 }),
10333 vec!["self"],
10334 )
10335 .is_err(),
10336 "self edge must fail cold restore"
10337 );
10338 assert!(
10339 restore(
10340 serde_json::json!({
10341 "a": { "role": "user", "previous_item_id": "b" },
10342 "b": { "role": "user", "previous_item_id": "a" }
10343 }),
10344 vec!["a", "b"],
10345 )
10346 .is_err(),
10347 "cycle must fail cold restore"
10348 );
10349 assert!(
10350 restore(
10351 serde_json::json!({
10352 "root": { "role": "user" },
10353 "materialized_child": {
10354 "role": "user",
10355 "previous_item_id": "root",
10356 "ready": true,
10357 "materialized": true
10358 }
10359 }),
10360 vec!["root", "materialized_child"],
10361 )
10362 .is_err(),
10363 "materialized child cannot have unmaterialized ancestry"
10364 );
10365 assert!(
10366 restore(
10367 serde_json::json!({
10368 "root": { "role": "user" },
10369 "waiting_child": { "role": "user", "previous_item_id": "root" }
10370 }),
10371 vec!["waiting_child", "root"],
10372 )
10373 .is_ok(),
10374 "valid acyclic waiting graph should restore even when first-seen order is child-first"
10375 );
10376 }
10377
10378 #[test]
10379 fn realtime_restore_handles_long_waiting_chain_with_bounded_graph_walk() {
10380 const ITEM_COUNT: usize = 4_096;
10381 let mut items = serde_json::Map::new();
10382 let mut order = Vec::with_capacity(ITEM_COUNT);
10383 for index in 0..ITEM_COUNT {
10384 let item_id = format!("item-{index:04}");
10385 let value = if index == 0 {
10386 serde_json::json!({ "role": "user" })
10387 } else {
10388 serde_json::json!({
10389 "role": "user",
10390 "previous_item_id": format!("item-{:04}", index - 1),
10391 })
10392 };
10393 order.push(item_id.clone());
10394 items.insert(item_id, value);
10395 }
10396 let state = serde_json::from_value(serde_json::json!({
10397 "items": items,
10398 "first_seen_order": order,
10399 }))
10400 .expect("long-chain fixture should deserialize");
10401 crate::realtime_transcript_revision::restore_realtime_transcript_state(state)
10402 .expect("long valid waiting DAG should restore in one bounded graph walk");
10403 }
10404
10405 #[test]
10410 fn realtime_transcript_final_text_overrides_partial_delta_and_promotes_to_spoken_lane() {
10411 let mut session = Session::new();
10412
10413 assert!(
10416 session
10417 .append_realtime_transcript_event(
10418 RealtimeTranscriptEvent::AssistantTranscriptDelta {
10419 response_id: "resp_a".to_string(),
10420 delta_id: "evt_1".to_string(),
10421 item_id: "item_a".to_string(),
10422 previous_item_id: None,
10423 content_index: 0,
10424 delta: "incom".to_string(),
10425 }
10426 )
10427 .is_inert()
10428 );
10429
10430 assert!(
10432 session
10433 .append_realtime_transcript_event(
10434 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
10435 response_id: "resp_a".to_string(),
10436 item_id: "item_a".to_string(),
10437 content_index: 0,
10438 text: "complete answer".to_string(),
10439 }
10440 )
10441 .is_inert()
10442 );
10443
10444 let outcome = session.append_realtime_transcript_event(
10446 RealtimeTranscriptEvent::AssistantTurnCompleted {
10447 response_id: "resp_a".to_string(),
10448 stop_reason: StopReason::EndTurn,
10449 usage: Usage::default(),
10450 },
10451 );
10452 assert!(!outcome.is_inert());
10453
10454 assert_eq!(session.messages().len(), 1);
10457 match &session.messages()[0] {
10458 Message::BlockAssistant(assistant) => {
10459 let mut found_transcript = false;
10460 for block in &assistant.blocks {
10461 if let AssistantBlock::Transcript { text, .. } = block {
10462 assert_eq!(text, "complete answer");
10463 found_transcript = true;
10464 }
10465 }
10466 assert!(
10467 found_transcript,
10468 "AssistantTranscriptFinalText must promote to the Spoken lane and \
10469 materialize as AssistantBlock::Transcript"
10470 );
10471 }
10472 other => unreachable!("expected BlockAssistant, got {other:?}"),
10473 }
10474 }
10475
10476 #[test]
10479 fn realtime_transcript_final_text_creates_item_when_no_delta_staged() {
10480 let mut session = Session::new();
10481
10482 assert!(
10483 session
10484 .append_realtime_transcript_event(
10485 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
10486 response_id: "resp_a".to_string(),
10487 item_id: "item_a".to_string(),
10488 content_index: 0,
10489 text: "spoken-final-only".to_string(),
10490 }
10491 )
10492 .is_inert()
10493 );
10494
10495 let outcome = session.append_realtime_transcript_event(
10496 RealtimeTranscriptEvent::AssistantTurnCompleted {
10497 response_id: "resp_a".to_string(),
10498 stop_reason: StopReason::EndTurn,
10499 usage: Usage::default(),
10500 },
10501 );
10502 assert!(!outcome.is_inert());
10503
10504 assert_eq!(session.messages().len(), 1);
10505 match &session.messages()[0] {
10506 Message::BlockAssistant(assistant) => {
10507 let has_transcript = assistant.blocks.iter().any(|b| {
10508 matches!(b, AssistantBlock::Transcript { text, .. } if text == "spoken-final-only")
10509 });
10510 assert!(
10511 has_transcript,
10512 "final-only provider path must materialize as Transcript on the Spoken lane"
10513 );
10514 }
10515 other => unreachable!("expected BlockAssistant, got {other:?}"),
10516 }
10517 }
10518
10519 #[test]
10520 fn realtime_transcript_append_orders_causally_equivalent_out_of_order_items() {
10521 let mut session = Session::new();
10522
10523 assert!(
10524 session
10525 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10526 response_id: "resp_assistant".to_string(),
10527 delta_id: "evt_delta_1".to_string(),
10528 item_id: "item_assistant".to_string(),
10529 previous_item_id: Some("item_user".to_string()),
10530 content_index: 0,
10531 delta: "answer".to_string(),
10532 })
10533 .is_inert()
10534 );
10535 assert!(
10536 session
10537 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10538 response_id: "resp_assistant".to_string(),
10539 stop_reason: StopReason::EndTurn,
10540 usage: Usage::default(),
10541 })
10542 .is_inert()
10543 );
10544
10545 let outcome = session.append_realtime_transcript_event(
10546 RealtimeTranscriptEvent::UserTranscriptFinal {
10547 item_id: "item_user".to_string(),
10548 previous_item_id: None,
10549 content_index: 0,
10550 text: "question".to_string(),
10551 },
10552 );
10553
10554 assert_eq!(outcome.materialized_messages.len(), 2);
10555 assert_eq!(session.messages().len(), 2);
10556 assert!(matches!(
10557 &session.messages()[0],
10558 Message::User(user) if user.text_content() == "question"
10559 ));
10560 assert!(matches!(
10561 &session.messages()[1],
10562 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer"
10563 ));
10564 }
10565
10566 #[test]
10567 fn realtime_transcript_replay_of_seen_provider_items_is_inert() {
10568 let mut session = Session::new();
10569 let events = vec![
10570 RealtimeTranscriptEvent::UserTranscriptFinal {
10571 item_id: "item_user".to_string(),
10572 previous_item_id: None,
10573 content_index: 0,
10574 text: "hello".to_string(),
10575 },
10576 RealtimeTranscriptEvent::AssistantTextDelta {
10577 response_id: "resp_assistant".to_string(),
10578 delta_id: "evt_delta_1".to_string(),
10579 item_id: "item_assistant".to_string(),
10580 previous_item_id: Some("item_user".to_string()),
10581 content_index: 0,
10582 delta: "world".to_string(),
10583 },
10584 RealtimeTranscriptEvent::AssistantTurnCompleted {
10585 response_id: "resp_assistant".to_string(),
10586 stop_reason: StopReason::EndTurn,
10587 usage: Usage::default(),
10588 },
10589 ];
10590
10591 for event in events.iter().cloned() {
10592 let _ = session.append_realtime_transcript_event(event);
10593 }
10594 let first_messages = serde_json::to_value(session.messages()).unwrap();
10595
10596 for event in events {
10597 assert!(session.append_realtime_transcript_event(event).is_inert());
10598 }
10599
10600 assert_eq!(
10601 serde_json::to_value(session.messages()).unwrap(),
10602 first_messages
10603 );
10604 }
10605
10606 #[test]
10607 fn realtime_transcript_user_final_replay_cannot_erase_existing_segment() {
10608 let mut session = Session::new();
10609
10610 let user = RealtimeTranscriptEvent::UserTranscriptFinal {
10611 item_id: "item_user".to_string(),
10612 previous_item_id: None,
10613 content_index: 0,
10614 text: "remember amber lantern".to_string(),
10615 };
10616 assert!(
10617 !session
10618 .append_realtime_transcript_event(user.clone())
10619 .is_inert()
10620 );
10621 let first_messages = serde_json::to_value(session.messages()).unwrap();
10622
10623 assert!(
10624 session
10625 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
10626 item_id: "item_user".to_string(),
10627 previous_item_id: None,
10628 content_index: 0,
10629 text: String::new(),
10630 })
10631 .is_inert()
10632 );
10633 assert!(session.append_realtime_transcript_event(user).is_inert());
10634 assert_eq!(
10635 serde_json::to_value(session.messages()).unwrap(),
10636 first_messages
10637 );
10638 }
10639
10640 #[test]
10641 fn realtime_transcript_empty_user_final_can_be_filled_by_later_nonempty_replay() {
10642 let mut session = Session::new();
10643
10644 assert!(
10645 session
10646 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
10647 item_id: "item_user".to_string(),
10648 previous_item_id: None,
10649 content_index: 0,
10650 text: String::new(),
10651 })
10652 .is_inert()
10653 );
10654 assert!(session.messages().is_empty());
10655
10656 let outcome = session.append_realtime_transcript_event(
10657 RealtimeTranscriptEvent::UserTranscriptFinal {
10658 item_id: "item_user".to_string(),
10659 previous_item_id: None,
10660 content_index: 0,
10661 text: "remember amber lantern".to_string(),
10662 },
10663 );
10664 assert_eq!(outcome.materialized_messages.len(), 1);
10665 assert_eq!(session.messages().len(), 1);
10666 assert!(matches!(
10667 &session.messages()[0],
10668 Message::User(user) if user.text_content() == "remember amber lantern"
10669 ));
10670 }
10671
10672 #[test]
10673 fn realtime_transcript_skipped_provider_items_preserve_causal_order_without_content() {
10674 let mut session = Session::new();
10675
10676 let assistant_delta = RealtimeTranscriptEvent::AssistantTextDelta {
10677 response_id: "resp_assistant".to_string(),
10678 delta_id: "evt_delta_1".to_string(),
10679 item_id: "item_assistant".to_string(),
10680 previous_item_id: Some("item_tool".to_string()),
10681 content_index: 0,
10682 delta: "done".to_string(),
10683 };
10684 assert!(
10685 session
10686 .append_realtime_transcript_event(assistant_delta.clone())
10687 .is_inert()
10688 );
10689 let assistant_complete = RealtimeTranscriptEvent::AssistantTurnCompleted {
10690 response_id: "resp_assistant".to_string(),
10691 stop_reason: StopReason::EndTurn,
10692 usage: Usage::default(),
10693 };
10694 assert!(
10695 session
10696 .append_realtime_transcript_event(assistant_complete.clone())
10697 .is_inert()
10698 );
10699
10700 let skipped = RealtimeTranscriptEvent::ItemSkipped {
10701 item_id: "item_tool".to_string(),
10702 previous_item_id: Some("item_user".to_string()),
10703 };
10704 assert!(
10705 session
10706 .append_realtime_transcript_event(skipped.clone())
10707 .is_inert(),
10708 "a skipped provider item must not append transcript content"
10709 );
10710 assert!(session.messages().is_empty());
10711
10712 let outcome = session.append_realtime_transcript_event(
10713 RealtimeTranscriptEvent::UserTranscriptFinal {
10714 item_id: "item_user".to_string(),
10715 previous_item_id: None,
10716 content_index: 0,
10717 text: "please use the tool".to_string(),
10718 },
10719 );
10720 assert_eq!(outcome.materialized_messages.len(), 2);
10721 assert_eq!(session.messages().len(), 2);
10722 assert!(matches!(
10723 &session.messages()[0],
10724 Message::User(user) if user.text_content() == "please use the tool"
10725 ));
10726 assert!(matches!(
10727 &session.messages()[1],
10728 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "done"
10729 ));
10730
10731 let first_messages = serde_json::to_value(session.messages()).unwrap();
10732 assert!(session.append_realtime_transcript_event(skipped).is_inert());
10733 assert!(
10734 session
10735 .append_realtime_transcript_event(assistant_delta)
10736 .is_inert()
10737 );
10738 assert!(
10739 session
10740 .append_realtime_transcript_event(assistant_complete)
10741 .is_inert()
10742 );
10743 assert_eq!(
10744 serde_json::to_value(session.messages()).unwrap(),
10745 first_messages
10746 );
10747 }
10748
10749 #[test]
10750 fn realtime_transcript_interrupted_assistant_item_unblocks_later_provider_items() {
10751 let mut session = Session::new();
10758
10759 let _ = session.append_realtime_transcript_event(
10760 RealtimeTranscriptEvent::UserTranscriptFinal {
10761 item_id: "item_repeat".to_string(),
10762 previous_item_id: None,
10763 content_index: 0,
10764 text: "repeat until stop".to_string(),
10765 },
10766 );
10767 assert!(
10768 session
10769 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10770 response_id: "resp_loop".to_string(),
10771 delta_id: "evt_loop_1".to_string(),
10772 item_id: "item_loop".to_string(),
10773 previous_item_id: Some("item_repeat".to_string()),
10774 content_index: 0,
10775 delta: "Looping now".to_string(),
10776 })
10777 .is_inert()
10778 );
10779 assert!(
10780 session
10781 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
10782 item_id: "item_stop".to_string(),
10783 previous_item_id: Some("item_loop".to_string()),
10784 content_index: 0,
10785 text: "Stop.".to_string(),
10786 })
10787 .is_inert(),
10788 "the stop turn waits until the interrupted assistant provider item is resolved"
10789 );
10790
10791 let outcome = session.append_realtime_transcript_event(
10792 RealtimeTranscriptEvent::AssistantTurnInterrupted {
10793 response_id: "resp_loop".to_string(),
10794 },
10795 );
10796
10797 assert_eq!(outcome.materialized_messages.len(), 2);
10800 assert_eq!(session.messages().len(), 3);
10802 assert!(matches!(
10803 &session.messages()[0],
10804 Message::User(user) if user.text_content() == "repeat until stop"
10805 ));
10806 match &session.messages()[1] {
10807 Message::BlockAssistant(assistant) => {
10808 let text = block_assistant_text(assistant);
10809 assert_eq!(text, "Looping now");
10810 }
10811 other => unreachable!(
10812 "Display lane assistant item must be retained on Interrupted, got {other:?}"
10813 ),
10814 }
10815 assert!(matches!(
10816 &session.messages()[2],
10817 Message::User(user) if user.text_content() == "Stop."
10818 ));
10819 }
10820
10821 #[test]
10822 fn realtime_transcript_late_interrupted_assistant_delta_stays_noncanonical() {
10823 let mut session = Session::new();
10824
10825 let _ = session.append_realtime_transcript_event(
10826 RealtimeTranscriptEvent::UserTranscriptFinal {
10827 item_id: "item_repeat".to_string(),
10828 previous_item_id: None,
10829 content_index: 0,
10830 text: "repeat until stop".to_string(),
10831 },
10832 );
10833 assert!(
10834 session
10835 .append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
10836 item_id: "item_loop".to_string(),
10837 previous_item_id: Some("item_repeat".to_string()),
10838 role: RealtimeTranscriptRole::Assistant,
10839 response_id: None,
10840 })
10841 .is_inert(),
10842 "provider can observe an assistant item before the adapter learns its response id"
10843 );
10844 assert!(
10845 session
10846 .append_realtime_transcript_event(
10847 RealtimeTranscriptEvent::AssistantTurnInterrupted {
10848 response_id: "resp_loop".to_string(),
10849 }
10850 )
10851 .is_inert(),
10852 "an interruption can arrive before delayed transcript deltas for the response"
10853 );
10854 assert!(
10855 session
10856 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
10857 item_id: "item_stop".to_string(),
10858 previous_item_id: Some("item_loop".to_string()),
10859 content_index: 0,
10860 text: "Stop.".to_string(),
10861 })
10862 .is_inert(),
10863 "the stop turn waits for the provider's interrupted assistant item anchor"
10864 );
10865
10866 let late_delta_outcome =
10867 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10868 response_id: "resp_loop".to_string(),
10869 delta_id: "evt_loop_late".to_string(),
10870 item_id: "item_loop".to_string(),
10871 previous_item_id: Some("item_repeat".to_string()),
10872 content_index: 0,
10873 delta: "Looping now".to_string(),
10874 });
10875 assert_eq!(late_delta_outcome.materialized_messages.len(), 1);
10876 assert!(matches!(
10877 &session.messages()[1],
10878 Message::User(user) if user.text_content() == "Stop."
10879 ));
10880 assert!(
10881 session
10882 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10883 response_id: "resp_loop".to_string(),
10884 stop_reason: StopReason::EndTurn,
10885 usage: Usage::default(),
10886 })
10887 .is_inert(),
10888 "late completion for an interrupted response must not resurrect its deltas"
10889 );
10890 assert!(
10891 session
10892 .messages()
10893 .iter()
10894 .filter_map(|message| match message {
10895 Message::BlockAssistant(assistant) => Some(block_assistant_text(assistant)),
10896 _ => None,
10897 })
10898 .all(|text| !text.contains("Looping now")),
10899 "late interrupted assistant text must remain non-canonical"
10900 );
10901 }
10902
10903 #[test]
10904 fn realtime_transcript_completion_only_finalizes_matching_response() {
10905 let mut session = Session::new();
10906
10907 let _ = session.append_realtime_transcript_event(
10908 RealtimeTranscriptEvent::UserTranscriptFinal {
10909 item_id: "item_user".to_string(),
10910 previous_item_id: None,
10911 content_index: 0,
10912 text: "question".to_string(),
10913 },
10914 );
10915 assert!(
10916 session
10917 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10918 response_id: "resp_a".to_string(),
10919 delta_id: "evt_a".to_string(),
10920 item_id: "item_a".to_string(),
10921 previous_item_id: Some("item_user".to_string()),
10922 content_index: 0,
10923 delta: "answer a".to_string(),
10924 })
10925 .is_inert()
10926 );
10927
10928 assert!(
10929 session
10930 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10931 response_id: "resp_b".to_string(),
10932 stop_reason: StopReason::EndTurn,
10933 usage: Usage::default(),
10934 })
10935 .is_inert(),
10936 "a completion for another response must not finalize buffered assistant text"
10937 );
10938 assert_eq!(session.messages().len(), 1);
10939
10940 let outcome = session.append_realtime_transcript_event(
10941 RealtimeTranscriptEvent::AssistantTurnCompleted {
10942 response_id: "resp_a".to_string(),
10943 stop_reason: StopReason::EndTurn,
10944 usage: Usage::default(),
10945 },
10946 );
10947 assert_eq!(outcome.materialized_messages.len(), 1);
10948 assert_eq!(session.messages().len(), 2);
10949 assert!(matches!(
10950 &session.messages()[1],
10951 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer a"
10952 ));
10953 }
10954
10955 #[test]
10956 fn realtime_transcript_completion_before_later_delta_is_response_scoped() {
10957 let mut session = Session::new();
10958
10959 let _ = session.append_realtime_transcript_event(
10960 RealtimeTranscriptEvent::UserTranscriptFinal {
10961 item_id: "item_user".to_string(),
10962 previous_item_id: None,
10963 content_index: 0,
10964 text: "question".to_string(),
10965 },
10966 );
10967 assert!(
10968 session
10969 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
10970 response_id: "resp_a".to_string(),
10971 stop_reason: StopReason::EndTurn,
10972 usage: Usage::default(),
10973 })
10974 .is_inert()
10975 );
10976 assert!(
10977 session
10978 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10979 response_id: "resp_b".to_string(),
10980 delta_id: "evt_b".to_string(),
10981 item_id: "item_b".to_string(),
10982 previous_item_id: Some("item_user".to_string()),
10983 content_index: 0,
10984 delta: "wrong response".to_string(),
10985 })
10986 .is_inert(),
10987 "a later delta for another response must not be finalized by resp_a's pending completion"
10988 );
10989
10990 let outcome =
10991 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10992 response_id: "resp_a".to_string(),
10993 delta_id: "evt_a".to_string(),
10994 item_id: "item_a".to_string(),
10995 previous_item_id: Some("item_user".to_string()),
10996 content_index: 0,
10997 delta: "right response".to_string(),
10998 });
10999
11000 assert_eq!(outcome.materialized_messages.len(), 1);
11001 assert_eq!(session.messages().len(), 2);
11002 assert!(matches!(
11003 &session.messages()[1],
11004 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "right response"
11005 ));
11006 }
11007
11008 #[test]
11009 fn realtime_transcript_late_duplicate_completion_cannot_finalize_unrelated_response() {
11010 let mut session = Session::new();
11011
11012 let _ = session.append_realtime_transcript_event(
11013 RealtimeTranscriptEvent::UserTranscriptFinal {
11014 item_id: "item_user".to_string(),
11015 previous_item_id: None,
11016 content_index: 0,
11017 text: "question".to_string(),
11018 },
11019 );
11020 let _ =
11021 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
11022 response_id: "resp_a".to_string(),
11023 delta_id: "evt_a".to_string(),
11024 item_id: "item_a".to_string(),
11025 previous_item_id: Some("item_user".to_string()),
11026 content_index: 0,
11027 delta: "first".to_string(),
11028 });
11029 let _ = session.append_realtime_transcript_event(
11030 RealtimeTranscriptEvent::AssistantTurnCompleted {
11031 response_id: "resp_a".to_string(),
11032 stop_reason: StopReason::EndTurn,
11033 usage: Usage::default(),
11034 },
11035 );
11036 assert_eq!(session.messages().len(), 2);
11037
11038 assert!(
11039 session
11040 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
11041 response_id: "resp_b".to_string(),
11042 delta_id: "evt_b".to_string(),
11043 item_id: "item_b".to_string(),
11044 previous_item_id: Some("item_a".to_string()),
11045 content_index: 0,
11046 delta: "second".to_string(),
11047 })
11048 .is_inert()
11049 );
11050 assert!(
11051 session
11052 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
11053 response_id: "resp_a".to_string(),
11054 stop_reason: StopReason::EndTurn,
11055 usage: Usage::default(),
11056 })
11057 .is_inert(),
11058 "a duplicate late terminal for resp_a must not finalize resp_b"
11059 );
11060 assert_eq!(session.messages().len(), 2);
11061
11062 let outcome = session.append_realtime_transcript_event(
11063 RealtimeTranscriptEvent::AssistantTurnCompleted {
11064 response_id: "resp_b".to_string(),
11065 stop_reason: StopReason::EndTurn,
11066 usage: Usage::default(),
11067 },
11068 );
11069 assert_eq!(outcome.materialized_messages.len(), 1);
11070 assert_eq!(session.messages().len(), 3);
11071 }
11072
11073 #[test]
11074 fn realtime_transcript_interruption_discards_only_matching_response() {
11075 let mut session = Session::new();
11081
11082 let _ = session.append_realtime_transcript_event(
11083 RealtimeTranscriptEvent::UserTranscriptFinal {
11084 item_id: "item_user".to_string(),
11085 previous_item_id: None,
11086 content_index: 0,
11087 text: "question".to_string(),
11088 },
11089 );
11090 let _ =
11091 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
11092 response_id: "resp_a".to_string(),
11093 delta_id: "evt_a".to_string(),
11094 item_id: "item_a".to_string(),
11095 previous_item_id: Some("item_user".to_string()),
11096 content_index: 0,
11097 delta: "interrupted display".to_string(),
11098 });
11099 let _ =
11100 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
11101 response_id: "resp_b".to_string(),
11102 delta_id: "evt_b".to_string(),
11103 item_id: "item_b".to_string(),
11104 previous_item_id: Some("item_user".to_string()),
11105 content_index: 0,
11106 delta: "keep me".to_string(),
11107 });
11108
11109 let interrupt_outcome = session.append_realtime_transcript_event(
11112 RealtimeTranscriptEvent::AssistantTurnInterrupted {
11113 response_id: "resp_a".to_string(),
11114 },
11115 );
11116 assert_eq!(
11117 interrupt_outcome.materialized_messages.len(),
11118 1,
11119 "resp_a's Display item commits on Interrupted"
11120 );
11121
11122 let outcome = session.append_realtime_transcript_event(
11123 RealtimeTranscriptEvent::AssistantTurnCompleted {
11124 response_id: "resp_b".to_string(),
11125 stop_reason: StopReason::EndTurn,
11126 usage: Usage::default(),
11127 },
11128 );
11129 assert_eq!(
11130 outcome.materialized_messages.len(),
11131 1,
11132 "resp_b commits on its TurnCompleted, untouched by resp_a's Interrupted"
11133 );
11134
11135 assert_eq!(session.messages().len(), 3);
11137 assert!(matches!(
11138 &session.messages()[1],
11139 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "interrupted display"
11140 ));
11141 assert!(matches!(
11142 &session.messages()[2],
11143 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "keep me"
11144 ));
11145 }
11146
11147 #[test]
11150 fn test_fork_shares_arc_no_clone() {
11151 let mut session = Session::new();
11152 for i in 0..100 {
11153 session.push(Message::User(UserMessage::text(format!("Message {i}"))));
11154 }
11155
11156 let forked = session.fork();
11158
11159 assert!(Arc::ptr_eq(&session.messages, &forked.messages));
11161 assert_eq!(forked.messages().len(), 100);
11162 }
11163
11164 #[test]
11165 fn test_fork_at_shares_arc_prefix() {
11166 let mut session = Session::new();
11167 for i in 0..100 {
11168 session.push(Message::User(UserMessage::text(format!("Message {i}"))));
11169 }
11170
11171 let forked = session.fork_at(50);
11173 assert_eq!(forked.messages().len(), 50);
11174
11175 assert_eq!(session.messages().len(), 100);
11177 }
11178
11179 #[test]
11180 fn test_fork_at_resets_transcript_history_state_for_branch_identity() {
11181 let mut session = Session::new();
11182 session.push(Message::User(UserMessage::text(
11183 "summarize this".to_string(),
11184 )));
11185 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
11186 vec![AssistantBlock::Text {
11187 text: "long assistant trace".to_string(),
11188 meta: None,
11189 }],
11190 StopReason::EndTurn,
11191 )));
11192 let parent_revision = session.transcript_revision().expect("parent revision");
11193 session
11194 .commit_transcript_rewrite(
11195 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
11196 vec![Message::BlockAssistant(BlockAssistantMessage::new(
11197 vec![AssistantBlock::Text {
11198 text: "compact trace".to_string(),
11199 meta: None,
11200 }],
11201 StopReason::EndTurn,
11202 ))],
11203 TranscriptRewriteReason::new("compaction"),
11204 Some("test".to_string()),
11205 Some(parent_revision),
11206 )
11207 .expect("rewrite should commit");
11208
11209 let source_head = session.transcript_revision().expect("source head");
11210 let mut forked = session.fork_at(1);
11211 assert_ne!(forked.id(), session.id());
11212 assert!(
11213 !forked
11214 .metadata()
11215 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
11216 );
11217 assert_eq!(
11218 forked.transcript_revision().expect("fork head"),
11219 transcript_messages_digest(forked.messages()).expect("fork digest")
11220 );
11221 assert!(
11222 forked
11223 .transcript_revision_messages(&source_head)
11224 .expect("fork history lookup")
11225 .is_none()
11226 );
11227
11228 let fork_parent = forked.transcript_revision().expect("fork parent");
11229 let commit = forked
11230 .commit_transcript_rewrite(
11231 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
11232 vec![Message::User(UserMessage::text(
11233 "branch prompt".to_string(),
11234 ))],
11235 TranscriptRewriteReason::new("branch_edit"),
11236 Some("test".to_string()),
11237 Some(fork_parent.clone()),
11238 )
11239 .expect("fork rewrite should use fork-local parent");
11240 assert_eq!(commit.parent_revision, fork_parent);
11241 }
11242
11243 #[test]
11244 fn test_push_cow_behavior() {
11245 let mut session = Session::new();
11246 session.push(Message::User(UserMessage::text("First".to_string())));
11247
11248 let forked = session.fork();
11250 assert!(Arc::ptr_eq(&session.messages, &forked.messages));
11251
11252 session.push(Message::User(UserMessage::text("Second".to_string())));
11254
11255 assert!(!Arc::ptr_eq(&session.messages, &forked.messages));
11257 assert_eq!(session.messages().len(), 2);
11258 assert_eq!(forked.messages().len(), 1);
11259 }
11260
11261 #[test]
11264 fn test_push_batch_single_timestamp() {
11265 let mut session = Session::new();
11266 let initial_updated = session.updated_at();
11267
11268 session.push_batch(vec![
11270 Message::User(UserMessage::text("First".to_string())),
11271 Message::User(UserMessage::text("Second".to_string())),
11272 Message::User(UserMessage::text("Third".to_string())),
11273 ]);
11274
11275 assert_eq!(session.messages().len(), 3);
11276 assert!(session.updated_at() >= initial_updated);
11278 }
11279
11280 #[test]
11281 fn test_touch_updates_timestamp() {
11282 let mut session = Session::new();
11283 let initial = session.updated_at();
11284
11285 std::thread::sleep(std::time::Duration::from_millis(10));
11286
11287 session.touch();
11289
11290 assert!(session.updated_at() > initial);
11291 }
11292
11293 #[test]
11294 fn test_session_push() {
11295 let mut session = Session::new();
11296 let initial_updated = session.updated_at();
11297
11298 std::thread::sleep(std::time::Duration::from_millis(10));
11300
11301 session.push(Message::User(UserMessage::text("Hello".to_string())));
11302
11303 assert_eq!(session.messages().len(), 1);
11304 assert!(session.updated_at() > initial_updated);
11305 }
11306
11307 #[test]
11308 fn test_session_fork() {
11309 let mut session = Session::new();
11310 session.push(Message::System(SystemMessage::new("System prompt")));
11311 session.push(Message::User(UserMessage::text("Hello".to_string())));
11312 session.push(Message::BlockAssistant(BlockAssistantMessage {
11313 blocks: vec![AssistantBlock::Text {
11314 text: "Hi!".to_string(),
11315 meta: None,
11316 }],
11317 stop_reason: StopReason::EndTurn,
11318 identity: crate::types::TranscriptMessageIdentity::default(),
11319 created_at: crate::types::message_timestamp_now(),
11320 }));
11321
11322 let forked = session.fork_at(2);
11324 assert_eq!(forked.messages().len(), 2);
11325 assert_ne!(forked.id(), session.id());
11326
11327 let full_fork = session.fork();
11329 assert_eq!(full_fork.messages().len(), 3);
11330 }
11331
11332 #[test]
11333 fn test_session_forks_drop_generated_authority_metadata() {
11334 let mut session = Session::new();
11335 session.push(Message::User(UserMessage::text("original")));
11336 session.set_metadata("ordinary", serde_json::json!("keep"));
11337 session
11338 .set_build_state(SessionBuildState::default())
11339 .expect("build state should serialize");
11340 session
11341 .set_system_context_state(SessionSystemContextState::default())
11342 .expect("system-context state should serialize");
11343 session
11344 .set_deferred_turn_state(SessionDeferredTurnState::default())
11345 .expect("deferred-turn state should serialize");
11346 session
11347 .set_tool_visibility_state(
11348 AuthorizedSessionToolVisibilityState::from_generated_authority(
11349 SessionToolVisibilityState::default(),
11350 ),
11351 )
11352 .expect("visibility state should serialize");
11353 let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
11354 item_id: "rt-item".to_string(),
11355 previous_item_id: None,
11356 role: RealtimeTranscriptRole::User,
11357 response_id: None,
11358 });
11359 session.metadata.insert(
11360 crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY.to_string(),
11361 serde_json::json!([{"sealed_projection": "must-not-fork"}]),
11362 );
11363 assert!(
11364 session
11365 .metadata()
11366 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
11367 "test setup should install realtime transcript authority state"
11368 );
11369
11370 let forked_at = session.fork_at(1);
11371 let full_fork = session.fork();
11372 let replaced = session
11373 .fork_replacing(
11374 0,
11375 TranscriptReplacement::Message {
11376 message: Message::User(UserMessage::text("replacement")),
11377 },
11378 )
11379 .expect("replacement fork should succeed");
11380
11381 for forked in [&forked_at, &full_fork, &replaced] {
11382 assert_eq!(forked.metadata().get("ordinary").unwrap(), "keep");
11383 assert!(
11384 !forked.metadata().contains_key(SESSION_BUILD_STATE_KEY),
11385 "forked sessions must not raw-copy durable build-state authority"
11386 );
11387 assert!(
11388 !forked
11389 .metadata()
11390 .contains_key(SESSION_SYSTEM_CONTEXT_STATE_KEY),
11391 "forked sessions must not raw-copy system-context authority state"
11392 );
11393 assert!(
11394 !forked
11395 .metadata()
11396 .contains_key(SESSION_DEFERRED_TURN_STATE_KEY),
11397 "forked sessions must not raw-copy deferred-turn authority state"
11398 );
11399 assert!(
11400 !forked
11401 .metadata()
11402 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY),
11403 "forked sessions must not raw-copy tool-visibility authority state"
11404 );
11405 assert!(
11406 !forked
11407 .metadata()
11408 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
11409 "forked sessions must not raw-copy realtime transcript authority state"
11410 );
11411 assert!(
11412 !forked
11413 .metadata()
11414 .contains_key(crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY),
11415 "forked sessions must not raw-copy compaction outbox authority"
11416 );
11417 }
11418 }
11419
11420 #[test]
11421 fn test_session_metadata() {
11422 let mut session = Session::new();
11423 session.set_metadata("key", serde_json::json!("value"));
11424
11425 assert_eq!(session.metadata().get("key").unwrap(), "value");
11426 }
11427
11428 #[test]
11429 fn identical_metadata_projection_is_checkpoint_idempotent() {
11430 let mut session = Session::new();
11431 session.set_metadata("key", serde_json::json!({ "value": 1 }));
11432 let updated_at = session.updated_at;
11433 let digest = crate::session_checkpoint_digest(&session)
11434 .expect("checkpoint digest before identical projection");
11435
11436 session.set_metadata("key", serde_json::json!({ "value": 1 }));
11437 session.remove_metadata("already_absent");
11438
11439 assert_eq!(
11440 session.updated_at, updated_at,
11441 "an identical durable projection must not manufacture a content mutation"
11442 );
11443 assert_eq!(
11444 crate::session_checkpoint_digest(&session)
11445 .expect("checkpoint digest after identical projection"),
11446 digest,
11447 "an identical durable projection must not rotate checkpoint authority"
11448 );
11449 }
11450
11451 #[test]
11452 fn session_metadata_realm_id_is_back_read_compatible_string() {
11453 let metadata = SessionMetadata {
11456 schema_version: SESSION_METADATA_SCHEMA_VERSION,
11457 model: "test-model".to_string(),
11458 max_tokens: 1024,
11459 structured_output_retries: 2,
11460 provider: Provider::Other,
11461 self_hosted_server_id: None,
11462 provider_params: None,
11463 tooling: SessionTooling::default(),
11464 keep_alive: false,
11465 comms_name: None,
11466 peer_meta: None,
11467 realm_id: Some(crate::RealmId::parse("env_default").unwrap()),
11468 instance_id: None,
11469 backend: None,
11470 config_generation: None,
11471 auth_binding: None,
11472 mob_member_binding: None,
11473 };
11474 let value = serde_json::to_value(&metadata).unwrap();
11475 assert_eq!(
11476 value.get("realm_id"),
11477 Some(&serde_json::json!("env_default")),
11478 "typed realm_id must serialize as a bare slug string"
11479 );
11480
11481 let legacy = serde_json::json!({
11484 "schema_version": SESSION_METADATA_SCHEMA_VERSION,
11485 "model": "test-model",
11486 "max_tokens": 1024,
11487 "structured_output_retries": 2,
11488 "provider": "other",
11489 "tooling": SessionTooling::default(),
11490 "keep_alive": false,
11491 "comms_name": null,
11492 "realm_id": "legacy_realm",
11493 });
11494 let restored: SessionMetadata = serde_json::from_value(legacy).unwrap();
11495 assert_eq!(
11496 restored.realm_id.as_ref().map(crate::RealmId::as_str),
11497 Some("legacy_realm")
11498 );
11499 }
11500
11501 #[test]
11506 fn session_tooling_tool_access_policy_round_trip_and_absent_default() {
11507 let legacy = serde_json::json!({});
11509 let restored: SessionTooling = serde_json::from_value(legacy).unwrap();
11510 assert_eq!(restored.tool_access_policy, None);
11511
11512 let value = serde_json::to_value(SessionTooling::default()).unwrap();
11514 assert!(
11515 value.get("tool_access_policy").is_none(),
11516 "None policy must not serialize"
11517 );
11518
11519 let tooling = SessionTooling {
11521 tool_access_policy: Some(crate::ops::ToolAccessPolicy::AllowList(
11522 ["read_file", "send_message"].into_iter().collect(),
11523 )),
11524 ..SessionTooling::default()
11525 };
11526 let value = serde_json::to_value(&tooling).unwrap();
11527 let restored: SessionTooling = serde_json::from_value(value).unwrap();
11528 assert_eq!(restored.tool_access_policy, tooling.tool_access_policy);
11529 }
11530
11531 #[test]
11532 fn lifecycle_terminal_typed_round_trip() {
11533 let mut session = Session::new();
11534 assert_eq!(session.lifecycle_terminal(), None);
11535
11536 session
11537 .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
11538 .expect("typed terminal write should serialize");
11539 assert_eq!(
11540 session.lifecycle_terminal(),
11541 Some(SessionLifecycleTerminal::Archived)
11542 );
11543 assert!(
11544 session
11545 .lifecycle_terminal()
11546 .is_some_and(SessionLifecycleTerminal::is_archived)
11547 );
11548 assert_eq!(
11550 session
11551 .metadata()
11552 .get(SESSION_LIFECYCLE_TERMINAL_KEY)
11553 .unwrap(),
11554 &serde_json::json!("archived")
11555 );
11556 }
11557
11558 #[test]
11559 fn lifecycle_terminal_key_rejects_raw_mutation() {
11560 let mut session = Session::new();
11561 assert!(
11562 session
11563 .try_set_metadata(
11564 SESSION_LIFECYCLE_TERMINAL_KEY,
11565 serde_json::json!("archived")
11566 )
11567 .is_err(),
11568 "the typed lifecycle-terminal key is reserved for session authority"
11569 );
11570 }
11571
11572 #[test]
11573 fn test_session_metadata_backfill_preserves_timestamp() {
11574 let mut session = Session::new();
11575 let initial_updated = session.updated_at();
11576
11577 std::thread::sleep(std::time::Duration::from_millis(10));
11578
11579 assert!(session.backfill_metadata_if_absent("key", serde_json::json!("value")));
11580 assert_eq!(session.metadata().get("key").unwrap(), "value");
11581 assert_eq!(session.updated_at(), initial_updated);
11582 assert!(!session.backfill_metadata_if_absent("key", serde_json::json!("other")));
11583 assert_eq!(session.metadata().get("key").unwrap(), "value");
11584 assert_eq!(session.updated_at(), initial_updated);
11585 }
11586
11587 #[test]
11588 fn test_reserved_generated_authority_metadata_rejects_raw_mutation() {
11589 let mut session = Session::new();
11590
11591 assert!(
11592 session
11593 .try_set_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY, serde_json::json!({}))
11594 .is_err()
11595 );
11596 assert!(
11597 session
11598 .try_set_metadata(SESSION_METADATA_KEY, serde_json::json!({}))
11599 .is_err()
11600 );
11601 assert!(
11602 session
11603 .try_set_metadata(SESSION_BUILD_STATE_KEY, serde_json::json!({}))
11604 .is_err()
11605 );
11606 let compaction_intents_key = crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY;
11607 let sealed_compaction_intents =
11608 serde_json::json!([{"sealed_projection": "typed-owner-only"}]);
11609 session.metadata.insert(
11610 compaction_intents_key.to_string(),
11611 sealed_compaction_intents.clone(),
11612 );
11613 assert!(
11614 session
11615 .try_set_metadata(compaction_intents_key, serde_json::json!([]))
11616 .is_err(),
11617 "raw metadata must not overwrite compaction outbox authority"
11618 );
11619 session.remove_metadata(compaction_intents_key);
11620 assert_eq!(
11621 session.metadata().get(compaction_intents_key),
11622 Some(&sealed_compaction_intents),
11623 "raw metadata removal must not erase compaction outbox authority"
11624 );
11625 let mut absent = Session::new();
11626 assert!(
11627 !absent.backfill_metadata_if_absent(
11628 compaction_intents_key,
11629 serde_json::json!([{"forged_projection": true}])
11630 ),
11631 "compatibility backfill must not fabricate compaction outbox authority"
11632 );
11633 assert!(!absent.metadata().contains_key(compaction_intents_key));
11634 session
11635 .set_session_metadata(SessionMetadata {
11636 schema_version: SESSION_METADATA_SCHEMA_VERSION,
11637 model: "test-model".to_string(),
11638 max_tokens: 1024,
11639 structured_output_retries: 2,
11640 provider: Provider::Other,
11641 self_hosted_server_id: None,
11642 provider_params: None,
11643 tooling: SessionTooling::default(),
11644 keep_alive: false,
11645 comms_name: None,
11646 peer_meta: None,
11647 realm_id: None,
11648 instance_id: None,
11649 backend: None,
11650 config_generation: None,
11651 auth_binding: None,
11652 mob_member_binding: None,
11653 })
11654 .expect("typed metadata setter should route through generated authority");
11655 session
11656 .set_build_state(SessionBuildState::default())
11657 .expect("typed build-state setter should route through generated authority");
11658 session.remove_metadata(SESSION_METADATA_KEY);
11659 session.remove_metadata(SESSION_BUILD_STATE_KEY);
11660 assert!(
11661 session.metadata().contains_key(SESSION_METADATA_KEY),
11662 "raw removal must not delete generated-authority session metadata"
11663 );
11664 assert!(
11665 session.metadata().contains_key(SESSION_BUILD_STATE_KEY),
11666 "raw removal must not delete generated-authority build state"
11667 );
11668 session.set_metadata(SESSION_DEFERRED_TURN_STATE_KEY, serde_json::json!({}));
11669 assert!(
11670 !session
11671 .metadata()
11672 .contains_key(SESSION_DEFERRED_TURN_STATE_KEY)
11673 );
11674 assert!(
11675 !session.backfill_metadata_if_absent(
11676 SESSION_SYSTEM_CONTEXT_STATE_KEY,
11677 serde_json::json!({})
11678 )
11679 );
11680
11681 let state = SessionSystemContextState::default();
11682 session
11683 .set_system_context_state(state.clone())
11684 .expect("typed setter should route through generated authority");
11685 session.remove_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY);
11686 assert_eq!(
11687 session
11688 .try_system_context_state()
11689 .expect("typed state should restore"),
11690 Some(state)
11691 );
11692
11693 session.metadata.insert(
11694 SESSION_SYSTEM_CONTEXT_STATE_KEY.to_string(),
11695 serde_json::json!("not-a-state"),
11696 );
11697 assert!(
11698 session.try_system_context_state().is_err(),
11699 "malformed generated authority state must not decode as absent/default"
11700 );
11701
11702 session.metadata.insert(
11703 SESSION_METADATA_KEY.to_string(),
11704 serde_json::json!("not-metadata"),
11705 );
11706 assert!(
11707 session.try_session_metadata().is_err(),
11708 "malformed session metadata must not decode as absent/default"
11709 );
11710
11711 session.metadata.insert(
11712 SESSION_BUILD_STATE_KEY.to_string(),
11713 serde_json::json!("not-build-state"),
11714 );
11715 assert!(
11716 session.try_build_state().is_err(),
11717 "malformed build state must not decode as absent/default"
11718 );
11719
11720 assert!(
11721 session
11722 .try_set_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY, serde_json::json!({}))
11723 .is_err()
11724 );
11725 session
11726 .set_tool_visibility_state(
11727 AuthorizedSessionToolVisibilityState::from_generated_authority(
11728 SessionToolVisibilityState::default(),
11729 ),
11730 )
11731 .expect("typed visibility setter should route through typed authority handoff");
11732 session.remove_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY);
11733 assert!(
11734 session
11735 .metadata()
11736 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
11737 );
11738 session.clear_tool_visibility_state();
11739 assert!(
11740 !session
11741 .metadata()
11742 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
11743 );
11744 assert!(
11745 session
11746 .try_set_metadata(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, serde_json::json!({}))
11747 .is_err()
11748 );
11749 let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
11750 item_id: "rt-item".to_string(),
11751 previous_item_id: None,
11752 role: RealtimeTranscriptRole::User,
11753 response_id: None,
11754 });
11755 assert!(
11756 session
11757 .metadata()
11758 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
11759 "typed realtime transcript append should retain authority to persist its state"
11760 );
11761 session.metadata.insert(
11762 SESSION_REALTIME_TRANSCRIPT_STATE_KEY.to_string(),
11763 serde_json::json!("not-a-state"),
11764 );
11765 assert!(
11766 session.try_realtime_transcript_state().is_err(),
11767 "malformed realtime generated authority state must not decode as absent/default"
11768 );
11769 }
11770
11771 #[test]
11772 fn test_session_mob_tool_authority_context_persists_projection_without_authority_seal() {
11773 let mut session = Session::new();
11774 session
11775 .set_build_state(SessionBuildState::default())
11776 .expect("session build state should serialize");
11777 let authority = MobToolAuthorityContext::generated_for_test(
11778 crate::service::OpaquePrincipalToken::new("opaque-principal"),
11779 false,
11780 false,
11781 false,
11782 std::collections::BTreeSet::from(["mob-a".to_string()]),
11783 std::collections::BTreeMap::new(),
11784 None,
11785 Some("audit-1".to_string()),
11786 );
11787
11788 session
11789 .set_mob_tool_authority_context(Some(authority))
11790 .expect("authority should serialize");
11791 assert!(session.mob_tool_authority_context().is_none());
11792 let stored = session
11793 .build_state()
11794 .and_then(|state| state.mob_tool_authority_context)
11795 .expect("stored projection should deserialize");
11796 assert!(!stored.is_generated_authority_context());
11797 assert!(!stored.can_manage_mob("mob-a"));
11798
11799 session
11800 .set_mob_tool_authority_context(None)
11801 .expect("authority should clear");
11802 assert!(session.mob_tool_authority_context().is_none());
11803 }
11804
11805 #[test]
11806 fn test_session_build_state_rejects_forged_mob_authority_projection() {
11807 let mut session = Session::new();
11808 let authority = MobToolAuthorityContext::generated_for_test(
11809 crate::service::OpaquePrincipalToken::new("opaque-principal"),
11810 false,
11811 false,
11812 false,
11813 std::collections::BTreeSet::from(["mob-a".to_string()]),
11814 std::collections::BTreeMap::new(),
11815 None,
11816 Some("audit-1".to_string()),
11817 );
11818 let forged_projection: MobToolAuthorityContext =
11819 serde_json::from_value(serde_json::to_value(authority).expect("serialize authority"))
11820 .expect("deserialize projection");
11821 assert!(!forged_projection.is_generated_authority_context());
11822
11823 let err = session
11824 .set_build_state(SessionBuildState {
11825 mob_tool_authority_context: Some(forged_projection),
11826 ..Default::default()
11827 })
11828 .expect_err("forged build state must be rejected by generated authority");
11829 assert!(
11833 err.to_string()
11834 .contains("generated session document authority rejected"),
11835 "unexpected error: {err}"
11836 );
11837 }
11838
11839 #[test]
11840 fn test_session_tool_visibility_state_roundtrip() {
11841 let mut session = Session::new();
11842 let state = SessionToolVisibilityState {
11843 inherited_base_filter: ToolFilter::Allow(["visible".to_string()].into_iter().collect()),
11844 active_filter: ToolFilter::Allow(
11845 ["visible".to_string(), "missing".to_string()]
11846 .into_iter()
11847 .collect(),
11848 ),
11849 staged_filter: ToolFilter::Allow(
11850 ["visible".to_string(), "missing".to_string()]
11851 .into_iter()
11852 .collect(),
11853 ),
11854 active_revision: 1,
11855 staged_revision: 2,
11856 ..Default::default()
11857 };
11858
11859 session
11860 .set_tool_visibility_state(
11861 AuthorizedSessionToolVisibilityState::from_generated_authority(state.clone()),
11862 )
11863 .expect("tool visibility state should serialize");
11864 assert_eq!(session.tool_visibility_state().unwrap(), Some(state));
11865 }
11866
11867 #[test]
11868 fn test_session_tool_visibility_state_malformed_returns_error() {
11869 let mut session = Session::new();
11870 session.metadata.insert(
11871 SESSION_TOOL_VISIBILITY_STATE_KEY.to_string(),
11872 serde_json::json!({
11873 "active_filter": {
11874 "unexpected_filter_kind": ["secret"]
11875 }
11876 }),
11877 );
11878
11879 assert!(
11880 session.tool_visibility_state().is_err(),
11881 "malformed canonical visibility metadata must not decode as absent/default"
11882 );
11883 }
11884
11885 #[test]
11886 fn test_session_serialization() {
11887 let mut session = Session::new();
11888 session.push(Message::User(UserMessage::text("Test".to_string())));
11889
11890 let json = serde_json::to_string(&session).unwrap();
11891 let parsed: Session = serde_json::from_str(&json).unwrap();
11892
11893 assert_eq!(parsed.id(), session.id());
11894 assert_eq!(parsed.messages().len(), 1);
11895 assert_eq!(parsed.version(), SESSION_VERSION);
11896 }
11897
11898 #[test]
11899 fn test_session_meta_from_session() {
11900 let mut session = Session::new();
11901 session.push(Message::User(UserMessage::text("Hello".to_string())));
11902 session.push(Message::BlockAssistant(BlockAssistantMessage {
11903 blocks: vec![AssistantBlock::Text {
11904 text: "Hi!".to_string(),
11905 meta: None,
11906 }],
11907 stop_reason: StopReason::EndTurn,
11908 identity: crate::types::TranscriptMessageIdentity::default(),
11909 created_at: crate::types::message_timestamp_now(),
11910 }));
11911 session.record_usage(Usage {
11912 input_tokens: 10,
11913 output_tokens: 5,
11914 cache_creation_tokens: None,
11915 cache_read_tokens: None,
11916 });
11917
11918 let meta = SessionMeta::from(&session);
11919 assert_eq!(meta.id, *session.id());
11920 assert_eq!(meta.message_count, 2);
11921 assert_eq!(meta.total_tokens, 15);
11922 }
11923
11924 #[test]
11925 fn system_context_state_preserves_applied_runtime_context() {
11926 let accepted_at = SystemTime::UNIX_EPOCH;
11927 let mut state = SessionSystemContextState::default();
11928 state
11929 .stage_append(
11930 &AppendSystemContextRequest {
11931 content: crate::lifecycle::run_primitive::CoreRenderable::text(
11932 "Authoritative peer token is birch seventeen.".to_string(),
11933 ),
11934 source: Some(
11935 "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1"
11936 .to_string(),
11937 ),
11938 idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
11939 source_kind: SystemContextSource::Normal,
11940 peer_response_terminal: None,
11941 },
11942 accepted_at,
11943 )
11944 .expect("append should stage");
11945
11946 state.mark_pending_applied();
11947
11948 assert!(state.pending.is_empty());
11949 assert_eq!(state.applied.len(), 1);
11950 assert_eq!(
11951 state.applied[0].content.render_text(),
11952 "Authoritative peer token is birch seventeen."
11953 );
11954 assert_eq!(
11955 state.applied[0].source.as_deref(),
11956 Some("peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1")
11957 );
11958
11959 let round_tripped: SessionSystemContextState =
11960 serde_json::from_value(serde_json::to_value(&state).expect("serialize state"))
11961 .expect("deserialize state");
11962 assert_eq!(round_tripped.applied, state.applied);
11963 }
11964
11965 #[test]
11966 fn active_turn_system_context_is_discarded_when_not_applied() {
11967 let mut state = SessionSystemContextState::default();
11968 state
11969 .stage_active_turn_append(
11970 &AppendSystemContextRequest {
11971 content: crate::lifecycle::run_primitive::CoreRenderable::text(
11972 "only for the active run".to_string(),
11973 ),
11974 source: Some("runtime:steer:input-1".to_string()),
11975 idempotency_key: Some("runtime:steer:input-1".to_string()),
11976 source_kind: SystemContextSource::RuntimeSteer,
11977 peer_response_terminal: None,
11978 },
11979 SystemTime::UNIX_EPOCH,
11980 )
11981 .expect("active context should stage");
11982
11983 let discarded = state.discard_unapplied_active_turn_pending();
11984
11985 assert_eq!(discarded.len(), 1);
11986 assert!(state.pending.is_empty());
11987 assert!(state.applied.is_empty());
11988 assert!(state.active_turn_pending_keys.is_empty());
11989 assert!(state.active_turn_pending_indices.is_empty());
11990 assert!(
11991 state.seen.is_empty(),
11992 "discarded active-turn context should not block later idempotency keys"
11993 );
11994 }
11995
11996 #[test]
11997 fn keyless_active_turn_system_context_is_owned_and_discarded() {
11998 let mut state = SessionSystemContextState::default();
11999 state
12000 .stage_active_turn_append(
12001 &AppendSystemContextRequest {
12002 content: crate::lifecycle::run_primitive::CoreRenderable::text(
12003 "keyless active-turn context".to_string(),
12004 ),
12005 source: Some("test:keyless-active-turn".to_string()),
12006 idempotency_key: None,
12007 source_kind: SystemContextSource::RuntimeSteer,
12008 peer_response_terminal: None,
12009 },
12010 SystemTime::UNIX_EPOCH,
12011 )
12012 .expect("keyless active context should stage");
12013
12014 assert!(state.active_turn_pending_keys.is_empty());
12015 assert_eq!(state.active_turn_pending_len(), 1);
12016 let discarded = state.discard_unapplied_active_turn_pending();
12017
12018 assert_eq!(discarded.len(), 1);
12019 assert!(state.pending.is_empty());
12020 assert_eq!(state.active_turn_pending_len(), 0);
12021 }
12022
12023 #[test]
12024 fn active_turn_system_context_can_roll_back_targeted_keys() {
12025 let mut state = SessionSystemContextState::default();
12026 for key in ["runtime:steer:input-1", "runtime:steer:input-2"] {
12027 state
12028 .stage_active_turn_append(
12029 &AppendSystemContextRequest {
12030 content: crate::lifecycle::run_primitive::CoreRenderable::text(format!(
12031 "context for {key}"
12032 )),
12033 source: Some(key.to_string()),
12034 idempotency_key: Some(key.to_string()),
12035 source_kind: SystemContextSource::RuntimeSteer,
12036 peer_response_terminal: None,
12037 },
12038 SystemTime::UNIX_EPOCH,
12039 )
12040 .expect("active context should stage");
12041 }
12042
12043 let discarded =
12044 state.discard_active_turn_pending_by_keys(&["runtime:steer:input-1".to_string()]);
12045
12046 assert_eq!(discarded.len(), 1);
12047 assert_eq!(
12048 discarded[0].idempotency_key.as_deref(),
12049 Some("runtime:steer:input-1")
12050 );
12051 assert_eq!(state.pending.len(), 1);
12052 assert_eq!(
12053 state.pending[0].idempotency_key.as_deref(),
12054 Some("runtime:steer:input-2")
12055 );
12056 assert!(!state.seen.contains_key("runtime:steer:input-1"));
12057 assert!(state.seen.contains_key("runtime:steer:input-2"));
12058 assert!(
12059 !state
12060 .active_turn_pending_keys
12061 .contains("runtime:steer:input-1")
12062 );
12063 assert!(
12064 state
12065 .active_turn_pending_keys
12066 .contains("runtime:steer:input-2")
12067 );
12068 }
12069
12070 #[test]
12071 fn active_turn_system_context_is_transient_when_boundary_consumes_it() {
12072 let mut state = SessionSystemContextState::default();
12073 state
12074 .stage_active_turn_append(
12075 &AppendSystemContextRequest {
12076 content: crate::lifecycle::run_primitive::CoreRenderable::text(
12077 "visible to this run".to_string(),
12078 ),
12079 source: Some("runtime:steer:input-2".to_string()),
12080 idempotency_key: Some("runtime:steer:input-2".to_string()),
12081 source_kind: SystemContextSource::RuntimeSteer,
12082 peer_response_terminal: None,
12083 },
12084 SystemTime::UNIX_EPOCH,
12085 )
12086 .expect("active context should stage");
12087
12088 state.mark_pending_applied();
12089 let discarded = state.discard_unapplied_active_turn_pending();
12090
12091 assert!(discarded.is_empty());
12092 assert!(state.pending.is_empty());
12093 assert!(state.applied.is_empty());
12094 assert!(state.active_turn_pending_keys.is_empty());
12095 assert!(state.active_turn_pending_indices.is_empty());
12096 assert_eq!(
12097 state.seen.get("runtime:steer:input-2"),
12098 None,
12099 "consumed active-turn steer context must not become durable state"
12100 );
12101 }
12102
12103 #[test]
12104 fn discard_transient_runtime_steer_context_removes_steer_via_typed_marker() {
12105 let mut session = Session::new();
12106 session.set_system_prompt(format!(
12110 "base{}{}{}{}",
12111 SYSTEM_CONTEXT_SEPARATOR,
12112 render_system_context_block(&PendingSystemContextAppend {
12113 content: crate::lifecycle::run_primitive::CoreRenderable::text(
12114 "old steer".to_string()
12115 ),
12116 source: Some("steer-source-old".to_string()),
12117 idempotency_key: Some("steer-key-old".to_string()),
12118 source_kind: SystemContextSource::RuntimeSteer,
12119 peer_response_terminal: None,
12120 accepted_at: SystemTime::UNIX_EPOCH,
12121 }),
12122 SYSTEM_CONTEXT_SEPARATOR,
12123 render_system_context_block(&PendingSystemContextAppend {
12124 content: crate::lifecycle::run_primitive::CoreRenderable::text(
12125 "durable peer fact".to_string()
12126 ),
12127 source: Some("peer_response_terminal:analyst:req".to_string()),
12128 idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
12129 source_kind: SystemContextSource::Normal,
12130 peer_response_terminal: None,
12131 accepted_at: SystemTime::UNIX_EPOCH,
12132 })
12133 ));
12134 session
12135 .set_system_context_state(SessionSystemContextState {
12136 pending: vec![PendingSystemContextAppend {
12137 content: crate::lifecycle::run_primitive::CoreRenderable::text(
12138 "pending steer".to_string(),
12139 ),
12140 source: Some("steer-source-pending".to_string()),
12141 idempotency_key: Some("steer-key-pending".to_string()),
12142 source_kind: SystemContextSource::RuntimeSteer,
12143 peer_response_terminal: None,
12144 accepted_at: SystemTime::UNIX_EPOCH,
12145 }],
12146 applied: vec![
12147 PendingSystemContextAppend {
12148 content: crate::lifecycle::run_primitive::CoreRenderable::text(
12149 "old steer".to_string(),
12150 ),
12151 source: Some("steer-source-old".to_string()),
12152 idempotency_key: Some("steer-key-old".to_string()),
12153 source_kind: SystemContextSource::RuntimeSteer,
12154 peer_response_terminal: None,
12155 accepted_at: SystemTime::UNIX_EPOCH,
12156 },
12157 PendingSystemContextAppend {
12158 content: crate::lifecycle::run_primitive::CoreRenderable::text(
12159 "durable peer fact".to_string(),
12160 ),
12161 source: Some("peer_response_terminal:analyst:req".to_string()),
12162 idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
12163 source_kind: SystemContextSource::Normal,
12164 peer_response_terminal: None,
12165 accepted_at: SystemTime::UNIX_EPOCH,
12166 },
12167 ],
12168 seen: BTreeMap::from([(
12169 "steer-key-old".to_string(),
12170 SeenSystemContextKey {
12171 content: crate::lifecycle::run_primitive::CoreRenderable::text(
12172 "old steer".to_string(),
12173 ),
12174 source: Some("steer-source-old".to_string()),
12175 source_kind: SystemContextSource::RuntimeSteer,
12176 state: SeenSystemContextState::Applied,
12177 },
12178 )]),
12179 active_turn_pending_keys: BTreeSet::from(["steer-key-pending".to_string()]),
12180 active_turn_pending_indices: BTreeSet::from([0]),
12181 })
12182 .expect("system context state should serialize");
12183
12184 let removed = session.discard_transient_runtime_steer_context();
12185
12186 assert!(removed >= 4);
12187 let system_prompt = match session.messages().first() {
12188 Some(Message::System(system)) => system.content.as_str(),
12189 other => panic!("expected system prompt, got {other:?}"),
12190 };
12191 assert!(!system_prompt.contains("old steer"));
12192 assert!(system_prompt.contains("durable peer fact"));
12193 let state = session.system_context_state().unwrap_or_default();
12194 assert!(state.pending.is_empty());
12195 assert_eq!(state.applied.len(), 1);
12196 assert_eq!(state.applied[0].content.render_text(), "durable peer fact");
12197 assert!(state.seen.is_empty());
12198 assert!(state.active_turn_pending_keys.is_empty());
12199 }
12200
12201 #[test]
12202 fn append_system_context_blocks_records_typed_applied_context() {
12203 let append = PendingSystemContextAppend {
12204 content: crate::lifecycle::run_primitive::CoreRenderable::text(
12205 "Authoritative peer token is birch seventeen.".to_string(),
12206 ),
12207 source: Some(
12208 "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string(),
12209 ),
12210 idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
12211 source_kind: SystemContextSource::Normal,
12212 peer_response_terminal: None,
12213 accepted_at: SystemTime::UNIX_EPOCH,
12214 };
12215 let mut session = Session::new();
12216
12217 session.append_system_context_blocks(std::slice::from_ref(&append));
12218
12219 let state = session
12220 .system_context_state()
12221 .expect("append should persist typed context state");
12222 assert_eq!(state.applied, vec![append]);
12223 }
12224
12225 fn roster_append() -> PendingSystemContextAppend {
12226 PendingSystemContextAppend {
12227 content: crate::lifecycle::run_primitive::CoreRenderable::text(
12228 "peer roster: lead-1, w-1".to_string(),
12229 ),
12230 source: Some("comms:roster".to_string()),
12231 idempotency_key: Some("comms:roster:v1".to_string()),
12232 source_kind: SystemContextSource::Normal,
12233 peer_response_terminal: None,
12234 accepted_at: SystemTime::UNIX_EPOCH,
12235 }
12236 }
12237
12238 fn resumed_session_with_context_appended_prompt(base: &str) -> Session {
12239 let mut session = Session::new();
12240 session.set_system_prompt(base.to_string());
12241 session.push(Message::User(UserMessage::text("hello".to_string())));
12242 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
12243 session
12244 }
12245
12246 #[test]
12247 fn reconcile_resumed_system_prompt_preserves_identical_base() {
12248 let mut session = Session::new();
12249 session.set_system_prompt("base prompt".to_string());
12250 session.push(Message::User(UserMessage::text("hello".to_string())));
12251 let digest_before = transcript_messages_digest(session.messages()).unwrap();
12252
12253 let outcome = session
12254 .reconcile_resumed_system_prompt("base prompt".to_string(), None)
12255 .expect("reconcile");
12256
12257 assert_eq!(
12258 outcome,
12259 ResumedSystemPromptReconciliation::PreservedContinuation
12260 );
12261 assert_eq!(
12262 transcript_messages_digest(session.messages()).unwrap(),
12263 digest_before,
12264 "identical base must leave the transcript revision unchanged"
12265 );
12266 }
12267
12268 #[test]
12269 fn reconcile_resumed_system_prompt_preserves_context_appended_base() {
12270 let mut session = resumed_session_with_context_appended_prompt("base prompt");
12271 let digest_before = transcript_messages_digest(session.messages()).unwrap();
12272
12273 let outcome = session
12274 .reconcile_resumed_system_prompt("base prompt".to_string(), None)
12275 .expect("reconcile");
12276
12277 assert_eq!(
12278 outcome,
12279 ResumedSystemPromptReconciliation::PreservedContinuation
12280 );
12281 assert_eq!(
12282 transcript_messages_digest(session.messages()).unwrap(),
12283 digest_before,
12284 "a base extended only by runtime context appends must stay untouched"
12285 );
12286 let system = match session.messages().first() {
12287 Some(Message::System(system)) => system.clone(),
12288 other => panic!("expected system message, got {other:?}"),
12289 };
12290 assert!(system.content.contains("peer roster: lead-1, w-1"));
12291 assert!(
12292 system.mutation_kind.is_runtime_context_append(),
12293 "the persisted mutation provenance must survive reconciliation"
12294 );
12295 }
12296
12297 #[test]
12298 fn reconcile_resumed_system_prompt_rewrites_changed_base_preserving_tail() {
12299 let mut session = resumed_session_with_context_appended_prompt("base prompt");
12300
12301 let outcome = session
12302 .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
12303 .expect("reconcile");
12304
12305 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
12306 let system_content = match session.messages().first() {
12307 Some(Message::System(system)) => system.content.clone(),
12308 other => panic!("expected system message, got {other:?}"),
12309 };
12310 assert!(
12311 system_content.starts_with("new base prompt"),
12312 "the changed base must be applied: {system_content}"
12313 );
12314 assert!(
12315 system_content.contains("peer roster: lead-1, w-1"),
12316 "the runtime-applied context tail must survive the base change: {system_content}"
12317 );
12318 let state = session
12319 .transcript_history_state()
12320 .expect("history state deserializes")
12321 .expect("rewrite must record transcript history");
12322 assert_eq!(state.commits.len(), 1);
12323 assert_eq!(
12324 state.commits[0].reason.kind,
12325 RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON
12326 );
12327 assert_eq!(
12328 state.head,
12329 transcript_messages_digest(session.messages()).unwrap(),
12330 "the committed head must match the rewritten transcript"
12331 );
12332 }
12333
12334 #[test]
12335 fn reconcile_resumed_system_prompt_inserts_prompt_on_promptless_transcript() {
12336 let mut session = Session::new();
12337 session.push(Message::User(UserMessage::text("hello".to_string())));
12338
12339 let outcome = session
12340 .reconcile_resumed_system_prompt("late prompt".to_string(), None)
12341 .expect("reconcile");
12342
12343 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
12344 assert!(matches!(
12345 session.messages().first(),
12346 Some(Message::System(system)) if system.content == "late prompt"
12347 ));
12348 let state = session
12349 .transcript_history_state()
12350 .expect("history state deserializes")
12351 .expect("insert must record transcript history");
12352 assert_eq!(state.commits.len(), 1);
12353 assert_eq!(
12354 state.commits[0].reason.kind,
12355 RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON
12356 );
12357 }
12358
12359 fn leading_system_content(session: &Session) -> String {
12360 match session.messages().first() {
12361 Some(Message::System(system)) => system.content.clone(),
12362 other => panic!("expected leading system message, got {other:?}"),
12363 }
12364 }
12365
12366 #[test]
12367 fn reconcile_resumed_system_prompt_preserves_full_context_prompt_from_empty_base() {
12368 let mut session = Session::new();
12373 session.push(Message::User(UserMessage::text("hello".to_string())));
12374 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
12375 let all_context_content = leading_system_content(&session);
12376 assert!(all_context_content.contains("peer roster: lead-1, w-1"));
12377
12378 let outcome = session
12379 .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
12380 .expect("reconcile");
12381
12382 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
12383 assert_eq!(
12384 leading_system_content(&session),
12385 format!("new base prompt{SYSTEM_CONTEXT_SEPARATOR}{all_context_content}"),
12386 "the all-context prompt must survive as the runtime tail of the new base"
12387 );
12388 }
12389
12390 #[test]
12391 fn reconcile_resumed_system_prompt_preserves_context_only_prompt_on_empty_base_resume() {
12392 let mut session = Session::new();
12395 session.push(Message::User(UserMessage::text("hello".to_string())));
12396 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
12397 let digest_before = transcript_messages_digest(session.messages()).unwrap();
12398
12399 let outcome = session
12400 .reconcile_resumed_system_prompt(String::new(), None)
12401 .expect("reconcile");
12402
12403 assert_eq!(
12404 outcome,
12405 ResumedSystemPromptReconciliation::PreservedContinuation
12406 );
12407 assert_eq!(
12408 transcript_messages_digest(session.messages()).unwrap(),
12409 digest_before
12410 );
12411 }
12412
12413 #[test]
12414 fn reconcile_resumed_system_prompt_applies_shortened_base_with_recorded_prior() {
12415 let full_base = format!("part one{SYSTEM_CONTEXT_SEPARATOR}part two");
12419 let mut session = Session::new();
12420 session.set_system_prompt(full_base.clone());
12421 session.push(Message::User(UserMessage::text("hello".to_string())));
12422 session
12423 .set_build_state(SessionBuildState {
12424 assembled_system_prompt: Some(full_base),
12425 ..Default::default()
12426 })
12427 .expect("build state");
12428
12429 let outcome = session
12430 .reconcile_resumed_system_prompt("part one".to_string(), None)
12431 .expect("reconcile");
12432
12433 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
12434 assert_eq!(leading_system_content(&session), "part one");
12435 }
12436
12437 #[test]
12438 fn reconcile_resumed_system_prompt_applies_shortened_base_without_context_provenance() {
12439 let full_base = format!("part one{SYSTEM_CONTEXT_SEPARATOR}part two");
12444 let mut session = Session::new();
12445 session.set_system_prompt(full_base);
12446 session.push(Message::User(UserMessage::text("hello".to_string())));
12447
12448 let outcome = session
12449 .reconcile_resumed_system_prompt("part one".to_string(), None)
12450 .expect("reconcile");
12451
12452 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
12453 assert_eq!(leading_system_content(&session), "part one");
12454 }
12455
12456 #[test]
12457 fn reconcile_resumed_system_prompt_preserves_appended_prompt_without_applied_records() {
12458 let mut session = resumed_session_with_context_appended_prompt("base prompt");
12463 session
12464 .set_system_context_state(SessionSystemContextState::default())
12465 .expect("sweep applied records");
12466 let digest_before = transcript_messages_digest(session.messages()).unwrap();
12467
12468 let outcome = session
12469 .reconcile_resumed_system_prompt("base prompt".to_string(), None)
12470 .expect("reconcile");
12471
12472 assert_eq!(
12473 outcome,
12474 ResumedSystemPromptReconciliation::PreservedContinuation
12475 );
12476 assert_eq!(
12477 transcript_messages_digest(session.messages()).unwrap(),
12478 digest_before
12479 );
12480 }
12481
12482 #[test]
12483 fn reconcile_resumed_system_prompt_clears_orphaned_applied_records_on_tail_drop() {
12484 let mut session = resumed_session_with_context_appended_prompt("base prompt");
12485 session.set_system_prompt(format!(
12490 "mutated base{SYSTEM_CONTEXT_SEPARATOR}stale-looking tail"
12491 ));
12492
12493 let outcome = session
12494 .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
12495 .expect("reconcile");
12496
12497 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
12498 assert_eq!(leading_system_content(&session), "new base prompt");
12499 let state = session.system_context_state().unwrap_or_default();
12500 assert!(
12501 state.applied.is_empty(),
12502 "orphaned applied records must be cleared so the context stays restorable"
12503 );
12504 assert!(
12505 state.seen.is_empty(),
12506 "orphaned idempotency keys must be cleared so keyed re-sends re-apply"
12507 );
12508
12509 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
12512 assert!(
12513 leading_system_content(&session).contains("peer roster: lead-1, w-1"),
12514 "re-sent keyed context must re-apply after the drop"
12515 );
12516 }
12517
12518 #[test]
12519 fn append_system_context_blocks_renders_pre_marked_pending_context() {
12520 let accepted_at = SystemTime::UNIX_EPOCH;
12521 let mut state = SessionSystemContextState::default();
12522 state
12523 .stage_append(
12524 &AppendSystemContextRequest {
12525 content: crate::lifecycle::run_primitive::CoreRenderable::text(
12526 "Apply this staged context at the request boundary.".to_string(),
12527 ),
12528 source: Some("rpc/session_inject_context".to_string()),
12529 idempotency_key: Some("ctx-boundary".to_string()),
12530 source_kind: SystemContextSource::Normal,
12531 peer_response_terminal: None,
12532 },
12533 accepted_at,
12534 )
12535 .expect("append should stage");
12536 let pending = state.pending.clone();
12537 state.mark_pending_applied();
12538 let mut session = Session::new();
12539 session
12540 .set_system_context_state(state)
12541 .expect("state should serialize");
12542
12543 session.append_system_context_blocks(&pending);
12544
12545 let system_prompt = session
12546 .messages()
12547 .first()
12548 .and_then(|message| match message {
12549 Message::System(system) => Some(system.content.as_str()),
12550 _ => None,
12551 })
12552 .unwrap_or_default();
12553 assert!(system_prompt.contains("Apply this staged context at the request boundary."));
12554 let state = session
12555 .system_context_state()
12556 .expect("append should persist typed context state");
12557 assert_eq!(state.applied.len(), 1);
12558 assert_eq!(
12559 state.seen["ctx-boundary"].state,
12560 SeenSystemContextState::Applied
12561 );
12562 }
12563
12564 #[test]
12565 fn append_system_context_blocks_renders_pre_marked_context_without_idempotency_key() {
12566 let accepted_at = SystemTime::UNIX_EPOCH;
12567 let mut state = SessionSystemContextState::default();
12568 state
12569 .stage_append(
12570 &AppendSystemContextRequest {
12571 content: crate::lifecycle::run_primitive::CoreRenderable::text(
12572 "Apply this unkeyed staged context at the request boundary.".to_string(),
12573 ),
12574 source: Some("rpc/session_inject_context".to_string()),
12575 idempotency_key: None,
12576 source_kind: SystemContextSource::Normal,
12577 peer_response_terminal: None,
12578 },
12579 accepted_at,
12580 )
12581 .expect("append should stage");
12582 let pending = state.pending.clone();
12583 state.mark_pending_applied();
12584 let mut session = Session::new();
12585 session
12586 .set_system_context_state(state)
12587 .expect("state should serialize");
12588
12589 session.append_system_context_blocks(&pending);
12590
12591 let system_prompt = session
12592 .messages()
12593 .first()
12594 .and_then(|message| match message {
12595 Message::System(system) => Some(system.content.as_str()),
12596 _ => None,
12597 })
12598 .unwrap_or_default();
12599 assert!(
12600 system_prompt.contains("Apply this unkeyed staged context at the request boundary.")
12601 );
12602 }
12603
12604 #[test]
12608 fn staged_system_context_carries_typed_renderable_to_render_seam() {
12609 use crate::lifecycle::run_primitive::CoreRenderable;
12610
12611 let accepted_at = SystemTime::UNIX_EPOCH;
12612 let mut state = SessionSystemContextState::default();
12613 let renderable = CoreRenderable::Json {
12614 value: serde_json::json!({"alert": "disk-full", "severity": 2}),
12615 };
12616 state
12617 .stage_append(
12618 &AppendSystemContextRequest {
12619 content: renderable.clone(),
12620 source: Some("ops/monitor".to_string()),
12621 idempotency_key: Some("alert-1".to_string()),
12622 source_kind: SystemContextSource::Normal,
12623 peer_response_terminal: None,
12624 },
12625 accepted_at,
12626 )
12627 .expect("typed renderable append should stage");
12628
12629 assert_eq!(state.pending.len(), 1);
12632 assert_eq!(state.pending[0].content, renderable);
12633
12634 let rendered = render_system_context_block(&state.pending[0]);
12637 assert!(rendered.starts_with(SYSTEM_CONTEXT_RENDER_LABEL));
12638 assert!(
12639 rendered.contains(renderable.render_text().trim()),
12640 "render seam must lower via CoreRenderable::render_text: {rendered}"
12641 );
12642 }
12643
12644 #[test]
12645 fn append_system_context_blocks_skips_duplicate_idempotency_key() {
12646 let first = PendingSystemContextAppend {
12647 content: crate::lifecycle::run_primitive::CoreRenderable::text(
12648 "Authoritative peer token is birch seventeen.".to_string(),
12649 ),
12650 source: Some("peer_response_terminal:analyst:req-1".to_string()),
12651 idempotency_key: Some("req-1".to_string()),
12652 source_kind: SystemContextSource::Normal,
12653 peer_response_terminal: None,
12654 accepted_at: SystemTime::UNIX_EPOCH,
12655 };
12656 let duplicate = PendingSystemContextAppend {
12657 accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
12658 ..first.clone()
12659 };
12660 let mut session = Session::new();
12661
12662 session.append_system_context_blocks(std::slice::from_ref(&first));
12663 session.append_system_context_blocks(std::slice::from_ref(&duplicate));
12664
12665 let state = session
12666 .system_context_state()
12667 .expect("append should persist typed context state");
12668 assert_eq!(state.applied, vec![first]);
12669 let system_prompt = session
12670 .messages()
12671 .first()
12672 .and_then(|message| match message {
12673 Message::System(system) => Some(system.content.as_str()),
12674 _ => None,
12675 })
12676 .unwrap_or_default();
12677 assert_eq!(
12678 system_prompt
12679 .matches("Authoritative peer token is birch seventeen.")
12680 .count(),
12681 1
12682 );
12683 }
12684
12685 #[test]
12686 fn append_system_context_blocks_skips_conflicting_duplicate_idempotency_key() {
12687 let first = PendingSystemContextAppend {
12688 content: crate::lifecycle::run_primitive::CoreRenderable::text(
12689 "Authoritative peer token is birch seventeen.".to_string(),
12690 ),
12691 source: Some("peer_response_terminal:analyst:req-1".to_string()),
12692 idempotency_key: Some("req-1".to_string()),
12693 source_kind: SystemContextSource::Normal,
12694 peer_response_terminal: None,
12695 accepted_at: SystemTime::UNIX_EPOCH,
12696 };
12697 let conflicting = PendingSystemContextAppend {
12698 content: crate::lifecycle::run_primitive::CoreRenderable::text(
12699 "Conflicting peer token should not reach the prompt.".to_string(),
12700 ),
12701 accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
12702 ..first.clone()
12703 };
12704 let mut session = Session::new();
12705
12706 session.append_system_context_blocks(std::slice::from_ref(&first));
12707 session.append_system_context_blocks(std::slice::from_ref(&conflicting));
12708
12709 let state = session
12710 .system_context_state()
12711 .expect("append should persist typed context state");
12712 assert_eq!(state.applied, vec![first]);
12713 let system_prompt = session
12714 .messages()
12715 .first()
12716 .and_then(|message| match message {
12717 Message::System(system) => Some(system.content.as_str()),
12718 _ => None,
12719 })
12720 .unwrap_or_default();
12721 assert!(system_prompt.contains("Authoritative peer token is birch seventeen."));
12722 assert!(!system_prompt.contains("Conflicting peer token should not reach the prompt."));
12723 }
12724
12725 #[test]
12737 fn realtime_transcript_assistant_transcript_delta_materializes_transcript_block() {
12738 let mut session = Session::new();
12739
12740 let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12741 response_id: "resp_spoken".to_string(),
12742 delta_id: "evt_delta_spoken_1".to_string(),
12743 item_id: "item_spoken".to_string(),
12744 previous_item_id: None,
12745 content_index: 0,
12746 delta: "I said hi".to_string(),
12747 };
12748 assert!(
12749 session.append_realtime_transcript_event(delta).is_inert(),
12750 "delta alone is inert until turn-completed flushes"
12751 );
12752
12753 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
12754 response_id: "resp_spoken".to_string(),
12755 stop_reason: StopReason::EndTurn,
12756 usage: Usage::default(),
12757 };
12758 let outcome = session.append_realtime_transcript_event(terminal);
12759 assert_eq!(outcome.materialized_messages.len(), 1);
12760
12761 let messages = session.messages();
12763 assert_eq!(messages.len(), 1);
12764 match &messages[0] {
12765 Message::BlockAssistant(assistant) => {
12766 assert_eq!(assistant.blocks.len(), 1);
12767 match &assistant.blocks[0] {
12768 AssistantBlock::Transcript { text, source, .. } => {
12769 assert_eq!(text, "I said hi");
12770 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
12771 }
12772 other => unreachable!(
12773 "AssistantTranscriptDelta must materialize as AssistantBlock::Transcript, got {other:?}"
12774 ),
12775 }
12776 }
12777 other => unreachable!("expected BlockAssistant message, got {other:?}"),
12778 }
12779 }
12780
12781 #[test]
12782 fn round4_cc4_in_flight_response_ids_lists_distinct_unmaterialized_responses() {
12783 let mut session = Session::new();
12789
12790 for (i, response_id) in [
12794 ("resp_a", "resp_a"),
12795 ("resp_a_extra", "resp_a"),
12796 ("resp_b", "resp_b"),
12797 ("resp_c", "resp_c"),
12798 ]
12799 .iter()
12800 .enumerate()
12801 {
12802 let event = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12803 response_id: response_id.1.to_string(),
12804 delta_id: format!("delta_{i}"),
12805 item_id: response_id.0.to_string(),
12806 previous_item_id: None,
12807 content_index: 0,
12808 delta: "x".to_string(),
12809 };
12810 let _ = session.append_realtime_transcript_event(event);
12811 }
12812
12813 let _ = session.append_realtime_transcript_event(
12815 RealtimeTranscriptEvent::AssistantTurnInterrupted {
12816 response_id: "resp_c".to_string(),
12817 },
12818 );
12819
12820 let _ = session.append_realtime_transcript_event(
12823 RealtimeTranscriptEvent::UserTranscriptFinal {
12824 item_id: "u_item".to_string(),
12825 previous_item_id: None,
12826 content_index: 0,
12827 text: "hi".to_string(),
12828 },
12829 );
12830
12831 let in_flight = session.in_flight_realtime_assistant_response_ids();
12832 assert!(in_flight.contains(&"resp_a".to_string()), "{in_flight:?}");
12833 assert!(in_flight.contains(&"resp_b".to_string()), "{in_flight:?}");
12834 assert!(
12835 !in_flight.contains(&"resp_c".to_string()),
12836 "discarded response must not appear in in_flight: {in_flight:?}"
12837 );
12838 assert_eq!(
12840 in_flight.iter().filter(|r| *r == "resp_a").count(),
12841 1,
12842 "distinct response_ids only: {in_flight:?}"
12843 );
12844 }
12845
12846 #[test]
12847 fn round4_cc2_assistant_turn_completed_after_transcript_deltas_materializes_transcript() {
12848 let mut session = Session::new();
12855
12856 let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12857 response_id: "resp_cc2".to_string(),
12858 delta_id: "delta_cc2_1".to_string(),
12859 item_id: "item_cc2".to_string(),
12860 previous_item_id: None,
12861 content_index: 0,
12862 delta: "hello world".to_string(),
12863 };
12864 assert!(session.append_realtime_transcript_event(delta).is_inert());
12865
12866 assert_eq!(
12868 session.in_flight_realtime_assistant_response_ids(),
12869 vec!["resp_cc2".to_string()]
12870 );
12871
12872 let outcome = session.append_realtime_transcript_event(
12873 RealtimeTranscriptEvent::AssistantTurnCompleted {
12874 response_id: "resp_cc2".to_string(),
12875 stop_reason: StopReason::EndTurn,
12876 usage: Usage::default(),
12877 },
12878 );
12879 assert_eq!(outcome.materialized_messages.len(), 1);
12880
12881 assert!(
12883 session
12884 .in_flight_realtime_assistant_response_ids()
12885 .is_empty(),
12886 "materialized items must not appear in in_flight_realtime_assistant_response_ids"
12887 );
12888
12889 let messages = session.messages();
12890 let assistant = messages.iter().find_map(|m| match m {
12891 Message::BlockAssistant(a) => Some(a),
12892 _ => None,
12893 });
12894 let assistant = assistant.expect("assistant block message expected");
12895 assert_eq!(assistant.blocks.len(), 1);
12896 assert!(matches!(
12897 &assistant.blocks[0],
12898 AssistantBlock::Transcript {
12899 source: crate::types::TranscriptSource::Spoken,
12900 ..
12901 }
12902 ));
12903 }
12904
12905 #[test]
12906 fn realtime_transcript_assistant_text_delta_still_materializes_text_block() {
12907 let mut session = Session::new();
12911
12912 let delta = RealtimeTranscriptEvent::AssistantTextDelta {
12913 response_id: "resp_display".to_string(),
12914 delta_id: "evt_delta_display_1".to_string(),
12915 item_id: "item_display".to_string(),
12916 previous_item_id: None,
12917 content_index: 0,
12918 delta: "I wrote".to_string(),
12919 };
12920 let _ = session.append_realtime_transcript_event(delta);
12921
12922 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
12923 response_id: "resp_display".to_string(),
12924 stop_reason: StopReason::EndTurn,
12925 usage: Usage::default(),
12926 };
12927 let outcome = session.append_realtime_transcript_event(terminal);
12928 assert_eq!(outcome.materialized_messages.len(), 1);
12929
12930 let messages = session.messages();
12931 match &messages[0] {
12932 Message::BlockAssistant(assistant) => match &assistant.blocks[0] {
12933 AssistantBlock::Text { text, .. } => assert_eq!(text, "I wrote"),
12934 other => unreachable!(
12935 "AssistantTextDelta must keep materializing AssistantBlock::Text, got {other:?}"
12936 ),
12937 },
12938 other => unreachable!("expected BlockAssistant message, got {other:?}"),
12939 }
12940 }
12941
12942 #[test]
12943 fn round4_cc7_mixed_response_persists_text_and_transcript_in_order() {
12944 let mut session = Session::new();
12962
12963 let display_a = RealtimeTranscriptEvent::AssistantTextDelta {
12965 response_id: "resp_mixed_1".to_string(),
12966 delta_id: "delta_disp_1".to_string(),
12967 item_id: "item_display".to_string(),
12968 previous_item_id: None,
12969 content_index: 0,
12970 delta: "Here's the report:".to_string(),
12971 };
12972 assert!(
12973 session
12974 .append_realtime_transcript_event(display_a)
12975 .is_inert()
12976 );
12977
12978 let display_b = RealtimeTranscriptEvent::AssistantTextDelta {
12979 response_id: "resp_mixed_1".to_string(),
12980 delta_id: "delta_disp_2".to_string(),
12981 item_id: "item_display".to_string(),
12982 previous_item_id: None,
12983 content_index: 0,
12984 delta: " (still writing)".to_string(),
12985 };
12986 assert!(
12987 session
12988 .append_realtime_transcript_event(display_b)
12989 .is_inert()
12990 );
12991
12992 let spoken_a = RealtimeTranscriptEvent::AssistantTranscriptDelta {
12997 response_id: "resp_mixed_1".to_string(),
12998 delta_id: "delta_spoken_1".to_string(),
12999 item_id: "item_spoken".to_string(),
13000 previous_item_id: Some("item_display".to_string()),
13001 content_index: 0,
13002 delta: "I'm reading the report aloud:".to_string(),
13003 };
13004 assert!(
13005 session
13006 .append_realtime_transcript_event(spoken_a)
13007 .is_inert()
13008 );
13009
13010 let spoken_b = RealtimeTranscriptEvent::AssistantTranscriptDelta {
13011 response_id: "resp_mixed_1".to_string(),
13012 delta_id: "delta_spoken_2".to_string(),
13013 item_id: "item_spoken".to_string(),
13014 previous_item_id: Some("item_display".to_string()),
13015 content_index: 0,
13016 delta: " sentence two.".to_string(),
13017 };
13018 assert!(
13019 session
13020 .append_realtime_transcript_event(spoken_b)
13021 .is_inert()
13022 );
13023
13024 let outcome = session.append_realtime_transcript_event(
13027 RealtimeTranscriptEvent::AssistantTurnCompleted {
13028 response_id: "resp_mixed_1".to_string(),
13029 stop_reason: StopReason::EndTurn,
13030 usage: Usage {
13031 input_tokens: 11,
13032 output_tokens: 22,
13033 cache_creation_tokens: None,
13034 cache_read_tokens: None,
13035 },
13036 },
13037 );
13038 assert_eq!(outcome.materialized_messages.len(), 2);
13040
13041 let messages = session.messages();
13044 let assistants: Vec<&BlockAssistantMessage> = messages
13045 .iter()
13046 .filter_map(|m| match m {
13047 Message::BlockAssistant(a) => Some(a),
13048 _ => None,
13049 })
13050 .collect();
13051 assert_eq!(
13052 assistants.len(),
13053 1,
13054 "mixed display+spoken response under one response_id must produce exactly ONE BlockAssistant message, got: {assistants:?}"
13055 );
13056 let assistant = assistants[0];
13057 assert_eq!(
13058 assistant.blocks.len(),
13059 2,
13060 "mixed response message must carry both blocks: {:?}",
13061 assistant.blocks
13062 );
13063
13064 match &assistant.blocks[0] {
13066 AssistantBlock::Text { text, .. } => {
13067 assert_eq!(text, "Here's the report: (still writing)");
13068 }
13069 other => unreachable!(
13070 "first block must be AssistantBlock::Text (display lane), got {other:?}"
13071 ),
13072 }
13073 match &assistant.blocks[1] {
13075 AssistantBlock::Transcript { text, source, .. } => {
13076 assert_eq!(text, "I'm reading the report aloud: sentence two.");
13077 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
13078 }
13079 other => unreachable!(
13080 "second block must be AssistantBlock::Transcript {{ source: Spoken }}, got {other:?}"
13081 ),
13082 }
13083
13084 assert_eq!(session.usage.input_tokens, 11);
13086 assert_eq!(session.usage.output_tokens, 22);
13087 }
13088
13089 #[test]
13090 fn round5_r55_mixed_response_barge_in_preserves_display_drops_spoken() {
13091 let mut session = Session::new();
13107
13108 let display = RealtimeTranscriptEvent::AssistantTextDelta {
13109 response_id: "resp_mixed_2".to_string(),
13110 delta_id: "delta_disp_1".to_string(),
13111 item_id: "item_display_2".to_string(),
13112 previous_item_id: None,
13113 content_index: 0,
13114 delta: "Working on the report...".to_string(),
13115 };
13116 let _ = session.append_realtime_transcript_event(display);
13117
13118 let spoken = RealtimeTranscriptEvent::AssistantTranscriptDelta {
13119 response_id: "resp_mixed_2".to_string(),
13120 delta_id: "delta_spoken_1".to_string(),
13121 item_id: "item_spoken_2".to_string(),
13122 previous_item_id: Some("item_display_2".to_string()),
13123 content_index: 0,
13124 delta: "I'm reading the report".to_string(),
13125 };
13126 let _ = session.append_realtime_transcript_event(spoken);
13127
13128 let outcome = session.append_realtime_transcript_event(
13132 RealtimeTranscriptEvent::AssistantTurnInterrupted {
13133 response_id: "resp_mixed_2".to_string(),
13134 },
13135 );
13136 assert_eq!(
13137 outcome.materialized_messages.len(),
13138 1,
13139 "Display lane item must materialize on Interrupted: {outcome:?}"
13140 );
13141
13142 let late_completion = session.append_realtime_transcript_event(
13146 RealtimeTranscriptEvent::AssistantTurnCompleted {
13147 response_id: "resp_mixed_2".to_string(),
13148 stop_reason: StopReason::Cancelled,
13149 usage: Usage::default(),
13150 },
13151 );
13152 assert_eq!(
13153 late_completion.materialized_messages.len(),
13154 0,
13155 "post-barge-in TurnCompleted must not resurrect anything"
13156 );
13157
13158 let messages = session.messages();
13161 let assistants: Vec<&BlockAssistantMessage> = messages
13162 .iter()
13163 .filter_map(|m| match m {
13164 Message::BlockAssistant(a) => Some(a),
13165 _ => None,
13166 })
13167 .collect();
13168 assert_eq!(
13169 assistants.len(),
13170 1,
13171 "barge-in must commit exactly one BlockAssistant containing the Display lane: {assistants:?}"
13172 );
13173 let assistant = assistants[0];
13174 assert_eq!(assistant.blocks.len(), 1, "blocks: {:?}", assistant.blocks);
13175 match &assistant.blocks[0] {
13176 AssistantBlock::Text { text, .. } => {
13177 assert_eq!(text, "Working on the report...");
13178 }
13179 other => {
13180 unreachable!("Display lane must materialize as AssistantBlock::Text, got {other:?}")
13181 }
13182 }
13183 assert!(
13185 !assistant
13186 .blocks
13187 .iter()
13188 .any(|b| matches!(b, AssistantBlock::Transcript { .. })),
13189 "Spoken lane must be dropped on barge-in"
13190 );
13191
13192 assert!(
13195 !session
13196 .in_flight_realtime_assistant_response_ids()
13197 .contains(&"resp_mixed_2".to_string()),
13198 "barged-in response must not appear in in_flight_realtime_assistant_response_ids"
13199 );
13200 }
13201
13202 #[test]
13203 fn round5_r55_barge_in_preserves_display_lane_drops_spoken() {
13204 let mut session = Session::new();
13208
13209 let _ =
13210 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
13211 response_id: "resp_a".to_string(),
13212 delta_id: "delta_d_1".to_string(),
13213 item_id: "item_display".to_string(),
13214 previous_item_id: None,
13215 content_index: 0,
13216 delta: "display-text".to_string(),
13217 });
13218 let _ = session.append_realtime_transcript_event(
13219 RealtimeTranscriptEvent::AssistantTranscriptDelta {
13220 response_id: "resp_a".to_string(),
13221 delta_id: "delta_s_1".to_string(),
13222 item_id: "item_spoken".to_string(),
13223 previous_item_id: None,
13224 content_index: 0,
13225 delta: "spoken-transcript".to_string(),
13226 },
13227 );
13228
13229 let outcome = session.append_realtime_transcript_event(
13230 RealtimeTranscriptEvent::AssistantTurnInterrupted {
13231 response_id: "resp_a".to_string(),
13232 },
13233 );
13234 assert_eq!(outcome.materialized_messages.len(), 1);
13236
13237 let messages = session.messages();
13238 let assistants: Vec<&BlockAssistantMessage> = messages
13239 .iter()
13240 .filter_map(|m| match m {
13241 Message::BlockAssistant(a) => Some(a),
13242 _ => None,
13243 })
13244 .collect();
13245 assert_eq!(assistants.len(), 1);
13246 assert_eq!(assistants[0].blocks.len(), 1);
13248 match &assistants[0].blocks[0] {
13249 AssistantBlock::Text { text, .. } => assert_eq!(text, "display-text"),
13250 other => unreachable!("expected Text, got {other:?}"),
13251 }
13252 }
13253
13254 #[test]
13255 fn round5_r55_barge_in_finalizes_retained_display_into_committed_block() {
13256 let mut session = Session::new();
13261
13262 let _ =
13263 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
13264 response_id: "resp_a".to_string(),
13265 delta_id: "delta_d_1".to_string(),
13266 item_id: "item_display".to_string(),
13267 previous_item_id: None,
13268 content_index: 0,
13269 delta: "committed-display-text".to_string(),
13270 });
13271
13272 assert!(session.messages().is_empty());
13274
13275 let outcome = session.append_realtime_transcript_event(
13276 RealtimeTranscriptEvent::AssistantTurnInterrupted {
13277 response_id: "resp_a".to_string(),
13278 },
13279 );
13280 assert_eq!(
13281 outcome.materialized_messages.len(),
13282 1,
13283 "Interrupted must finalize retained Display lane immediately"
13284 );
13285
13286 let messages = session.messages();
13288 assert_eq!(messages.len(), 1);
13289 match &messages[0] {
13290 Message::BlockAssistant(assistant) => {
13291 assert_eq!(assistant.blocks.len(), 1);
13292 match &assistant.blocks[0] {
13293 AssistantBlock::Text { text, .. } => {
13294 assert_eq!(text, "committed-display-text");
13295 }
13296 other => unreachable!("expected Text, got {other:?}"),
13297 }
13298 }
13299 other => unreachable!("expected BlockAssistant, got {other:?}"),
13300 }
13301 }
13302
13303 #[test]
13304 fn round5_r56_truncation_promotes_default_lane_item_to_spoken() {
13305 let mut session = Session::new();
13312
13313 let _ = session.append_realtime_transcript_event(
13314 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
13315 response_id: "resp_a".to_string(),
13316 item_id: "item_a".to_string(),
13317 content_index: 0,
13318 text: "what was actually heard".to_string(),
13319 },
13320 );
13321
13322 let outcome = session.append_realtime_transcript_event(
13323 RealtimeTranscriptEvent::AssistantTurnCompleted {
13324 response_id: "resp_a".to_string(),
13325 stop_reason: StopReason::EndTurn,
13326 usage: Usage::default(),
13327 },
13328 );
13329 assert_eq!(outcome.materialized_messages.len(), 1);
13330
13331 assert_eq!(session.messages().len(), 1);
13332 match &session.messages()[0] {
13333 Message::BlockAssistant(assistant) => {
13334 assert_eq!(assistant.blocks.len(), 1);
13335 match &assistant.blocks[0] {
13336 AssistantBlock::Transcript { text, source, .. } => {
13337 assert_eq!(text, "what was actually heard");
13338 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
13339 }
13340 other => unreachable!(
13341 "truncation-only path must materialize as AssistantBlock::Transcript, got {other:?}"
13342 ),
13343 }
13344 }
13345 other => unreachable!("expected BlockAssistant, got {other:?}"),
13346 }
13347 }
13348
13349 #[test]
13350 fn round5_r56_truncation_after_display_delta_is_no_op_keeping_display_content() {
13351 let mut session = Session::new();
13359
13360 let _ =
13361 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
13362 response_id: "resp_a".to_string(),
13363 delta_id: "delta_d_1".to_string(),
13364 item_id: "item_a".to_string(),
13365 previous_item_id: None,
13366 content_index: 0,
13367 delta: "display-text-from-delta".to_string(),
13368 });
13369
13370 let _ = session.append_realtime_transcript_event(
13371 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
13372 response_id: "resp_a".to_string(),
13373 item_id: "item_a".to_string(),
13374 content_index: 0,
13375 text: "spoken-truncation-text".to_string(),
13376 },
13377 );
13378
13379 let _ = session.append_realtime_transcript_event(
13380 RealtimeTranscriptEvent::AssistantTurnCompleted {
13381 response_id: "resp_a".to_string(),
13382 stop_reason: StopReason::EndTurn,
13383 usage: Usage::default(),
13384 },
13385 );
13386
13387 assert_eq!(session.messages().len(), 1);
13390 match &session.messages()[0] {
13391 Message::BlockAssistant(assistant) => {
13392 assert_eq!(assistant.blocks.len(), 1);
13393 match &assistant.blocks[0] {
13394 AssistantBlock::Text { text, .. } => {
13395 assert_eq!(text, "display-text-from-delta");
13396 }
13397 other => unreachable!(
13398 "Display content must survive misrouted truncation, got {other:?}"
13399 ),
13400 }
13401 }
13402 other => unreachable!("expected BlockAssistant, got {other:?}"),
13403 }
13404 }
13405
13406 #[test]
13414 fn round5_r56_sibling_display_delta_skipped_on_spoken_item() {
13415 let mut session = Session::new();
13416
13417 let _ = session.append_realtime_transcript_event(
13419 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
13420 response_id: "resp_a".to_string(),
13421 item_id: "item_a".to_string(),
13422 content_index: 0,
13423 text: "what was actually heard".to_string(),
13424 },
13425 );
13426
13427 let _ =
13430 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
13431 response_id: "resp_a".to_string(),
13432 delta_id: "delta_d_1".to_string(),
13433 item_id: "item_a".to_string(),
13434 previous_item_id: None,
13435 content_index: 0,
13436 delta: "should-not-appear".to_string(),
13437 });
13438
13439 let _ = session.append_realtime_transcript_event(
13440 RealtimeTranscriptEvent::AssistantTurnCompleted {
13441 response_id: "resp_a".to_string(),
13442 stop_reason: StopReason::EndTurn,
13443 usage: Usage::default(),
13444 },
13445 );
13446
13447 assert_eq!(session.messages().len(), 1);
13450 match &session.messages()[0] {
13451 Message::BlockAssistant(assistant) => {
13452 assert_eq!(assistant.blocks.len(), 1);
13453 match &assistant.blocks[0] {
13454 AssistantBlock::Transcript { text, source, .. } => {
13455 assert_eq!(text, "what was actually heard");
13456 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
13457 }
13458 other => unreachable!(
13459 "Spoken-locked item must materialize as Transcript, got {other:?}"
13460 ),
13461 }
13462 }
13463 other => unreachable!("expected BlockAssistant, got {other:?}"),
13464 }
13465 }
13466
13467 #[test]
13474 fn round5_r56_sibling_spoken_delta_skipped_on_display_item() {
13475 let mut session = Session::new();
13476
13477 let _ =
13479 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
13480 response_id: "resp_a".to_string(),
13481 delta_id: "delta_d_1".to_string(),
13482 item_id: "item_a".to_string(),
13483 previous_item_id: None,
13484 content_index: 0,
13485 delta: "display-locked-text".to_string(),
13486 });
13487
13488 let _ = session.append_realtime_transcript_event(
13491 RealtimeTranscriptEvent::AssistantTranscriptDelta {
13492 response_id: "resp_a".to_string(),
13493 delta_id: "delta_s_1".to_string(),
13494 item_id: "item_a".to_string(),
13495 previous_item_id: None,
13496 content_index: 0,
13497 delta: "should-not-appear".to_string(),
13498 },
13499 );
13500
13501 let _ = session.append_realtime_transcript_event(
13502 RealtimeTranscriptEvent::AssistantTurnCompleted {
13503 response_id: "resp_a".to_string(),
13504 stop_reason: StopReason::EndTurn,
13505 usage: Usage::default(),
13506 },
13507 );
13508
13509 assert_eq!(session.messages().len(), 1);
13511 match &session.messages()[0] {
13512 Message::BlockAssistant(assistant) => {
13513 assert_eq!(assistant.blocks.len(), 1);
13514 match &assistant.blocks[0] {
13515 AssistantBlock::Text { text, .. } => {
13516 assert_eq!(text, "display-locked-text");
13517 }
13518 other => {
13519 unreachable!("Display-locked item must materialize as Text, got {other:?}")
13520 }
13521 }
13522 }
13523 other => unreachable!("expected BlockAssistant, got {other:?}"),
13524 }
13525 }
13526
13527 #[test]
13535 fn round5_r57_late_final_text_after_turn_completed_warns_and_skips() {
13536 let mut session = Session::new();
13537
13538 let _ = session.append_realtime_transcript_event(
13540 RealtimeTranscriptEvent::AssistantTranscriptDelta {
13541 response_id: "resp_a".to_string(),
13542 delta_id: "delta_s_1".to_string(),
13543 item_id: "item_a".to_string(),
13544 previous_item_id: None,
13545 content_index: 0,
13546 delta: "delta-accumulated".to_string(),
13547 },
13548 );
13549
13550 let commit_outcome = session.append_realtime_transcript_event(
13552 RealtimeTranscriptEvent::AssistantTurnCompleted {
13553 response_id: "resp_a".to_string(),
13554 stop_reason: StopReason::EndTurn,
13555 usage: Usage::default(),
13556 },
13557 );
13558 assert_eq!(commit_outcome.materialized_messages.len(), 1);
13559
13560 let late_outcome = session.append_realtime_transcript_event(
13564 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
13565 response_id: "resp_a".to_string(),
13566 item_id: "item_a".to_string(),
13567 content_index: 0,
13568 text: "authoritative-final-that-must-not-land".to_string(),
13569 },
13570 );
13571 assert!(
13572 late_outcome.is_inert(),
13573 "late FinalText after materialization must produce inert outcome"
13574 );
13575
13576 assert_eq!(session.messages().len(), 1);
13579 match &session.messages()[0] {
13580 Message::BlockAssistant(assistant) => {
13581 assert_eq!(assistant.blocks.len(), 1);
13582 match &assistant.blocks[0] {
13583 AssistantBlock::Transcript { text, .. } => {
13584 assert_eq!(
13585 text, "delta-accumulated",
13586 "canonical message must preserve delta-accumulated text; \
13587 append-only history forbids late FinalText repair"
13588 );
13589 }
13590 other => unreachable!("expected Transcript, got {other:?}"),
13591 }
13592 }
13593 other => unreachable!("expected BlockAssistant, got {other:?}"),
13594 }
13595 }
13596
13597 fn metadata_seam_session_metadata() -> SessionMetadata {
13598 SessionMetadata {
13599 schema_version: SESSION_METADATA_SCHEMA_VERSION,
13600 model: "test-model".to_string(),
13601 max_tokens: 1024,
13602 structured_output_retries: 2,
13603 provider: Provider::Anthropic,
13604 self_hosted_server_id: None,
13605 provider_params: None,
13606 tooling: SessionTooling::default(),
13607 keep_alive: false,
13608 comms_name: Some("team/reviewer/alice".to_string()),
13609 peer_meta: None,
13610 realm_id: None,
13611 instance_id: None,
13612 backend: None,
13613 config_generation: None,
13614 auth_binding: None,
13615 mob_member_binding: Some(crate::MobMemberBinding {
13616 mob_id: "team".to_string(),
13617 role: "reviewer".to_string(),
13618 member: "alice".to_string(),
13619 }),
13620 }
13621 }
13622
13623 #[test]
13628 fn session_metadata_document_lockstep_with_full_envelope() {
13629 let mut session = Session::new();
13630 session.push(Message::User(UserMessage::text("hello".to_string())));
13631 session
13632 .set_session_metadata(metadata_seam_session_metadata())
13633 .expect("session metadata should persist");
13634 session
13635 .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
13636 .expect("lifecycle terminal should persist");
13637
13638 let bytes = serde_json::to_vec(&session).expect("session should serialize");
13639 let document = session_metadata_document_from_slice(&bytes)
13640 .expect("partial decode must accept the canonical envelope");
13641
13642 assert_eq!(document.session_id(), session.id());
13643 assert_eq!(
13644 document.session_metadata_value(),
13645 session.metadata().get(SESSION_METADATA_KEY),
13646 "partial decode must project the identical raw session-metadata value"
13647 );
13648 assert_eq!(
13649 document.lifecycle_terminal_value(),
13650 session.metadata().get(SESSION_LIFECYCLE_TERMINAL_KEY),
13651 "partial decode must project the identical raw lifecycle-terminal value"
13652 );
13653
13654 let view = document
13655 .try_into_view()
13656 .expect("typed view must decode from the partial document");
13657 let full_view =
13658 PersistedSessionMetadataView::try_from_session(&session).expect("full-session view");
13659 assert_eq!(view.session_id, full_view.session_id);
13660 assert_eq!(
13661 view.session_metadata.as_ref().map(|m| m.model.clone()),
13662 full_view.session_metadata.as_ref().map(|m| m.model.clone())
13663 );
13664 assert_eq!(
13665 view.mob_member_binding(),
13666 full_view.mob_member_binding(),
13667 "typed binding must be identical across the two decode paths"
13668 );
13669 assert_eq!(
13670 view.lifecycle_terminal,
13671 Some(SessionLifecycleTerminal::Archived)
13672 );
13673 assert_eq!(
13674 full_view.lifecycle_terminal,
13675 Some(SessionLifecycleTerminal::Archived)
13676 );
13677 }
13678
13679 #[test]
13682 fn session_metadata_document_fails_closed_on_envelope_version() {
13683 let session = Session::new();
13684 let mut value = serde_json::to_value(&session).expect("session should serialize");
13685 value["version"] = serde_json::json!(SESSION_VERSION + 999);
13686 let bytes = serde_json::to_vec(&value).expect("mangled envelope should serialize");
13687
13688 session_metadata_document_from_slice(&bytes)
13689 .expect_err("an unsupported envelope version must fail the partial decode closed");
13690 }
13691
13692 #[test]
13695 fn persisted_session_metadata_view_fails_closed_on_corrupt_values() {
13696 let session_id = SessionId::new();
13697
13698 let mut corrupt_metadata = serde_json::Map::new();
13699 corrupt_metadata.insert(SESSION_METADATA_KEY.to_string(), serde_json::json!(42));
13700 PersistedSessionMetadataView::try_from_metadata_map(session_id.clone(), &corrupt_metadata)
13701 .expect_err("corrupt session_metadata must fail the view decode closed");
13702
13703 let mut corrupt_terminal = serde_json::Map::new();
13704 corrupt_terminal.insert(
13705 SESSION_LIFECYCLE_TERMINAL_KEY.to_string(),
13706 serde_json::json!("definitely-not-a-terminal"),
13707 );
13708 PersistedSessionMetadataView::try_from_metadata_map(session_id, &corrupt_terminal)
13709 .expect_err("corrupt lifecycle terminal must fail the view decode closed");
13710 }
13711
13712 #[test]
13714 fn persisted_session_metadata_view_reads_absent_facts_as_none() {
13715 let view = PersistedSessionMetadataView::try_from_metadata_map(
13716 SessionId::new(),
13717 &serde_json::Map::new(),
13718 )
13719 .expect("empty metadata map must decode");
13720 assert!(view.session_metadata.is_none());
13721 assert!(view.lifecycle_terminal.is_none());
13722 assert!(view.mob_member_binding().is_none());
13723 }
13724}