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