1use crate::Provider;
13use crate::generated::{session_document, session_persistence_version_authority};
14use crate::lifecycle::run_primitive::TurnMetadataOverride;
15use crate::peer_meta::PeerMeta;
16use crate::realtime_transcript::{
17 RealtimeTranscriptApplyOutcome, RealtimeTranscriptEvent, RealtimeUserContentIdentity,
18 SESSION_REALTIME_TRANSCRIPT_STATE_KEY,
19};
20use crate::realtime_transcript_revision::{self, SessionRealtimeTranscriptState};
21use crate::service::{AppendSystemContextRequest, MobToolAuthorityContext};
22use crate::session_durable_config_authority;
23use crate::time_compat::SystemTime;
24use crate::tool_scope::ToolFilter;
25use crate::types::{
26 AssistantBlock, BlockAssistantMessage, ContentBlock, ContentInput, Message, SessionId,
27 StopReason, ToolDef, ToolName, ToolProvenance, ToolResult, Usage, UserMessage,
28};
29use serde::{Deserialize, Deserializer, Serialize, Serializer};
30use sha2::{Digest, Sha256};
31use std::collections::{BTreeMap, BTreeSet, HashMap};
32use std::sync::Arc;
33
34pub use crate::generated::session_persistence_version_authority::SESSION_VERSION;
41
42pub use crate::generated::session_persistence_version_authority::SESSION_METADATA_SCHEMA_VERSION;
47
48pub fn session_version() -> u32 {
50 session_persistence_version_authority::session_envelope_version()
51}
52
53pub fn session_metadata_schema_version() -> u32 {
55 session_persistence_version_authority::session_metadata_schema_version()
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(tag = "type", rename_all = "snake_case")]
64pub enum TranscriptReplacement {
65 Message { message: Message },
67 UserContentBlock {
69 block_index: usize,
70 block: ContentBlock,
71 },
72 AssistantBlock {
74 block_index: usize,
75 block: AssistantBlock,
76 },
77 ToolResultContentBlock {
79 result_index: usize,
80 block_index: usize,
81 block: ContentBlock,
82 },
83}
84
85pub const SESSION_TRANSCRIPT_HISTORY_STATE_KEY: &str = "session_transcript_history_state_v1";
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
91#[serde(tag = "type", rename_all = "snake_case")]
92pub enum TranscriptRewriteSelection {
93 MessageRange { start: usize, end: usize },
97 EditMessageRange { range: TranscriptEditRewriteRange },
99 CompactionMessageRange { range: CompactionRewriteRange },
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
110pub struct TranscriptEditRewriteRange {
111 start: usize,
112 end: usize,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
117#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
118pub struct CompactionRewriteRange {
119 start: usize,
120 end: usize,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum TranscriptRewriteSemantic {
126 Edit,
128 Compaction,
130}
131
132impl TranscriptRewriteSelection {
133 pub fn bounds(&self) -> (usize, usize) {
136 match self {
137 Self::MessageRange { start, end } => (*start, *end),
138 Self::EditMessageRange { range } => (range.start, range.end),
139 Self::CompactionMessageRange { range } => (range.start, range.end),
140 }
141 }
142
143 pub fn semantic(&self) -> TranscriptRewriteSemantic {
144 match self {
145 Self::MessageRange { .. } | Self::EditMessageRange { .. } => {
146 TranscriptRewriteSemantic::Edit
147 }
148 Self::CompactionMessageRange { .. } => TranscriptRewriteSemantic::Compaction,
149 }
150 }
151
152 fn into_current_edit_semantic(self) -> Self {
153 match self {
154 Self::MessageRange { start, end } => Self::EditMessageRange {
155 range: TranscriptEditRewriteRange { start, end },
156 },
157 current => current,
158 }
159 }
160
161 fn is_legacy_untyped(&self) -> bool {
162 matches!(self, Self::MessageRange { .. })
163 }
164
165 fn validated_compaction(
166 start: usize,
167 end: usize,
168 _authority: &crate::agent::compact::ValidatedCompactionRewrite,
169 ) -> Self {
170 Self::CompactionMessageRange {
171 range: CompactionRewriteRange { start, end },
172 }
173 }
174
175 fn migrated_legacy_compaction(start: usize, end: usize) -> Self {
176 Self::CompactionMessageRange {
177 range: CompactionRewriteRange { start, end },
178 }
179 }
180
181 #[cfg(test)]
182 pub(crate) fn typed_compaction_for_test(start: usize, end: usize) -> Self {
183 Self::CompactionMessageRange {
184 range: CompactionRewriteRange { start, end },
185 }
186 }
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
195#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
196#[serde(rename_all = "snake_case")]
197pub struct TranscriptRewriteReason {
198 pub kind: String,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub note: Option<String>,
201}
202
203impl TranscriptRewriteReason {
204 pub fn new(kind: impl Into<String>) -> Self {
205 Self {
206 kind: kind.into(),
207 note: None,
208 }
209 }
210}
211
212pub const RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON: &str = "resume-system-prompt-refresh";
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum ResumedSystemPromptReconciliation {
219 PreservedContinuation,
224 RewrittenBase,
228 NoChange,
231}
232
233impl std::fmt::Display for TranscriptRewriteReason {
234 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238 match &self.note {
239 Some(note) => write!(f, "{}: {note}", self.kind),
240 None => f.write_str(&self.kind),
241 }
242 }
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
247#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
248#[serde(rename_all = "snake_case")]
249pub struct TranscriptRewriteCommit {
250 pub parent_revision: String,
251 pub revision: String,
252 pub selection: TranscriptRewriteSelection,
253 pub original_span_digest: String,
254 pub replacement_digest: String,
255 pub messages_before: usize,
256 pub messages_after: usize,
257 pub reason: TranscriptRewriteReason,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub actor: Option<String>,
260 #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
261 pub committed_at: SystemTime,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
266#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
267#[serde(rename_all = "snake_case")]
268pub struct TranscriptRevisionBody {
269 pub revision: String,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub parent_revision: Option<String>,
272 #[cfg_attr(feature = "schema", schemars(with = "Vec<serde_json::Value>"))]
273 pub messages: Vec<Message>,
274 #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
275 pub created_at: SystemTime,
276}
277
278#[cfg(feature = "schema")]
279#[allow(dead_code)]
280#[derive(schemars::JsonSchema)]
281#[schemars(rename = "SystemTime")]
282struct SchemaSystemTime {
283 secs_since_epoch: u64,
284 nanos_since_epoch: u32,
285}
286
287#[derive(Debug, Clone, Serialize)]
289#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
290#[serde(rename_all = "snake_case")]
291pub struct TranscriptRewriteRecord {
292 pub commit: TranscriptRewriteCommit,
293 pub parent_body: TranscriptRevisionBody,
294 pub revision_body: TranscriptRevisionBody,
295}
296
297impl<'de> Deserialize<'de> for TranscriptRewriteRecord {
298 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
299 where
300 D: Deserializer<'de>,
301 {
302 #[derive(Deserialize)]
303 #[serde(rename_all = "snake_case")]
304 struct Wire {
305 commit: TranscriptRewriteCommit,
306 parent_body: TranscriptRevisionBody,
307 revision_body: TranscriptRevisionBody,
308 }
309 let wire = Wire::deserialize(deserializer)?;
310 let mut revisions = vec![wire.parent_body, wire.revision_body];
311 let mut commits = vec![wire.commit];
312 heal_legacy_revision_strings(&mut revisions, &mut commits, None)
313 .map_err(serde::de::Error::custom)?;
314 heal_legacy_compaction_rewrite_semantics(&mut commits, &revisions);
315 let mut revisions = revisions.into_iter();
316 let parent_body = revisions
317 .next()
318 .ok_or_else(|| serde::de::Error::custom("rewrite record lost its parent body"))?;
319 let revision_body = revisions
320 .next()
321 .ok_or_else(|| serde::de::Error::custom("rewrite record lost its revision body"))?;
322 let commit = commits
323 .into_iter()
324 .next()
325 .ok_or_else(|| serde::de::Error::custom("rewrite record lost its commit"))?;
326 Ok(Self {
327 commit,
328 parent_body,
329 revision_body,
330 })
331 }
332}
333
334impl TranscriptRewriteRecord {
335 pub fn new(
336 commit: TranscriptRewriteCommit,
337 parent_body: TranscriptRevisionBody,
338 revision_body: TranscriptRevisionBody,
339 ) -> Result<Self, TranscriptEditError> {
340 validate_transcript_rewrite_record(&commit, &parent_body, &revision_body)?;
341 Ok(Self {
342 commit,
343 parent_body,
344 revision_body,
345 })
346 }
347}
348
349#[derive(Debug, Clone, Serialize)]
351#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
352#[serde(rename_all = "snake_case")]
353pub struct TranscriptHistoryState {
354 pub head: String,
355 #[serde(default, skip_serializing_if = "Vec::is_empty")]
356 pub commits: Vec<TranscriptRewriteCommit>,
357 #[serde(default, skip_serializing_if = "Vec::is_empty")]
358 pub revisions: Vec<TranscriptRevisionBody>,
359}
360
361impl<'de> Deserialize<'de> for TranscriptHistoryState {
362 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
363 where
364 D: Deserializer<'de>,
365 {
366 #[derive(Deserialize)]
367 #[serde(rename_all = "snake_case")]
368 struct Wire {
369 head: String,
370 #[serde(default)]
371 commits: Vec<TranscriptRewriteCommit>,
372 #[serde(default)]
373 revisions: Vec<TranscriptRevisionBody>,
374 }
375 let wire = Wire::deserialize(deserializer)?;
376 let mut state = TranscriptHistoryState {
377 head: wire.head,
378 commits: wire.commits,
379 revisions: wire.revisions,
380 };
381 if state.revisions.len() > 1
387 && state
388 .revisions
389 .iter()
390 .skip(1)
391 .all(|body| body.parent_revision.is_none())
392 {
393 for index in 1..state.revisions.len() {
394 let parent = state.revisions[index - 1].revision.clone();
395 state.revisions[index].parent_revision = Some(parent);
396 }
397 }
398 let head_is_current = match state
401 .revisions
402 .iter()
403 .find(|body| body.revision == state.head)
404 {
405 Some(head_body) => {
406 transcript_messages_digest(&head_body.messages).map_err(serde::de::Error::custom)?
407 == state.head
408 }
409 None => true,
410 };
411 if !head_is_current {
412 let TranscriptHistoryState {
413 head,
414 commits,
415 revisions,
416 } = &mut state;
417 heal_legacy_revision_strings(revisions, commits, Some(head))
418 .map_err(serde::de::Error::custom)?;
419 }
420 heal_legacy_compaction_rewrite_semantics(&mut state.commits, &state.revisions);
421 Ok(state)
422 }
423}
424
425impl TranscriptHistoryState {
426 fn compact_mechanical_revision_bodies(&mut self) -> Result<(), TranscriptEditError> {
436 validate_transcript_history_state(self)?;
437
438 let mut retained = BTreeSet::from([self.head.clone()]);
439 for commit in &self.commits {
440 retained.insert(commit.parent_revision.clone());
441 retained.insert(commit.revision.clone());
442 }
443
444 let head_is_audited_endpoint = self
445 .commits
446 .iter()
447 .any(|commit| commit.parent_revision == self.head || commit.revision == self.head);
448 if !head_is_audited_endpoint
449 && let Some(last_commit) = self
450 .commits
451 .last()
452 .filter(|commit| commit.revision != self.head)
453 && let Some(head_body) = self
454 .revisions
455 .iter_mut()
456 .find(|body| body.revision == self.head)
457 {
458 head_body.parent_revision = Some(last_commit.revision.clone());
459 }
460
461 let mut seen = BTreeSet::new();
462 self.revisions
463 .retain(|body| retained.contains(&body.revision) && seen.insert(body.revision.clone()));
464
465 validate_transcript_history_state(self)
466 }
467}
468
469fn heal_legacy_revision_strings(
478 revisions: &mut [TranscriptRevisionBody],
479 commits: &mut [TranscriptRewriteCommit],
480 head: Option<&mut String>,
481) -> Result<(), serde_json::Error> {
482 let mut remap: BTreeMap<String, String> = BTreeMap::new();
483 for body in revisions.iter() {
484 let content = transcript_messages_digest(&body.messages)?;
485 if body.revision == content {
486 continue;
487 }
488 if body.revision == legacy_transcript_messages_digest(&body.messages)? {
489 remap.insert(body.revision.clone(), content);
490 }
491 }
492 if remap.is_empty() {
493 return Ok(());
494 }
495 for body in revisions.iter_mut() {
496 if let Some(current) = remap.get(&body.revision) {
497 body.revision = current.clone();
498 }
499 if let Some(parent) = body.parent_revision.as_ref()
500 && let Some(current) = remap.get(parent)
501 {
502 body.parent_revision = Some(current.clone());
503 }
504 }
505 for commit in commits.iter_mut() {
506 if let Some(current) = remap.get(&commit.parent_revision) {
507 commit.parent_revision = current.clone();
508 }
509 if let Some(current) = remap.get(&commit.revision) {
510 commit.revision = current.clone();
511 }
512 heal_legacy_commit_span_digests(commit, revisions)?;
513 }
514 if let Some(head) = head
515 && let Some(current) = remap.get(head.as_str())
516 {
517 *head = current.clone();
518 }
519 Ok(())
520}
521
522fn heal_legacy_commit_span_digests(
528 commit: &mut TranscriptRewriteCommit,
529 revisions: &[TranscriptRevisionBody],
530) -> Result<(), serde_json::Error> {
531 let Some(parent_body) = revisions
532 .iter()
533 .find(|body| body.revision == commit.parent_revision)
534 else {
535 return Ok(());
536 };
537 let Some(revision_body) = revisions
538 .iter()
539 .find(|body| body.revision == commit.revision)
540 else {
541 return Ok(());
542 };
543 let (start, end) = commit.selection.bounds();
544 if start > end || end > parent_body.messages.len() {
545 return Ok(());
546 }
547 let removed_len = end - start;
548 let Some(retained_len) = commit.messages_before.checked_sub(removed_len) else {
549 return Ok(());
550 };
551 let Some(replacement_len) = commit.messages_after.checked_sub(retained_len) else {
552 return Ok(());
553 };
554 let Some(replacement_end) = start.checked_add(replacement_len) else {
555 return Ok(());
556 };
557 if replacement_end > revision_body.messages.len() {
558 return Ok(());
559 }
560 let original_span = &parent_body.messages[start..end];
561 if commit.original_span_digest == legacy_transcript_messages_digest(original_span)? {
562 commit.original_span_digest = transcript_messages_digest(original_span)?;
563 }
564 let replacement_span = &revision_body.messages[start..replacement_end];
565 if commit.replacement_digest == legacy_transcript_messages_digest(replacement_span)? {
566 commit.replacement_digest = transcript_messages_digest(replacement_span)?;
567 }
568 Ok(())
569}
570
571fn heal_legacy_compaction_rewrite_semantics(
580 commits: &mut [TranscriptRewriteCommit],
581 revisions: &[TranscriptRevisionBody],
582) {
583 for commit in commits {
584 if !commit.selection.is_legacy_untyped() {
585 continue;
586 }
587 let (start, end) = commit.selection.bounds();
588 if start != 0
589 || end != commit.messages_before
590 || commit.messages_after >= commit.messages_before
591 {
592 continue;
593 }
594 let Some(parent) = revisions
595 .iter()
596 .find(|body| body.revision == commit.parent_revision)
597 else {
598 continue;
599 };
600 let Some(revision) = revisions
601 .iter()
602 .find(|body| body.revision == commit.revision)
603 else {
604 continue;
605 };
606 if parent.messages.len() != commit.messages_before
607 || revision.messages.len() != commit.messages_after
608 {
609 continue;
610 }
611 let summary_count = revision
612 .messages
613 .iter()
614 .filter(|message| {
615 matches!(message, Message::User(user) if user.transcript_role.is_compaction_summary())
616 })
617 .count();
618 if summary_count == 1 {
619 commit.selection = TranscriptRewriteSelection::migrated_legacy_compaction(start, end);
620 }
621 }
622}
623
624impl TranscriptHistoryState {
625 pub fn from_rewrite_records<I>(records: I) -> Result<Option<Self>, TranscriptEditError>
627 where
628 I: IntoIterator<Item = TranscriptRewriteRecord>,
629 {
630 let mut state: Option<Self> = None;
631 for record in records {
632 validate_transcript_rewrite_record(
633 &record.commit,
634 &record.parent_body,
635 &record.revision_body,
636 )?;
637 let state = state.get_or_insert_with(|| Self {
638 head: record.commit.parent_revision.clone(),
639 commits: Vec::new(),
640 revisions: Vec::new(),
641 });
642 if record.commit.parent_revision != state.head {
643 if revision_body_extends_head(&record.parent_body, &state.revisions, &state.head)? {
644 state.head = record.commit.parent_revision.clone();
645 } else {
646 return Err(TranscriptEditError::HistoryStateMalformed(format!(
647 "rewrite record parent {} does not extend transcript head {}",
648 record.commit.parent_revision, state.head
649 )));
650 }
651 }
652 if !state
653 .revisions
654 .iter()
655 .any(|body| body.revision == record.parent_body.revision)
656 {
657 state.revisions.push(record.parent_body);
658 }
659 if !state
660 .revisions
661 .iter()
662 .any(|body| body.revision == record.revision_body.revision)
663 {
664 state.revisions.push(record.revision_body);
665 }
666 state.head = record.commit.revision.clone();
667 state.commits.push(record.commit);
668 }
669 Ok(state)
670 }
671}
672
673#[derive(Debug, Clone, thiserror::Error)]
675pub enum TranscriptEditError {
676 #[error("message index {message_index} out of bounds for {message_count} messages")]
677 MessageIndexOutOfBounds {
678 message_index: usize,
679 message_count: usize,
680 },
681 #[error("{block_kind} index {block_index} out of bounds for {block_count} blocks")]
682 BlockIndexOutOfBounds {
683 block_kind: &'static str,
684 block_index: usize,
685 block_count: usize,
686 },
687 #[error("replacement expected {expected} at message index {message_index}, found {actual}")]
688 MessageRoleMismatch {
689 message_index: usize,
690 expected: &'static str,
691 actual: &'static str,
692 },
693 #[error("invalid transcript rewrite range {start}..{end} for {message_count} messages")]
694 InvalidRewriteRange {
695 start: usize,
696 end: usize,
697 message_count: usize,
698 },
699 #[error("transcript rewrite does not change transcript revision {revision}")]
700 NoOpRewrite { revision: String },
701 #[error("transcript rewrite parent revision mismatch: expected {expected}, actual {actual}")]
702 RevisionConflict { expected: String, actual: String },
703 #[error("transcript history state is malformed: {0}")]
704 HistoryStateMalformed(String),
705 #[error("invalid transcript shape after rewrite: {0}")]
706 InvalidTranscriptShape(String),
707}
708
709fn message_role_name(message: &Message) -> &'static str {
710 match message {
711 Message::System(_) => "system",
712 Message::SystemNotice(_) => "system_notice",
713 Message::User(_) => "user",
714 Message::BlockAssistant(_) => "block_assistant",
715 Message::ToolResults { .. } => "tool_results",
716 }
717}
718
719fn assistant_tool_use_ids(message: &Message) -> Vec<&str> {
720 match message {
721 Message::BlockAssistant(assistant) => assistant
722 .blocks
723 .iter()
724 .filter_map(|block| match block {
725 AssistantBlock::ToolUse { id, .. } => Some(id.as_str()),
726 _ => None,
727 })
728 .collect(),
729 _ => Vec::new(),
730 }
731}
732
733fn validate_transcript_tool_result_shape(messages: &[Message]) -> Result<(), TranscriptEditError> {
734 for (index, message) in messages.iter().enumerate() {
735 if let Message::ToolResults { results, .. } = message {
736 let Some(previous) = index
737 .checked_sub(1)
738 .and_then(|previous| messages.get(previous))
739 else {
740 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
741 "tool_results at message {index} has no preceding assistant tool-use message"
742 )));
743 };
744 let expected = assistant_tool_use_ids(previous);
745 if expected.is_empty() {
746 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
747 "tool_results at message {index} follows {}, not an assistant tool-use message",
748 message_role_name(previous)
749 )));
750 }
751 let actual = results
752 .iter()
753 .map(|result| result.tool_use_id.as_str())
754 .collect::<Vec<_>>();
755 let actual_set = actual.iter().copied().collect::<BTreeSet<_>>();
756 let expected_set = expected.iter().copied().collect::<BTreeSet<_>>();
757 if actual.len() != actual_set.len() {
758 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
759 "tool_results at message {index} contains duplicate tool ids"
760 )));
761 }
762 if expected.len() != expected_set.len() {
763 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
764 "assistant tool-use message before tool_results at message {index} contains duplicate tool ids"
765 )));
766 }
767 if actual_set != expected_set {
768 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
769 "tool_results at message {index} resolve tool ids {actual_set:?}, expected {expected_set:?}"
770 )));
771 }
772 }
773
774 let tool_use_ids = assistant_tool_use_ids(message);
775 if tool_use_ids.is_empty() {
776 continue;
777 }
778 let Some(next) = messages.get(index + 1) else {
779 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
780 "assistant tool-use message {index} has no following tool_results"
781 )));
782 };
783 if !matches!(next, Message::ToolResults { .. }) {
784 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
785 "assistant tool-use message {index} is followed by {}, not tool_results",
786 message_role_name(next)
787 )));
788 }
789 }
790 Ok(())
791}
792
793fn canonicalize_digest_image_blocks(blocks: &mut [crate::types::ContentBlock]) {
794 for block in blocks.iter_mut() {
795 if let crate::types::ContentBlock::Image {
796 media_type,
797 data: crate::types::ImageData::Inline { data },
798 } = block
799 {
800 let blob_id = crate::blob::content_blob_id(media_type, data);
803 *block = crate::types::ContentBlock::Image {
804 media_type: media_type.clone(),
805 data: crate::types::ImageData::Blob { blob_id },
806 };
807 }
808 }
809}
810
811fn canonicalize_message_images_for_digest(messages: &[Message]) -> Vec<Message> {
820 let mut canonical = messages.to_vec();
821 for message in &mut canonical {
822 match message {
823 Message::User(user) => canonicalize_digest_image_blocks(&mut user.content),
824 Message::ToolResults { results, .. } => {
825 for result in results.iter_mut() {
826 canonicalize_digest_image_blocks(&mut result.content);
827 }
828 }
829 Message::SystemNotice(notice) => {
830 for block in &mut notice.blocks {
831 match block {
832 crate::types::SystemNoticeBlock::Comms { content, .. }
833 | crate::types::SystemNoticeBlock::ExternalEvent { content, .. } => {
834 canonicalize_digest_image_blocks(content);
835 }
836 _ => {}
837 }
838 }
839 }
840 _ => {}
841 }
842 }
843 canonical
844}
845
846fn digest_timestamp_sentinel() -> crate::types::MessageTimestamp {
850 chrono::DateTime::<chrono::Utc>::UNIX_EPOCH
851}
852
853fn canonicalize_messages_for_digest(messages: &[Message]) -> Vec<Message> {
871 let mut canonical = canonicalize_message_images_for_digest(messages);
872 for message in &mut canonical {
873 match message {
874 Message::System(system) => {
875 system.created_at = digest_timestamp_sentinel();
876 }
877 Message::SystemNotice(notice) => {
878 notice.created_at = digest_timestamp_sentinel();
879 }
880 Message::User(user) => {
881 user.identity = crate::types::TranscriptMessageIdentity::default();
882 user.created_at = digest_timestamp_sentinel();
883 }
884 Message::BlockAssistant(assistant) => {
885 assistant.identity = crate::types::TranscriptMessageIdentity::default();
886 assistant.created_at = digest_timestamp_sentinel();
887 }
888 Message::ToolResults { created_at, .. } => {
889 *created_at = digest_timestamp_sentinel();
890 }
891 }
892 }
893 canonical
894}
895
896pub fn transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
897 sha256_json_digest(&canonicalize_messages_for_digest(messages))
898}
899
900fn legacy_transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
909 sha256_json_digest(&canonicalize_message_images_for_digest(messages))
910}
911
912fn validate_transcript_rewrite_record(
913 commit: &TranscriptRewriteCommit,
914 parent_body: &TranscriptRevisionBody,
915 revision_body: &TranscriptRevisionBody,
916) -> Result<(), TranscriptEditError> {
917 if parent_body.revision != commit.parent_revision {
918 return Err(TranscriptEditError::HistoryStateMalformed(format!(
919 "parent body revision {} does not match commit parent {}",
920 parent_body.revision, commit.parent_revision
921 )));
922 }
923 if revision_body.revision != commit.revision {
924 return Err(TranscriptEditError::HistoryStateMalformed(format!(
925 "revision body {} does not match commit revision {}",
926 revision_body.revision, commit.revision
927 )));
928 }
929 if commit.parent_revision == commit.revision {
930 return Err(TranscriptEditError::NoOpRewrite {
931 revision: commit.revision.clone(),
932 });
933 }
934 let parent_digest = transcript_messages_digest(&parent_body.messages)
935 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
936 if parent_digest != commit.parent_revision {
937 return Err(TranscriptEditError::HistoryStateMalformed(format!(
938 "parent body digest {parent_digest} does not match commit parent {}",
939 commit.parent_revision
940 )));
941 }
942 let revision_digest = transcript_messages_digest(&revision_body.messages)
943 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
944 if revision_digest != commit.revision {
945 return Err(TranscriptEditError::HistoryStateMalformed(format!(
946 "revision body digest {revision_digest} does not match commit revision {}",
947 commit.revision
948 )));
949 }
950 let (start, end) = commit.selection.bounds();
951 if start > end || end > parent_body.messages.len() {
952 return Err(TranscriptEditError::InvalidRewriteRange {
953 start,
954 end,
955 message_count: parent_body.messages.len(),
956 });
957 }
958 if commit.messages_before != parent_body.messages.len()
959 || commit.messages_after != revision_body.messages.len()
960 {
961 return Err(TranscriptEditError::HistoryStateMalformed(format!(
962 "commit message counts {} -> {} do not match revision bodies {} -> {}",
963 commit.messages_before,
964 commit.messages_after,
965 parent_body.messages.len(),
966 revision_body.messages.len()
967 )));
968 }
969 let original_span_digest = transcript_messages_digest(&parent_body.messages[start..end])
970 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
971 if original_span_digest != commit.original_span_digest {
972 return Err(TranscriptEditError::HistoryStateMalformed(format!(
973 "original span digest {original_span_digest} does not match commit digest {}",
974 commit.original_span_digest
975 )));
976 }
977 let removed_len = end - start;
978 let retained_len = commit
979 .messages_before
980 .checked_sub(removed_len)
981 .ok_or_else(|| {
982 TranscriptEditError::HistoryStateMalformed(
983 "commit removed more messages than it recorded before rewrite".to_string(),
984 )
985 })?;
986 let replacement_len = commit
987 .messages_after
988 .checked_sub(retained_len)
989 .ok_or_else(|| {
990 TranscriptEditError::HistoryStateMalformed(
991 "commit message counts cannot describe a replacement span".to_string(),
992 )
993 })?;
994 let replacement_end = start.checked_add(replacement_len).ok_or_else(|| {
995 TranscriptEditError::HistoryStateMalformed("replacement span end overflowed".to_string())
996 })?;
997 if replacement_end > revision_body.messages.len() {
998 return Err(TranscriptEditError::InvalidRewriteRange {
999 start,
1000 end: replacement_end,
1001 message_count: revision_body.messages.len(),
1002 });
1003 }
1004 if commit.selection.semantic() == TranscriptRewriteSemantic::Compaction {
1005 let summary_count = revision_body.messages[start..replacement_end]
1006 .iter()
1007 .filter(|message| {
1008 matches!(message, Message::User(user) if user.transcript_role.is_compaction_summary())
1009 })
1010 .count();
1011 if start != 0
1012 || end != commit.messages_before
1013 || commit.messages_after >= commit.messages_before
1014 || summary_count != 1
1015 {
1016 return Err(TranscriptEditError::HistoryStateMalformed(
1017 "typed compaction rewrite must shrink the full transcript and carry exactly one CompactionSummary"
1018 .to_string(),
1019 ));
1020 }
1021 }
1022 let parent_prefix_digest = transcript_messages_digest(&parent_body.messages[..start])
1023 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1024 let revision_prefix_digest = transcript_messages_digest(&revision_body.messages[..start])
1025 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1026 if parent_prefix_digest != revision_prefix_digest {
1027 return Err(TranscriptEditError::HistoryStateMalformed(
1028 "rewrite revision changed messages before the selected span".to_string(),
1029 ));
1030 }
1031 let parent_suffix_digest = transcript_messages_digest(&parent_body.messages[end..])
1032 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1033 let revision_suffix_digest =
1034 transcript_messages_digest(&revision_body.messages[replacement_end..])
1035 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1036 if parent_suffix_digest != revision_suffix_digest {
1037 return Err(TranscriptEditError::HistoryStateMalformed(
1038 "rewrite revision changed messages after the selected span".to_string(),
1039 ));
1040 }
1041 let replacement_digest =
1042 transcript_messages_digest(&revision_body.messages[start..replacement_end])
1043 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1044 if replacement_digest != commit.replacement_digest {
1045 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1046 "replacement span digest {replacement_digest} does not match commit digest {}",
1047 commit.replacement_digest
1048 )));
1049 }
1050 Ok(())
1051}
1052
1053pub(crate) fn validate_transcript_history_state(
1054 state: &TranscriptHistoryState,
1055) -> Result<(), TranscriptEditError> {
1056 if state
1057 .revisions
1058 .iter()
1059 .all(|body| body.revision != state.head)
1060 {
1061 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1062 "missing transcript head body {}",
1063 state.head
1064 )));
1065 }
1066 for body in &state.revisions {
1067 let digest = transcript_messages_digest(&body.messages)
1068 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1069 if digest != body.revision {
1070 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1071 "transcript revision body {} has digest {digest}",
1072 body.revision
1073 )));
1074 }
1075 }
1076 for commit in &state.commits {
1077 let parent_body = state
1078 .revisions
1079 .iter()
1080 .find(|body| body.revision == commit.parent_revision)
1081 .ok_or_else(|| {
1082 TranscriptEditError::HistoryStateMalformed(format!(
1083 "missing parent transcript body {}",
1084 commit.parent_revision
1085 ))
1086 })?;
1087 let revision_body = state
1088 .revisions
1089 .iter()
1090 .find(|body| body.revision == commit.revision)
1091 .ok_or_else(|| {
1092 TranscriptEditError::HistoryStateMalformed(format!(
1093 "missing transcript revision body {}",
1094 commit.revision
1095 ))
1096 })?;
1097 validate_transcript_rewrite_record(commit, parent_body, revision_body)?;
1098 }
1099 let Some(first_commit) = state.commits.first() else {
1100 return Ok(());
1101 };
1102 let mut expected_head = first_commit.parent_revision.clone();
1103 for commit in &state.commits {
1104 let parent_body = state
1105 .revisions
1106 .iter()
1107 .find(|body| body.revision == commit.parent_revision)
1108 .ok_or_else(|| {
1109 TranscriptEditError::HistoryStateMalformed(format!(
1110 "missing parent transcript body {}",
1111 commit.parent_revision
1112 ))
1113 })?;
1114 if commit.parent_revision != expected_head
1115 && !revision_body_extends_head(parent_body, &state.revisions, &expected_head)?
1116 {
1117 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1118 "rewrite commit parent {} does not extend transcript head {}",
1119 commit.parent_revision, expected_head
1120 )));
1121 }
1122 expected_head = commit.revision.clone();
1123 }
1124 let head_is_audited_endpoint = state
1125 .commits
1126 .iter()
1127 .any(|commit| commit.parent_revision == state.head || commit.revision == state.head);
1128 let head_extends_latest_commit = if head_is_audited_endpoint {
1129 let Some(head_body) = state
1130 .revisions
1131 .iter()
1132 .find(|body| body.revision == state.head)
1133 else {
1134 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1135 "missing transcript head body {}",
1136 state.head
1137 )));
1138 };
1139 revision_body_extends_head(head_body, &state.revisions, &expected_head)?
1140 } else {
1141 let mut cursor = state.head.as_str();
1142 let mut visited = BTreeSet::new();
1143 while cursor != expected_head {
1144 if !visited.insert(cursor.to_string()) {
1145 break;
1146 }
1147 let Some(head_body) = state.revisions.iter().find(|body| body.revision == cursor)
1148 else {
1149 break;
1150 };
1151 let Some(parent) = head_body.parent_revision.as_deref() else {
1152 break;
1153 };
1154 cursor = parent;
1155 }
1156 cursor == expected_head
1157 };
1158 if !head_extends_latest_commit {
1159 return Err(TranscriptEditError::HistoryStateMalformed(format!(
1160 "transcript head {} does not extend the rewrite chain",
1161 state.head
1162 )));
1163 }
1164 Ok(())
1165}
1166
1167fn revision_body_extends_head(
1168 candidate: &TranscriptRevisionBody,
1169 revisions: &[TranscriptRevisionBody],
1170 head: &str,
1171) -> Result<bool, TranscriptEditError> {
1172 let Some(head_body) = revisions.iter().find(|body| body.revision == head) else {
1173 return Ok(false);
1174 };
1175 if candidate.revision == head {
1176 return Ok(true);
1177 }
1178 if candidate.messages.len() < head_body.messages.len() {
1179 return Ok(false);
1180 }
1181 let prefix_digest = transcript_messages_digest(&candidate.messages[..head_body.messages.len()])
1182 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1183 if prefix_digest == head {
1184 return Ok(true);
1185 }
1186
1187 let (Some(Message::System(_)), Some(Message::System(_))) =
1193 (candidate.messages.first(), head_body.messages.first())
1194 else {
1195 return Ok(false);
1196 };
1197 let head_tail_len = head_body.messages.len().saturating_sub(1);
1198 if head_tail_len == 0 {
1199 return Ok(true);
1200 }
1201 let candidate_tail = &candidate.messages[1..];
1202 if candidate_tail.len() < head_tail_len {
1203 return Ok(false);
1204 }
1205 let head_tail_digest = transcript_messages_digest(&head_body.messages[1..])
1206 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1207 let candidate_tail_prefix_digest = transcript_messages_digest(&candidate_tail[..head_tail_len])
1208 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
1209 Ok(candidate_tail_prefix_digest == head_tail_digest)
1210}
1211
1212fn sha256_json_digest<T: Serialize + ?Sized>(value: &T) -> Result<String, serde_json::Error> {
1213 let bytes = serde_json::to_vec(value)?;
1214 let digest = Sha256::digest(bytes);
1215 let mut out = String::with_capacity(digest.len() * 2);
1216 const HEX: &[u8; 16] = b"0123456789abcdef";
1217 for byte in digest {
1218 out.push(HEX[(byte >> 4) as usize] as char);
1219 out.push(HEX[(byte & 0x0f) as usize] as char);
1220 }
1221 Ok(format!("sha256:{out}"))
1222}
1223
1224#[derive(Debug, Clone)]
1228pub struct Session {
1229 version: u32,
1232 id: SessionId,
1234 pub(crate) messages: Arc<Vec<Message>>,
1236 created_at: SystemTime,
1238 updated_at: SystemTime,
1240 metadata: serde_json::Map<String, serde_json::Value>,
1242 usage: Usage,
1244}
1245
1246#[derive(Serialize, Deserialize)]
1248#[serde(rename_all = "snake_case")]
1249struct SessionSerde {
1250 version: u32,
1251 id: SessionId,
1252 messages: Vec<Message>,
1253 created_at: SystemTime,
1254 updated_at: SystemTime,
1255 #[serde(default)]
1256 metadata: serde_json::Map<String, serde_json::Value>,
1257 #[serde(default)]
1258 usage: Usage,
1259}
1260
1261impl Serialize for Session {
1262 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1263 where
1264 S: Serializer,
1265 {
1266 let mut metadata = self.metadata.clone();
1267 compact_transcript_history_metadata_for_snapshot(&mut metadata)
1268 .map_err(<S::Error as serde::ser::Error>::custom)?;
1269 let serde_repr = SessionSerde {
1270 version: self.version,
1271 id: self.id.clone(),
1272 messages: (*self.messages).clone(),
1273 created_at: self.created_at,
1274 updated_at: self.updated_at,
1275 metadata,
1276 usage: self.usage.clone(),
1277 };
1278 serde_repr.serialize(serializer)
1279 }
1280}
1281
1282fn compact_transcript_history_metadata_for_snapshot(
1283 metadata: &mut serde_json::Map<String, serde_json::Value>,
1284) -> Result<(), String> {
1285 let Some(value) = metadata.remove(SESSION_TRANSCRIPT_HISTORY_STATE_KEY) else {
1286 return Ok(());
1287 };
1288 let mut state: TranscriptHistoryState =
1289 serde_json::from_value(value).map_err(|error| error.to_string())?;
1290 state
1291 .compact_mechanical_revision_bodies()
1292 .map_err(|error| error.to_string())?;
1293 metadata.insert(
1294 SESSION_TRANSCRIPT_HISTORY_STATE_KEY.to_string(),
1295 serde_json::to_value(state).map_err(|error| error.to_string())?,
1296 );
1297 Ok(())
1298}
1299
1300impl<'de> Deserialize<'de> for Session {
1301 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1302 where
1303 D: Deserializer<'de>,
1304 {
1305 let serde_repr = SessionSerde::deserialize(deserializer)?;
1306 let version = session_persistence_version_authority::restore_session_envelope_version(
1307 serde_repr.version,
1308 )
1309 .map_err(<D::Error as serde::de::Error>::custom)?;
1310 let mut metadata = serde_repr.metadata;
1311 compact_transcript_history_metadata_for_snapshot(&mut metadata)
1312 .map_err(<D::Error as serde::de::Error>::custom)?;
1313 Ok(Session {
1314 version,
1315 id: serde_repr.id,
1316 messages: Arc::new(serde_repr.messages),
1317 created_at: serde_repr.created_at,
1318 updated_at: serde_repr.updated_at,
1319 metadata,
1320 usage: serde_repr.usage,
1321 })
1322 }
1323}
1324
1325#[derive(Deserialize)]
1334#[serde(rename_all = "snake_case")]
1335struct SessionMetadataDocumentSerde {
1336 version: u32,
1337 id: SessionId,
1338 #[serde(default)]
1339 metadata: serde_json::Map<String, serde_json::Value>,
1340}
1341
1342#[derive(Debug, Clone)]
1351pub struct SessionMetadataDocument {
1352 session_id: SessionId,
1353 metadata: serde_json::Map<String, serde_json::Value>,
1354}
1355
1356impl SessionMetadataDocument {
1357 pub fn session_id(&self) -> &SessionId {
1359 &self.session_id
1360 }
1361
1362 pub fn session_metadata_value(&self) -> Option<&serde_json::Value> {
1365 self.metadata.get(SESSION_METADATA_KEY)
1366 }
1367
1368 pub fn lifecycle_terminal_value(&self) -> Option<&serde_json::Value> {
1371 self.metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY)
1372 }
1373
1374 pub fn try_into_view(self) -> Result<PersistedSessionMetadataView, serde_json::Error> {
1377 PersistedSessionMetadataView::try_from_metadata_map(self.session_id, &self.metadata)
1378 }
1379}
1380
1381pub fn session_metadata_document_from_slice(
1388 bytes: &[u8],
1389) -> Result<SessionMetadataDocument, serde_json::Error> {
1390 let serde_repr: SessionMetadataDocumentSerde = serde_json::from_slice(bytes)?;
1391 session_persistence_version_authority::restore_session_envelope_version(serde_repr.version)
1392 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1393 Ok(SessionMetadataDocument {
1394 session_id: serde_repr.id,
1395 metadata: serde_repr.metadata,
1396 })
1397}
1398
1399impl Session {
1400 pub(crate) fn from_head_parts(
1408 version: u32,
1409 id: SessionId,
1410 messages: Vec<Message>,
1411 created_at: SystemTime,
1412 updated_at: SystemTime,
1413 metadata: serde_json::Map<String, serde_json::Value>,
1414 usage: Usage,
1415 ) -> Result<Self, String> {
1416 let version =
1417 session_persistence_version_authority::restore_session_envelope_version(version)
1418 .map_err(|err| err.to_string())?;
1419 Ok(Self {
1420 version,
1421 id,
1422 messages: Arc::new(messages),
1423 created_at,
1424 updated_at,
1425 metadata,
1426 usage,
1427 })
1428 }
1429}
1430
1431pub const SESSION_SYSTEM_CONTEXT_STATE_KEY: &str = "session_system_context_state";
1433
1434pub const SESSION_DEFERRED_TURN_STATE_KEY: &str = "session_deferred_turn_state";
1436
1437pub const SESSION_BUILD_STATE_KEY: &str = "session_build_state";
1439
1440pub const SESSION_TOOL_VISIBILITY_STATE_KEY: &str = "session_tool_visibility_state_v1";
1442
1443pub const SESSION_LIFECYCLE_TERMINAL_KEY: &str = "session_lifecycle_terminal";
1445
1446pub const SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY: &str =
1454 "session_runtime_checkpoint_provenance_v1";
1455
1456pub const VIEW_IMAGE_TOOL_NAME: &str = "view_image";
1458
1459pub const SYSTEM_CONTEXT_SEPARATOR: &str = "\n\n---\n\n";
1461
1462#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1463#[error("metadata key `{key}` is reserved for session authority")]
1464pub struct ReservedSessionMetadataKey {
1465 key: String,
1466}
1467
1468impl ReservedSessionMetadataKey {
1469 fn new(key: &str) -> Self {
1470 Self {
1471 key: key.to_string(),
1472 }
1473 }
1474}
1475
1476fn is_session_authority_metadata_key(key: &str) -> bool {
1477 crate::surface_metadata::ReservedMetadataKey::is_session_authority(key)
1480}
1481
1482#[allow(clippy::panic)]
1483fn fail_closed_generated_restore(authority: &'static str, err: serde_json::Error) -> ! {
1484 tracing::error!(
1485 authority,
1486 error = %err,
1487 "generated authority rejected durable restore"
1488 );
1489 panic!("generated {authority} authority rejected durable restore: {err}");
1490}
1491
1492#[derive(Clone)]
1498pub struct SystemContextStateHandle {
1499 inner: Arc<std::sync::Mutex<SessionSystemContextState>>,
1500}
1501
1502impl std::fmt::Debug for SystemContextStateHandle {
1503 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1504 f.debug_struct("SystemContextStateHandle")
1505 .field("inner", &"<Arc<Mutex<SessionSystemContextState>>>")
1506 .finish()
1507 }
1508}
1509
1510impl SystemContextStateHandle {
1511 pub fn new(state: SessionSystemContextState) -> Result<Self, serde_json::Error> {
1512 let state = system_context_authority::restore_system_context_state(state)
1513 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1514 Ok(Self {
1515 inner: Arc::new(std::sync::Mutex::new(state)),
1516 })
1517 }
1518
1519 pub fn from_shared_authority_state(
1520 inner: Arc<std::sync::Mutex<SessionSystemContextState>>,
1521 ) -> Self {
1522 Self { inner }
1523 }
1524
1525 pub fn snapshot(&self) -> SessionSystemContextState {
1526 match self.inner.lock() {
1527 Ok(guard) => guard.clone(),
1528 Err(poisoned) => {
1529 tracing::warn!("system-context state lock poisoned while reading snapshot");
1530 poisoned.into_inner().clone()
1531 }
1532 }
1533 }
1534
1535 pub fn replace_from_generated_restore(
1536 &self,
1537 state: SessionSystemContextState,
1538 ) -> Result<(), serde_json::Error> {
1539 let state = system_context_authority::restore_system_context_state(state)
1540 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1541 match self.inner.lock() {
1542 Ok(mut guard) => {
1543 *guard = state;
1544 }
1545 Err(poisoned) => {
1546 tracing::warn!("system-context state lock poisoned while restoring state");
1547 *poisoned.into_inner() = state;
1548 }
1549 }
1550 Ok(())
1551 }
1552
1553 pub fn replace_from_generated_restore_if_changed(
1554 &self,
1555 state: SessionSystemContextState,
1556 ) -> Result<bool, serde_json::Error> {
1557 let state = system_context_authority::restore_system_context_state(state)
1558 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1559 let mut guard = match self.inner.lock() {
1560 Ok(guard) => guard,
1561 Err(poisoned) => {
1562 tracing::warn!(
1563 "system-context state lock poisoned while replacing generated-restored state"
1564 );
1565 poisoned.into_inner()
1566 }
1567 };
1568 if *guard == state {
1569 return Ok(false);
1570 }
1571 *guard = state;
1572 Ok(true)
1573 }
1574
1575 pub fn replace_from_generated_restore_if_current(
1576 &self,
1577 current: &SessionSystemContextState,
1578 replacement: SessionSystemContextState,
1579 ) -> Result<bool, serde_json::Error> {
1580 let replacement = system_context_authority::restore_system_context_state(replacement)
1581 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1582 let mut guard = match self.inner.lock() {
1583 Ok(guard) => guard,
1584 Err(poisoned) => {
1585 tracing::warn!(
1586 "system-context state lock poisoned while conditionally replacing generated-restored state"
1587 );
1588 poisoned.into_inner()
1589 }
1590 };
1591 if *guard != *current {
1592 return Ok(false);
1593 }
1594 *guard = replacement;
1595 Ok(true)
1596 }
1597
1598 pub fn stage_append_with_snapshot(
1599 &self,
1600 req: &AppendSystemContextRequest,
1601 accepted_at: SystemTime,
1602 ) -> Result<
1603 (
1604 crate::service::AppendSystemContextStatus,
1605 SessionSystemContextState,
1606 SessionSystemContextState,
1607 ),
1608 SystemContextStageError,
1609 > {
1610 let mut guard = match self.inner.lock() {
1611 Ok(guard) => guard,
1612 Err(poisoned) => {
1613 tracing::warn!("system-context state lock poisoned while staging append");
1614 poisoned.into_inner()
1615 }
1616 };
1617 let snapshot = guard.clone();
1618 let status = guard.stage_append(req, accepted_at)?;
1619 let staged = guard.clone();
1620 Ok((status, snapshot, staged))
1621 }
1622
1623 pub fn stage_active_turn_appends_with_snapshot(
1624 &self,
1625 appends: Vec<(AppendSystemContextRequest, SystemTime)>,
1626 ) -> Result<(SessionSystemContextState, SessionSystemContextState), SystemContextStageError>
1627 {
1628 let mut guard = match self.inner.lock() {
1629 Ok(guard) => guard,
1630 Err(poisoned) => {
1631 tracing::warn!(
1632 "system-context state lock poisoned while staging active-turn appends"
1633 );
1634 poisoned.into_inner()
1635 }
1636 };
1637 let snapshot = guard.clone();
1638 let mut candidate = snapshot.clone();
1639 for (req, accepted_at) in appends {
1640 candidate.stage_active_turn_append(&req, accepted_at)?;
1641 }
1642 *guard = candidate.clone();
1643 let staged = candidate;
1644 Ok((snapshot, staged))
1645 }
1646
1647 pub fn discard_unapplied_active_turn_pending(&self) -> usize {
1648 let discarded = match self.inner.lock() {
1649 Ok(mut guard) => guard.discard_unapplied_active_turn_pending(),
1650 Err(poisoned) => {
1651 tracing::warn!(
1652 "system-context state lock poisoned while discarding active-turn context"
1653 );
1654 poisoned
1655 .into_inner()
1656 .discard_unapplied_active_turn_pending()
1657 }
1658 };
1659 discarded.len()
1660 }
1661
1662 pub fn discard_active_turn_pending_by_keys(
1663 &self,
1664 idempotency_keys: &[String],
1665 ) -> Vec<PendingSystemContextAppend> {
1666 match self.inner.lock() {
1667 Ok(mut guard) => guard.discard_active_turn_pending_by_keys(idempotency_keys),
1668 Err(poisoned) => {
1669 tracing::warn!(
1670 "system-context state lock poisoned while discarding active-turn pending appends"
1671 );
1672 poisoned
1673 .into_inner()
1674 .discard_active_turn_pending_by_keys(idempotency_keys)
1675 }
1676 }
1677 }
1678
1679 pub fn stage_active_turn_append(
1680 &self,
1681 req: &AppendSystemContextRequest,
1682 accepted_at: SystemTime,
1683 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
1684 match self.inner.lock() {
1685 Ok(mut guard) => guard.stage_active_turn_append(req, accepted_at),
1686 Err(poisoned) => {
1687 tracing::warn!(
1688 "system-context state lock poisoned while staging active-turn context"
1689 );
1690 poisoned
1691 .into_inner()
1692 .stage_active_turn_append(req, accepted_at)
1693 }
1694 }
1695 }
1696}
1697
1698#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
1702#[serde(rename_all = "snake_case")]
1703pub struct SessionSystemContextState {
1704 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1705 pub(crate) pending: Vec<PendingSystemContextAppend>,
1706 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1707 pub(crate) applied: Vec<PendingSystemContextAppend>,
1708 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1709 pub(crate) seen: std::collections::BTreeMap<String, SeenSystemContextKey>,
1710 #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
1711 pub(crate) active_turn_pending_keys: std::collections::BTreeSet<String>,
1712}
1713
1714#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1724#[serde(rename_all = "snake_case")]
1725pub enum SystemContextSource {
1726 #[default]
1728 Normal,
1729 RuntimeSteer,
1732}
1733
1734impl From<SystemContextSource> for session_document::SystemContextSource {
1735 fn from(value: SystemContextSource) -> Self {
1736 match value {
1737 SystemContextSource::Normal => Self::Normal,
1738 SystemContextSource::RuntimeSteer => Self::RuntimeSteer,
1739 }
1740 }
1741}
1742
1743impl SystemContextSource {
1744 #[must_use]
1747 pub fn is_normal(&self) -> bool {
1748 matches!(self, Self::Normal)
1749 }
1750
1751 #[must_use]
1753 pub fn is_runtime_steer(&self) -> bool {
1754 matches!(self, Self::RuntimeSteer)
1755 }
1756}
1757
1758#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1762#[serde(rename_all = "snake_case")]
1763pub struct PendingSystemContextAppend {
1764 pub content: crate::lifecycle::run_primitive::CoreRenderable,
1772 #[serde(default, skip_serializing_if = "Option::is_none")]
1773 pub source: Option<String>,
1774 #[serde(default, skip_serializing_if = "Option::is_none")]
1775 pub idempotency_key: Option<String>,
1776 #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
1778 pub source_kind: SystemContextSource,
1779 #[serde(default, skip_serializing_if = "Option::is_none")]
1787 pub peer_response_terminal: Option<crate::handles::PeerResponseTerminalFact>,
1788 pub accepted_at: SystemTime,
1789}
1790
1791#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1806#[serde(rename_all = "snake_case")]
1807pub enum SessionLifecycleTerminal {
1808 Active,
1810 Archived,
1812}
1813
1814impl SessionLifecycleTerminal {
1815 #[must_use]
1817 pub fn is_archived(self) -> bool {
1818 matches!(self, Self::Archived)
1819 }
1820}
1821
1822impl From<SessionLifecycleTerminal> for session_document::SessionDocumentLifecycle {
1823 fn from(value: SessionLifecycleTerminal) -> Self {
1824 match value {
1825 SessionLifecycleTerminal::Active => Self::Active,
1826 SessionLifecycleTerminal::Archived => Self::Archived,
1827 }
1828 }
1829}
1830
1831impl From<session_document::SessionDocumentLifecycle> for SessionLifecycleTerminal {
1832 fn from(value: session_document::SessionDocumentLifecycle) -> Self {
1833 match value {
1834 session_document::SessionDocumentLifecycle::Active => Self::Active,
1835 session_document::SessionDocumentLifecycle::Archived => Self::Archived,
1836 }
1837 }
1838}
1839
1840#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
1842#[serde(rename_all = "snake_case")]
1843pub struct SessionDeferredTurnState {
1844 #[serde(default, skip_serializing_if = "DeferredFirstTurnPhase::is_inactive")]
1845 pub(crate) first_turn_phase: DeferredFirstTurnPhase,
1846 #[serde(default, skip_serializing_if = "Option::is_none")]
1847 pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
1848 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1849 pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
1850}
1851
1852#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
1854#[serde(rename_all = "snake_case")]
1855pub enum DeferredFirstTurnPhase {
1856 #[default]
1858 Inactive,
1859 Pending,
1861 Consumed,
1863}
1864
1865impl DeferredFirstTurnPhase {
1866 pub fn is_inactive(&self) -> bool {
1867 matches!(self, Self::Inactive)
1868 }
1869}
1870
1871impl From<DeferredFirstTurnPhase> for session_document::SessionFirstTurnPhase {
1872 fn from(value: DeferredFirstTurnPhase) -> Self {
1873 match value {
1874 DeferredFirstTurnPhase::Inactive => Self::Inactive,
1875 DeferredFirstTurnPhase::Pending => Self::Pending,
1876 DeferredFirstTurnPhase::Consumed => Self::Consumed,
1877 }
1878 }
1879}
1880
1881impl From<session_document::SessionFirstTurnPhase> for DeferredFirstTurnPhase {
1882 fn from(value: session_document::SessionFirstTurnPhase) -> Self {
1883 match value {
1884 session_document::SessionFirstTurnPhase::Inactive => Self::Inactive,
1885 session_document::SessionFirstTurnPhase::Pending => Self::Pending,
1886 session_document::SessionFirstTurnPhase::Consumed => Self::Consumed,
1887 }
1888 }
1889}
1890
1891fn is_default_hook_run_overrides(value: &crate::HookRunOverrides) -> bool {
1892 value == &crate::HookRunOverrides::default()
1893}
1894
1895fn is_default_call_timeout_override(value: &crate::CallTimeoutOverride) -> bool {
1896 value == &crate::CallTimeoutOverride::default()
1897}
1898
1899fn is_tool_filter_all(value: &ToolFilter) -> bool {
1900 matches!(value, ToolFilter::All)
1901}
1902
1903fn is_zero(value: &u64) -> bool {
1904 *value == 0
1905}
1906
1907pub fn capability_base_filter_for_image_tool_results(image_tool_results: bool) -> ToolFilter {
1909 if image_tool_results {
1910 ToolFilter::All
1911 } else {
1912 ToolFilter::Deny([VIEW_IMAGE_TOOL_NAME.to_string()].into_iter().collect())
1913 }
1914}
1915
1916#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1923#[serde(rename_all = "snake_case")]
1924pub struct ToolVisibilityWitness {
1925 #[serde(default, skip_serializing_if = "Option::is_none")]
1926 pub last_seen_provenance: Option<ToolProvenance>,
1927}
1928
1929impl ToolVisibilityWitness {
1930 pub fn has_identity_witness(&self) -> bool {
1931 self.last_seen_provenance.is_some()
1932 }
1933}
1934
1935#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1941#[serde(rename_all = "snake_case")]
1942pub struct DeferredToolLoadAuthority {
1943 pub name: ToolName,
1944 pub witness: ToolVisibilityWitness,
1945}
1946
1947impl DeferredToolLoadAuthority {
1948 pub fn new(name: impl Into<ToolName>, witness: ToolVisibilityWitness) -> Self {
1949 Self {
1950 name: name.into(),
1951 witness,
1952 }
1953 }
1954
1955 pub fn into_parts(self) -> (ToolName, ToolVisibilityWitness) {
1956 (self.name, self.witness)
1957 }
1958}
1959
1960#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1963#[serde(rename_all = "snake_case")]
1964pub struct WitnessedToolFilter {
1965 pub filter: ToolFilter,
1966 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1967 pub witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1968}
1969
1970impl WitnessedToolFilter {
1971 pub fn new(filter: ToolFilter, witnesses: BTreeMap<ToolName, ToolVisibilityWitness>) -> Self {
1972 Self { filter, witnesses }
1973 }
1974
1975 pub fn into_parts(self) -> (ToolFilter, BTreeMap<ToolName, ToolVisibilityWitness>) {
1976 (self.filter, self.witnesses)
1977 }
1978}
1979
1980#[derive(Debug, Clone, PartialEq, Eq)]
1987pub struct InheritedToolVisibilityAuthority {
1988 filter: ToolFilter,
1989 witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1990}
1991
1992impl InheritedToolVisibilityAuthority {
1993 pub(crate) fn from_generated_composition_authority(
1994 filter: ToolFilter,
1995 witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1996 ) -> Self {
1997 Self { filter, witnesses }
1998 }
1999
2000 pub fn filter(&self) -> &ToolFilter {
2001 &self.filter
2002 }
2003
2004 pub fn witnesses(&self) -> &BTreeMap<ToolName, ToolVisibilityWitness> {
2005 &self.witnesses
2006 }
2007
2008 pub(crate) fn into_initial_visibility_state(self) -> SessionToolVisibilityState {
2009 SessionToolVisibilityState {
2010 inherited_base_filter: self.filter,
2011 filter_witnesses: self.witnesses,
2012 ..Default::default()
2013 }
2014 }
2015}
2016
2017#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
2019#[serde(rename_all = "snake_case")]
2020pub struct SessionToolVisibilityState {
2021 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
2022 pub capability_base_filter: ToolFilter,
2023 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
2024 pub inherited_base_filter: ToolFilter,
2025 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
2026 pub active_filter: ToolFilter,
2027 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
2028 pub staged_filter: ToolFilter,
2029 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
2030 pub active_requested_deferred_names: BTreeSet<ToolName>,
2031 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
2032 pub staged_requested_deferred_names: BTreeSet<ToolName>,
2033 #[serde(default, skip_serializing_if = "is_zero")]
2034 pub active_revision: u64,
2035 #[serde(default, skip_serializing_if = "is_zero")]
2036 pub staged_revision: u64,
2037 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2038 pub requested_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
2039 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2040 pub filter_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
2041}
2042
2043impl SessionToolVisibilityState {
2044 #[cfg(test)]
2048 pub(crate) fn projected_boundary_applied(&self) -> Self {
2049 let mut projected = self.clone();
2050 projected.active_filter = self.staged_filter.clone();
2051 projected.active_requested_deferred_names = self.staged_requested_deferred_names.clone();
2052 projected.active_revision = self.staged_revision;
2053 projected
2054 }
2055}
2056
2057#[derive(Debug, Clone, PartialEq, Eq)]
2063pub struct AuthorizedSessionToolVisibilityState {
2064 state: SessionToolVisibilityState,
2065}
2066
2067impl AuthorizedSessionToolVisibilityState {
2068 pub(crate) fn from_generated_authority(state: SessionToolVisibilityState) -> Self {
2069 Self { state }
2070 }
2071
2072 pub fn as_state(&self) -> &SessionToolVisibilityState {
2073 &self.state
2074 }
2075
2076 pub fn into_state(self) -> SessionToolVisibilityState {
2077 self.state
2078 }
2079}
2080
2081#[derive(Debug, Clone, Serialize, Deserialize, Default)]
2084#[serde(rename_all = "snake_case")]
2085pub struct SessionBuildState {
2086 #[serde(
2087 default,
2088 skip_serializing_if = "crate::config::SystemPromptOverride::is_inherit"
2089 )]
2090 pub system_prompt: crate::config::SystemPromptOverride,
2091 #[serde(default, skip_serializing_if = "Option::is_none")]
2092 pub output_schema: Option<crate::OutputSchema>,
2093 #[serde(default, skip_serializing_if = "is_default_hook_run_overrides")]
2094 pub hooks_override: crate::HookRunOverrides,
2095 #[serde(default, skip_serializing_if = "Option::is_none")]
2096 pub budget_limits: Option<crate::BudgetLimits>,
2097 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2098 pub recoverable_tool_defs: Vec<ToolDef>,
2099 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2100 pub silent_comms_intents: Vec<String>,
2101 #[serde(default, skip_serializing_if = "Option::is_none")]
2102 pub max_inline_peer_notifications: Option<i32>,
2103 #[serde(default, skip_serializing_if = "Option::is_none")]
2104 pub app_context: Option<serde_json::Value>,
2105 #[serde(default, skip_serializing_if = "Option::is_none")]
2106 pub additional_instructions: Option<Vec<String>>,
2107 #[serde(default, skip_serializing_if = "Option::is_none")]
2108 pub shell_env: Option<HashMap<String, String>>,
2109 #[serde(default, skip_serializing_if = "Option::is_none")]
2115 pub mob_tool_authority_context: Option<MobToolAuthorityContext>,
2116 #[serde(default, skip_serializing_if = "is_default_call_timeout_override")]
2117 pub call_timeout_override: crate::CallTimeoutOverride,
2118 #[serde(default, skip_serializing_if = "Option::is_none")]
2125 pub assembled_system_prompt: Option<String>,
2126}
2127
2128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2130#[serde(rename_all = "snake_case")]
2131pub struct PendingDeferredPrompt {
2132 pub prompt: ContentInput,
2133 pub accepted_at: SystemTime,
2134}
2135
2136#[derive(Debug, Clone, Serialize, Deserialize)]
2138#[serde(rename_all = "snake_case")]
2139pub struct PendingToolResultsMessage {
2140 pub results: Vec<ToolResult>,
2141 pub accepted_at: SystemTime,
2142}
2143
2144impl PartialEq for PendingToolResultsMessage {
2145 fn eq(&self, other: &Self) -> bool {
2146 self.accepted_at == other.accepted_at
2147 && serde_json::to_value(&self.results).ok() == serde_json::to_value(&other.results).ok()
2148 }
2149}
2150
2151#[derive(Debug, Clone, Default, PartialEq)]
2153pub struct ConsumedDeferredTurnInputs {
2154 pub(crate) restore_first_turn_pending: bool,
2155 pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
2156 pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
2157}
2158
2159impl ConsumedDeferredTurnInputs {
2160 pub fn is_empty(&self) -> bool {
2161 !self.restore_first_turn_pending
2162 && self.pending_initial_prompt.is_none()
2163 && self.pending_tool_results.is_empty()
2164 }
2165
2166 pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
2167 self.pending_initial_prompt.as_ref()
2168 }
2169
2170 pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
2171 &self.pending_tool_results
2172 }
2173}
2174
2175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2177#[serde(rename_all = "snake_case")]
2178pub struct SeenSystemContextKey {
2179 pub content: crate::lifecycle::run_primitive::CoreRenderable,
2181 #[serde(default, skip_serializing_if = "Option::is_none")]
2182 pub source: Option<String>,
2183 #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
2186 pub source_kind: SystemContextSource,
2187 pub state: SeenSystemContextState,
2188}
2189
2190#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2192#[serde(rename_all = "snake_case")]
2193pub enum SeenSystemContextState {
2194 Pending,
2195 Applied,
2196}
2197
2198impl SessionSystemContextState {
2199 pub fn pending(&self) -> &[PendingSystemContextAppend] {
2200 &self.pending
2201 }
2202
2203 pub fn applied(&self) -> &[PendingSystemContextAppend] {
2204 &self.applied
2205 }
2206
2207 pub fn seen(&self) -> &BTreeMap<String, SeenSystemContextKey> {
2208 &self.seen
2209 }
2210
2211 pub fn active_turn_pending_keys(&self) -> &BTreeSet<String> {
2212 &self.active_turn_pending_keys
2213 }
2214
2215 pub fn pending_len(&self) -> usize {
2216 self.pending.len()
2217 }
2218
2219 pub fn applied_len(&self) -> usize {
2220 self.applied.len()
2221 }
2222
2223 pub fn active_turn_pending_len(&self) -> usize {
2224 self.active_turn_pending_keys.len()
2225 }
2226
2227 pub fn realtime_projection_appends(&self) -> Vec<PendingSystemContextAppend> {
2228 self.applied
2229 .iter()
2230 .chain(self.pending.iter())
2231 .cloned()
2232 .collect()
2233 }
2234
2235 pub fn stage_append(
2237 &mut self,
2238 req: &AppendSystemContextRequest,
2239 accepted_at: SystemTime,
2240 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
2241 system_context_authority::stage_append(self, req, accepted_at, false)
2242 }
2243
2244 fn stage_append_with_generated_authority(
2245 &mut self,
2246 req: &AppendSystemContextRequest,
2247 accepted_at: SystemTime,
2248 active_turn_scoped: bool,
2249 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
2250 system_context_authority::stage_append(self, req, accepted_at, active_turn_scoped)
2251 }
2252
2253 pub fn stage_active_turn_append(
2260 &mut self,
2261 req: &AppendSystemContextRequest,
2262 accepted_at: SystemTime,
2263 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
2264 self.stage_append_with_generated_authority(req, accepted_at, true)
2265 }
2266
2267 pub fn mark_pending_applied(&mut self) {
2269 system_context_authority::mark_pending_applied(self);
2270 }
2271
2272 pub fn discard_unapplied_active_turn_pending(&mut self) -> Vec<PendingSystemContextAppend> {
2275 system_context_authority::discard_unapplied_active_turn_pending(self)
2276 }
2277
2278 pub fn discard_active_turn_pending_by_keys(
2285 &mut self,
2286 idempotency_keys: &[String],
2287 ) -> Vec<PendingSystemContextAppend> {
2288 system_context_authority::discard_active_turn_pending_by_keys(self, idempotency_keys)
2289 }
2290
2291 pub fn restore_from_snapshot(self) -> Result<Self, SystemContextStageError> {
2295 system_context_authority::restore_system_context_state(self)
2296 }
2297
2298 pub fn record_applied_blocks(
2302 &mut self,
2303 appends: &[PendingSystemContextAppend],
2304 current_system_prompt: &str,
2305 ) -> Vec<PendingSystemContextAppend> {
2306 system_context_authority::record_applied_system_context_blocks(
2307 self,
2308 appends,
2309 current_system_prompt,
2310 )
2311 }
2312}
2313
2314const SESSION_DOCUMENT_FIRST_TURN_KEY: &str = "first_turn";
2319
2320fn usize_to_u64(value: usize) -> u64 {
2321 u64::try_from(value).unwrap_or(u64::MAX)
2322}
2323
2324fn validate_deferred_turn_snapshot(
2332 state: SessionDeferredTurnState,
2333) -> Result<SessionDeferredTurnState, session_document::SessionDocumentError> {
2334 let mut authority = session_document::SessionDocumentMachineAuthority::new();
2335 let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
2336 authority.recover_session_first_turn_phase(
2340 key,
2341 state.first_turn_phase.into(),
2342 state.pending_initial_prompt.is_some(),
2343 usize_to_u64(state.pending_tool_results.len()),
2344 )?;
2345 Ok(state)
2346}
2347
2348impl SessionDeferredTurnState {
2349 pub fn first_turn_phase(&self) -> DeferredFirstTurnPhase {
2350 self.first_turn_phase
2351 }
2352
2353 pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
2354 self.pending_initial_prompt.as_ref()
2355 }
2356
2357 pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
2358 &self.pending_tool_results
2359 }
2360
2361 pub fn pending_tool_results_len(&self) -> usize {
2362 self.pending_tool_results.len()
2363 }
2364
2365 pub(crate) fn pending_initial_prompt_mut_for_blob_rewrite(
2366 &mut self,
2367 ) -> Option<&mut PendingDeferredPrompt> {
2368 self.pending_initial_prompt.as_mut()
2369 }
2370
2371 pub(crate) fn pending_tool_results_mut_for_blob_rewrite(
2372 &mut self,
2373 ) -> &mut [PendingToolResultsMessage] {
2374 &mut self.pending_tool_results
2375 }
2376
2377 fn document_authority(
2387 &self,
2388 ) -> (
2389 session_document::SessionDocumentMachineAuthority,
2390 session_document::SessionDocumentKey,
2391 ) {
2392 let mut authority = session_document::SessionDocumentMachineAuthority::new();
2393 let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
2394 if let Err(err) = authority.recover_session_first_turn_phase(
2395 key.clone(),
2396 self.first_turn_phase.into(),
2397 self.pending_initial_prompt.is_some(),
2398 usize_to_u64(self.pending_tool_results.len()),
2399 ) {
2400 tracing::warn!(
2401 error = %err,
2402 "generated session document authority rejected first-turn recovery"
2403 );
2404 }
2405 (authority, key)
2406 }
2407
2408 fn mirror_first_turn_phase(
2411 &mut self,
2412 effects: &[session_document::SessionDocumentEffect],
2413 ) -> Option<bool> {
2414 for effect in effects {
2415 if let session_document::SessionDocumentEffect::SessionFirstTurnPhaseResolved {
2416 phase,
2417 was_pending,
2418 } = effect
2419 {
2420 self.first_turn_phase = (*phase).into();
2421 return Some(*was_pending);
2422 }
2423 }
2424 None
2425 }
2426
2427 pub fn mark_initial_turn_pending(&mut self) {
2429 let (mut authority, key) = self.document_authority();
2430 match authority.mark_session_initial_turn_pending(key) {
2431 Ok(effects) => {
2432 self.mirror_first_turn_phase(&effects);
2433 }
2434 Err(err) => tracing::warn!(
2435 error = %err,
2436 "generated session document authority rejected pending mark"
2437 ),
2438 }
2439 }
2440
2441 pub fn mark_initial_turn_started(&mut self) -> bool {
2445 let (mut authority, key) = self.document_authority();
2446 match authority.start_session_initial_turn(key) {
2447 Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
2448 Err(err) => {
2449 tracing::warn!(
2450 error = %err,
2451 "generated session document authority rejected first-turn start"
2452 );
2453 false
2454 }
2455 }
2456 }
2457
2458 pub fn restore_initial_turn_pending(&mut self) {
2460 let (mut authority, key) = self.document_authority();
2465 match authority.restore_session_consumed_inputs(
2466 key.clone(),
2467 true,
2468 self.pending_initial_prompt.is_some(),
2469 usize_to_u64(self.pending_tool_results.len()),
2470 ) {
2471 Ok(_) => {
2472 if let Some(phase) = authority.session_first_turn_phase_for(&key) {
2475 self.first_turn_phase = phase.into();
2476 }
2477 }
2478 Err(err) => tracing::warn!(
2479 error = %err,
2480 "generated session document authority rejected pending restore"
2481 ),
2482 }
2483 }
2484
2485 pub fn allows_initial_turn_overrides(&self) -> bool {
2487 let (mut authority, key) = self.document_authority();
2488 match authority.resolve_session_first_turn_overrides_allowed(key) {
2489 Ok(effects) => effects
2490 .iter()
2491 .find_map(|effect| {
2492 match effect {
2493 session_document::SessionDocumentEffect::SessionFirstTurnOverridesResolved {
2494 allowed,
2495 } => Some(*allowed),
2496 _ => None,
2497 }
2498 })
2499 .unwrap_or(false),
2500 Err(err) => {
2501 tracing::warn!(
2502 error = %err,
2503 "generated session document authority rejected override resolution"
2504 );
2505 false
2506 }
2507 }
2508 }
2509
2510 pub fn stage_initial_prompt(&mut self, prompt: ContentInput, accepted_at: SystemTime) {
2512 let prompt_has_content = prompt.has_images() || !prompt.text_content().trim().is_empty();
2513 let (mut authority, key) = self.document_authority();
2514 match authority.stage_session_initial_prompt(key, prompt_has_content) {
2515 Ok(effects) => {
2516 let decision = effects.iter().find_map(|effect| {
2517 match effect {
2518 session_document::SessionDocumentEffect::SessionInitialPromptStageResolved {
2519 decision,
2520 } => Some(*decision),
2521 _ => None,
2522 }
2523 });
2524 match decision {
2525 Some(session_document::SessionInitialPromptStageDecision::Store) => {
2526 self.pending_initial_prompt = Some(PendingDeferredPrompt {
2527 prompt,
2528 accepted_at,
2529 });
2530 }
2531 Some(session_document::SessionInitialPromptStageDecision::Clear) => {
2532 self.pending_initial_prompt = None;
2533 }
2534 None => tracing::warn!(
2535 "generated session document authority returned no prompt-stage decision"
2536 ),
2537 }
2538 }
2539 Err(err) => tracing::warn!(
2540 error = %err,
2541 "generated session document authority rejected initial prompt stage"
2542 ),
2543 }
2544 }
2545
2546 pub fn stage_tool_results(
2548 &mut self,
2549 results: Vec<ToolResult>,
2550 accepted_at: SystemTime,
2551 ) -> usize {
2552 let (mut authority, key) = self.document_authority();
2553 let accepted = match authority.stage_session_tool_results(key, usize_to_u64(results.len()))
2554 {
2555 Ok(effects) => effects.iter().find_map(|effect| match effect {
2556 session_document::SessionDocumentEffect::SessionToolResultsStageResolved {
2557 accepted_count,
2558 } => Some(*accepted_count),
2559 _ => None,
2560 }),
2561 Err(err) => {
2562 tracing::warn!(
2563 error = %err,
2564 "generated session document authority rejected tool-results stage"
2565 );
2566 return 0;
2567 }
2568 };
2569 let Some(accepted) = accepted else {
2570 tracing::warn!(
2571 "generated session document authority returned no tool-results decision"
2572 );
2573 return 0;
2574 };
2575 if accepted == 0 {
2576 return 0;
2577 }
2578 let accepted = usize::try_from(accepted).unwrap_or(usize::MAX);
2579 self.pending_tool_results.push(PendingToolResultsMessage {
2580 results,
2581 accepted_at,
2582 });
2583 accepted
2584 }
2585
2586 pub fn has_pending_tool_results(&self) -> bool {
2588 !self.pending_tool_results.is_empty()
2589 }
2590
2591 pub fn consume_for_started_turn(&mut self) -> ConsumedDeferredTurnInputs {
2593 let (mut authority, key) = self.document_authority();
2594 let was_pending = match authority.consume_session_deferred_inputs(key) {
2595 Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
2596 Err(err) => {
2597 tracing::warn!(
2598 error = %err,
2599 "generated session document authority rejected started-turn consumption"
2600 );
2601 return ConsumedDeferredTurnInputs::default();
2602 }
2603 };
2604 ConsumedDeferredTurnInputs {
2605 restore_first_turn_pending: was_pending,
2606 pending_initial_prompt: self.pending_initial_prompt.take(),
2607 pending_tool_results: std::mem::take(&mut self.pending_tool_results),
2608 }
2609 }
2610
2611 pub fn restore_consumed_turn_inputs(&mut self, consumed: ConsumedDeferredTurnInputs) {
2613 if consumed.is_empty() {
2614 return;
2615 }
2616 let (mut authority, key) = self.document_authority();
2617 let effects = match authority.restore_session_consumed_inputs(
2618 key,
2619 consumed.restore_first_turn_pending,
2620 consumed.pending_initial_prompt.is_some(),
2621 usize_to_u64(consumed.pending_tool_results.len()),
2622 ) {
2623 Ok(effects) => effects,
2624 Err(err) => {
2625 tracing::warn!(
2626 error = %err,
2627 "generated session document authority rejected consumed input restore"
2628 );
2629 return;
2630 }
2631 };
2632 let Some((restore_first_turn_pending, restore_initial_prompt, restore_tool_results)) =
2633 effects.iter().find_map(|effect| match effect {
2634 session_document::SessionDocumentEffect::SessionConsumedInputsRestoreResolved {
2635 restore_first_turn_pending,
2636 restore_initial_prompt,
2637 restore_tool_results,
2638 } => Some((
2639 *restore_first_turn_pending,
2640 *restore_initial_prompt,
2641 *restore_tool_results,
2642 )),
2643 _ => None,
2644 })
2645 else {
2646 tracing::warn!(
2647 "generated session document authority returned no consumed-input restore decision"
2648 );
2649 return;
2650 };
2651 if restore_first_turn_pending {
2652 self.restore_initial_turn_pending();
2653 }
2654 if restore_initial_prompt && self.pending_initial_prompt.is_none() {
2655 self.pending_initial_prompt = consumed.pending_initial_prompt;
2656 }
2657 if restore_tool_results {
2658 let mut restored = consumed.pending_tool_results;
2659 restored.extend(std::mem::take(&mut self.pending_tool_results));
2660 self.pending_tool_results = restored;
2661 }
2662 }
2663}
2664
2665#[derive(Debug, Clone, PartialEq, Eq)]
2667pub enum SystemContextStageError {
2668 InvalidRequest(String),
2669 Conflict {
2670 key: String,
2671 existing_text: String,
2672 existing_source: Option<String>,
2673 },
2674}
2675
2676impl std::fmt::Display for SystemContextStageError {
2677 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2678 match self {
2679 Self::InvalidRequest(message) => {
2680 write!(f, "invalid system-context append request: {message}")
2681 }
2682 Self::Conflict { key, .. } => {
2683 write!(
2684 f,
2685 "system-context append conflict for idempotency key `{key}`"
2686 )
2687 }
2688 }
2689 }
2690}
2691
2692impl std::error::Error for SystemContextStageError {}
2693
2694fn render_system_context_block(append: &PendingSystemContextAppend) -> String {
2702 let mut rendered = String::from(SYSTEM_CONTEXT_RENDER_LABEL);
2703 if let Some(source) = &append.source {
2704 rendered.push_str("\nsource: ");
2705 rendered.push_str(source);
2706 }
2707 rendered.push_str("\n\n");
2708 rendered.push_str(append.content.render_text().trim());
2711 rendered
2712}
2713
2714const SYSTEM_CONTEXT_RENDER_LABEL: &str = "[Runtime System Context]";
2719
2720fn render_system_context_blocks_joined(appends: &[PendingSystemContextAppend]) -> String {
2726 appends
2727 .iter()
2728 .map(render_system_context_block)
2729 .collect::<Vec<_>>()
2730 .join(SYSTEM_CONTEXT_SEPARATOR)
2731}
2732
2733fn compose_system_prompt_with_context_tail(base: &str, tail: &str) -> String {
2738 if tail.is_empty() {
2739 return base.to_string();
2740 }
2741 if base.is_empty() {
2742 return tail
2743 .strip_prefix(SYSTEM_CONTEXT_SEPARATOR)
2744 .unwrap_or(tail)
2745 .to_string();
2746 }
2747 format!("{base}{tail}")
2748}
2749
2750fn persisted_prompt_is_admitted_context_append_continuation(
2761 assembled_base: &str,
2762 persisted_content: &str,
2763 persisted_mutation_kind: crate::types::SystemPromptMutationKind,
2764) -> bool {
2765 let content_identical = persisted_content == assembled_base;
2766 let content_extends = persisted_content.starts_with(assembled_base);
2767 let appended_starts_with_separator = content_extends
2768 && persisted_content[assembled_base.len()..].starts_with(SYSTEM_CONTEXT_SEPARATOR);
2769 let mut authority = session_document::SessionDocumentMachineAuthority::new();
2770 match authority.resolve_system_context_persist_append_admission(
2771 true,
2772 content_identical,
2773 content_extends,
2774 appended_starts_with_separator,
2775 persisted_mutation_kind.is_runtime_context_append(),
2776 ) {
2777 Ok(effects) => effects.into_iter().any(|effect| {
2778 matches!(
2779 effect,
2780 session_document::SessionDocumentEffect::SystemContextPersistAppendAdmissionResolved {
2781 admission: session_document::SystemContextPersistAppendAdmission::Admit,
2782 }
2783 )
2784 }),
2785 Err(error) => {
2786 tracing::warn!(
2787 error = %error,
2788 "session document authority refused resume prompt continuation admission; \
2789 falling back to audited rewrite"
2790 );
2791 false
2792 }
2793 }
2794}
2795
2796mod system_context_authority {
2807 use super::{
2808 AppendSystemContextRequest, BTreeSet, PendingSystemContextAppend, SeenSystemContextKey,
2809 SeenSystemContextState, SessionSystemContextState, SystemContextSource,
2810 SystemContextStageError, SystemTime, render_system_context_block, session_document,
2811 usize_to_u64,
2812 };
2813 use crate::service::AppendSystemContextStatus;
2814
2815 fn document_authority() -> session_document::SessionDocumentMachineAuthority {
2816 session_document::SessionDocumentMachineAuthority::new()
2817 }
2818
2819 fn resolve_append_decision(
2821 trimmed_text_byte_count: u64,
2822 idempotency_key_present: bool,
2823 existing_key_matches: bool,
2824 existing_key_conflicts: bool,
2825 active_turn_scoped: bool,
2826 ) -> Result<session_document::SystemContextAppendDecision, SystemContextStageError> {
2827 let mut authority = document_authority();
2828 let effects = authority
2829 .resolve_system_context_append(
2830 trimmed_text_byte_count,
2831 idempotency_key_present,
2832 existing_key_matches,
2833 existing_key_conflicts,
2834 active_turn_scoped,
2835 )
2836 .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
2837 effects
2838 .into_iter()
2839 .find_map(|effect| match effect {
2840 session_document::SessionDocumentEffect::SystemContextAppendResolved {
2841 decision,
2842 ..
2843 } => Some(decision),
2844 _ => None,
2845 })
2846 .ok_or_else(|| {
2847 SystemContextStageError::InvalidRequest(
2848 "generated session document authority returned no append decision".to_string(),
2849 )
2850 })
2851 }
2852
2853 fn pending_apply_item(source_kind: SystemContextSource) -> Option<(bool, bool, bool)> {
2856 let mut authority = document_authority();
2857 match authority.resolve_system_context_pending_apply_item(source_kind.into()) {
2858 Ok(effects) => effects.into_iter().find_map(|effect| {
2859 match effect {
2860 session_document::SessionDocumentEffect::SystemContextPendingApplyItemResolved {
2861 promote_to_applied,
2862 mark_seen_applied,
2863 remove_seen,
2864 } => Some((promote_to_applied, mark_seen_applied, remove_seen)),
2865 _ => None,
2866 }
2867 }),
2868 Err(err) => {
2869 tracing::warn!(
2870 error = %err,
2871 "generated session document authority rejected system-context apply item"
2872 );
2873 None
2874 }
2875 }
2876 }
2877
2878 fn steer_cleanup_discards(source_kind: SystemContextSource) -> bool {
2881 let mut authority = document_authority();
2882 match authority.resolve_system_context_steer_cleanup_item(source_kind.into()) {
2883 Ok(effects) => effects
2884 .into_iter()
2885 .find_map(|effect| {
2886 match effect {
2887 session_document::SessionDocumentEffect::SystemContextSteerCleanupItemResolved {
2888 discard,
2889 } => Some(discard),
2890 _ => None,
2891 }
2892 })
2893 .unwrap_or(false),
2894 Err(err) => {
2895 tracing::warn!(
2896 error = %err,
2897 "generated session document authority rejected system-context steer cleanup item"
2898 );
2899 false
2900 }
2901 }
2902 }
2903
2904 pub(super) fn restore_system_context_state(
2905 state: SessionSystemContextState,
2906 ) -> Result<SessionSystemContextState, SystemContextStageError> {
2907 let active_keys_have_known_pending_or_seen =
2908 state.active_turn_pending_keys.iter().all(|key| {
2909 state.seen.contains_key(key)
2910 || state
2911 .pending
2912 .iter()
2913 .any(|append| append.idempotency_key.as_ref() == Some(key))
2914 });
2915 let seen_keys_match_known_appends = state.seen.iter().all(|(key, seen)| {
2916 state
2917 .pending
2918 .iter()
2919 .chain(state.applied.iter())
2920 .any(|append| {
2921 append.idempotency_key.as_ref() == Some(key)
2922 && seen.content == append.content
2923 && seen.source.as_deref() == append.source.as_deref()
2924 })
2925 });
2926 let mut authority = document_authority();
2927 authority
2928 .restore_system_context_snapshot(
2929 active_keys_have_known_pending_or_seen,
2930 seen_keys_match_known_appends,
2931 )
2932 .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
2933 Ok(state)
2934 }
2935
2936 pub(super) fn stage_append(
2937 state: &mut SessionSystemContextState,
2938 req: &AppendSystemContextRequest,
2939 accepted_at: SystemTime,
2940 active_turn_scoped: bool,
2941 ) -> Result<AppendSystemContextStatus, SystemContextStageError> {
2942 let rendered_text = req.content.render_text();
2946 let rendered_len = rendered_text.trim().len();
2947 let existing = req
2948 .idempotency_key
2949 .as_ref()
2950 .and_then(|key| state.seen.get(key));
2951 let existing_key_matches = existing.is_some_and(|existing| {
2952 existing.content == req.content && existing.source.as_deref() == req.source.as_deref()
2953 });
2954 let existing_key_conflicts = existing.is_some() && !existing_key_matches;
2955 let decision = resolve_append_decision(
2956 usize_to_u64(rendered_len),
2957 req.idempotency_key.is_some(),
2958 existing_key_matches,
2959 existing_key_conflicts,
2960 active_turn_scoped,
2961 )?;
2962
2963 match decision {
2964 session_document::SystemContextAppendDecision::RejectEmpty => {
2965 return Err(SystemContextStageError::InvalidRequest(
2966 "system context text must not be empty".to_string(),
2967 ));
2968 }
2969 session_document::SystemContextAppendDecision::RejectConflict => {
2970 let Some(key) = req.idempotency_key.as_ref() else {
2971 return Err(SystemContextStageError::InvalidRequest(
2972 "generated system-context authority rejected append without a key"
2973 .to_string(),
2974 ));
2975 };
2976 let Some(existing) = existing else {
2977 return Err(SystemContextStageError::InvalidRequest(
2978 "generated system-context authority rejected append without a conflict"
2979 .to_string(),
2980 ));
2981 };
2982 return Err(SystemContextStageError::Conflict {
2983 key: key.clone(),
2984 existing_text: existing.content.render_text(),
2985 existing_source: existing.source.clone(),
2986 });
2987 }
2988 session_document::SystemContextAppendDecision::Duplicate => {
2989 return Ok(AppendSystemContextStatus::Duplicate);
2990 }
2991 session_document::SystemContextAppendDecision::Staged => {}
2992 }
2993
2994 let append = PendingSystemContextAppend {
2995 content: req.content.clone(),
2996 source: req.source.clone(),
2997 idempotency_key: req.idempotency_key.clone(),
2998 source_kind: req.source_kind,
2999 peer_response_terminal: req.peer_response_terminal.clone(),
3003 accepted_at,
3004 };
3005 if let Some(key) = req.idempotency_key.as_ref() {
3006 state.seen.insert(
3007 key.clone(),
3008 SeenSystemContextKey {
3009 content: append.content.clone(),
3010 source: append.source.clone(),
3011 source_kind: append.source_kind,
3012 state: SeenSystemContextState::Pending,
3013 },
3014 );
3015 }
3016 if active_turn_scoped && let Some(key) = req.idempotency_key.as_ref() {
3017 state.active_turn_pending_keys.insert(key.clone());
3018 }
3019 state.pending.push(append);
3020 Ok(AppendSystemContextStatus::Staged)
3021 }
3022
3023 pub(super) fn mark_pending_applied(state: &mut SessionSystemContextState) {
3024 let pending = std::mem::take(&mut state.pending);
3027 let mut seen_to_remove = Vec::new();
3028 for append in &pending {
3029 let Some((promote_to_applied, mark_seen_applied, remove_seen)) =
3030 pending_apply_item(append.source_kind)
3031 else {
3032 continue;
3033 };
3034 if promote_to_applied && !state.applied.contains(append) {
3035 state.applied.push(append.clone());
3036 }
3037 if let Some(key) = append.idempotency_key.as_ref() {
3038 if remove_seen {
3039 seen_to_remove.push(key.clone());
3040 } else if mark_seen_applied && let Some(seen) = state.seen.get_mut(key) {
3041 seen.state = SeenSystemContextState::Applied;
3042 }
3043 }
3044 }
3045 for key in seen_to_remove {
3046 state.seen.remove(&key);
3047 }
3048 state.active_turn_pending_keys.clear();
3049 }
3050
3051 pub(super) fn discard_unapplied_active_turn_pending(
3052 state: &mut SessionSystemContextState,
3053 ) -> Vec<PendingSystemContextAppend> {
3054 if state.active_turn_pending_keys.is_empty() {
3055 return Vec::new();
3056 }
3057 let active_keys = std::mem::take(&mut state.active_turn_pending_keys);
3058 let mut discarded = Vec::new();
3059 state.pending.retain(|append| {
3060 let should_discard = append
3061 .idempotency_key
3062 .as_ref()
3063 .is_some_and(|key| active_keys.contains(key));
3064 if should_discard {
3065 discarded.push(append.clone());
3066 }
3067 !should_discard
3068 });
3069
3070 for append in &discarded {
3071 if let Some(key) = append.idempotency_key.as_ref()
3072 && state
3073 .seen
3074 .get(key)
3075 .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
3076 {
3077 state.seen.remove(key);
3078 }
3079 }
3080
3081 discarded
3082 }
3083
3084 pub(super) fn discard_active_turn_pending_by_keys(
3085 state: &mut SessionSystemContextState,
3086 idempotency_keys: &[String],
3087 ) -> Vec<PendingSystemContextAppend> {
3088 if idempotency_keys.is_empty() || state.active_turn_pending_keys.is_empty() {
3089 return Vec::new();
3090 }
3091 let requested_keys: BTreeSet<&str> = idempotency_keys.iter().map(String::as_str).collect();
3092 let mut discarded = Vec::new();
3093 let mut discarded_keys = Vec::new();
3094 state.pending.retain(|append| {
3095 let should_discard = append.idempotency_key.as_ref().is_some_and(|key| {
3096 requested_keys.contains(key.as_str())
3097 && state.active_turn_pending_keys.contains(key)
3098 });
3099 if should_discard {
3100 if let Some(key) = append.idempotency_key.as_ref() {
3101 discarded_keys.push(key.clone());
3102 }
3103 discarded.push(append.clone());
3104 }
3105 !should_discard
3106 });
3107
3108 for key in discarded_keys {
3109 state.active_turn_pending_keys.remove(&key);
3110 if state
3111 .seen
3112 .get(&key)
3113 .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
3114 {
3115 state.seen.remove(&key);
3116 }
3117 }
3118
3119 discarded
3120 }
3121
3122 pub(super) fn discard_transient_runtime_steer_state(
3123 state: &mut SessionSystemContextState,
3124 ) -> usize {
3125 let mut removed = 0usize;
3126
3127 let before_pending = state.pending.len();
3128 state
3129 .pending
3130 .retain(|append| !steer_cleanup_discards(append.source_kind));
3131 removed += before_pending.saturating_sub(state.pending.len());
3132
3133 let before_applied = state.applied.len();
3134 state
3135 .applied
3136 .retain(|append| !steer_cleanup_discards(append.source_kind));
3137 removed += before_applied.saturating_sub(state.applied.len());
3138
3139 let before_seen = state.seen.len();
3140 state
3141 .seen
3142 .retain(|_key, seen| !steer_cleanup_discards(seen.source_kind));
3143 removed += before_seen.saturating_sub(state.seen.len());
3144
3145 let before_active = state.active_turn_pending_keys.len();
3149 let steer_keys: BTreeSet<String> = state
3150 .seen
3151 .iter()
3152 .filter(|(_key, seen)| steer_cleanup_discards(seen.source_kind))
3153 .map(|(key, _seen)| key.clone())
3154 .collect();
3155 state
3159 .active_turn_pending_keys
3160 .retain(|key| state.seen.contains_key(key) && !steer_keys.contains(key));
3161 removed += before_active.saturating_sub(state.active_turn_pending_keys.len());
3162
3163 removed
3164 }
3165
3166 pub(super) fn remove_runtime_steer_blocks_for_rendered(
3167 system_prompt: &str,
3168 runtime_steer_appends: &[PendingSystemContextAppend],
3169 ) -> (String, usize) {
3170 if runtime_steer_appends.is_empty() {
3171 return (system_prompt.to_string(), 0);
3172 }
3173 let steer_blocks: BTreeSet<String> = runtime_steer_appends
3177 .iter()
3178 .map(render_system_context_block)
3179 .collect();
3180 let parts = system_prompt
3181 .split(super::SYSTEM_CONTEXT_SEPARATOR)
3182 .map(str::to_string)
3183 .collect::<Vec<_>>();
3184 let original_len = parts.len();
3185 let retained = parts
3186 .into_iter()
3187 .filter(|part| !steer_blocks.contains(part))
3188 .collect::<Vec<_>>();
3189 let removed = original_len.saturating_sub(retained.len());
3190 (retained.join(super::SYSTEM_CONTEXT_SEPARATOR), removed)
3191 }
3192
3193 pub(super) fn record_applied_system_context_blocks(
3194 state: &mut SessionSystemContextState,
3195 appends: &[PendingSystemContextAppend],
3196 current_system_prompt: &str,
3197 ) -> Vec<PendingSystemContextAppend> {
3198 let mut new_appends: Vec<PendingSystemContextAppend> = Vec::new();
3199 for append in appends {
3200 if append.content.render_text().trim().is_empty() {
3201 continue;
3202 }
3203 let rendered = render_system_context_block(append);
3204 if let Some(key) = append.idempotency_key.as_ref() {
3205 if let Some(existing) = state.seen.get(key)
3206 && !seen_system_context_matches(existing, append)
3207 {
3208 tracing::warn!(
3209 idempotency_key = %key,
3210 "skipping conflicting runtime system-context append"
3211 );
3212 continue;
3213 }
3214 if let Some(existing) = state
3215 .applied
3216 .iter()
3217 .find(|applied| applied.idempotency_key.as_ref() == Some(key))
3218 && !pending_system_context_matches(existing, append)
3219 {
3220 tracing::warn!(
3221 idempotency_key = %key,
3222 "skipping conflicting runtime system-context append"
3223 );
3224 continue;
3225 }
3226 if let Some(existing) = new_appends
3227 .iter()
3228 .find(|pending| pending.idempotency_key.as_ref() == Some(key))
3229 {
3230 if !pending_system_context_matches(existing, append) {
3231 tracing::warn!(
3232 idempotency_key = %key,
3233 "skipping conflicting runtime system-context append"
3234 );
3235 }
3236 continue;
3237 }
3238 if current_system_prompt.contains(&rendered) {
3239 record_applied_append(state, append);
3240 continue;
3241 }
3242 } else if new_appends.contains(append) || current_system_prompt.contains(&rendered) {
3243 continue;
3244 }
3245 record_applied_append(state, append);
3246 new_appends.push(append.clone());
3247 }
3248 new_appends
3249 }
3250
3251 fn record_applied_append(
3252 state: &mut SessionSystemContextState,
3253 append: &PendingSystemContextAppend,
3254 ) {
3255 if let Some(key) = append.idempotency_key.as_ref() {
3256 state.seen.insert(
3257 key.clone(),
3258 SeenSystemContextKey {
3259 content: append.content.clone(),
3260 source: append.source.clone(),
3261 source_kind: append.source_kind,
3262 state: SeenSystemContextState::Applied,
3263 },
3264 );
3265 if state
3266 .applied
3267 .iter()
3268 .any(|applied| applied.idempotency_key.as_ref() == Some(key))
3269 {
3270 return;
3271 }
3272 } else if state.applied.contains(append) {
3273 return;
3274 }
3275 state.applied.push(append.clone());
3276 }
3277
3278 fn seen_system_context_matches(
3279 seen: &SeenSystemContextKey,
3280 append: &PendingSystemContextAppend,
3281 ) -> bool {
3282 seen.content == append.content && seen.source.as_deref() == append.source.as_deref()
3283 }
3284
3285 fn pending_system_context_matches(
3286 existing: &PendingSystemContextAppend,
3287 append: &PendingSystemContextAppend,
3288 ) -> bool {
3289 existing.content == append.content && existing.source.as_deref() == append.source.as_deref()
3290 }
3291}
3292
3293impl Session {
3294 pub fn new() -> Self {
3296 let now = SystemTime::now();
3297 Self {
3298 version: session_version(),
3299 id: SessionId::new(),
3300 messages: Arc::new(Vec::new()),
3301 created_at: now,
3302 updated_at: now,
3303 metadata: serde_json::Map::new(),
3304 usage: Usage::default(),
3305 }
3306 }
3307
3308 pub fn with_id(id: SessionId) -> Self {
3310 let mut session = Self::new();
3311 session.id = id;
3312 session
3313 }
3314
3315 pub fn id(&self) -> &SessionId {
3317 &self.id
3318 }
3319
3320 pub fn version(&self) -> u32 {
3322 self.version
3323 }
3324
3325 pub fn messages(&self) -> &[Message] {
3327 &self.messages
3328 }
3329
3330 #[allow(dead_code)] pub(crate) fn replace_messages_internal(
3337 &mut self,
3338 messages: Vec<Message>,
3339 reason: TranscriptRewriteReason,
3340 ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError> {
3341 if transcript_messages_digest(self.messages()).ok()
3342 == transcript_messages_digest(&messages).ok()
3343 {
3344 return Ok(None);
3345 }
3346 let commit = self.commit_transcript_rewrite(
3347 TranscriptRewriteSelection::MessageRange {
3348 start: 0,
3349 end: self.messages.len(),
3350 },
3351 messages,
3352 reason,
3353 Some("meerkat-core".to_string()),
3354 None,
3355 )?;
3356 Ok(Some(commit))
3357 }
3358
3359 pub(crate) fn replace_messages_for_compaction_internal(
3362 &mut self,
3363 messages: Vec<Message>,
3364 authority: &crate::agent::compact::ValidatedCompactionRewrite,
3365 ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError> {
3366 if transcript_messages_digest(self.messages()).ok()
3367 == transcript_messages_digest(&messages).ok()
3368 {
3369 return Ok(None);
3370 }
3371 if !authority
3372 .authorizes(self.messages(), &messages)
3373 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?
3374 {
3375 return Err(TranscriptEditError::InvalidTranscriptShape(
3376 "validated compaction witness does not authorize this exact transcript rebuild"
3377 .to_string(),
3378 ));
3379 }
3380 let summary_count = messages
3381 .iter()
3382 .filter(|message| {
3383 matches!(message, Message::User(user) if user.transcript_role.is_compaction_summary())
3384 })
3385 .count();
3386 if messages.len() >= self.messages.len() || summary_count != 1 {
3387 return Err(TranscriptEditError::InvalidTranscriptShape(
3388 "validated compaction rewrite must shrink the transcript and carry exactly one CompactionSummary"
3389 .to_string(),
3390 ));
3391 }
3392 let selection =
3393 TranscriptRewriteSelection::validated_compaction(0, self.messages.len(), authority);
3394 let commit = self.commit_transcript_rewrite_authorized(
3395 selection,
3396 messages,
3397 TranscriptRewriteReason::new("compaction"),
3398 Some("meerkat-core".to_string()),
3399 None,
3400 )?;
3401 Ok(Some(commit))
3402 }
3403
3404 pub fn replace_synthetic_notices(
3416 &mut self,
3417 kind: crate::types::SystemNoticeKind,
3418 replacements: Vec<Message>,
3419 ) -> Result<(), TranscriptEditError> {
3420 if !kind.is_synthetic_refresh_projection() {
3421 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
3422 "system notice kind {kind:?} is durable transcript content, not a synthetic refresh projection"
3423 )));
3424 }
3425 for (index, message) in replacements.iter().enumerate() {
3426 let matches_kind = matches!(
3427 message,
3428 Message::SystemNotice(notice)
3429 if notice.kind == kind && notice.is_synthetic_refresh_projection()
3430 );
3431 if !matches_kind {
3432 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
3433 "replacement {index} for synthetic notice kind {kind:?} is not a system notice of that kind"
3434 )));
3435 }
3436 }
3437
3438 let mut refreshed = self
3439 .messages
3440 .iter()
3441 .filter(|message| {
3442 !matches!(
3443 message,
3444 Message::SystemNotice(notice)
3445 if notice.kind == kind && notice.is_synthetic_refresh_projection()
3446 )
3447 })
3448 .cloned()
3449 .collect::<Vec<_>>();
3450 refreshed.extend(replacements);
3451 if transcript_messages_digest(self.messages()).ok()
3452 == transcript_messages_digest(&refreshed).ok()
3453 {
3454 return Ok(());
3455 }
3456
3457 let realtime_state =
3458 self.reconciled_realtime_transcript_metadata_after_rewrite(&refreshed)?;
3459 let updated_at = SystemTime::now();
3460 let history_state = self
3461 .transcript_history_state_after_message_mutation(&refreshed, updated_at)?
3462 .map(serde_json::to_value)
3463 .transpose()
3464 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
3465
3466 self.messages = Arc::new(refreshed);
3467 self.updated_at = updated_at;
3468 if let Some(value) = realtime_state {
3469 self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
3470 }
3471 if let Some(value) = history_state {
3472 self.set_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, value);
3473 }
3474 Ok(())
3475 }
3476
3477 pub fn created_at(&self) -> SystemTime {
3479 self.created_at
3480 }
3481
3482 pub fn updated_at(&self) -> SystemTime {
3484 self.updated_at
3485 }
3486
3487 pub fn push(&mut self, message: Message) {
3491 Arc::make_mut(&mut self.messages).push(message);
3492 self.updated_at = SystemTime::now();
3493 self.refresh_transcript_head_after_message_mutation();
3494 }
3495
3496 pub fn push_batch(&mut self, messages: Vec<Message>) {
3500 if messages.is_empty() {
3501 return;
3502 }
3503 let inner = Arc::make_mut(&mut self.messages);
3504 inner.extend(messages);
3505 self.updated_at = SystemTime::now();
3506 self.refresh_transcript_head_after_message_mutation();
3507 }
3508
3509 pub async fn externalize_media(
3520 &mut self,
3521 blob_store: &dyn crate::BlobStore,
3522 start: usize,
3523 ) -> Result<(), crate::blob::BlobStoreError> {
3524 let previous_digest = if self
3525 .metadata
3526 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
3527 {
3528 transcript_messages_digest(self.messages()).ok()
3529 } else {
3530 None
3531 };
3532 let messages = Arc::make_mut(&mut self.messages);
3533 crate::image_content::externalize_messages_from(blob_store, messages, start).await?;
3534 if let Some(previous_digest) = previous_digest
3535 && transcript_messages_digest(self.messages()).ok().as_ref() != Some(&previous_digest)
3536 {
3537 self.refresh_transcript_head_after_message_mutation();
3538 }
3539 Ok(())
3540 }
3541
3542 pub async fn hydrate_realtime_user_images(
3550 &mut self,
3551 blob_store: &dyn crate::BlobStore,
3552 max_decoded_bytes: usize,
3553 ) -> Result<(), crate::image_content::RealtimeUserImageHydrationError> {
3554 self.hydrate_realtime_user_images_with_usage(blob_store, max_decoded_bytes)
3555 .await
3556 .map(|_| ())
3557 }
3558
3559 pub async fn hydrate_realtime_user_images_with_usage(
3562 &mut self,
3563 blob_store: &dyn crate::BlobStore,
3564 max_decoded_bytes: usize,
3565 ) -> Result<usize, crate::image_content::RealtimeUserImageHydrationError> {
3566 let previous_digest = if self
3567 .metadata
3568 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
3569 {
3570 transcript_messages_digest(self.messages()).ok()
3571 } else {
3572 None
3573 };
3574 let messages = Arc::make_mut(&mut self.messages);
3575 let decoded_total =
3576 crate::image_content::hydrate_user_images_for_realtime_projection_with_usage(
3577 blob_store,
3578 messages,
3579 max_decoded_bytes,
3580 )
3581 .await?;
3582 if let Some(previous_digest) = previous_digest
3583 && transcript_messages_digest(self.messages()).ok().as_ref() != Some(&previous_digest)
3584 {
3585 self.refresh_transcript_head_after_message_mutation();
3586 }
3587 Ok(decoded_total)
3588 }
3589
3590 pub fn touch(&mut self) {
3594 self.updated_at = SystemTime::now();
3595 }
3596
3597 pub fn last_n(&self, n: usize) -> &[Message] {
3599 let start = self.messages.len().saturating_sub(n);
3600 &self.messages[start..]
3601 }
3602
3603 pub fn total_tokens(&self) -> u64 {
3605 self.usage.total_tokens()
3606 }
3607
3608 pub fn total_usage(&self) -> Usage {
3610 self.usage.clone()
3611 }
3612
3613 pub fn record_usage(&mut self, turn_usage: Usage) {
3615 self.usage.add(&turn_usage);
3616 self.updated_at = SystemTime::now();
3617 }
3618
3619 pub fn append_external_user_content(&mut self, content: ContentInput) {
3621 self.push(Message::User(UserMessage::with_blocks(
3622 content.into_blocks(),
3623 )));
3624 }
3625
3626 pub fn append_external_assistant_blocks(
3628 &mut self,
3629 blocks: Vec<AssistantBlock>,
3630 stop_reason: StopReason,
3631 usage: Usage,
3632 ) {
3633 if !blocks.is_empty() {
3634 self.push(Message::BlockAssistant(BlockAssistantMessage::new(
3635 blocks,
3636 stop_reason,
3637 )));
3638 }
3639 if usage != Usage::default() {
3640 self.record_usage(usage);
3641 }
3642 }
3643
3644 pub fn append_realtime_transcript_event(
3652 &mut self,
3653 event: RealtimeTranscriptEvent,
3654 ) -> RealtimeTranscriptApplyOutcome {
3655 let mut state = self.realtime_transcript_state();
3656 let commit =
3657 realtime_transcript_revision::apply_realtime_transcript_event(&mut state, event)
3658 .unwrap_or_else(|err| {
3659 fail_closed_generated_restore(
3660 "realtime-transcript",
3661 <serde_json::Error as serde::de::Error>::custom(err),
3662 )
3663 });
3664 self.store_realtime_transcript_state(&state);
3665 self.push_batch(commit.messages);
3666 if commit.usage != Usage::default() {
3667 self.record_usage(commit.usage);
3668 }
3669 commit.outcome
3670 }
3671
3672 #[must_use]
3675 pub fn preflight_realtime_user_content_event(
3676 &self,
3677 event: &RealtimeTranscriptEvent,
3678 ) -> Option<crate::RealtimeUserContentApplyOutcome> {
3679 let state = self.realtime_transcript_state();
3680 realtime_transcript_revision::preflight_realtime_user_content_event(&state, event)
3681 .unwrap_or_else(|err| {
3682 fail_closed_generated_restore(
3683 "realtime-user-content-preflight",
3684 <serde_json::Error as serde::de::Error>::custom(err),
3685 )
3686 })
3687 }
3688
3689 #[must_use]
3708 pub fn in_flight_realtime_assistant_response_ids(&self) -> Vec<String> {
3709 let state = self.realtime_transcript_state();
3710 realtime_transcript_revision::in_flight_realtime_assistant_response_ids(&state)
3711 }
3712
3713 #[must_use]
3716 pub fn realtime_user_content_identities(&self) -> Vec<RealtimeUserContentIdentity> {
3717 let state = self.realtime_transcript_state();
3718 realtime_transcript_revision::realtime_user_content_identities(&state)
3719 }
3720
3721 #[must_use]
3724 pub fn pending_realtime_user_content_blob(
3725 &self,
3726 ) -> Option<crate::PendingRealtimeUserContentBlob> {
3727 let state = self.realtime_transcript_state();
3728 realtime_transcript_revision::pending_realtime_user_content_blob(&state)
3729 }
3730
3731 pub fn stage_pending_realtime_user_content_blob(
3734 &mut self,
3735 pending: crate::PendingRealtimeUserContentBlob,
3736 ) -> Result<
3737 crate::generated::session_document::RealtimeUserContentBlobStageDisposition,
3738 realtime_transcript_revision::RealtimeTranscriptShellError,
3739 > {
3740 let mut state = self.realtime_transcript_state();
3741 let disposition = realtime_transcript_revision::stage_pending_realtime_user_content_blob(
3742 &mut state, pending,
3743 )?;
3744 self.store_realtime_transcript_state(&state);
3745 Ok(disposition)
3746 }
3747
3748 pub fn resolve_pending_realtime_user_content_blob_recovery(
3749 &self,
3750 request: Option<&crate::PendingRealtimeUserContentBlob>,
3751 pending_blob_valid: bool,
3752 ) -> Result<
3753 crate::generated::session_document::RealtimeUserContentBlobRecoveryDisposition,
3754 realtime_transcript_revision::RealtimeTranscriptShellError,
3755 > {
3756 let state = self.realtime_transcript_state();
3757 realtime_transcript_revision::resolve_pending_realtime_user_content_blob_recovery(
3758 &state,
3759 request,
3760 pending_blob_valid,
3761 )
3762 }
3763
3764 pub fn clear_invalid_pending_realtime_user_content_blob(
3767 &mut self,
3768 request: Option<&crate::PendingRealtimeUserContentBlob>,
3769 ) -> Result<(), realtime_transcript_revision::RealtimeTranscriptShellError> {
3770 let mut state = self.realtime_transcript_state();
3771 realtime_transcript_revision::clear_invalid_pending_realtime_user_content_blob(
3772 &mut state, request,
3773 )?;
3774 self.store_realtime_transcript_state(&state);
3775 Ok(())
3776 }
3777
3778 #[must_use]
3782 pub fn realtime_user_content_tombstones(
3783 &self,
3784 ) -> Vec<crate::realtime_transcript::RealtimeUserContentTombstone> {
3785 let state = self.realtime_transcript_state();
3786 realtime_transcript_revision::realtime_user_content_tombstones(&state)
3787 }
3788
3789 fn realtime_transcript_state(&self) -> SessionRealtimeTranscriptState {
3790 match self.try_realtime_transcript_state() {
3791 Ok(Some(state)) => state,
3792 Ok(None) => SessionRealtimeTranscriptState::default(),
3793 Err(err) => fail_closed_generated_restore("realtime-transcript", err),
3794 }
3795 }
3796
3797 fn try_realtime_transcript_state(
3798 &self,
3799 ) -> Result<Option<SessionRealtimeTranscriptState>, serde_json::Error> {
3800 self.metadata
3801 .get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY)
3802 .map(|value| {
3803 let state = serde_json::from_value(value.clone())?;
3804 realtime_transcript_revision::restore_realtime_transcript_state(state)
3805 .map_err(<serde_json::Error as serde::de::Error>::custom)
3806 })
3807 .transpose()
3808 }
3809
3810 fn store_realtime_transcript_state(&mut self, state: &SessionRealtimeTranscriptState) {
3811 match serde_json::to_value(state) {
3812 Ok(value) => self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value),
3813 Err(error) => {
3814 tracing::warn!(error = %error, "failed to serialize realtime transcript state");
3815 }
3816 }
3817 }
3818
3819 fn reconciled_realtime_transcript_metadata_after_rewrite(
3820 &self,
3821 messages: &[Message],
3822 ) -> Result<Option<serde_json::Value>, TranscriptEditError> {
3823 let Some(state) = self
3824 .try_realtime_transcript_state()
3825 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
3826 else {
3827 return Ok(None);
3828 };
3829 let state =
3830 realtime_transcript_revision::reconcile_realtime_transcript_state_after_rewrite(
3831 state, messages,
3832 )
3833 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
3834 serde_json::to_value(state)
3835 .map(Some)
3836 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))
3837 }
3838
3839 fn apply_authorized_system_prompt(
3840 &mut self,
3841 prompt: session_durable_config_authority::AuthorizedSystemPrompt,
3842 ) {
3843 use crate::types::SystemMessage;
3844
3845 let mutation_kind = prompt.mutation_kind();
3850 let (prompt, _replacing_existing) = prompt.into_parts();
3851 let message = SystemMessage::with_mutation_kind(prompt, mutation_kind);
3852 let inner = Arc::make_mut(&mut self.messages);
3853 if let Some(Message::System(_)) = inner.first() {
3855 inner[0] = Message::System(message);
3856 } else {
3857 inner.insert(0, Message::System(message));
3858 }
3859 self.updated_at = SystemTime::now();
3860 self.refresh_transcript_head_after_message_mutation();
3861 }
3862
3863 pub fn set_system_prompt_with_source(
3865 &mut self,
3866 prompt: String,
3867 source: session_durable_config_authority::SessionSystemPromptSource,
3868 ) -> Result<(), session_durable_config_authority::SessionDurableConfigAuthorityError> {
3869 let replacing_existing = matches!(self.messages.first(), Some(Message::System(_)));
3870 let prompt = session_durable_config_authority::authorize_system_prompt_mutation(
3871 prompt,
3872 source,
3873 replacing_existing,
3874 )?;
3875 self.apply_authorized_system_prompt(prompt);
3876 Ok(())
3877 }
3878
3879 pub fn set_system_prompt(&mut self, prompt: String) {
3881 if let Err(err) = self.set_system_prompt_with_source(
3882 prompt,
3883 session_durable_config_authority::SessionSystemPromptSource::DirectMutation,
3884 ) {
3885 tracing::warn!(error = %err, "generated session durable-config authority rejected system prompt mutation");
3886 }
3887 }
3888
3889 pub fn discard_transient_runtime_steer_context(&mut self) -> usize {
3895 let mut removed = 0usize;
3896
3897 let mut state = match self.try_system_context_state() {
3898 Ok(state) => state.unwrap_or_default(),
3899 Err(err) => {
3900 tracing::warn!(
3901 error = %err,
3902 "generated system-context authority rejected runtime steer cleanup state"
3903 );
3904 return removed;
3905 }
3906 };
3907
3908 let runtime_steer_appends = state
3913 .pending
3914 .iter()
3915 .chain(state.applied.iter())
3916 .filter(|append| append.source_kind.is_runtime_steer())
3917 .cloned()
3918 .collect::<Vec<_>>();
3919 if let Some(Message::System(system)) = self.messages.first() {
3920 let (retained_prompt, removed_blocks) =
3921 system_context_authority::remove_runtime_steer_blocks_for_rendered(
3922 &system.content,
3923 &runtime_steer_appends,
3924 );
3925 if removed_blocks > 0 {
3926 removed += removed_blocks;
3927 if let Err(err) = self.set_system_prompt_with_source(
3928 retained_prompt,
3929 session_durable_config_authority::SessionSystemPromptSource::RuntimeSteerCleanup,
3930 ) {
3931 tracing::warn!(
3932 error = %err,
3933 "generated session durable-config authority rejected runtime steer prompt cleanup"
3934 );
3935 }
3936 }
3937 }
3938
3939 removed += system_context_authority::discard_transient_runtime_steer_state(&mut state);
3940
3941 if removed > 0
3942 && let Err(err) = self.set_system_context_state(state)
3943 {
3944 tracing::warn!(
3945 error = %err,
3946 "failed to persist runtime steer context cleanup"
3947 );
3948 }
3949
3950 removed
3951 }
3952
3953 pub fn append_system_context_blocks(&mut self, appends: &[PendingSystemContextAppend]) {
3955 if appends.is_empty() {
3956 return;
3957 }
3958
3959 let current_system_prompt = self
3960 .messages
3961 .first()
3962 .and_then(|message| match message {
3963 Message::System(system) => Some(system.content.as_str()),
3964 _ => None,
3965 })
3966 .unwrap_or_default();
3967 let mut state = match self.try_system_context_state() {
3968 Ok(state) => state.unwrap_or_default(),
3969 Err(err) => {
3970 tracing::warn!(
3971 error = %err,
3972 "generated system-context authority rejected applied context state"
3973 );
3974 return;
3975 }
3976 };
3977 let new_appends = system_context_authority::record_applied_system_context_blocks(
3978 &mut state,
3979 appends,
3980 current_system_prompt,
3981 );
3982 if new_appends.is_empty() {
3983 if let Err(err) = self.set_system_context_state(state) {
3984 tracing::warn!(error = %err, "failed to persist applied system-context state");
3985 }
3986 return;
3987 }
3988
3989 let rendered = render_system_context_blocks_joined(&new_appends);
3990
3991 let next = match self.messages.first() {
3992 Some(Message::System(sys)) if !sys.content.is_empty() => {
3993 format!("{}{}{}", sys.content, SYSTEM_CONTEXT_SEPARATOR, rendered)
3994 }
3995 _ => rendered,
3996 };
3997 if let Err(err) = self.set_system_prompt_with_source(
3998 next,
3999 session_durable_config_authority::SessionSystemPromptSource::RuntimeContextAppend,
4000 ) {
4001 tracing::warn!(
4002 error = %err,
4003 "generated session durable-config authority rejected system-context prompt append"
4004 );
4005 return;
4006 }
4007 if let Err(err) = self.set_system_context_state(state) {
4008 tracing::warn!(error = %err, "failed to persist applied system-context state");
4009 }
4010 }
4011
4012 pub fn reconcile_resumed_system_prompt(
4038 &mut self,
4039 assembled_base: String,
4040 actor: Option<String>,
4041 ) -> Result<ResumedSystemPromptReconciliation, TranscriptEditError> {
4042 let persisted = match self.messages.first() {
4043 Some(Message::System(system)) => Some((system.content.clone(), system.mutation_kind)),
4044 _ => None,
4045 };
4046
4047 let Some((persisted_content, persisted_mutation_kind)) = persisted else {
4048 if assembled_base.is_empty() {
4049 return Ok(ResumedSystemPromptReconciliation::NoChange);
4050 }
4051 self.commit_resume_system_prompt_rewrite(assembled_base, false, actor)?;
4055 return Ok(ResumedSystemPromptReconciliation::RewrittenBase);
4056 };
4057
4058 if persisted_content == assembled_base {
4059 return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
4060 }
4061
4062 if let Some(tail) = self.verified_runtime_context_tail(&persisted_content) {
4071 let expected = compose_system_prompt_with_context_tail(&assembled_base, &tail);
4072 if expected == persisted_content {
4073 return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
4074 }
4075 self.commit_resume_system_prompt_rewrite(expected, true, actor)?;
4076 return Ok(ResumedSystemPromptReconciliation::RewrittenBase);
4077 }
4078
4079 if persisted_prompt_is_admitted_context_append_continuation(
4087 &assembled_base,
4088 &persisted_content,
4089 persisted_mutation_kind,
4090 ) {
4091 return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
4092 }
4093
4094 let dropping_applied_context = persisted_mutation_kind.is_runtime_context_append()
4101 || self
4102 .system_context_state()
4103 .is_some_and(|state| !state.applied.is_empty());
4104 self.commit_resume_system_prompt_rewrite(assembled_base, true, actor)?;
4105 if dropping_applied_context {
4106 tracing::warn!(
4107 session_id = %self.id,
4108 "resume base-prompt refresh dropped an unverifiable runtime system-context tail; \
4109 clearing applied-append records so keyed re-sends can restore the context"
4110 );
4111 self.clear_applied_system_context_records();
4112 }
4113 Ok(ResumedSystemPromptReconciliation::RewrittenBase)
4114 }
4115
4116 fn verified_runtime_context_tail(&self, persisted_content: &str) -> Option<String> {
4126 if let Some(prior_base) = self
4127 .build_state()
4128 .and_then(|state| state.assembled_system_prompt)
4129 {
4130 if persisted_content == prior_base {
4131 return Some(String::new());
4132 }
4133 if let Some(appended) = persisted_content.strip_prefix(prior_base.as_str())
4134 && appended.starts_with(SYSTEM_CONTEXT_SEPARATOR)
4135 {
4136 return Some(appended.to_string());
4137 }
4138 }
4141 let rendered_tail = self
4142 .system_context_state()
4143 .map(|state| render_system_context_blocks_joined(&state.applied))
4144 .unwrap_or_default();
4145 if rendered_tail.is_empty() {
4146 return None;
4147 }
4148 if persisted_content == rendered_tail {
4149 return Some(format!("{SYSTEM_CONTEXT_SEPARATOR}{rendered_tail}"));
4153 }
4154 let with_separator = format!("{SYSTEM_CONTEXT_SEPARATOR}{rendered_tail}");
4155 persisted_content
4156 .ends_with(&with_separator)
4157 .then_some(with_separator)
4158 }
4159
4160 fn commit_resume_system_prompt_rewrite(
4163 &mut self,
4164 content: String,
4165 replacing_existing: bool,
4166 actor: Option<String>,
4167 ) -> Result<(), TranscriptEditError> {
4168 let authorized = session_durable_config_authority::authorize_system_prompt_mutation(
4169 content,
4170 session_durable_config_authority::SessionSystemPromptSource::ExplicitBuild,
4171 replacing_existing,
4172 )
4173 .map_err(|err| {
4174 TranscriptEditError::HistoryStateMalformed(format!(
4175 "generated session durable-config authority rejected resume system prompt refresh: {err}"
4176 ))
4177 })?;
4178 let mutation_kind = authorized.mutation_kind();
4179 let (content, _replacing_existing) = authorized.into_parts();
4180 let replacement = Message::System(crate::types::SystemMessage::with_mutation_kind(
4181 content,
4182 mutation_kind,
4183 ));
4184 let end = usize::from(replacing_existing);
4185 self.commit_transcript_rewrite(
4186 TranscriptRewriteSelection::MessageRange { start: 0, end },
4187 vec![replacement],
4188 TranscriptRewriteReason::new(RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON),
4189 actor,
4190 None,
4191 )?;
4192 Ok(())
4193 }
4194
4195 fn clear_applied_system_context_records(&mut self) {
4199 let mut state = match self.try_system_context_state() {
4200 Ok(Some(state)) => state,
4201 Ok(None) => return,
4202 Err(error) => {
4203 tracing::warn!(
4204 session_id = %self.id,
4205 error = %error,
4206 "failed to read system-context state while clearing orphaned applied records"
4207 );
4208 return;
4209 }
4210 };
4211 if state.applied.is_empty() {
4212 return;
4213 }
4214 let dropped_keys: Vec<String> = state
4215 .applied
4216 .iter()
4217 .filter_map(|append| append.idempotency_key.clone())
4218 .collect();
4219 state.applied.clear();
4220 for key in &dropped_keys {
4221 state.seen.remove(key);
4222 }
4223 if let Err(error) = self.set_system_context_state(state) {
4224 tracing::warn!(
4225 session_id = %self.id,
4226 error = %error,
4227 "failed to persist cleared applied system-context records after resume prompt refresh"
4228 );
4229 }
4230 }
4231
4232 pub fn last_assistant_text(&self) -> Option<String> {
4239 self.messages.iter().rev().find_map(|m| match m {
4240 Message::BlockAssistant(a) => {
4241 let mut buf = String::new();
4242 for block in &a.blocks {
4243 match block {
4244 crate::types::AssistantBlock::Text { text, .. }
4245 | crate::types::AssistantBlock::Transcript { text, .. } => {
4246 buf.push_str(text);
4247 }
4248 _ => {}
4249 }
4250 }
4251 if buf.is_empty() { None } else { Some(buf) }
4252 }
4253 _ => None,
4254 })
4255 }
4256
4257 pub fn tool_call_count(&self) -> usize {
4259 self.messages
4260 .iter()
4261 .filter_map(|m| match m {
4262 Message::BlockAssistant(a) => Some(
4263 a.blocks
4264 .iter()
4265 .filter(|b| matches!(b, crate::types::AssistantBlock::ToolUse { .. }))
4266 .count(),
4267 ),
4268 _ => None,
4269 })
4270 .sum()
4271 }
4272
4273 pub fn metadata(&self) -> &serde_json::Map<String, serde_json::Value> {
4275 &self.metadata
4276 }
4277
4278 fn set_metadata_unchecked(&mut self, key: &str, value: serde_json::Value) {
4279 self.metadata.insert(key.to_string(), value);
4280 self.updated_at = SystemTime::now();
4281 }
4282
4283 #[cfg(test)]
4284 pub(crate) fn set_metadata_unchecked_for_test(&mut self, key: &str, value: serde_json::Value) {
4285 self.set_metadata_unchecked(key, value);
4286 }
4287
4288 fn fork_metadata_projection(&self) -> serde_json::Map<String, serde_json::Value> {
4289 let mut metadata = self.metadata.clone();
4290 metadata.retain(|key, _| !is_session_authority_metadata_key(key));
4291 metadata
4292 }
4293
4294 fn remove_metadata_unchecked(&mut self, key: &str) {
4295 self.metadata.remove(key);
4296 self.updated_at = SystemTime::now();
4297 }
4298
4299 pub fn try_set_metadata(
4301 &mut self,
4302 key: &str,
4303 value: serde_json::Value,
4304 ) -> Result<(), ReservedSessionMetadataKey> {
4305 if is_session_authority_metadata_key(key) {
4306 return Err(ReservedSessionMetadataKey::new(key));
4307 }
4308 self.set_metadata_unchecked(key, value);
4309 Ok(())
4310 }
4311
4312 pub fn set_metadata(&mut self, key: &str, value: serde_json::Value) {
4317 if let Err(err) = self.try_set_metadata(key, value) {
4318 tracing::warn!(error = %err, "rejected raw session metadata mutation");
4319 }
4320 }
4321
4322 pub fn backfill_metadata_if_absent(&mut self, key: &str, value: serde_json::Value) -> bool {
4328 if is_session_authority_metadata_key(key) {
4329 tracing::warn!(
4330 metadata_key = key,
4331 "rejected raw session metadata backfill for authority key"
4332 );
4333 return false;
4334 }
4335 if self.metadata.contains_key(key) {
4336 false
4337 } else {
4338 self.metadata.insert(key.to_string(), value);
4339 true
4340 }
4341 }
4342
4343 pub fn remove_metadata(&mut self, key: &str) {
4345 if is_session_authority_metadata_key(key) {
4346 tracing::warn!(
4347 metadata_key = key,
4348 "rejected raw session metadata removal for authority key"
4349 );
4350 return;
4351 }
4352 self.metadata.remove(key);
4353 self.updated_at = SystemTime::now();
4354 }
4355
4356 pub fn set_session_metadata(
4358 &mut self,
4359 metadata: SessionMetadata,
4360 ) -> Result<(), serde_json::Error> {
4361 let metadata =
4362 session_durable_config_authority::authorize_session_metadata_persist(metadata)
4363 .map_err(<serde_json::Error as serde::ser::Error>::custom)?
4364 .into_metadata();
4365 let value = serde_json::to_value(metadata)?;
4366 self.set_metadata_unchecked(SESSION_METADATA_KEY, value);
4367 Ok(())
4368 }
4369
4370 pub fn session_metadata(&self) -> Option<SessionMetadata> {
4375 match self.try_session_metadata() {
4376 Ok(metadata) => metadata,
4377 Err(err) => fail_closed_generated_restore("session-metadata", err),
4378 }
4379 }
4380
4381 pub fn try_session_metadata(&self) -> Result<Option<SessionMetadata>, serde_json::Error> {
4383 try_session_metadata_from_map(&self.metadata)
4384 }
4385
4386 pub fn set_system_context_state(
4388 &mut self,
4389 state: SessionSystemContextState,
4390 ) -> Result<(), serde_json::Error> {
4391 let state = system_context_authority::restore_system_context_state(state)
4392 .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
4393 let value = serde_json::to_value(state)?;
4394 self.set_metadata_unchecked(SESSION_SYSTEM_CONTEXT_STATE_KEY, value);
4395 Ok(())
4396 }
4397
4398 pub fn try_system_context_state(
4400 &self,
4401 ) -> Result<Option<SessionSystemContextState>, serde_json::Error> {
4402 self.metadata
4403 .get(SESSION_SYSTEM_CONTEXT_STATE_KEY)
4404 .map(|value| {
4405 let state = serde_json::from_value(value.clone())?;
4406 system_context_authority::restore_system_context_state(state)
4407 .map_err(<serde_json::Error as serde::de::Error>::custom)
4408 })
4409 .transpose()
4410 }
4411
4412 pub fn system_context_state(&self) -> Option<SessionSystemContextState> {
4418 match self.try_system_context_state() {
4419 Ok(state) => state,
4420 Err(err) => fail_closed_generated_restore("system-context", err),
4421 }
4422 }
4423
4424 pub fn set_deferred_turn_state(
4426 &mut self,
4427 state: SessionDeferredTurnState,
4428 ) -> Result<(), serde_json::Error> {
4429 let state = validate_deferred_turn_snapshot(state)
4430 .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
4431 let value = serde_json::to_value(state)?;
4432 self.set_metadata_unchecked(SESSION_DEFERRED_TURN_STATE_KEY, value);
4433 Ok(())
4434 }
4435
4436 pub fn try_deferred_turn_state(
4438 &self,
4439 ) -> Result<Option<SessionDeferredTurnState>, serde_json::Error> {
4440 self.metadata
4441 .get(SESSION_DEFERRED_TURN_STATE_KEY)
4442 .map(|value| {
4443 let state = serde_json::from_value(value.clone())?;
4444 validate_deferred_turn_snapshot(state)
4445 .map_err(<serde_json::Error as serde::de::Error>::custom)
4446 })
4447 .transpose()
4448 }
4449
4450 pub fn deferred_turn_state(&self) -> Option<SessionDeferredTurnState> {
4456 match self.try_deferred_turn_state() {
4457 Ok(state) => state,
4458 Err(err) => fail_closed_generated_restore("deferred-turn", err),
4459 }
4460 }
4461
4462 pub fn set_lifecycle_terminal(
4471 &mut self,
4472 terminal: SessionLifecycleTerminal,
4473 ) -> Result<(), serde_json::Error> {
4474 let value = serde_json::to_value(terminal)?;
4475 self.set_metadata_unchecked(SESSION_LIFECYCLE_TERMINAL_KEY, value);
4476 Ok(())
4477 }
4478
4479 pub fn try_lifecycle_terminal(
4484 &self,
4485 ) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
4486 try_lifecycle_terminal_from_map(&self.metadata)
4487 }
4488
4489 pub fn lifecycle_terminal(&self) -> Option<SessionLifecycleTerminal> {
4495 match self.try_lifecycle_terminal() {
4496 Ok(state) => state,
4497 Err(err) => fail_closed_generated_restore("session-lifecycle-terminal", err),
4498 }
4499 }
4500
4501 pub fn set_build_state(&mut self, state: SessionBuildState) -> Result<(), serde_json::Error> {
4503 let state = session_durable_config_authority::authorize_session_build_state_persist(state)
4504 .map_err(<serde_json::Error as serde::ser::Error>::custom)?
4505 .into_state();
4506 let value = serde_json::to_value(state)?;
4507 self.set_metadata_unchecked(SESSION_BUILD_STATE_KEY, value);
4508 Ok(())
4509 }
4510
4511 pub fn build_state(&self) -> Option<SessionBuildState> {
4516 match self.try_build_state() {
4517 Ok(state) => state,
4518 Err(err) => fail_closed_generated_restore("session-build-state", err),
4519 }
4520 }
4521
4522 pub fn try_build_state(&self) -> Result<Option<SessionBuildState>, serde_json::Error> {
4524 let Some(value) = self.metadata.get(SESSION_BUILD_STATE_KEY) else {
4525 return Ok(None);
4526 };
4527 let state = serde_json::from_value::<SessionBuildState>(value.clone())?;
4528 session_durable_config_authority::restore_session_build_state(state)
4529 .map(Some)
4530 .map_err(<serde_json::Error as serde::de::Error>::custom)
4531 }
4532
4533 pub fn set_tool_visibility_state(
4535 &mut self,
4536 state: AuthorizedSessionToolVisibilityState,
4537 ) -> Result<(), serde_json::Error> {
4538 let value = serde_json::to_value(state.into_state())?;
4539 self.set_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY, value);
4540 Ok(())
4541 }
4542
4543 #[cfg(test)]
4548 pub(crate) fn clear_tool_visibility_state(&mut self) {
4549 self.remove_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY);
4550 }
4551
4552 pub fn tool_visibility_state(
4554 &self,
4555 ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
4556 self.try_tool_visibility_state()
4557 }
4558
4559 pub fn try_tool_visibility_state(
4562 &self,
4563 ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
4564 self.metadata
4565 .get(SESSION_TOOL_VISIBILITY_STATE_KEY)
4566 .map(|value| serde_json::from_value(value.clone()))
4567 .transpose()
4568 }
4569
4570 pub fn transcript_history_state(
4572 &self,
4573 ) -> Result<Option<TranscriptHistoryState>, serde_json::Error> {
4574 self.metadata
4575 .get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
4576 .map(|value| serde_json::from_value(value.clone()))
4577 .transpose()
4578 }
4579
4580 pub fn compaction_projection_intents(
4583 &self,
4584 ) -> Result<Vec<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
4585 self.metadata
4586 .get(crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY)
4587 .map(|value| serde_json::from_value(value.clone()))
4588 .transpose()
4589 .map(Option::unwrap_or_default)
4590 }
4591
4592 pub fn validated_compaction_projection_intents(
4600 &self,
4601 ) -> Result<Vec<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
4602 self.validate_transcript_history_state()
4603 .map_err(|error| <serde_json::Error as serde::ser::Error>::custom(error.to_string()))?;
4604 let intents = self.compaction_projection_intents()?;
4605 let history = self.transcript_history_state()?;
4606 let commits = history
4607 .as_ref()
4608 .map(|history| history.commits.as_slice())
4609 .unwrap_or_default();
4610 let mut unique = std::collections::HashSet::new();
4611 for intent in &intents {
4612 if intent.projection.session_id() != self.id() {
4613 return Err(<serde_json::Error as serde::ser::Error>::custom(
4614 "compaction projection outbox intent has a foreign session id",
4615 ));
4616 }
4617 if !unique.insert(intent.projection.clone()) {
4618 return Err(<serde_json::Error as serde::ser::Error>::custom(
4619 "compaction projection outbox contains a duplicate rewrite identity",
4620 ));
4621 }
4622 let backed = commits.iter().any(|commit| {
4623 intent
4624 .projection
4625 .matches_transcript_rewrite(self.id(), commit)
4626 });
4627 if !backed {
4628 return Err(<serde_json::Error as serde::ser::Error>::custom(format!(
4629 "compaction projection outbox intent {} has no matching TranscriptRewriteCommit",
4630 intent.projection.revision()
4631 )));
4632 }
4633 }
4634 Ok(intents)
4635 }
4636
4637 pub fn add_compaction_projection_intent(
4640 &mut self,
4641 intent: crate::memory::CompactionProjectionIntent,
4642 ) -> Result<(), serde_json::Error> {
4643 if intent.projection.session_id() != self.id() {
4644 return Err(<serde_json::Error as serde::ser::Error>::custom(
4645 "compaction projection intent session does not match snapshot session",
4646 ));
4647 }
4648 self.validate_transcript_history_state()
4649 .map_err(|error| <serde_json::Error as serde::ser::Error>::custom(error.to_string()))?;
4650 let history = self.transcript_history_state()?.ok_or_else(|| {
4651 <serde_json::Error as serde::ser::Error>::custom(
4652 "compaction projection intent requires transcript history state",
4653 )
4654 })?;
4655 let owns_commit = history.commits.iter().any(|commit| {
4656 commit.parent_revision == intent.projection.parent_revision()
4657 && commit.revision == intent.projection.revision()
4658 && intent
4659 .projection
4660 .matches_transcript_rewrite(self.id(), commit)
4661 });
4662 if !owns_commit {
4663 return Err(<serde_json::Error as serde::ser::Error>::custom(
4664 "compaction projection intent is not backed by the session transcript graph",
4665 ));
4666 }
4667 let mut intents = self.validated_compaction_projection_intents()?;
4668 if let Some(existing) = intents
4669 .iter()
4670 .find(|existing| existing.projection == intent.projection)
4671 {
4672 if existing == &intent {
4673 return Ok(());
4674 }
4675 return Err(<serde_json::Error as serde::ser::Error>::custom(
4676 "compaction projection intent conflicts with an existing rewrite identity",
4677 ));
4678 }
4679 intents.push(intent);
4680 self.set_metadata_unchecked(
4681 crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
4682 serde_json::to_value(intents)?,
4683 );
4684 Ok(())
4685 }
4686
4687 pub fn complete_compaction_projection_intent(
4690 &mut self,
4691 projection: &crate::memory::CompactionProjectionId,
4692 ) -> Result<Option<crate::memory::CompactionProjectionIntent>, serde_json::Error> {
4693 let mut intents = self.compaction_projection_intents()?;
4694 let Some(position) = intents
4695 .iter()
4696 .position(|intent| &intent.projection == projection)
4697 else {
4698 return Ok(None);
4699 };
4700 let completed = intents.remove(position);
4701 if intents.is_empty() {
4702 self.remove_metadata_unchecked(
4703 crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
4704 );
4705 } else {
4706 self.set_metadata_unchecked(
4707 crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY,
4708 serde_json::to_value(intents)?,
4709 );
4710 }
4711 Ok(Some(completed))
4712 }
4713
4714 pub fn validate_transcript_history_state(&self) -> Result<(), TranscriptEditError> {
4716 let Some(state) = self
4717 .transcript_history_state()
4718 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
4719 else {
4720 return Ok(());
4721 };
4722 validate_transcript_history_state(&state)
4723 }
4724
4725 pub fn clear_transcript_history_state(&mut self) {
4728 self.remove_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY);
4729 }
4730
4731 pub fn set_runtime_checkpoint_provenance(&mut self) {
4737 self.set_metadata_unchecked(
4738 SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY,
4739 serde_json::Value::Bool(true),
4740 );
4741 }
4742
4743 pub fn clear_runtime_checkpoint_provenance(&mut self) {
4747 self.remove_metadata_unchecked(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY);
4748 }
4749
4750 pub fn has_runtime_checkpoint_provenance(&self) -> bool {
4754 self.metadata
4755 .get(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY)
4756 .and_then(serde_json::Value::as_bool)
4757 .unwrap_or(false)
4758 }
4759
4760 pub fn transcript_revision_body(
4762 &self,
4763 revision: &str,
4764 ) -> Result<Option<TranscriptRevisionBody>, serde_json::Error> {
4765 Ok(self.transcript_history_state()?.and_then(|state| {
4766 state
4767 .revisions
4768 .into_iter()
4769 .find(|body| body.revision == revision)
4770 }))
4771 }
4772
4773 pub fn transcript_revision_messages(
4775 &self,
4776 revision: &str,
4777 ) -> Result<Option<Vec<Message>>, serde_json::Error> {
4778 Ok(self
4779 .transcript_revision_body(revision)?
4780 .map(|body| body.messages))
4781 }
4782
4783 pub fn apply_transcript_history_state(
4785 &mut self,
4786 mut state: TranscriptHistoryState,
4787 ) -> Result<(), TranscriptEditError> {
4788 state.compact_mechanical_revision_bodies()?;
4789 let head_body = state
4790 .revisions
4791 .iter()
4792 .find(|body| body.revision == state.head)
4793 .ok_or_else(|| {
4794 TranscriptEditError::HistoryStateMalformed(format!(
4795 "missing transcript head body {}",
4796 state.head
4797 ))
4798 })?
4799 .clone();
4800 let realtime_state =
4801 self.reconciled_realtime_transcript_metadata_after_rewrite(&head_body.messages)?;
4802 let value = serde_json::to_value(&state)
4803 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4804 self.set_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, value);
4805 if let Some(value) = realtime_state {
4806 self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
4807 }
4808 let mut updated_at = head_body.created_at;
4809 for commit in &state.commits {
4810 if commit.committed_at > updated_at {
4811 updated_at = commit.committed_at;
4812 }
4813 }
4814 self.messages = Arc::new(head_body.messages);
4815 self.updated_at = updated_at;
4816 Ok(())
4817 }
4818
4819 pub fn transcript_revision(&self) -> Result<String, serde_json::Error> {
4822 if let Some(state) = self.transcript_history_state()? {
4823 Ok(state.head)
4824 } else {
4825 transcript_messages_digest(self.messages())
4826 }
4827 }
4828
4829 pub fn transcript_rewrite_generation(&self) -> Result<u64, serde_json::Error> {
4834 Ok(self.transcript_history_state()?.map_or(0, |state| {
4835 u64::try_from(state.commits.len()).unwrap_or(u64::MAX)
4836 }))
4837 }
4838
4839 pub fn commit_transcript_rewrite(
4841 &mut self,
4842 selection: TranscriptRewriteSelection,
4843 replacement: Vec<Message>,
4844 reason: TranscriptRewriteReason,
4845 actor: Option<String>,
4846 expected_parent_revision: Option<String>,
4847 ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
4848 let selection = selection.into_current_edit_semantic();
4849 if selection.semantic() == TranscriptRewriteSemantic::Compaction {
4850 return Err(TranscriptEditError::InvalidTranscriptShape(
4851 "typed compaction rewrites require a core-validated compaction witness".to_string(),
4852 ));
4853 }
4854 self.commit_transcript_rewrite_authorized(
4855 selection,
4856 replacement,
4857 reason,
4858 actor,
4859 expected_parent_revision,
4860 )
4861 }
4862
4863 fn commit_transcript_rewrite_authorized(
4864 &mut self,
4865 selection: TranscriptRewriteSelection,
4866 replacement: Vec<Message>,
4867 reason: TranscriptRewriteReason,
4868 actor: Option<String>,
4869 expected_parent_revision: Option<String>,
4870 ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
4871 let parent_revision = self
4872 .transcript_revision()
4873 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4874 if let Some(expected) = expected_parent_revision
4875 && expected != parent_revision
4876 {
4877 return Err(TranscriptEditError::RevisionConflict {
4878 expected,
4879 actual: parent_revision,
4880 });
4881 }
4882
4883 let (start, end) = selection.bounds();
4884 let message_count = self.messages.len();
4885 if start > end || end > message_count {
4886 return Err(TranscriptEditError::InvalidRewriteRange {
4887 start,
4888 end,
4889 message_count,
4890 });
4891 }
4892
4893 let replacement_len = replacement.len();
4894 let mut rewritten = Vec::with_capacity(
4895 start
4896 .saturating_add(replacement_len)
4897 .saturating_add(message_count.saturating_sub(end)),
4898 );
4899 rewritten.extend_from_slice(&self.messages[..start]);
4900 rewritten.extend(replacement);
4901 rewritten.extend_from_slice(&self.messages[end..]);
4902 validate_transcript_tool_result_shape(&rewritten)?;
4903
4904 let original_span_digest = transcript_messages_digest(&self.messages[start..end])
4905 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4906 let replacement_digest =
4907 transcript_messages_digest(&rewritten[start..start + replacement_len])
4908 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4909 let revision = transcript_messages_digest(&rewritten)
4910 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4911 if revision == parent_revision {
4912 return Err(TranscriptEditError::NoOpRewrite { revision });
4913 }
4914 let realtime_state =
4915 self.reconciled_realtime_transcript_metadata_after_rewrite(&rewritten)?;
4916
4917 let commit = TranscriptRewriteCommit {
4918 parent_revision,
4919 revision: revision.clone(),
4920 selection,
4921 original_span_digest,
4922 replacement_digest,
4923 messages_before: message_count,
4924 messages_after: rewritten.len(),
4925 reason,
4926 actor,
4927 committed_at: SystemTime::now(),
4928 };
4929
4930 let mut state = self
4931 .transcript_history_state()
4932 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
4933 .unwrap_or_else(|| TranscriptHistoryState {
4934 head: commit.parent_revision.clone(),
4935 commits: Vec::new(),
4936 revisions: Vec::new(),
4937 });
4938 if !state
4939 .revisions
4940 .iter()
4941 .any(|body| body.revision == commit.parent_revision)
4942 {
4943 state.revisions.push(TranscriptRevisionBody {
4944 revision: commit.parent_revision.clone(),
4945 parent_revision: None,
4946 messages: self.messages().to_vec(),
4947 created_at: self.updated_at,
4948 });
4949 }
4950 if !state
4951 .revisions
4952 .iter()
4953 .any(|body| body.revision == commit.revision)
4954 {
4955 state.revisions.push(TranscriptRevisionBody {
4956 revision: commit.revision.clone(),
4957 parent_revision: Some(commit.parent_revision.clone()),
4958 messages: rewritten.clone(),
4959 created_at: commit.committed_at,
4960 });
4961 }
4962 state.head = revision;
4963 state.commits.push(commit.clone());
4964 state.compact_mechanical_revision_bodies()?;
4965 let value = serde_json::to_value(state)
4966 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4967 self.set_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, value);
4968 if let Some(value) = realtime_state {
4969 self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
4970 }
4971
4972 self.messages = Arc::new(rewritten);
4973 self.updated_at = SystemTime::now();
4974 Ok(commit)
4975 }
4976
4977 fn transcript_history_state_after_message_mutation(
4978 &self,
4979 messages: &[Message],
4980 created_at: SystemTime,
4981 ) -> Result<Option<TranscriptHistoryState>, TranscriptEditError> {
4982 if !self
4983 .metadata
4984 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
4985 {
4986 return Ok(None);
4987 }
4988 let mut state = self
4989 .transcript_history_state()
4990 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?
4991 .ok_or_else(|| {
4992 TranscriptEditError::HistoryStateMalformed(
4993 "transcript history metadata key decoded without state".to_string(),
4994 )
4995 })?;
4996 state.compact_mechanical_revision_bodies()?;
4997 let head = transcript_messages_digest(messages)
4998 .map_err(|error| TranscriptEditError::HistoryStateMalformed(error.to_string()))?;
4999 if !state.revisions.iter().any(|body| body.revision == head) {
5000 state.revisions.push(TranscriptRevisionBody {
5001 revision: head.clone(),
5002 parent_revision: state.commits.last().map(|commit| commit.revision.clone()),
5003 messages: messages.to_vec(),
5004 created_at,
5005 });
5006 }
5007 state.head = head;
5008 state.compact_mechanical_revision_bodies()?;
5009 Ok(Some(state))
5010 }
5011
5012 fn refresh_transcript_head_after_message_mutation(&mut self) {
5013 match self
5014 .transcript_history_state_after_message_mutation(self.messages(), SystemTime::now())
5015 {
5016 Ok(Some(state)) => match serde_json::to_value(state) {
5017 Ok(value) => {
5018 self.set_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, value);
5019 }
5020 Err(error) => {
5021 tracing::warn!(
5022 session_id = %self.id,
5023 error = %error,
5024 "failed to serialize transcript history state after message mutation"
5025 );
5026 }
5027 },
5028 Ok(None) => {}
5029 Err(error) => {
5030 tracing::warn!(
5031 session_id = %self.id,
5032 error = %error,
5033 "transcript history state failed validation after message mutation"
5034 );
5035 }
5036 }
5037 }
5038
5039 pub fn set_mob_tool_authority_context(
5047 &mut self,
5048 authority_context: Option<MobToolAuthorityContext>,
5049 ) -> Result<(), serde_json::Error> {
5050 if let Some(authority_context) = authority_context.as_ref()
5051 && !authority_context.is_generated_authority_context()
5052 {
5053 return Err(<serde_json::Error as serde::de::Error>::custom(
5054 "mob authority context was not minted by generated authority",
5055 ));
5056 }
5057 let mut build_state = self.build_state().ok_or_else(|| {
5058 <serde_json::Error as serde::de::Error>::custom(format!(
5059 "session {} is missing session build state",
5060 self.id
5061 ))
5062 })?;
5063 build_state.mob_tool_authority_context = authority_context;
5064 self.set_build_state(build_state)
5065 }
5066
5067 pub fn mob_tool_authority_context(&self) -> Option<MobToolAuthorityContext> {
5072 self.build_state()
5073 .and_then(|state| state.mob_tool_authority_context)
5074 .filter(MobToolAuthorityContext::is_generated_authority_context)
5075 }
5076
5077 pub fn fork_at(&self, index: usize) -> Self {
5082 let now = SystemTime::now();
5083 let truncated = self.messages[..index.min(self.messages.len())].to_vec();
5084 Self {
5085 version: session_version(),
5086 id: SessionId::new(),
5087 messages: Arc::new(truncated),
5088 created_at: now,
5089 updated_at: now,
5090 metadata: self.fork_metadata_projection(),
5091 usage: self.usage.clone(),
5092 }
5093 }
5094
5095 pub fn fork_replacing(
5102 &self,
5103 message_index: usize,
5104 replacement: TranscriptReplacement,
5105 ) -> Result<Self, TranscriptEditError> {
5106 let Some(original) = self.messages.get(message_index) else {
5107 return Err(TranscriptEditError::MessageIndexOutOfBounds {
5108 message_index,
5109 message_count: self.messages.len(),
5110 });
5111 };
5112
5113 let replacement_message = match replacement {
5114 TranscriptReplacement::Message { message } => message,
5115 TranscriptReplacement::UserContentBlock { block_index, block } => {
5116 let Message::User(user) = original else {
5117 return Err(TranscriptEditError::MessageRoleMismatch {
5118 message_index,
5119 expected: "user",
5120 actual: message_role_name(original),
5121 });
5122 };
5123 if block_index >= user.content.len() {
5124 return Err(TranscriptEditError::BlockIndexOutOfBounds {
5125 block_kind: "user content block",
5126 block_index,
5127 block_count: user.content.len(),
5128 });
5129 }
5130 let mut edited = user.clone();
5131 edited.content[block_index] = block;
5132 Message::User(edited)
5133 }
5134 TranscriptReplacement::AssistantBlock { block_index, block } => {
5135 let Message::BlockAssistant(assistant) = original else {
5136 return Err(TranscriptEditError::MessageRoleMismatch {
5137 message_index,
5138 expected: "block_assistant",
5139 actual: message_role_name(original),
5140 });
5141 };
5142 if block_index >= assistant.blocks.len() {
5143 return Err(TranscriptEditError::BlockIndexOutOfBounds {
5144 block_kind: "assistant block",
5145 block_index,
5146 block_count: assistant.blocks.len(),
5147 });
5148 }
5149 let mut edited = assistant.clone();
5150 edited.blocks[block_index] = block;
5151 Message::BlockAssistant(edited)
5152 }
5153 TranscriptReplacement::ToolResultContentBlock {
5154 result_index,
5155 block_index,
5156 block,
5157 } => {
5158 let Message::ToolResults {
5159 results,
5160 created_at,
5161 } = original
5162 else {
5163 return Err(TranscriptEditError::MessageRoleMismatch {
5164 message_index,
5165 expected: "tool_results",
5166 actual: message_role_name(original),
5167 });
5168 };
5169 let Some(result) = results.get(result_index) else {
5170 return Err(TranscriptEditError::BlockIndexOutOfBounds {
5171 block_kind: "tool result",
5172 block_index: result_index,
5173 block_count: results.len(),
5174 });
5175 };
5176 if block_index >= result.content.len() {
5177 return Err(TranscriptEditError::BlockIndexOutOfBounds {
5178 block_kind: "tool result content block",
5179 block_index,
5180 block_count: result.content.len(),
5181 });
5182 }
5183 let mut edited_results = results.clone();
5184 edited_results[result_index].content[block_index] = block;
5185 Message::ToolResults {
5186 results: edited_results,
5187 created_at: *created_at,
5188 }
5189 }
5190 };
5191
5192 let mut forked = self.fork_at(message_index);
5193 forked.push(replacement_message);
5194 Ok(forked)
5195 }
5196
5197 pub fn fork(&self) -> Self {
5202 let now = SystemTime::now();
5203 Self {
5204 version: session_version(),
5205 id: SessionId::new(),
5206 messages: Arc::clone(&self.messages),
5207 created_at: now,
5208 updated_at: now,
5209 metadata: self.fork_metadata_projection(),
5210 usage: self.usage.clone(),
5211 }
5212 }
5213}
5214
5215impl Default for Session {
5216 fn default() -> Self {
5217 Self::new()
5218 }
5219}
5220
5221#[derive(Debug, Clone, Serialize, Deserialize)]
5223#[serde(rename_all = "snake_case")]
5224pub struct SessionMeta {
5225 pub id: SessionId,
5226 pub created_at: SystemTime,
5227 pub updated_at: SystemTime,
5228 pub message_count: usize,
5229 pub total_tokens: u64,
5230 #[serde(default)]
5231 pub metadata: serde_json::Map<String, serde_json::Value>,
5232}
5233
5234#[derive(Debug, Clone, Serialize, Deserialize)]
5236#[serde(rename_all = "snake_case")]
5237pub struct SessionMetadata {
5238 pub schema_version: u32,
5245 pub model: String,
5246 pub max_tokens: u32,
5247 #[serde(default = "crate::config::default_structured_output_retries")]
5248 pub structured_output_retries: u32,
5249 pub provider: Provider,
5250 #[serde(default, skip_serializing_if = "Option::is_none")]
5251 pub self_hosted_server_id: Option<String>,
5252 #[serde(default, skip_serializing_if = "Option::is_none")]
5255 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
5256 pub tooling: SessionTooling,
5257 #[serde(default)]
5258 pub keep_alive: bool,
5259 pub comms_name: Option<String>,
5260 #[serde(default, skip_serializing_if = "Option::is_none")]
5262 pub peer_meta: Option<PeerMeta>,
5263 #[serde(default, skip_serializing_if = "Option::is_none")]
5269 pub realm_id: Option<crate::RealmId>,
5270 #[serde(default, skip_serializing_if = "Option::is_none")]
5272 pub instance_id: Option<String>,
5273 #[serde(default, skip_serializing_if = "Option::is_none")]
5275 pub backend: Option<String>,
5276 #[serde(default, skip_serializing_if = "Option::is_none")]
5278 pub config_generation: Option<u64>,
5279 #[serde(default, skip_serializing_if = "Option::is_none")]
5289 pub auth_binding: Option<crate::AuthBindingRef>,
5290 #[serde(default, skip_serializing_if = "Option::is_none")]
5303 pub mob_member_binding: Option<crate::MobMemberBinding>,
5304}
5305
5306#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
5308#[serde(rename_all = "snake_case")]
5309pub struct SessionLlmIdentity {
5310 pub model: String,
5311 pub provider: Provider,
5312 #[serde(default, skip_serializing_if = "Option::is_none")]
5313 pub self_hosted_server_id: Option<String>,
5314 #[serde(default, skip_serializing_if = "Option::is_none")]
5316 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
5317 #[serde(default, skip_serializing_if = "Option::is_none")]
5330 pub auth_binding: Option<crate::AuthBindingRef>,
5331}
5332
5333pub struct SessionLlmIdentityOverride<'a> {
5341 pub model: Option<&'a str>,
5342 pub provider: Option<Provider>,
5343 pub provider_params:
5344 Option<TurnMetadataOverride<&'a crate::lifecycle::run_primitive::ProviderParamsOverride>>,
5345 pub auth_binding: Option<TurnMetadataOverride<&'a crate::AuthBindingRef>>,
5346}
5347
5348#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
5349pub enum SessionLlmIdentityOverrideError {
5350 #[error("provider override requires model on an existing session")]
5351 ProviderRequiresModel,
5352 #[error("{0}")]
5353 ProviderModelMismatch(String),
5354 #[error("self-hosted provider requires a registered model alias; '{model}' is not configured")]
5355 MissingSelfHostedAlias { model: String },
5356}
5357
5358pub fn resolve_session_llm_identity_override(
5366 current: &SessionLlmIdentity,
5367 registry: &crate::ModelRegistry,
5368 overrides: SessionLlmIdentityOverride<'_>,
5369) -> Result<SessionLlmIdentity, SessionLlmIdentityOverrideError> {
5370 if overrides.provider.is_some() && overrides.model.is_none() {
5371 return Err(SessionLlmIdentityOverrideError::ProviderRequiresModel);
5372 }
5373
5374 let model = overrides
5375 .model
5376 .map(str::to_string)
5377 .unwrap_or_else(|| current.model.clone());
5378 let provider = if let Some(provider) = overrides.provider {
5379 provider
5380 } else if overrides.model.is_some() {
5381 registry
5382 .entry(&model)
5383 .map_or(current.provider, |entry| entry.provider)
5384 } else {
5385 current.provider
5386 };
5387
5388 if (overrides.model.is_some() || overrides.provider.is_some())
5389 && let Some(reason) = registry.provider_override_mismatch_reason(provider, &model)
5390 {
5391 return Err(SessionLlmIdentityOverrideError::ProviderModelMismatch(
5392 reason,
5393 ));
5394 }
5395
5396 let provider_params = match overrides.provider_params {
5397 Some(TurnMetadataOverride::Clear) => None,
5398 Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
5399 None => current.provider_params.clone(),
5400 };
5401 let self_hosted_server_id = if provider == Provider::SelfHosted {
5402 if overrides.model.is_none() {
5403 current.self_hosted_server_id.clone().or_else(|| {
5404 registry
5405 .entry_for_provider(Provider::SelfHosted, &model)
5406 .and_then(|entry| entry.self_hosted.as_ref())
5407 .map(|server| server.server_id.clone())
5408 })
5409 } else {
5410 let entry = registry
5411 .entry_for_provider(Provider::SelfHosted, &model)
5412 .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
5413 model: model.clone(),
5414 })?;
5415 entry
5416 .self_hosted
5417 .as_ref()
5418 .map(|server| server.server_id.clone())
5419 }
5420 } else {
5421 None
5422 };
5423
5424 let auth_binding = match overrides.auth_binding {
5425 Some(TurnMetadataOverride::Clear) => None,
5426 Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
5427 None if provider != current.provider => None,
5430 None => current.auth_binding.clone(),
5431 };
5432
5433 Ok(SessionLlmIdentity {
5434 model,
5435 provider,
5436 self_hosted_server_id,
5437 provider_params,
5438 auth_binding,
5439 })
5440}
5441
5442#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
5449#[serde(rename_all = "snake_case")]
5450pub struct SessionLlmRequestPolicy {
5451 pub model: String,
5452 #[serde(default, skip_serializing_if = "Option::is_none")]
5454 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
5455 #[serde(default, skip_serializing_if = "Option::is_none")]
5457 pub provider_tool_defaults: Option<crate::lifecycle::run_primitive::ProviderTag>,
5458}
5459
5460impl SessionMetadata {
5461 pub fn llm_identity(&self) -> SessionLlmIdentity {
5463 SessionLlmIdentity {
5464 model: self.model.clone(),
5465 provider: self.provider,
5466 self_hosted_server_id: self.self_hosted_server_id.clone(),
5467 provider_params: self.provider_params.clone(),
5468 auth_binding: self.auth_binding.clone(),
5469 }
5470 }
5471
5472 pub fn apply_llm_identity(&mut self, identity: &SessionLlmIdentity) {
5474 self.model = identity.model.clone();
5475 self.provider = identity.provider;
5476 self.self_hosted_server_id = identity.self_hosted_server_id.clone();
5477 self.provider_params = identity.provider_params.clone();
5478 self.auth_binding = identity.auth_binding.clone();
5479 }
5480}
5481
5482pub const SESSION_METADATA_KEY: &str = "session_metadata";
5484
5485#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
5493#[serde(rename_all = "snake_case")]
5494pub enum ToolCategoryOverride {
5495 #[default]
5497 Inherit,
5498 Enable,
5500 Disable,
5502}
5503
5504impl ToolCategoryOverride {
5505 #[must_use]
5511 pub fn resolve(self, runtime_default: bool) -> bool {
5512 match self {
5513 Self::Enable => true,
5514 Self::Disable => false,
5515 Self::Inherit => runtime_default,
5516 }
5517 }
5518
5519 #[must_use]
5525 pub fn to_override(self) -> Option<bool> {
5526 match self {
5527 Self::Enable => Some(true),
5528 Self::Disable => Some(false),
5529 Self::Inherit => None,
5530 }
5531 }
5532
5533 #[must_use]
5541 pub fn from_effective(enabled: bool) -> Self {
5542 if enabled { Self::Enable } else { Self::Disable }
5543 }
5544
5545 #[must_use]
5555 pub fn from_override(value: Option<bool>) -> Self {
5556 match value {
5557 Some(true) => Self::Enable,
5558 Some(false) => Self::Disable,
5559 None => Self::Inherit,
5560 }
5561 }
5562}
5563
5564#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
5571#[serde(rename_all = "snake_case")]
5572pub struct SessionTooling {
5573 #[serde(default)]
5574 pub builtins: ToolCategoryOverride,
5575 #[serde(default)]
5576 pub shell: ToolCategoryOverride,
5577 #[serde(default)]
5578 pub comms: ToolCategoryOverride,
5579 #[serde(default)]
5581 pub mob: ToolCategoryOverride,
5582 #[serde(default)]
5584 pub memory: ToolCategoryOverride,
5585 #[serde(default)]
5587 pub schedule: ToolCategoryOverride,
5588 #[serde(default)]
5590 pub workgraph: ToolCategoryOverride,
5591 #[serde(default)]
5593 pub image_generation: ToolCategoryOverride,
5594 #[serde(default)]
5596 pub web_search: ToolCategoryOverride,
5597 #[serde(default, skip_serializing_if = "Option::is_none")]
5607 pub tool_access_policy: Option<crate::ops::ToolAccessPolicy>,
5608 #[serde(default, skip_serializing_if = "Option::is_none")]
5610 pub active_skills: Option<Vec<crate::skills::SkillKey>>,
5611}
5612
5613impl From<&Session> for SessionMeta {
5614 fn from(session: &Session) -> Self {
5615 Self {
5616 id: session.id.clone(),
5617 created_at: session.created_at,
5618 updated_at: session.updated_at,
5619 message_count: session.messages.len(),
5620 total_tokens: session.total_tokens(),
5621 metadata: session.metadata.clone(),
5622 }
5623 }
5624}
5625
5626pub fn try_session_metadata_from_map(
5635 metadata: &serde_json::Map<String, serde_json::Value>,
5636) -> Result<Option<SessionMetadata>, serde_json::Error> {
5637 let Some(value) = metadata.get(SESSION_METADATA_KEY) else {
5638 return Ok(None);
5639 };
5640 let mut metadata = serde_json::from_value::<SessionMetadata>(value.clone())?;
5641 metadata.schema_version =
5642 session_persistence_version_authority::restore_session_metadata_schema_version(
5643 metadata.schema_version,
5644 )
5645 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
5646 session_durable_config_authority::restore_session_metadata(metadata)
5647 .map(Some)
5648 .map_err(<serde_json::Error as serde::de::Error>::custom)
5649}
5650
5651pub fn try_lifecycle_terminal_from_map(
5658 metadata: &serde_json::Map<String, serde_json::Value>,
5659) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
5660 match metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY) {
5661 Some(value) => serde_json::from_value(value.clone()).map(Some),
5662 None => Ok(None),
5663 }
5664}
5665
5666#[derive(Debug, Clone)]
5674pub struct PersistedSessionMetadataView {
5675 pub session_id: SessionId,
5676 pub session_metadata: Option<SessionMetadata>,
5677 pub lifecycle_terminal: Option<SessionLifecycleTerminal>,
5678}
5679
5680impl PersistedSessionMetadataView {
5681 pub fn try_from_metadata_map(
5687 session_id: SessionId,
5688 metadata: &serde_json::Map<String, serde_json::Value>,
5689 ) -> Result<Self, serde_json::Error> {
5690 Ok(Self {
5691 session_id,
5692 session_metadata: try_session_metadata_from_map(metadata)?,
5693 lifecycle_terminal: try_lifecycle_terminal_from_map(metadata)?,
5694 })
5695 }
5696
5697 pub fn try_from_session(session: &Session) -> Result<Self, serde_json::Error> {
5699 Ok(Self {
5700 session_id: session.id().clone(),
5701 session_metadata: session.try_session_metadata()?,
5702 lifecycle_terminal: session.try_lifecycle_terminal()?,
5703 })
5704 }
5705
5706 pub fn mob_member_binding(&self) -> Option<&crate::MobMemberBinding> {
5709 self.session_metadata.as_ref()?.mob_member_binding.as_ref()
5710 }
5711}
5712
5713#[cfg(test)]
5714#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
5715mod tests {
5716 use super::*;
5717 use crate::realtime_transcript::RealtimeTranscriptRole;
5718 use crate::types::{
5719 AssistantBlock, BlockAssistantMessage, ContentBlock, StopReason, SystemMessage, Usage,
5720 UserMessage,
5721 };
5722 use std::sync::Arc;
5723
5724 fn block_assistant_text(message: &BlockAssistantMessage) -> String {
5725 message
5726 .blocks
5727 .iter()
5728 .filter_map(|block| match block {
5729 AssistantBlock::Text { text, .. } => Some(text.as_str()),
5730 _ => None,
5731 })
5732 .collect()
5733 }
5734
5735 fn append_staged_user_image(
5740 session: &mut Session,
5741 event: &RealtimeTranscriptEvent,
5742 ) -> RealtimeTranscriptApplyOutcome {
5743 let RealtimeTranscriptEvent::UserContentFinal {
5744 idempotency_key,
5745 item_id,
5746 previous_item_id,
5747 content_index,
5748 content,
5749 } = event
5750 else {
5751 panic!("test helper requires user content final")
5752 };
5753 let [ContentBlock::Image { media_type, data }] = content.as_slice() else {
5754 panic!("test helper requires exactly one image")
5755 };
5756 let media_type = crate::image_generation::MediaType::canonical_str(media_type);
5757 let blob_id = match data {
5758 crate::types::ImageData::Inline { data } => {
5759 crate::blob::content_blob_id(&media_type, data)
5760 }
5761 crate::types::ImageData::Blob { blob_id } => blob_id.clone(),
5762 };
5763 let pending = crate::PendingRealtimeUserContentBlob {
5764 idempotency_key: idempotency_key.clone(),
5765 item_id: item_id.clone(),
5766 previous_item_id: previous_item_id.clone(),
5767 content_index: *content_index,
5768 blob_id,
5769 media_type,
5770 };
5771 assert_eq!(
5772 session
5773 .stage_pending_realtime_user_content_blob(pending.clone())
5774 .expect("test pending anchor should stage"),
5775 crate::generated::session_document::RealtimeUserContentBlobStageDisposition::StageNew
5776 );
5777 session.append_realtime_transcript_event(pending.canonical_event())
5778 }
5779
5780 #[test]
5781 fn transcript_digest_is_content_addressed() {
5782 let base_time = crate::types::message_timestamp_now();
5783 let stamped = vec![
5784 Message::User(UserMessage::text("turn one".to_string())),
5785 Message::BlockAssistant(BlockAssistantMessage {
5786 blocks: vec![AssistantBlock::Text {
5787 text: "answer one".to_string(),
5788 meta: None,
5789 }],
5790 stop_reason: StopReason::EndTurn,
5791 identity: crate::types::TranscriptMessageIdentity {
5792 interaction_id: None,
5793 run_id: Some(crate::lifecycle::RunId::new()),
5794 objective_id: None,
5795 },
5796 created_at: base_time,
5797 }),
5798 ];
5799 let mut restamped = stamped.clone();
5800 for message in &mut restamped {
5801 match message {
5802 Message::User(user) => {
5803 user.created_at = base_time + chrono::Duration::hours(2);
5804 }
5805 Message::BlockAssistant(assistant) => {
5806 assistant.identity = crate::types::TranscriptMessageIdentity {
5807 interaction_id: None,
5808 run_id: Some(crate::lifecycle::RunId::new()),
5809 objective_id: None,
5810 };
5811 assistant.created_at = base_time + chrono::Duration::hours(2);
5812 }
5813 _ => {}
5814 }
5815 }
5816 assert_eq!(
5817 transcript_messages_digest(&stamped).expect("digest"),
5818 transcript_messages_digest(&restamped).expect("digest"),
5819 "bookkeeping variance must not fork the transcript revision"
5820 );
5821
5822 let mut content_changed = stamped.clone();
5823 if let Message::User(user) = &mut content_changed[0] {
5824 user.content = vec![ContentBlock::Text {
5825 text: "a different turn".to_string(),
5826 }];
5827 }
5828 assert_ne!(
5829 transcript_messages_digest(&stamped).expect("digest"),
5830 transcript_messages_digest(&content_changed).expect("digest"),
5831 "content changes must fork the transcript revision"
5832 );
5833 }
5834
5835 #[test]
5836 fn public_generic_rewrite_api_rejects_typed_compaction_semantic() {
5837 let mut session = Session::new();
5838 session.push(Message::User(UserMessage::text("old context")));
5839 let error = session
5840 .commit_transcript_rewrite(
5841 TranscriptRewriteSelection::typed_compaction_for_test(0, 1),
5842 vec![Message::User(UserMessage::compaction_summary("summary"))],
5843 TranscriptRewriteReason::new("anything"),
5844 None,
5845 None,
5846 )
5847 .unwrap_err();
5848 assert!(matches!(
5849 error,
5850 TranscriptEditError::InvalidTranscriptShape(_)
5851 ));
5852 assert_eq!(session.messages().len(), 1);
5853 }
5854
5855 #[test]
5856 fn compaction_witness_authorizes_only_the_exact_validated_rebuild() {
5857 let mut session = Session::new();
5858 session.push(Message::User(UserMessage::text("old context one")));
5859 session.push(Message::User(UserMessage::text("old context two")));
5860 let validated = vec![Message::User(UserMessage::compaction_summary(
5861 "validated summary",
5862 ))];
5863 let authority = crate::agent::compact::ValidatedCompactionRewrite::for_test(
5864 session.messages(),
5865 &validated,
5866 )
5867 .unwrap();
5868 let error = session
5869 .replace_messages_for_compaction_internal(
5870 vec![Message::User(UserMessage::compaction_summary(
5871 "substituted summary",
5872 ))],
5873 &authority,
5874 )
5875 .unwrap_err();
5876 assert!(matches!(
5877 error,
5878 TranscriptEditError::InvalidTranscriptShape(_)
5879 ));
5880 assert_eq!(session.messages().len(), 2);
5881 }
5882
5883 #[test]
5884 fn semantic_marker_prevents_new_generic_compaction_forgery_and_heals_prior_data() {
5885 let mut session = Session::new();
5886 session.push(Message::User(UserMessage::text("old context one")));
5887 session.push(Message::User(UserMessage::text("old context two")));
5888 session
5889 .commit_transcript_rewrite(
5890 TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
5891 vec![Message::User(UserMessage::compaction_summary("summary"))],
5892 TranscriptRewriteReason::new("compaction"),
5893 None,
5894 None,
5895 )
5896 .unwrap();
5897 let session: Session =
5898 serde_json::from_value(serde_json::to_value(&session).unwrap()).unwrap();
5899 let history = session.transcript_history_state().unwrap().unwrap();
5900 assert_eq!(
5901 history.commits[0].selection.semantic(),
5902 TranscriptRewriteSemantic::Edit,
5903 "new generic rewrites retain an explicit typed edit marker after roundtrip"
5904 );
5905 assert_eq!(history.commits[0].reason.kind, "compaction");
5906
5907 let mut legacy = history;
5908 legacy.commits[0].selection = TranscriptRewriteSelection::MessageRange { start: 0, end: 2 };
5909 let legacy: TranscriptHistoryState =
5910 serde_json::from_value(serde_json::to_value(legacy).unwrap()).unwrap();
5911 assert_eq!(
5912 legacy.commits[0].selection.semantic(),
5913 TranscriptRewriteSemantic::Compaction,
5914 "marker-absent prior data derives compaction from typed transcript evidence"
5915 );
5916
5917 let mut ordinary = Session::new();
5918 ordinary.push(Message::User(UserMessage::text("ordinary old one")));
5919 ordinary.push(Message::User(UserMessage::text("ordinary old two")));
5920 ordinary
5921 .commit_transcript_rewrite(
5922 TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
5923 vec![Message::User(UserMessage::text("ordinary replacement"))],
5924 TranscriptRewriteReason::new("compaction"),
5925 None,
5926 None,
5927 )
5928 .unwrap();
5929 let history = ordinary.transcript_history_state().unwrap().unwrap();
5930 assert_eq!(
5931 history.commits[0].selection.semantic(),
5932 TranscriptRewriteSemantic::Edit,
5933 "free-form reason must not upgrade an ordinary edit"
5934 );
5935 }
5936
5937 fn legacy_rewrite_fixture() -> (TranscriptRewriteCommit, Vec<Message>, Vec<Message>) {
5938 let parent_messages = vec![
5939 Message::User(UserMessage::text("before rewrite".to_string())),
5940 Message::User(UserMessage::text("retained tail".to_string())),
5941 ];
5942 let revision_messages = vec![
5943 Message::User(UserMessage::text("after rewrite".to_string())),
5944 Message::User(UserMessage::text("retained tail".to_string())),
5945 ];
5946 let parent_revision =
5949 legacy_transcript_messages_digest(&parent_messages).expect("legacy parent digest");
5950 let revision =
5951 legacy_transcript_messages_digest(&revision_messages).expect("legacy revision digest");
5952 let commit = TranscriptRewriteCommit {
5953 parent_revision,
5954 revision,
5955 selection: TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
5956 original_span_digest: legacy_transcript_messages_digest(&parent_messages[0..1])
5957 .expect("legacy span digest"),
5958 replacement_digest: legacy_transcript_messages_digest(&revision_messages[0..1])
5959 .expect("legacy replacement digest"),
5960 messages_before: 2,
5961 messages_after: 2,
5962 reason: TranscriptRewriteReason::new("compaction"),
5963 actor: Some("legacy-test".to_string()),
5964 committed_at: SystemTime::now(),
5965 };
5966 (commit, parent_messages, revision_messages)
5967 }
5968
5969 #[test]
5970 fn legacy_transcript_history_state_heals_to_content_addressed_on_parse() {
5971 let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
5972 let state = TranscriptHistoryState {
5973 head: commit.revision.clone(),
5974 commits: vec![commit.clone()],
5975 revisions: vec![
5976 TranscriptRevisionBody {
5977 revision: commit.parent_revision.clone(),
5978 parent_revision: None,
5979 messages: parent_messages.clone(),
5980 created_at: SystemTime::now(),
5981 },
5982 TranscriptRevisionBody {
5983 revision: commit.revision.clone(),
5984 parent_revision: Some(commit.parent_revision),
5985 messages: revision_messages.clone(),
5986 created_at: SystemTime::now(),
5987 },
5988 ],
5989 };
5990 let value = serde_json::to_value(&state).expect("serialize legacy state");
5991 let healed: TranscriptHistoryState =
5992 serde_json::from_value(value).expect("parse legacy state");
5993
5994 let content_parent =
5995 transcript_messages_digest(&parent_messages).expect("content parent digest");
5996 let content_revision =
5997 transcript_messages_digest(&revision_messages).expect("content revision digest");
5998 assert_eq!(healed.head, content_revision, "head must re-derive");
5999 assert_eq!(healed.commits[0].parent_revision, content_parent);
6000 assert_eq!(healed.commits[0].revision, content_revision);
6001 assert_eq!(healed.revisions[0].revision, content_parent);
6002 assert_eq!(healed.revisions[1].revision, content_revision);
6003 assert_eq!(
6004 healed.revisions[1].parent_revision.as_deref(),
6005 Some(content_parent.as_str())
6006 );
6007 validate_transcript_history_state(&healed).expect("healed graph must validate");
6008
6009 let mut session = Session::new();
6012 session
6013 .apply_transcript_history_state(healed)
6014 .expect("apply healed graph");
6015 session
6016 .commit_transcript_rewrite(
6017 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
6018 vec![Message::User(UserMessage::text(
6019 "rewritten again".to_string(),
6020 ))],
6021 TranscriptRewriteReason::new("unit-test"),
6022 None,
6023 None,
6024 )
6025 .expect("extend healed graph with a new rewrite");
6026 session
6027 .validate_transcript_history_state()
6028 .expect("extended graph must validate");
6029 }
6030
6031 #[test]
6032 fn legacy_transcript_rewrite_record_heals_on_parse() {
6033 let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
6034 let record_value = serde_json::json!({
6035 "commit": commit,
6036 "parent_body": TranscriptRevisionBody {
6037 revision: commit.parent_revision.clone(),
6038 parent_revision: None,
6039 messages: parent_messages,
6040 created_at: SystemTime::now(),
6041 },
6042 "revision_body": TranscriptRevisionBody {
6043 revision: commit.revision.clone(),
6044 parent_revision: Some(commit.parent_revision),
6045 messages: revision_messages.clone(),
6046 created_at: SystemTime::now(),
6047 },
6048 });
6049 let healed: TranscriptRewriteRecord =
6050 serde_json::from_value(record_value).expect("parse legacy record");
6051 assert_eq!(
6052 healed.commit.revision,
6053 transcript_messages_digest(&revision_messages).expect("content digest")
6054 );
6055 TranscriptRewriteRecord::new(healed.commit, healed.parent_body, healed.revision_body)
6057 .expect("healed record must validate");
6058 }
6059
6060 #[test]
6061 fn corrupt_transcript_history_strings_stay_untouched_and_fail_validation() {
6062 let (commit, parent_messages, _revision_messages) = legacy_rewrite_fixture();
6063 let bogus = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
6064 let state = TranscriptHistoryState {
6065 head: bogus.to_string(),
6066 commits: Vec::new(),
6067 revisions: vec![TranscriptRevisionBody {
6068 revision: bogus.to_string(),
6069 parent_revision: None,
6070 messages: parent_messages,
6071 created_at: SystemTime::now(),
6072 }],
6073 };
6074 let _ = commit;
6075 let value = serde_json::to_value(&state).expect("serialize corrupt state");
6076 let parsed: TranscriptHistoryState =
6077 serde_json::from_value(value).expect("corrupt strings still parse");
6078 assert_eq!(
6079 parsed.head, bogus,
6080 "unverifiable strings must not be rewritten"
6081 );
6082 assert!(
6083 validate_transcript_history_state(&parsed).is_err(),
6084 "corrupt graph must keep failing validation"
6085 );
6086 }
6087
6088 #[test]
6092 fn replace_synthetic_notices_leaves_only_replacements_of_kind() {
6093 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
6094
6095 let mut session = Session::new();
6096 session.push(Message::User(UserMessage::text("hello".to_string())));
6097 session.push(Message::SystemNotice(SystemNoticeMessage::new(
6098 SystemNoticeKind::McpPending,
6099 "stale one",
6100 )));
6101 session.push(Message::SystemNotice(SystemNoticeMessage::new(
6102 SystemNoticeKind::McpPending,
6103 "stale two",
6104 )));
6105 session.push(Message::SystemNotice(SystemNoticeMessage::new(
6107 SystemNoticeKind::BackgroundJob,
6108 "other-kind",
6109 )));
6110
6111 session
6112 .replace_synthetic_notices(
6113 SystemNoticeKind::McpPending,
6114 vec![Message::SystemNotice(SystemNoticeMessage::new(
6115 SystemNoticeKind::McpPending,
6116 "fresh",
6117 ))],
6118 )
6119 .expect("notice refresh succeeds");
6120
6121 let mcp_pending: Vec<&SystemNoticeMessage> = session
6122 .messages()
6123 .iter()
6124 .filter_map(|message| match message {
6125 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending => {
6126 Some(notice)
6127 }
6128 _ => None,
6129 })
6130 .collect();
6131 assert_eq!(mcp_pending.len(), 1, "exactly one notice of the kind");
6132 assert_eq!(mcp_pending[0].body.as_deref(), Some("fresh"));
6133 assert!(
6134 session.messages().iter().any(|message| matches!(
6135 message,
6136 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::BackgroundJob
6137 )),
6138 "other-kind notices are untouched"
6139 );
6140
6141 session
6143 .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
6144 .expect("pure strip succeeds");
6145 assert!(
6146 !session.messages().iter().any(|message| matches!(
6147 message,
6148 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending
6149 )),
6150 "empty replacement clears the kind"
6151 );
6152 }
6153
6154 #[test]
6155 fn ordinary_appends_after_rewrite_coalesce_mechanical_revision_bodies() {
6156 let mut session = Session::new();
6157 for message in 0..133 {
6158 session.push(Message::User(UserMessage::text(format!(
6159 "seed message {message}"
6160 ))));
6161 }
6162 let parent = session.transcript_revision().expect("parent revision");
6163 session
6164 .commit_transcript_rewrite(
6165 TranscriptRewriteSelection::MessageRange {
6166 start: 132,
6167 end: 133,
6168 },
6169 vec![Message::User(UserMessage::text("edited question"))],
6170 TranscriptRewriteReason::new("unit-test-edit"),
6171 Some("unit-test".to_string()),
6172 Some(parent),
6173 )
6174 .expect("rewrite should commit");
6175
6176 for turn in 0..762 {
6177 session.push(Message::User(UserMessage::text(format!("turn {turn}"))));
6178 }
6179
6180 let state = session
6181 .transcript_history_state()
6182 .expect("history state should decode")
6183 .expect("rewrite should create history state");
6184 assert_eq!(session.messages().len(), 895);
6185 assert_eq!(state.commits.len(), 1, "ordinary appends are not rewrites");
6186 assert_eq!(
6187 state.revisions.len(),
6188 3,
6189 "one real rewrite retains its two audited endpoints plus one live head"
6190 );
6191 let retained_message_entries = state
6192 .revisions
6193 .iter()
6194 .map(|body| body.messages.len())
6195 .sum::<usize>();
6196 assert!(retained_message_entries <= 3 * session.messages().len());
6197
6198 let live_bytes = serde_json::to_vec(session.messages())
6199 .expect("live transcript should serialize")
6200 .len();
6201 let snapshot_bytes = serde_json::to_vec(&session)
6202 .expect("session snapshot should serialize")
6203 .len();
6204 assert!(
6205 snapshot_bytes <= live_bytes.saturating_mul(5).saturating_add(64 * 1024),
6206 "snapshot must remain linear in the live transcript: {snapshot_bytes} bytes for {live_bytes} live bytes"
6207 );
6208 }
6209
6210 #[test]
6211 fn repeated_synthetic_notice_refreshes_do_not_mint_rewrite_commits() {
6212 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
6213
6214 let mut session = Session::new();
6215 session.push(Message::User(UserMessage::text("before".to_string())));
6216 session
6217 .commit_transcript_rewrite(
6218 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
6219 vec![Message::User(UserMessage::text("after".to_string()))],
6220 TranscriptRewriteReason::new("unit-test-edit"),
6221 Some("unit-test".to_string()),
6222 None,
6223 )
6224 .expect("seed rewrite");
6225
6226 for refresh in 0..64 {
6227 session
6228 .replace_synthetic_notices(
6229 SystemNoticeKind::McpPending,
6230 vec![Message::SystemNotice(SystemNoticeMessage::new(
6231 SystemNoticeKind::McpPending,
6232 format!("refresh {refresh}"),
6233 ))],
6234 )
6235 .expect("mechanical refresh");
6236 }
6237
6238 let state = session
6239 .transcript_history_state()
6240 .expect("history state")
6241 .expect("seed rewrite history");
6242 assert_eq!(state.commits.len(), 1);
6243 assert_eq!(session.transcript_rewrite_generation().unwrap(), 1);
6244 assert_eq!(state.revisions.len(), 3);
6245 }
6246
6247 #[test]
6248 fn legacy_append_head_chain_compacts_during_session_restore() {
6249 let mut session = Session::new();
6250 session.push(Message::User(UserMessage::text("seed".to_string())));
6251 session
6252 .commit_transcript_rewrite(
6253 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
6254 vec![Message::User(UserMessage::text(
6255 "rewritten seed".to_string(),
6256 ))],
6257 TranscriptRewriteReason::new("unit-test-edit"),
6258 Some("unit-test".to_string()),
6259 None,
6260 )
6261 .expect("seed rewrite");
6262
6263 let mut legacy = session
6264 .transcript_history_state()
6265 .expect("history state")
6266 .expect("seed history");
6267 let mut messages = session.messages().to_vec();
6268 let mut previous_head = legacy.head.clone();
6269 for append in 0..32 {
6270 messages.push(Message::User(UserMessage::text(format!(
6271 "legacy append {append}"
6272 ))));
6273 let revision = transcript_messages_digest(&messages).expect("revision digest");
6274 legacy.revisions.push(TranscriptRevisionBody {
6275 revision: revision.clone(),
6276 parent_revision: Some(previous_head),
6277 messages: messages.clone(),
6278 created_at: SystemTime::now(),
6279 });
6280 previous_head = revision;
6281 }
6282 legacy.head = previous_head;
6283 assert_eq!(legacy.revisions.len(), 34, "fixture matches old shape");
6284
6285 let mut envelope = serde_json::to_value(&session).expect("base envelope");
6286 envelope["messages"] = serde_json::to_value(&messages).expect("legacy live messages");
6287 envelope["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY] =
6288 serde_json::to_value(&legacy).expect("legacy unbounded history");
6289 for body in envelope["metadata"][SESSION_TRANSCRIPT_HISTORY_STATE_KEY]["revisions"]
6290 .as_array_mut()
6291 .expect("legacy revisions")
6292 {
6293 body.as_object_mut()
6294 .expect("legacy revision body")
6295 .remove("parent_revision");
6296 }
6297 let raw = serde_json::to_vec(&envelope).expect("raw legacy bytes");
6298
6299 let restored: Session = serde_json::from_slice(&raw).expect("legacy restore");
6300 let compact = restored
6301 .transcript_history_state()
6302 .expect("compacted state")
6303 .expect("history retained");
6304 assert_eq!(compact.commits, legacy.commits);
6305 assert_eq!(compact.revisions.len(), 3);
6306 validate_transcript_history_state(&compact).expect("compacted history remains valid");
6307 let repaired = serde_json::to_vec(&restored).expect("repaired snapshot");
6308 assert!(
6309 repaired.len() * 4 < raw.len(),
6310 "repair should shed old bodies"
6311 );
6312 }
6313
6314 #[test]
6315 fn snapshot_compaction_does_not_launder_corrupt_old_body() {
6316 let mut session = Session::new();
6317 session.push(Message::User(UserMessage::text("seed".to_string())));
6318 session
6319 .commit_transcript_rewrite(
6320 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
6321 vec![Message::User(UserMessage::text("rewritten".to_string()))],
6322 TranscriptRewriteReason::new("unit-test-edit"),
6323 Some("unit-test".to_string()),
6324 None,
6325 )
6326 .expect("seed rewrite");
6327 let mut state = session
6328 .transcript_history_state()
6329 .expect("state")
6330 .expect("history");
6331 state.revisions.push(TranscriptRevisionBody {
6332 revision: "sha256:corrupt-old-body".to_string(),
6333 parent_revision: Some(state.head.clone()),
6334 messages: vec![Message::User(UserMessage::text("tampered".to_string()))],
6335 created_at: SystemTime::now(),
6336 });
6337 session.set_metadata_unchecked_for_test(
6338 SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
6339 serde_json::to_value(state).expect("corrupt history value"),
6340 );
6341
6342 assert!(
6343 serde_json::to_vec(&session).is_err(),
6344 "serialization must fail before pruning a corrupt old body"
6345 );
6346 }
6347
6348 #[test]
6349 fn transcript_history_rejects_stale_branch_after_digest_recurrence() {
6350 let mut restored = Session::new();
6351 restored.push(Message::User(UserMessage::text("A".to_string())));
6352 restored
6353 .commit_transcript_rewrite(
6354 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
6355 vec![Message::User(UserMessage::text("B".to_string()))],
6356 TranscriptRewriteReason::new("to-b"),
6357 Some("unit-test".to_string()),
6358 None,
6359 )
6360 .expect("A to B");
6361 let mut stale_branch = restored.clone();
6362 restored
6363 .commit_transcript_rewrite(
6364 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
6365 vec![Message::User(UserMessage::text("A".to_string()))],
6366 TranscriptRewriteReason::new("restore-a"),
6367 Some("unit-test".to_string()),
6368 None,
6369 )
6370 .expect("B back to A");
6371 stale_branch
6372 .commit_transcript_rewrite(
6373 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
6374 vec![Message::User(UserMessage::text("C".to_string()))],
6375 TranscriptRewriteReason::new("stale-b-to-c"),
6376 Some("unit-test".to_string()),
6377 None,
6378 )
6379 .expect("stale B to C is locally valid");
6380
6381 let stale_state = stale_branch
6382 .transcript_history_state()
6383 .expect("stale state")
6384 .expect("stale history");
6385 let stale_commit = stale_state.commits.last().expect("stale commit").clone();
6386 let stale_body = stale_state
6387 .revisions
6388 .iter()
6389 .find(|body| body.revision == stale_commit.revision)
6390 .expect("stale revision body")
6391 .clone();
6392 let mut forged = restored
6393 .transcript_history_state()
6394 .expect("restored state")
6395 .expect("restored history");
6396 forged.commits.push(stale_commit);
6397 forged.revisions.push(stale_body);
6398 forged.head = forged
6399 .commits
6400 .last()
6401 .expect("forged commit")
6402 .revision
6403 .clone();
6404
6405 assert!(
6406 validate_transcript_history_state(&forged).is_err(),
6407 "an old B<-A body edge cannot authorize stale B->C after B->A restored A"
6408 );
6409 }
6410
6411 #[test]
6412 fn transcript_history_rejects_orphan_head_parent_cycle() {
6413 let mut session = Session::new();
6414 session.push(Message::User(UserMessage::text("P".to_string())));
6415 session
6416 .commit_transcript_rewrite(
6417 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
6418 vec![Message::User(UserMessage::text("Q".to_string()))],
6419 TranscriptRewriteReason::new("valid"),
6420 Some("unit-test".to_string()),
6421 None,
6422 )
6423 .expect("valid seed rewrite");
6424 let mut state = session
6425 .transcript_history_state()
6426 .expect("state")
6427 .expect("history");
6428 let x_messages = vec![Message::User(UserMessage::text("X".to_string()))];
6429 let y_messages = vec![Message::User(UserMessage::text("Y".to_string()))];
6430 let x = transcript_messages_digest(&x_messages).expect("X digest");
6431 let y = transcript_messages_digest(&y_messages).expect("Y digest");
6432 state.revisions.push(TranscriptRevisionBody {
6433 revision: x.clone(),
6434 parent_revision: Some(y.clone()),
6435 messages: x_messages,
6436 created_at: SystemTime::now(),
6437 });
6438 state.revisions.push(TranscriptRevisionBody {
6439 revision: y,
6440 parent_revision: Some(x.clone()),
6441 messages: y_messages,
6442 created_at: SystemTime::now(),
6443 });
6444 state.head = x;
6445 session.set_metadata_unchecked_for_test(
6446 SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
6447 serde_json::to_value(state).expect("cyclic state"),
6448 );
6449
6450 assert!(
6451 serde_json::to_vec(&session).is_err(),
6452 "cyclic orphan head lineage must fail instead of looping"
6453 );
6454 }
6455
6456 #[test]
6457 fn mechanical_append_can_recur_to_an_audited_digest_without_mutating_its_body() {
6458 let a = Message::User(UserMessage::text("A".to_string()));
6459 let b = Message::User(UserMessage::text("B".to_string()));
6460 let mut session = Session::new();
6461 session.push(Message::User(UserMessage::text("X".to_string())));
6462 let first = session
6463 .commit_transcript_rewrite(
6464 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
6465 vec![a.clone(), b.clone()],
6466 TranscriptRewriteReason::new("to-a-b"),
6467 Some("unit-test".to_string()),
6468 None,
6469 )
6470 .expect("X to [A,B]");
6471 let h_parent = session
6472 .transcript_revision_body(&first.revision)
6473 .expect("H body")
6474 .expect("H retained")
6475 .parent_revision;
6476 session
6477 .commit_transcript_rewrite(
6478 TranscriptRewriteSelection::MessageRange { start: 0, end: 2 },
6479 vec![a],
6480 TranscriptRewriteReason::new("to-a"),
6481 Some("unit-test".to_string()),
6482 None,
6483 )
6484 .expect("[A,B] to [A]");
6485
6486 session.push(b);
6487
6488 let state = session
6489 .transcript_history_state()
6490 .expect("state")
6491 .expect("history");
6492 assert_eq!(state.head, first.revision);
6493 assert_eq!(session.transcript_revision().unwrap(), first.revision);
6494 assert_eq!(
6495 state
6496 .revisions
6497 .iter()
6498 .find(|body| body.revision == first.revision)
6499 .expect("recurred H body")
6500 .parent_revision,
6501 h_parent,
6502 "reusing an audited digest must not rewrite its occurrence metadata"
6503 );
6504 validate_transcript_history_state(&state).expect("recurred mechanical head is valid");
6505 }
6506
6507 #[test]
6511 fn replace_synthetic_notices_rejects_mismatched_kind_without_mutation() {
6512 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
6513
6514 let mut session = Session::new();
6515 session.push(Message::SystemNotice(SystemNoticeMessage::new(
6516 SystemNoticeKind::McpPending,
6517 "stale",
6518 )));
6519 let before = session.messages().to_vec();
6520
6521 let err = session
6522 .replace_synthetic_notices(
6523 SystemNoticeKind::McpPending,
6524 vec![Message::User(UserMessage::text("not a notice".to_string()))],
6525 )
6526 .expect_err("mismatched replacement must fail typed");
6527 assert!(
6528 matches!(err, TranscriptEditError::InvalidTranscriptShape(_)),
6529 "expected InvalidTranscriptShape, got {err:?}"
6530 );
6531 assert_eq!(
6532 session.messages(),
6533 before.as_slice(),
6534 "fault must leave the transcript unchanged (no partial strip)"
6535 );
6536 }
6537
6538 #[test]
6539 fn replace_synthetic_notices_rejects_malformed_history_atomically() {
6540 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
6541
6542 let mut session = Session::new();
6543 session.push(Message::User(UserMessage::text("before".to_string())));
6544 session
6545 .commit_transcript_rewrite(
6546 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
6547 vec![Message::User(UserMessage::text("after".to_string()))],
6548 TranscriptRewriteReason::new("unit-test-edit"),
6549 Some("unit-test".to_string()),
6550 None,
6551 )
6552 .expect("seed rewrite");
6553 session.push(Message::SystemNotice(SystemNoticeMessage::new(
6554 SystemNoticeKind::McpPending,
6555 "stale",
6556 )));
6557 let mut state = session
6558 .transcript_history_state()
6559 .expect("state")
6560 .expect("history");
6561 state.revisions[0].messages[0] = Message::User(UserMessage::text("tampered".to_string()));
6562 session.set_metadata_unchecked_for_test(
6563 SESSION_TRANSCRIPT_HISTORY_STATE_KEY,
6564 serde_json::to_value(state).expect("corrupt state"),
6565 );
6566 let before_messages = session.messages.clone();
6567 let before_metadata = session.metadata.clone();
6568 let before_updated_at = session.updated_at;
6569
6570 assert!(
6571 session
6572 .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
6573 .is_err()
6574 );
6575 assert_eq!(session.messages, before_messages);
6576 assert_eq!(session.metadata, before_metadata);
6577 assert_eq!(session.updated_at, before_updated_at);
6578 }
6579
6580 #[test]
6581 fn replace_synthetic_notices_rejects_durable_notice_kinds() {
6582 use crate::types::SystemNoticeKind;
6583
6584 let mut session = Session::new();
6585 let before = session.messages().to_vec();
6586 assert!(
6587 session
6588 .replace_synthetic_notices(SystemNoticeKind::Comms, Vec::new())
6589 .is_err()
6590 );
6591 assert_eq!(session.messages(), before);
6592 }
6593
6594 #[test]
6595 fn replace_synthetic_notices_preserves_persisted_mcp_pending_notice() {
6596 use crate::types::{SystemNoticeBlock, SystemNoticeKind, SystemNoticeMessage};
6597
6598 let mut session = Session::new();
6599 session.push(Message::SystemNotice(SystemNoticeMessage::with_block(
6600 SystemNoticeKind::McpPending,
6601 Some("persisted pending fact".to_string()),
6602 SystemNoticeBlock::Mcp {
6603 server_id: Some("server".to_string()),
6604 operation: None,
6605 phase: None,
6606 persisted: true,
6607 detail: None,
6608 pending_sources: Vec::new(),
6609 },
6610 )));
6611 let before = session.messages().to_vec();
6612
6613 session
6614 .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
6615 .expect("synthetic refresh must coexist with a durable notice of the same kind");
6616 assert_eq!(session.messages(), before);
6617 }
6618
6619 #[test]
6620 fn replace_synthetic_notices_replaces_projection_beside_persisted_mcp_fact() {
6621 use crate::types::{SystemNoticeBlock, SystemNoticeKind, SystemNoticeMessage};
6622
6623 let durable = Message::SystemNotice(SystemNoticeMessage::with_block(
6624 SystemNoticeKind::McpPending,
6625 Some("persisted pending fact".to_string()),
6626 SystemNoticeBlock::Mcp {
6627 server_id: Some("server".to_string()),
6628 operation: None,
6629 phase: None,
6630 persisted: true,
6631 detail: None,
6632 pending_sources: Vec::new(),
6633 },
6634 ));
6635 let stale = Message::SystemNotice(SystemNoticeMessage::new(
6636 SystemNoticeKind::McpPending,
6637 "stale synthetic projection",
6638 ));
6639 let fresh = Message::SystemNotice(SystemNoticeMessage::new(
6640 SystemNoticeKind::McpPending,
6641 "fresh synthetic projection",
6642 ));
6643 let mut session = Session::new();
6644 session.push(durable.clone());
6645 session.push(stale);
6646
6647 session
6648 .replace_synthetic_notices(SystemNoticeKind::McpPending, vec![fresh.clone()])
6649 .expect("synthetic refresh beside durable fact");
6650
6651 assert_eq!(session.messages(), &[durable, fresh]);
6652 }
6653
6654 #[test]
6655 fn transcript_rewrite_preserves_full_assistant_block_trace() {
6656 let mut session = Session::new();
6657 session.push(Message::User(UserMessage::text(
6658 "run the trace".to_string(),
6659 )));
6660 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
6661 vec![AssistantBlock::Text {
6662 text: "original assistant trace".to_string(),
6663 meta: None,
6664 }],
6665 StopReason::EndTurn,
6666 )));
6667
6668 let parent_revision = session.transcript_revision().expect("parent revision");
6669 let replacement = vec![
6670 Message::BlockAssistant(BlockAssistantMessage::new(
6671 vec![
6672 AssistantBlock::Text {
6673 text: "compacted assistant trace".to_string(),
6674 meta: None,
6675 },
6676 AssistantBlock::ToolUse {
6677 id: "toolu_trace".to_string(),
6678 name: "trace_probe".to_string(),
6679 args: serde_json::value::RawValue::from_string(
6680 r#"{"path":"N-3"}"#.to_string(),
6681 )
6682 .expect("valid tool args"),
6683 meta: None,
6684 },
6685 ],
6686 StopReason::ToolUse,
6687 )),
6688 Message::tool_results(vec![ToolResult::new(
6689 "toolu_trace".to_string(),
6690 "trace complete".to_string(),
6691 false,
6692 )]),
6693 ];
6694
6695 let commit = session
6696 .commit_transcript_rewrite(
6697 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6698 replacement,
6699 TranscriptRewriteReason::new("compaction"),
6700 Some("unit-test".to_string()),
6701 Some(parent_revision.clone()),
6702 )
6703 .expect("rewrite should commit");
6704
6705 assert_eq!(commit.parent_revision, parent_revision);
6706 let current = session
6707 .transcript_revision_messages(&commit.revision)
6708 .expect("history state should decode")
6709 .expect("current revision should be retained");
6710 let Message::BlockAssistant(assistant) = ¤t[1] else {
6711 panic!("replacement should remain a block assistant message");
6712 };
6713 assert!(assistant.blocks.iter().any(|block| matches!(
6714 block,
6715 AssistantBlock::ToolUse { name, args, .. }
6716 if name == "trace_probe" && args.get().contains("\"N-3\"")
6717 )));
6718
6719 let parent = session
6720 .transcript_revision_messages(&parent_revision)
6721 .expect("history state should decode")
6722 .expect("parent revision should remain retained");
6723 assert!(matches!(
6724 &parent[1],
6725 Message::BlockAssistant(assistant)
6726 if block_assistant_text(assistant).contains("original assistant trace")
6727 ));
6728 }
6729
6730 #[test]
6731 fn transcript_rewrite_rejects_trailing_block_assistant_tool_call() {
6732 let mut session = Session::new();
6733 session.push(Message::User(UserMessage::text("question".to_string())));
6734 session.push(Message::BlockAssistant(BlockAssistantMessage {
6735 blocks: vec![AssistantBlock::Text {
6736 text: "plain answer".to_string(),
6737 meta: None,
6738 }],
6739 stop_reason: StopReason::EndTurn,
6740 identity: crate::types::TranscriptMessageIdentity::default(),
6741 created_at: crate::types::message_timestamp_now(),
6742 }));
6743 let parent_revision = session.transcript_revision().expect("parent revision");
6744
6745 let err = session
6746 .commit_transcript_rewrite(
6747 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6748 vec![Message::BlockAssistant(BlockAssistantMessage::new(
6749 vec![AssistantBlock::ToolUse {
6750 id: "toolu_1".to_string(),
6751 name: "lookup".to_string(),
6752 args: serde_json::value::RawValue::from_string("{}".to_string())
6753 .expect("valid args"),
6754 meta: None,
6755 }],
6756 StopReason::ToolUse,
6757 ))],
6758 TranscriptRewriteReason::new("compaction"),
6759 Some("unit-test".to_string()),
6760 Some(parent_revision),
6761 )
6762 .expect_err("rewrite should reject trailing unresolved block-assistant tool call");
6763 assert!(matches!(
6764 err,
6765 TranscriptEditError::InvalidTranscriptShape(_)
6766 ));
6767 }
6768
6769 #[test]
6770 fn transcript_rewrite_rejects_no_op_self_edge() {
6771 let mut session = Session::new();
6772 session.push(Message::User(UserMessage::text(
6773 "keep this exact transcript".to_string(),
6774 )));
6775 session.push(Message::BlockAssistant(BlockAssistantMessage {
6776 blocks: vec![AssistantBlock::Text {
6777 text: "unchanged".to_string(),
6778 meta: None,
6779 }],
6780 stop_reason: StopReason::EndTurn,
6781 identity: crate::types::TranscriptMessageIdentity::default(),
6782 created_at: crate::types::message_timestamp_now(),
6783 }));
6784
6785 let parent_revision = session.transcript_revision().expect("parent revision");
6786 let err = session
6787 .commit_transcript_rewrite(
6788 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6789 vec![session.messages()[1].clone()],
6790 TranscriptRewriteReason::new("retry"),
6791 Some("unit-test".to_string()),
6792 Some(parent_revision.clone()),
6793 )
6794 .expect_err("same-content rewrite should not emit a self-edge commit");
6795
6796 assert!(matches!(
6797 err,
6798 TranscriptEditError::NoOpRewrite { revision } if revision == parent_revision
6799 ));
6800 assert!(
6801 session
6802 .transcript_history_state()
6803 .expect("history state should decode")
6804 .is_none()
6805 );
6806 }
6807
6808 #[test]
6809 fn transcript_rewrite_run_boundary_guard_accepts_rewrite_then_append() {
6810 let mut original = Session::new();
6811 original.push(Message::User(UserMessage::text("question".to_string())));
6812 original.push(Message::BlockAssistant(BlockAssistantMessage {
6813 blocks: vec![AssistantBlock::Text {
6814 text: "verbose answer".to_string(),
6815 meta: None,
6816 }],
6817 stop_reason: StopReason::EndTurn,
6818 identity: crate::types::TranscriptMessageIdentity::default(),
6819 created_at: crate::types::message_timestamp_now(),
6820 }));
6821
6822 let parent_revision = original.transcript_revision().expect("parent revision");
6823 let mut incoming = original.clone();
6824 incoming
6825 .commit_transcript_rewrite(
6826 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6827 vec![Message::BlockAssistant(BlockAssistantMessage {
6828 blocks: vec![AssistantBlock::Text {
6829 text: "compact answer".to_string(),
6830 meta: None,
6831 }],
6832 stop_reason: StopReason::EndTurn,
6833 identity: crate::types::TranscriptMessageIdentity::default(),
6834 created_at: crate::types::message_timestamp_now(),
6835 })],
6836 TranscriptRewriteReason::new("compaction"),
6837 Some("unit-test".to_string()),
6838 Some(parent_revision),
6839 )
6840 .expect("rewrite should commit");
6841 incoming.push(Message::User(UserMessage::text("follow-up".to_string())));
6842 incoming.push(Message::BlockAssistant(BlockAssistantMessage {
6843 blocks: vec![AssistantBlock::Text {
6844 text: "follow-up answer".to_string(),
6845 meta: None,
6846 }],
6847 stop_reason: StopReason::EndTurn,
6848 identity: crate::types::TranscriptMessageIdentity::default(),
6849 created_at: crate::types::message_timestamp_now(),
6850 }));
6851
6852 crate::session_store::run_boundary_snapshot_save_guard(&incoming, Some(&original))
6853 .expect("rewrite plus appended turn should be a valid run-boundary commit");
6854 }
6855
6856 #[test]
6857 fn transcript_rewrite_rejects_orphaned_tool_results() {
6858 let mut session = Session::new();
6859 session.push(Message::User(UserMessage::text("use a tool".to_string())));
6860 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
6861 vec![AssistantBlock::ToolUse {
6862 id: "toolu_1".to_string(),
6863 name: "lookup".to_string(),
6864 args: serde_json::value::RawValue::from_string("{}".to_string())
6865 .expect("valid args"),
6866 meta: None,
6867 }],
6868 StopReason::ToolUse,
6869 )));
6870 session.push(Message::tool_results(vec![ToolResult::new(
6871 "toolu_1".to_string(),
6872 "done".to_string(),
6873 false,
6874 )]));
6875 let parent_revision = session.transcript_revision().expect("parent revision");
6876
6877 let err = session
6878 .commit_transcript_rewrite(
6879 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6880 vec![Message::BlockAssistant(BlockAssistantMessage {
6881 blocks: vec![AssistantBlock::Text {
6882 text: "no tool after all".to_string(),
6883 meta: None,
6884 }],
6885 stop_reason: StopReason::EndTurn,
6886 identity: crate::types::TranscriptMessageIdentity::default(),
6887 created_at: crate::types::message_timestamp_now(),
6888 })],
6889 TranscriptRewriteReason::new("compaction"),
6890 Some("unit-test".to_string()),
6891 Some(parent_revision),
6892 )
6893 .expect_err("rewrite should reject stranded tool results");
6894 assert!(matches!(
6895 err,
6896 TranscriptEditError::InvalidTranscriptShape(_)
6897 ));
6898 }
6899
6900 #[test]
6901 fn transcript_rewrite_rejects_trailing_assistant_tool_call() {
6902 let mut session = Session::new();
6903 session.push(Message::User(UserMessage::text("question".to_string())));
6904 session.push(Message::BlockAssistant(BlockAssistantMessage {
6905 blocks: vec![AssistantBlock::Text {
6906 text: "plain answer".to_string(),
6907 meta: None,
6908 }],
6909 stop_reason: StopReason::EndTurn,
6910 identity: crate::types::TranscriptMessageIdentity::default(),
6911 created_at: crate::types::message_timestamp_now(),
6912 }));
6913 let parent_revision = session.transcript_revision().expect("parent revision");
6914
6915 let err = session
6916 .commit_transcript_rewrite(
6917 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6918 vec![Message::BlockAssistant(BlockAssistantMessage {
6919 blocks: vec![AssistantBlock::ToolUse {
6920 id: "toolu_1".to_string(),
6921 name: "lookup".to_string(),
6922 args: serde_json::value::RawValue::from_string("{}".to_string())
6923 .expect("valid args"),
6924 meta: None,
6925 }],
6926 stop_reason: StopReason::ToolUse,
6927 identity: crate::types::TranscriptMessageIdentity::default(),
6928 created_at: crate::types::message_timestamp_now(),
6929 })],
6930 TranscriptRewriteReason::new("compaction"),
6931 Some("unit-test".to_string()),
6932 Some(parent_revision),
6933 )
6934 .expect_err("rewrite should reject trailing unresolved tool call");
6935 assert!(matches!(
6936 err,
6937 TranscriptEditError::InvalidTranscriptShape(_)
6938 ));
6939 }
6940
6941 #[test]
6942 fn transcript_rewrite_rejects_duplicate_tool_results() {
6943 let mut session = Session::new();
6944 session.push(Message::User(UserMessage::text("use a tool".to_string())));
6945 session.push(Message::BlockAssistant(BlockAssistantMessage {
6946 blocks: vec![AssistantBlock::Text {
6947 text: "plain answer".to_string(),
6948 meta: None,
6949 }],
6950 stop_reason: StopReason::EndTurn,
6951 identity: crate::types::TranscriptMessageIdentity::default(),
6952 created_at: crate::types::message_timestamp_now(),
6953 }));
6954 let parent_revision = session.transcript_revision().expect("parent revision");
6955
6956 let err = session
6957 .commit_transcript_rewrite(
6958 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6959 vec![
6960 Message::BlockAssistant(BlockAssistantMessage::new(
6961 vec![AssistantBlock::ToolUse {
6962 id: "toolu_1".to_string(),
6963 name: "lookup".to_string(),
6964 args: serde_json::value::RawValue::from_string("{}".to_string())
6965 .expect("valid args"),
6966 meta: None,
6967 }],
6968 StopReason::ToolUse,
6969 )),
6970 Message::tool_results(vec![
6971 ToolResult::new("toolu_1".to_string(), "one".to_string(), false),
6972 ToolResult::new("toolu_1".to_string(), "two".to_string(), false),
6973 ]),
6974 ],
6975 TranscriptRewriteReason::new("compaction"),
6976 Some("unit-test".to_string()),
6977 Some(parent_revision),
6978 )
6979 .expect_err("rewrite should reject duplicate tool results");
6980 assert!(matches!(
6981 err,
6982 TranscriptEditError::InvalidTranscriptShape(_)
6983 ));
6984 }
6985
6986 #[test]
6987 fn transcript_rewrite_record_rejects_prefix_or_suffix_tampering() {
6988 let mut session = Session::new();
6989 session.push(Message::System(SystemMessage::new("keep prefix")));
6990 session.push(Message::BlockAssistant(BlockAssistantMessage {
6991 blocks: vec![AssistantBlock::Text {
6992 text: "verbose answer".to_string(),
6993 meta: None,
6994 }],
6995 stop_reason: StopReason::EndTurn,
6996 identity: crate::types::TranscriptMessageIdentity::default(),
6997 created_at: crate::types::message_timestamp_now(),
6998 }));
6999 session.push(Message::User(UserMessage::text("keep suffix".to_string())));
7000
7001 let parent_revision = session.transcript_revision().expect("parent revision");
7002 let commit = session
7003 .commit_transcript_rewrite(
7004 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
7005 vec![Message::BlockAssistant(BlockAssistantMessage {
7006 blocks: vec![AssistantBlock::Text {
7007 text: "compact answer".to_string(),
7008 meta: None,
7009 }],
7010 stop_reason: StopReason::EndTurn,
7011 identity: crate::types::TranscriptMessageIdentity::default(),
7012 created_at: crate::types::message_timestamp_now(),
7013 })],
7014 TranscriptRewriteReason::new("compaction"),
7015 Some("unit-test".to_string()),
7016 Some(parent_revision),
7017 )
7018 .expect("rewrite should commit");
7019 let state = session
7020 .transcript_history_state()
7021 .expect("history state should decode")
7022 .expect("history state should exist");
7023 let parent_body = state
7024 .revisions
7025 .iter()
7026 .find(|body| body.revision == commit.parent_revision)
7027 .expect("parent body retained")
7028 .clone();
7029 let revision_body = state
7030 .revisions
7031 .iter()
7032 .find(|body| body.revision == commit.revision)
7033 .expect("revision body retained")
7034 .clone();
7035
7036 let mut forged_body = revision_body;
7037 forged_body.messages[0] = Message::System(SystemMessage::new("tampered prefix"));
7038 forged_body.revision =
7039 transcript_messages_digest(&forged_body.messages).expect("forged digest");
7040 let mut forged_commit = commit;
7041 forged_commit.revision = forged_body.revision.clone();
7042 let err = TranscriptRewriteRecord::new(forged_commit, parent_body, forged_body)
7043 .expect_err("record validation must reject changes outside selected span");
7044 assert!(
7045 err.to_string().contains("before the selected span"),
7046 "unexpected error: {err}"
7047 );
7048 }
7049
7050 #[test]
7051 fn transcript_rewrite_replay_allows_normal_turn_revisions_between_rewrites() {
7052 let mut session = Session::new();
7053 session.push(Message::User(UserMessage::text("first".to_string())));
7054 session.push(Message::BlockAssistant(BlockAssistantMessage {
7055 blocks: vec![AssistantBlock::Text {
7056 text: "verbose first answer".to_string(),
7057 meta: None,
7058 }],
7059 stop_reason: StopReason::EndTurn,
7060 identity: crate::types::TranscriptMessageIdentity::default(),
7061 created_at: crate::types::message_timestamp_now(),
7062 }));
7063
7064 let first_parent = session.transcript_revision().expect("first parent");
7065 let first_commit = session
7066 .commit_transcript_rewrite(
7067 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
7068 vec![Message::BlockAssistant(BlockAssistantMessage {
7069 blocks: vec![AssistantBlock::Text {
7070 text: "compact first answer".to_string(),
7071 meta: None,
7072 }],
7073 stop_reason: StopReason::EndTurn,
7074 identity: crate::types::TranscriptMessageIdentity::default(),
7075 created_at: crate::types::message_timestamp_now(),
7076 })],
7077 TranscriptRewriteReason::new("compaction"),
7078 Some("unit-test".to_string()),
7079 Some(first_parent),
7080 )
7081 .expect("first rewrite");
7082
7083 session.push(Message::User(UserMessage::text("normal turn".to_string())));
7084 session.push(Message::BlockAssistant(BlockAssistantMessage {
7085 blocks: vec![AssistantBlock::Text {
7086 text: "verbose second answer".to_string(),
7087 meta: None,
7088 }],
7089 stop_reason: StopReason::EndTurn,
7090 identity: crate::types::TranscriptMessageIdentity::default(),
7091 created_at: crate::types::message_timestamp_now(),
7092 }));
7093 let bridge_parent = session
7094 .transcript_revision()
7095 .expect("normal turn should advance transcript head");
7096 assert_ne!(bridge_parent, first_commit.revision);
7097 validate_transcript_history_state(
7098 &session
7099 .transcript_history_state()
7100 .expect("history state should decode")
7101 .expect("history state should exist"),
7102 )
7103 .expect("normal turn head may legitimately differ from last rewrite commit");
7104
7105 let second_commit = session
7106 .commit_transcript_rewrite(
7107 TranscriptRewriteSelection::MessageRange { start: 3, end: 4 },
7108 vec![Message::BlockAssistant(BlockAssistantMessage {
7109 blocks: vec![AssistantBlock::Text {
7110 text: "compact second answer".to_string(),
7111 meta: None,
7112 }],
7113 stop_reason: StopReason::EndTurn,
7114 identity: crate::types::TranscriptMessageIdentity::default(),
7115 created_at: crate::types::message_timestamp_now(),
7116 })],
7117 TranscriptRewriteReason::new("compaction"),
7118 Some("unit-test".to_string()),
7119 Some(bridge_parent.clone()),
7120 )
7121 .expect("second rewrite");
7122
7123 let state = session
7124 .transcript_history_state()
7125 .expect("history state should decode")
7126 .expect("history state should exist");
7127 let records = state.commits.iter().map(|commit| {
7128 let parent_body = state
7129 .revisions
7130 .iter()
7131 .find(|body| body.revision == commit.parent_revision)
7132 .expect("parent body retained")
7133 .clone();
7134 let revision_body = state
7135 .revisions
7136 .iter()
7137 .find(|body| body.revision == commit.revision)
7138 .expect("revision body retained")
7139 .clone();
7140 TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
7141 .expect("record should validate")
7142 });
7143
7144 let replayed = TranscriptHistoryState::from_rewrite_records(records)
7145 .expect("rewrite replay should accept normal-turn bridge revisions")
7146 .expect("rewrite records should exist");
7147 assert_eq!(replayed.head, second_commit.revision);
7148 assert!(
7149 replayed
7150 .revisions
7151 .iter()
7152 .any(|body| body.revision == bridge_parent)
7153 );
7154 }
7155
7156 #[test]
7157 fn transcript_rewrite_replay_rejects_branched_rewrite_records() {
7158 let mut base = Session::new();
7159 base.push(Message::User(UserMessage::text("question".to_string())));
7160 base.push(Message::BlockAssistant(BlockAssistantMessage {
7161 blocks: vec![AssistantBlock::Text {
7162 text: "verbose answer".to_string(),
7163 meta: None,
7164 }],
7165 stop_reason: StopReason::EndTurn,
7166 identity: crate::types::TranscriptMessageIdentity::default(),
7167 created_at: crate::types::message_timestamp_now(),
7168 }));
7169 let parent = base.transcript_revision().expect("parent revision");
7170
7171 let mut first = base.clone();
7172 let first_commit = first
7173 .commit_transcript_rewrite(
7174 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
7175 vec![Message::BlockAssistant(BlockAssistantMessage {
7176 blocks: vec![AssistantBlock::Text {
7177 text: "first compact answer".to_string(),
7178 meta: None,
7179 }],
7180 stop_reason: StopReason::EndTurn,
7181 identity: crate::types::TranscriptMessageIdentity::default(),
7182 created_at: crate::types::message_timestamp_now(),
7183 })],
7184 TranscriptRewriteReason::new("compaction"),
7185 Some("unit-test".to_string()),
7186 Some(parent.clone()),
7187 )
7188 .expect("first rewrite");
7189 let first_state = first
7190 .transcript_history_state()
7191 .expect("first state decodes")
7192 .expect("first state exists");
7193
7194 let mut second = base;
7195 let second_commit = second
7196 .commit_transcript_rewrite(
7197 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
7198 vec![Message::BlockAssistant(BlockAssistantMessage {
7199 blocks: vec![AssistantBlock::Text {
7200 text: "second compact answer".to_string(),
7201 meta: None,
7202 }],
7203 stop_reason: StopReason::EndTurn,
7204 identity: crate::types::TranscriptMessageIdentity::default(),
7205 created_at: crate::types::message_timestamp_now(),
7206 })],
7207 TranscriptRewriteReason::new("compaction"),
7208 Some("unit-test".to_string()),
7209 Some(parent),
7210 )
7211 .expect("second rewrite");
7212 let second_state = second
7213 .transcript_history_state()
7214 .expect("second state decodes")
7215 .expect("second state exists");
7216
7217 let record = |state: &TranscriptHistoryState, commit: &TranscriptRewriteCommit| {
7218 let parent_body = state
7219 .revisions
7220 .iter()
7221 .find(|body| body.revision == commit.parent_revision)
7222 .expect("parent body retained")
7223 .clone();
7224 let revision_body = state
7225 .revisions
7226 .iter()
7227 .find(|body| body.revision == commit.revision)
7228 .expect("revision body retained")
7229 .clone();
7230 TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
7231 .expect("record should validate")
7232 };
7233
7234 let err = TranscriptHistoryState::from_rewrite_records(vec![
7235 record(&first_state, &first_commit),
7236 record(&second_state, &second_commit),
7237 ])
7238 .expect_err("branched rewrite records must not replay as a linear source history");
7239 assert!(
7240 err.to_string().contains("does not extend transcript head"),
7241 "unexpected error: {err}"
7242 );
7243 }
7244
7245 #[test]
7246 fn internal_message_rewrites_refresh_transcript_history_head() {
7247 let mut session = Session::new();
7248 session.push(Message::User(UserMessage::text("question".to_string())));
7249 session.push(Message::BlockAssistant(BlockAssistantMessage {
7250 blocks: vec![AssistantBlock::Text {
7251 text: "verbose answer".to_string(),
7252 meta: None,
7253 }],
7254 stop_reason: StopReason::EndTurn,
7255 identity: crate::types::TranscriptMessageIdentity::default(),
7256 created_at: crate::types::message_timestamp_now(),
7257 }));
7258
7259 let parent = session.transcript_revision().expect("parent revision");
7260 session
7261 .commit_transcript_rewrite(
7262 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
7263 vec![Message::BlockAssistant(BlockAssistantMessage {
7264 blocks: vec![AssistantBlock::Text {
7265 text: "compact answer".to_string(),
7266 meta: None,
7267 }],
7268 stop_reason: StopReason::EndTurn,
7269 identity: crate::types::TranscriptMessageIdentity::default(),
7270 created_at: crate::types::message_timestamp_now(),
7271 })],
7272 TranscriptRewriteReason::new("compaction"),
7273 Some("unit-test".to_string()),
7274 Some(parent),
7275 )
7276 .expect("rewrite should commit");
7277
7278 session.push(Message::User(UserMessage::text(
7279 "notice-bearing turn".to_string(),
7280 )));
7281 let retained = session
7282 .messages()
7283 .iter()
7284 .filter(|message| {
7285 !matches!(
7286 message,
7287 Message::User(user)
7288 if user.content.iter().any(|block| matches!(
7289 block,
7290 ContentBlock::Text { text } if text.contains("notice-bearing")
7291 ))
7292 )
7293 })
7294 .cloned()
7295 .collect();
7296 session
7297 .replace_messages_internal(
7298 retained,
7299 TranscriptRewriteReason::new("synthetic_notice_cleanup"),
7300 )
7301 .expect("retain should commit internal rewrite");
7302 let retained_digest =
7303 transcript_messages_digest(session.messages()).expect("retained digest");
7304 assert_eq!(
7305 session.transcript_revision().expect("retained head"),
7306 retained_digest
7307 );
7308
7309 session
7310 .replace_messages_internal(
7311 vec![
7312 Message::User(UserMessage::text("compacted question".to_string())),
7313 Message::BlockAssistant(BlockAssistantMessage {
7314 blocks: vec![AssistantBlock::Text {
7315 text: "compacted answer".to_string(),
7316 meta: None,
7317 }],
7318 stop_reason: StopReason::EndTurn,
7319 identity: crate::types::TranscriptMessageIdentity::default(),
7320 created_at: crate::types::message_timestamp_now(),
7321 }),
7322 ],
7323 TranscriptRewriteReason::new("compaction"),
7324 )
7325 .expect("replace should commit internal rewrite");
7326 let replaced_digest =
7327 transcript_messages_digest(session.messages()).expect("replaced digest");
7328 assert_eq!(
7329 session.transcript_revision().expect("replaced head"),
7330 replaced_digest
7331 );
7332 let state = session
7333 .transcript_history_state()
7334 .expect("history state should decode")
7335 .expect("history state should exist");
7336 assert!(
7337 state
7338 .revisions
7339 .iter()
7340 .any(|body| body.revision == replaced_digest)
7341 );
7342 validate_transcript_history_state(&state).expect("history state remains valid");
7343 }
7344
7345 #[test]
7346 fn set_system_prompt_refreshes_transcript_history_head_after_rewrite() {
7347 let mut session = Session::new();
7348 session.push(Message::User(UserMessage::text("question".to_string())));
7349 session.push(Message::BlockAssistant(BlockAssistantMessage {
7350 blocks: vec![AssistantBlock::Text {
7351 text: "verbose answer".to_string(),
7352 meta: None,
7353 }],
7354 stop_reason: StopReason::EndTurn,
7355 identity: crate::types::TranscriptMessageIdentity::default(),
7356 created_at: crate::types::message_timestamp_now(),
7357 }));
7358
7359 let parent = session.transcript_revision().expect("parent revision");
7360 let rewrite = session
7361 .commit_transcript_rewrite(
7362 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
7363 vec![Message::BlockAssistant(BlockAssistantMessage {
7364 blocks: vec![AssistantBlock::Text {
7365 text: "compact answer".to_string(),
7366 meta: None,
7367 }],
7368 stop_reason: StopReason::EndTurn,
7369 identity: crate::types::TranscriptMessageIdentity::default(),
7370 created_at: crate::types::message_timestamp_now(),
7371 })],
7372 TranscriptRewriteReason::new("compaction"),
7373 Some("unit-test".to_string()),
7374 Some(parent),
7375 )
7376 .expect("rewrite should commit");
7377
7378 session.set_system_prompt("durable system prompt".to_string());
7379
7380 let head = session
7381 .transcript_revision()
7382 .expect("system prompt should refresh transcript head");
7383 assert_ne!(head, rewrite.revision);
7384 assert_eq!(
7385 head,
7386 transcript_messages_digest(session.messages()).expect("current digest")
7387 );
7388 let head_messages = session
7389 .transcript_revision_messages(&head)
7390 .expect("history state should decode")
7391 .expect("refreshed head body should be retained");
7392 assert_eq!(
7393 serde_json::to_value(&head_messages).expect("head serializes"),
7394 serde_json::to_value(session.messages()).expect("session serializes")
7395 );
7396 validate_transcript_history_state(
7397 &session
7398 .transcript_history_state()
7399 .expect("history state should decode")
7400 .expect("history state should exist"),
7401 )
7402 .expect("history state remains valid after system prompt update");
7403 }
7404
7405 #[test]
7406 fn apply_transcript_history_state_uses_latest_commit_time_for_restored_head() {
7407 let mut session = Session::new();
7408 session.push(Message::User(UserMessage::text("question".to_string())));
7409 session.push(Message::BlockAssistant(BlockAssistantMessage {
7410 blocks: vec![AssistantBlock::Text {
7411 text: "verbose answer".to_string(),
7412 meta: None,
7413 }],
7414 stop_reason: StopReason::EndTurn,
7415 identity: crate::types::TranscriptMessageIdentity::default(),
7416 created_at: crate::types::message_timestamp_now(),
7417 }));
7418 let original_messages = session.messages().to_vec();
7419 let parent = session.transcript_revision().expect("parent revision");
7420 let compact = session
7421 .commit_transcript_rewrite(
7422 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
7423 vec![Message::BlockAssistant(BlockAssistantMessage {
7424 blocks: vec![AssistantBlock::Text {
7425 text: "compact answer".to_string(),
7426 meta: None,
7427 }],
7428 stop_reason: StopReason::EndTurn,
7429 identity: crate::types::TranscriptMessageIdentity::default(),
7430 created_at: crate::types::message_timestamp_now(),
7431 })],
7432 TranscriptRewriteReason::new("compaction"),
7433 Some("unit-test".to_string()),
7434 Some(parent.clone()),
7435 )
7436 .expect("rewrite should commit");
7437
7438 std::thread::sleep(std::time::Duration::from_millis(2));
7439 let restore = session
7440 .commit_transcript_rewrite(
7441 TranscriptRewriteSelection::MessageRange {
7442 start: 0,
7443 end: session.messages().len(),
7444 },
7445 original_messages.clone(),
7446 TranscriptRewriteReason::new("restore"),
7447 Some("unit-test".to_string()),
7448 Some(compact.revision),
7449 )
7450 .expect("restore should commit");
7451 assert_eq!(restore.revision, parent);
7452
7453 let state = session
7454 .transcript_history_state()
7455 .expect("history state should decode")
7456 .expect("history state should exist");
7457 let restored_body_created_at = state
7458 .revisions
7459 .iter()
7460 .find(|body| body.revision == restore.revision)
7461 .expect("restored body should be retained")
7462 .created_at;
7463 assert!(
7464 restored_body_created_at < restore.committed_at,
7465 "test requires restore commit to be newer than retained body"
7466 );
7467
7468 let mut replayed = Session::new();
7469 replayed
7470 .apply_transcript_history_state(state)
7471 .expect("replay should materialize restored head");
7472 assert_eq!(
7473 serde_json::to_value(replayed.messages()).expect("replayed serializes"),
7474 serde_json::to_value(&original_messages).expect("original serializes")
7475 );
7476 assert_eq!(replayed.updated_at(), restore.committed_at);
7477 }
7478
7479 #[test]
7480 fn test_session_new() {
7481 let session = Session::new();
7482 assert_eq!(session.version(), SESSION_VERSION);
7483 assert!(session.messages().is_empty());
7484 assert!(session.created_at() <= session.updated_at());
7485 }
7486
7487 #[test]
7488 fn llm_identity_model_override_switches_to_catalog_provider() {
7489 let registry = crate::ModelRegistry::from_config(
7490 &crate::Config::default(),
7491 *crate::model_profile::test_catalog::TEST_CATALOG,
7492 )
7493 .unwrap();
7494 let current = SessionLlmIdentity {
7495 model: "test-anthropic-default".to_string(),
7496 provider: Provider::Anthropic,
7497 self_hosted_server_id: None,
7498 provider_params: None,
7499 auth_binding: Some(crate::AuthBindingRef {
7500 realm: crate::RealmId::parse("tenant_a").unwrap(),
7501 binding: crate::BindingId::parse("anthropic_default").unwrap(),
7502 profile: None,
7503 origin: crate::BindingOrigin::Configured,
7504 }),
7505 };
7506
7507 let resolved = resolve_session_llm_identity_override(
7508 ¤t,
7509 ®istry,
7510 SessionLlmIdentityOverride {
7511 model: Some("test-openai-default"),
7512 provider: None,
7513 provider_params: None,
7514 auth_binding: None,
7515 },
7516 )
7517 .unwrap();
7518
7519 assert_eq!(resolved.model, "test-openai-default");
7520 assert_eq!(resolved.provider, Provider::OpenAI);
7521 assert!(
7522 resolved.auth_binding.is_none(),
7523 "provider switches must not inherit a binding from the previous provider"
7524 );
7525 }
7526
7527 #[test]
7528 fn llm_identity_model_override_keeps_uncatalogued_model_on_current_provider() {
7529 let registry = crate::ModelRegistry::from_config(
7530 &crate::Config::default(),
7531 *crate::model_profile::test_catalog::TEST_CATALOG,
7532 )
7533 .unwrap();
7534 let current = SessionLlmIdentity {
7535 model: "custom-model".to_string(),
7536 provider: Provider::Anthropic,
7537 self_hosted_server_id: None,
7538 provider_params: None,
7539 auth_binding: None,
7540 };
7541
7542 let resolved = resolve_session_llm_identity_override(
7543 ¤t,
7544 ®istry,
7545 SessionLlmIdentityOverride {
7546 model: Some("uncatalogued-custom-model"),
7547 provider: None,
7548 provider_params: None,
7549 auth_binding: None,
7550 },
7551 )
7552 .unwrap();
7553
7554 assert_eq!(resolved.model, "uncatalogued-custom-model");
7555 assert_eq!(resolved.provider, Provider::Anthropic);
7556 }
7557
7558 #[test]
7559 fn realtime_transcript_append_is_idempotent_by_provider_item_and_delta_id() {
7560 let mut session = Session::new();
7561
7562 let user = RealtimeTranscriptEvent::UserTranscriptFinal {
7563 item_id: "item_user".to_string(),
7564 previous_item_id: None,
7565 content_index: 0,
7566 text: "hello".to_string(),
7567 };
7568 assert!(
7569 !session
7570 .append_realtime_transcript_event(user.clone())
7571 .is_inert()
7572 );
7573 assert!(session.append_realtime_transcript_event(user).is_inert());
7574
7575 let delta = RealtimeTranscriptEvent::AssistantTextDelta {
7576 response_id: "resp_assistant".to_string(),
7577 delta_id: "evt_delta_1".to_string(),
7578 item_id: "item_assistant".to_string(),
7579 previous_item_id: Some("item_user".to_string()),
7580 content_index: 0,
7581 delta: "hi".to_string(),
7582 };
7583 assert!(
7584 session
7585 .append_realtime_transcript_event(delta.clone())
7586 .is_inert()
7587 );
7588 assert!(session.append_realtime_transcript_event(delta).is_inert());
7589
7590 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
7591 response_id: "resp_assistant".to_string(),
7592 stop_reason: StopReason::EndTurn,
7593 usage: Usage::default(),
7594 };
7595 assert!(
7596 !session
7597 .append_realtime_transcript_event(terminal.clone())
7598 .is_inert()
7599 );
7600 assert!(
7601 session
7602 .append_realtime_transcript_event(terminal)
7603 .is_inert()
7604 );
7605
7606 assert_eq!(session.messages().len(), 2);
7607 assert!(matches!(
7608 &session.messages()[0],
7609 Message::User(user) if user.text_content() == "hello"
7610 ));
7611 assert!(matches!(
7612 &session.messages()[1],
7613 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "hi"
7614 ));
7615 }
7616
7617 #[test]
7618 fn realtime_user_image_materializes_once_and_unblocks_causal_assistant() {
7619 let mut session = Session::new();
7620 let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
7621 let image = RealtimeTranscriptEvent::UserContentFinal {
7622 idempotency_key: "image-request-1".to_string(),
7623 item_id: "item_image".to_string(),
7624 previous_item_id: None,
7625 content_index: 0,
7626 content: vec![ContentBlock::Image {
7627 media_type: "image/png".to_string(),
7628 data: crate::types::ImageData::Inline {
7629 data: image_data.clone(),
7630 },
7631 }],
7632 };
7633
7634 assert!(
7635 !append_staged_user_image(&mut session, &image).is_inert(),
7636 "first image final must materialize canonical user content"
7637 );
7638 let replay = session
7639 .preflight_realtime_user_content_event(&image)
7640 .expect("exact retry should preflight as committed");
7641 assert!(matches!(
7642 replay,
7643 crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(_)
7644 ));
7645
7646 let staged_state = session
7647 .metadata
7648 .get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY)
7649 .expect("realtime state must be persisted");
7650 assert!(
7651 !staged_state.to_string().contains(&image_data),
7652 "materialized image bytes must not remain duplicated in transcript metadata"
7653 );
7654
7655 assert!(
7656 session
7657 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
7658 response_id: "resp_image".to_string(),
7659 delta_id: "delta_image".to_string(),
7660 item_id: "item_assistant".to_string(),
7661 previous_item_id: Some("item_image".to_string()),
7662 content_index: 0,
7663 delta: "I see red.".to_string(),
7664 })
7665 .is_inert()
7666 );
7667 assert!(
7668 !session
7669 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
7670 response_id: "resp_image".to_string(),
7671 stop_reason: StopReason::EndTurn,
7672 usage: Usage::default(),
7673 },)
7674 .is_inert(),
7675 "materialized image predecessor must unblock the assistant response"
7676 );
7677
7678 assert_eq!(session.messages().len(), 2);
7679 assert!(matches!(
7680 &session.messages()[0],
7681 Message::User(user)
7682 if matches!(
7683 user.content.as_slice(),
7684 [ContentBlock::Image {
7685 media_type,
7686 data: crate::types::ImageData::Blob { blob_id },
7687 }] if media_type == "image/png"
7688 && blob_id == &crate::blob::content_blob_id("image/png", &image_data)
7689 )
7690 ));
7691 assert!(matches!(
7692 &session.messages()[1],
7693 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "I see red."
7694 ));
7695 }
7696
7697 #[test]
7698 fn realtime_user_image_identity_is_durable_canonical_and_conflict_safe() {
7699 let mut session = Session::new();
7700 let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
7701 let initial = RealtimeTranscriptEvent::UserContentFinal {
7702 idempotency_key: "stable-image-key".to_string(),
7703 item_id: "canonical-image-item".to_string(),
7704 previous_item_id: None,
7705 content_index: 0,
7706 content: vec![ContentBlock::Image {
7707 media_type: " image/PNG; charset=binary ".to_string(),
7708 data: crate::types::ImageData::Inline { data: data.clone() },
7709 }],
7710 };
7711 let committed = append_staged_user_image(&mut session, &initial);
7712 let Some(crate::RealtimeUserContentApplyOutcome::Committed(identity)) =
7713 committed.user_content
7714 else {
7715 panic!("first image must commit its durable identity");
7716 };
7717 assert_eq!(identity.item_id, "canonical-image-item");
7718 assert_eq!(identity.media_type, "image/png");
7719
7720 let encoded = serde_json::to_string(&session).expect("session should serialize");
7721 let restored: Session =
7722 serde_json::from_str(&encoded).expect("committed identity should restore");
7723
7724 let replay_event = RealtimeTranscriptEvent::UserContentFinal {
7725 idempotency_key: "stable-image-key".to_string(),
7726 item_id: "ignored-retry-item".to_string(),
7727 previous_item_id: None,
7728 content_index: 0,
7729 content: vec![ContentBlock::Image {
7730 media_type: "image/png".to_string(),
7731 data: crate::types::ImageData::Inline { data: data.clone() },
7732 }],
7733 };
7734 let replay = restored
7735 .preflight_realtime_user_content_event(&replay_event)
7736 .expect("exact retry should preflight");
7737 assert!(matches!(
7738 replay,
7739 crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(
7740 crate::RealtimeUserContentIdentity { ref item_id, .. }
7741 ) if item_id == "canonical-image-item"
7742 ));
7743
7744 let conflict = restored
7745 .preflight_realtime_user_content_event(&RealtimeTranscriptEvent::UserContentFinal {
7746 idempotency_key: "stable-image-key".to_string(),
7747 item_id: "conflicting-item".to_string(),
7748 previous_item_id: None,
7749 content_index: 0,
7750 content: vec![ContentBlock::Image {
7751 media_type: "image/png".to_string(),
7752 data: crate::types::ImageData::Inline {
7753 data: "different-payload".to_string(),
7754 },
7755 }],
7756 })
7757 .expect("conflicting retry should preflight");
7758 assert!(matches!(
7759 conflict,
7760 crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. }
7761 ));
7762
7763 let item_collision = restored
7764 .preflight_realtime_user_content_event(&RealtimeTranscriptEvent::UserContentFinal {
7765 idempotency_key: "another-key".to_string(),
7766 item_id: "canonical-image-item".to_string(),
7767 previous_item_id: None,
7768 content_index: 0,
7769 content: vec![ContentBlock::Image {
7770 media_type: "image/png".to_string(),
7771 data: crate::types::ImageData::Inline { data },
7772 }],
7773 })
7774 .expect("item collision should preflight");
7775 assert!(matches!(
7776 item_collision,
7777 crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. }
7778 ));
7779 assert_eq!(restored.messages().len(), 1);
7780 serde_json::to_string(&restored).expect("rejections must not corrupt durable state");
7781 }
7782
7783 #[test]
7784 fn realtime_user_image_reducer_never_receipts_without_pending_blob_proof() {
7785 for data in [
7786 crate::types::ImageData::Inline {
7787 data: "iVBORw0KGgo=".to_string(),
7788 },
7789 crate::types::ImageData::Blob {
7790 blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgo="),
7791 },
7792 ] {
7793 let mut session = Session::new();
7794 let outcome = session.append_realtime_transcript_event(
7795 RealtimeTranscriptEvent::UserContentFinal {
7796 idempotency_key: "unstaged-image-key".to_string(),
7797 item_id: "unstaged-image-item".to_string(),
7798 previous_item_id: None,
7799 content_index: 0,
7800 content: vec![ContentBlock::Image {
7801 media_type: "image/png".to_string(),
7802 data,
7803 }],
7804 },
7805 );
7806 assert!(matches!(
7807 outcome.user_content,
7808 Some(crate::RealtimeUserContentApplyOutcome::RejectedInvalidIdentity { .. })
7809 ));
7810 assert!(session.messages().is_empty());
7811 assert!(session.realtime_user_content_identities().is_empty());
7812 }
7813 }
7814
7815 #[test]
7816 fn realtime_user_image_pending_slot_is_generated_bounded_and_recovery_typed() {
7817 use crate::generated::session_document::{
7818 RealtimeUserContentBlobRecoveryDisposition, RealtimeUserContentBlobStageDisposition,
7819 };
7820 let mut session = Session::new();
7821 let pending = crate::PendingRealtimeUserContentBlob {
7822 idempotency_key: "pending-key-a".to_string(),
7823 item_id: "pending-item-a".to_string(),
7824 previous_item_id: None,
7825 content_index: 0,
7826 blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgo="),
7827 media_type: "image/png".to_string(),
7828 };
7829 let different = crate::PendingRealtimeUserContentBlob {
7830 idempotency_key: "pending-key-b".to_string(),
7831 item_id: "pending-item-b".to_string(),
7832 previous_item_id: None,
7833 content_index: 0,
7834 blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgoB"),
7835 media_type: "image/png".to_string(),
7836 };
7837 assert_eq!(
7838 session
7839 .stage_pending_realtime_user_content_blob(pending.clone())
7840 .expect("empty slot stages"),
7841 RealtimeUserContentBlobStageDisposition::StageNew
7842 );
7843 assert_eq!(
7844 session
7845 .stage_pending_realtime_user_content_blob(pending.clone())
7846 .expect("exact stage retry is idempotent"),
7847 RealtimeUserContentBlobStageDisposition::ReuseExact
7848 );
7849 assert_eq!(
7850 session
7851 .stage_pending_realtime_user_content_blob(different.clone())
7852 .expect("occupied decision is typed"),
7853 RealtimeUserContentBlobStageDisposition::RejectOccupied
7854 );
7855 assert_eq!(
7856 session.pending_realtime_user_content_blob(),
7857 Some(pending.clone())
7858 );
7859 assert_eq!(
7860 session
7861 .resolve_pending_realtime_user_content_blob_recovery(Some(&pending), false)
7862 .expect("exact recovery decision"),
7863 RealtimeUserContentBlobRecoveryDisposition::RetryExact
7864 );
7865 assert_eq!(
7866 session
7867 .resolve_pending_realtime_user_content_blob_recovery(Some(&different), true)
7868 .expect("verified older recovery decision"),
7869 RealtimeUserContentBlobRecoveryDisposition::CommitVerifiedBeforeCurrent
7870 );
7871 assert_eq!(
7872 session
7873 .resolve_pending_realtime_user_content_blob_recovery(Some(&different), false)
7874 .expect("invalid older recovery decision"),
7875 RealtimeUserContentBlobRecoveryDisposition::ClearInvalidBeforeCurrent
7876 );
7877 session
7878 .clear_invalid_pending_realtime_user_content_blob(Some(&different))
7879 .expect("generated clear-invalid disposition authorizes clear");
7880 assert!(session.pending_realtime_user_content_blob().is_none());
7881 }
7882
7883 #[test]
7884 fn transcript_rewrite_tombstones_removed_image_key_and_accepts_new_key() {
7885 let mut session = Session::new();
7886 let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
7887 let original = RealtimeTranscriptEvent::UserContentFinal {
7888 idempotency_key: "removed-image-key".to_string(),
7889 item_id: "removed-image-item".to_string(),
7890 previous_item_id: None,
7891 content_index: 0,
7892 content: vec![ContentBlock::Image {
7893 media_type: "image/png".to_string(),
7894 data: crate::types::ImageData::Inline { data: data.clone() },
7895 }],
7896 };
7897 assert!(matches!(
7898 append_staged_user_image(&mut session, &original).user_content,
7899 Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
7900 ));
7901
7902 let parent = session.transcript_revision().expect("parent revision");
7903 session
7904 .commit_transcript_rewrite(
7905 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7906 vec![Message::User(UserMessage::text("image removed"))],
7907 TranscriptRewriteReason::new("remove-image"),
7908 None,
7909 Some(parent),
7910 )
7911 .expect("rewrite should tombstone removed image identity");
7912
7913 assert!(session.realtime_user_content_identities().is_empty());
7914 assert_eq!(
7915 session.realtime_user_content_tombstones(),
7916 vec![crate::RealtimeUserContentTombstone {
7917 idempotency_key: "removed-image-key".to_string(),
7918 }]
7919 );
7920 assert!(matches!(
7921 session.preflight_realtime_user_content_event(&original),
7922 Some(crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. })
7923 ));
7924 assert!(matches!(
7925 session
7926 .append_realtime_transcript_event(original)
7927 .user_content,
7928 Some(crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. })
7929 ));
7930 assert_eq!(
7931 session.messages().len(),
7932 1,
7933 "stale retry emits no receipt content"
7934 );
7935
7936 let new_image = RealtimeTranscriptEvent::UserContentFinal {
7937 idempotency_key: "new-image-key".to_string(),
7938 item_id: "new-image-item".to_string(),
7939 previous_item_id: None,
7940 content_index: 0,
7941 content: vec![ContentBlock::Image {
7942 media_type: "image/png".to_string(),
7943 data: crate::types::ImageData::Inline { data },
7944 }],
7945 };
7946 assert!(matches!(
7947 append_staged_user_image(&mut session, &new_image).user_content,
7948 Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
7949 ));
7950 assert_eq!(session.messages().len(), 2);
7951
7952 let restored: Session = serde_json::from_str(
7953 &serde_json::to_string(&session).expect("serialize rewritten session"),
7954 )
7955 .expect("cold restore rewritten session");
7956 assert_eq!(restored.realtime_user_content_identities().len(), 1);
7957 assert_eq!(restored.realtime_user_content_tombstones().len(), 1);
7958 }
7959
7960 #[test]
7961 fn transcript_rewrite_retains_only_canonical_image_occurrence_for_exact_replay() {
7962 let mut session = Session::new();
7963 let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
7964 let original = RealtimeTranscriptEvent::UserContentFinal {
7965 idempotency_key: "retained-image-key".to_string(),
7966 item_id: "retained-image-item".to_string(),
7967 previous_item_id: None,
7968 content_index: 0,
7969 content: vec![ContentBlock::Image {
7970 media_type: "image/png".to_string(),
7971 data: crate::types::ImageData::Inline { data },
7972 }],
7973 };
7974 assert!(matches!(
7975 append_staged_user_image(&mut session, &original).user_content,
7976 Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
7977 ));
7978 let retained_message = session.messages()[0].clone();
7979 let parent = session.transcript_revision().expect("parent revision");
7980 session
7981 .commit_transcript_rewrite(
7982 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7983 vec![
7984 retained_message,
7985 Message::User(UserMessage::text("new canonical neighbor")),
7986 ],
7987 TranscriptRewriteReason::new("retain-image"),
7988 None,
7989 Some(parent),
7990 )
7991 .expect("rewrite retaining exact inline image should reconcile");
7992
7993 assert!(session.realtime_user_content_tombstones().is_empty());
7994 let replay = session
7995 .preflight_realtime_user_content_event(&original)
7996 .expect("retained image should preflight as exact replay");
7997 assert!(matches!(
7998 replay,
7999 crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(_)
8000 ));
8001 assert_eq!(session.messages().len(), 2);
8002 }
8003
8004 #[test]
8005 fn transcript_rewrite_rejects_atomically_while_image_blob_anchor_is_pending() {
8006 let mut session = Session::new();
8007 session.push(Message::User(UserMessage::text("before rewrite")));
8008 let pending = crate::PendingRealtimeUserContentBlob {
8009 idempotency_key: "pending-rewrite-key".to_string(),
8010 item_id: "pending-rewrite-item".to_string(),
8011 previous_item_id: None,
8012 content_index: 0,
8013 blob_id: crate::blob::content_blob_id("image/png", "pending-bytes"),
8014 media_type: "image/png".to_string(),
8015 };
8016 session
8017 .stage_pending_realtime_user_content_blob(pending.clone())
8018 .expect("stage durable pending anchor");
8019 let parent = session.transcript_revision().expect("parent revision");
8020 let error = session
8021 .commit_transcript_rewrite(
8022 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
8023 vec![Message::User(UserMessage::text("after rewrite"))],
8024 TranscriptRewriteReason::new("blocked-pending-image"),
8025 None,
8026 Some(parent),
8027 )
8028 .expect_err("rewrite must not cross an unresolved image anchor");
8029 assert!(
8030 error
8031 .to_string()
8032 .contains("history_rewrite_pending_user_content_blob")
8033 );
8034 assert!(matches!(
8035 &session.messages()[0],
8036 Message::User(user) if user.text_content() == "before rewrite"
8037 ));
8038 assert_eq!(session.pending_realtime_user_content_blob(), Some(pending));
8039 }
8040
8041 #[test]
8042 fn realtime_user_image_rejects_noncanonical_blob_and_multiblock_shape() {
8043 let mut session = Session::new();
8044 for (key, content) in [
8045 (
8046 "invalid-blob",
8047 vec![ContentBlock::Image {
8048 media_type: "image/png".to_string(),
8049 data: crate::types::ImageData::Blob {
8050 blob_id: crate::BlobId::new("sha256:not-a-digest"),
8051 },
8052 }],
8053 ),
8054 (
8055 "multi-block",
8056 vec![
8057 ContentBlock::Image {
8058 media_type: "image/png".to_string(),
8059 data: crate::types::ImageData::Inline {
8060 data: "payload".to_string(),
8061 },
8062 },
8063 ContentBlock::Text {
8064 text: "smuggled".to_string(),
8065 },
8066 ],
8067 ),
8068 ] {
8069 let outcome = session.append_realtime_transcript_event(
8070 RealtimeTranscriptEvent::UserContentFinal {
8071 idempotency_key: key.to_string(),
8072 item_id: format!("item-{key}"),
8073 previous_item_id: None,
8074 content_index: 0,
8075 content,
8076 },
8077 );
8078 assert!(matches!(
8079 outcome.user_content,
8080 Some(crate::RealtimeUserContentApplyOutcome::RejectedInvalidIdentity { .. })
8081 ));
8082 }
8083 assert!(session.messages().is_empty());
8084 let encoded = serde_json::to_string(&session).expect("session should serialize");
8085 serde_json::from_str::<Session>(&encoded).expect("rejections must leave restorable state");
8086 }
8087
8088 #[test]
8089 fn realtime_restore_rejects_malformed_causal_graphs_and_accepts_waiting_dag() {
8090 fn restore(
8091 items: serde_json::Value,
8092 first_seen_order: Vec<&str>,
8093 ) -> Result<
8094 crate::realtime_transcript_revision::SessionRealtimeTranscriptState,
8095 crate::realtime_transcript_revision::RealtimeTranscriptShellError,
8096 > {
8097 let state = serde_json::from_value(serde_json::json!({
8098 "items": items,
8099 "first_seen_order": first_seen_order,
8100 }))
8101 .expect("test state shape should deserialize");
8102 crate::realtime_transcript_revision::restore_realtime_transcript_state(state)
8103 }
8104
8105 assert!(
8106 restore(
8107 serde_json::json!({
8108 "child": { "role": "user", "previous_item_id": "missing" }
8109 }),
8110 vec!["child"],
8111 )
8112 .is_ok(),
8113 "an unmaterialized out-of-order item must survive cold restore until its predecessor arrives"
8114 );
8115 assert!(
8116 restore(
8117 serde_json::json!({
8118 "child": {
8119 "role": "user",
8120 "previous_item_id": "missing",
8121 "ready": true,
8122 "materialized": true
8123 }
8124 }),
8125 vec!["child"],
8126 )
8127 .is_err(),
8128 "a materialized item cannot reference a missing predecessor"
8129 );
8130 assert!(
8131 restore(
8132 serde_json::json!({
8133 "self": { "role": "user", "previous_item_id": "self" }
8134 }),
8135 vec!["self"],
8136 )
8137 .is_err(),
8138 "self edge must fail cold restore"
8139 );
8140 assert!(
8141 restore(
8142 serde_json::json!({
8143 "a": { "role": "user", "previous_item_id": "b" },
8144 "b": { "role": "user", "previous_item_id": "a" }
8145 }),
8146 vec!["a", "b"],
8147 )
8148 .is_err(),
8149 "cycle must fail cold restore"
8150 );
8151 assert!(
8152 restore(
8153 serde_json::json!({
8154 "root": { "role": "user" },
8155 "materialized_child": {
8156 "role": "user",
8157 "previous_item_id": "root",
8158 "ready": true,
8159 "materialized": true
8160 }
8161 }),
8162 vec!["root", "materialized_child"],
8163 )
8164 .is_err(),
8165 "materialized child cannot have unmaterialized ancestry"
8166 );
8167 assert!(
8168 restore(
8169 serde_json::json!({
8170 "root": { "role": "user" },
8171 "waiting_child": { "role": "user", "previous_item_id": "root" }
8172 }),
8173 vec!["waiting_child", "root"],
8174 )
8175 .is_ok(),
8176 "valid acyclic waiting graph should restore even when first-seen order is child-first"
8177 );
8178 }
8179
8180 #[test]
8181 fn realtime_restore_handles_long_waiting_chain_with_bounded_graph_walk() {
8182 const ITEM_COUNT: usize = 4_096;
8183 let mut items = serde_json::Map::new();
8184 let mut order = Vec::with_capacity(ITEM_COUNT);
8185 for index in 0..ITEM_COUNT {
8186 let item_id = format!("item-{index:04}");
8187 let value = if index == 0 {
8188 serde_json::json!({ "role": "user" })
8189 } else {
8190 serde_json::json!({
8191 "role": "user",
8192 "previous_item_id": format!("item-{:04}", index - 1),
8193 })
8194 };
8195 order.push(item_id.clone());
8196 items.insert(item_id, value);
8197 }
8198 let state = serde_json::from_value(serde_json::json!({
8199 "items": items,
8200 "first_seen_order": order,
8201 }))
8202 .expect("long-chain fixture should deserialize");
8203 crate::realtime_transcript_revision::restore_realtime_transcript_state(state)
8204 .expect("long valid waiting DAG should restore in one bounded graph walk");
8205 }
8206
8207 #[test]
8212 fn realtime_transcript_final_text_overrides_partial_delta_and_promotes_to_spoken_lane() {
8213 let mut session = Session::new();
8214
8215 assert!(
8218 session
8219 .append_realtime_transcript_event(
8220 RealtimeTranscriptEvent::AssistantTranscriptDelta {
8221 response_id: "resp_a".to_string(),
8222 delta_id: "evt_1".to_string(),
8223 item_id: "item_a".to_string(),
8224 previous_item_id: None,
8225 content_index: 0,
8226 delta: "incom".to_string(),
8227 }
8228 )
8229 .is_inert()
8230 );
8231
8232 assert!(
8234 session
8235 .append_realtime_transcript_event(
8236 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
8237 response_id: "resp_a".to_string(),
8238 item_id: "item_a".to_string(),
8239 content_index: 0,
8240 text: "complete answer".to_string(),
8241 }
8242 )
8243 .is_inert()
8244 );
8245
8246 let outcome = session.append_realtime_transcript_event(
8248 RealtimeTranscriptEvent::AssistantTurnCompleted {
8249 response_id: "resp_a".to_string(),
8250 stop_reason: StopReason::EndTurn,
8251 usage: Usage::default(),
8252 },
8253 );
8254 assert!(!outcome.is_inert());
8255
8256 assert_eq!(session.messages().len(), 1);
8259 match &session.messages()[0] {
8260 Message::BlockAssistant(assistant) => {
8261 let mut found_transcript = false;
8262 for block in &assistant.blocks {
8263 if let AssistantBlock::Transcript { text, .. } = block {
8264 assert_eq!(text, "complete answer");
8265 found_transcript = true;
8266 }
8267 }
8268 assert!(
8269 found_transcript,
8270 "AssistantTranscriptFinalText must promote to the Spoken lane and \
8271 materialize as AssistantBlock::Transcript"
8272 );
8273 }
8274 other => unreachable!("expected BlockAssistant, got {other:?}"),
8275 }
8276 }
8277
8278 #[test]
8281 fn realtime_transcript_final_text_creates_item_when_no_delta_staged() {
8282 let mut session = Session::new();
8283
8284 assert!(
8285 session
8286 .append_realtime_transcript_event(
8287 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
8288 response_id: "resp_a".to_string(),
8289 item_id: "item_a".to_string(),
8290 content_index: 0,
8291 text: "spoken-final-only".to_string(),
8292 }
8293 )
8294 .is_inert()
8295 );
8296
8297 let outcome = session.append_realtime_transcript_event(
8298 RealtimeTranscriptEvent::AssistantTurnCompleted {
8299 response_id: "resp_a".to_string(),
8300 stop_reason: StopReason::EndTurn,
8301 usage: Usage::default(),
8302 },
8303 );
8304 assert!(!outcome.is_inert());
8305
8306 assert_eq!(session.messages().len(), 1);
8307 match &session.messages()[0] {
8308 Message::BlockAssistant(assistant) => {
8309 let has_transcript = assistant.blocks.iter().any(|b| {
8310 matches!(b, AssistantBlock::Transcript { text, .. } if text == "spoken-final-only")
8311 });
8312 assert!(
8313 has_transcript,
8314 "final-only provider path must materialize as Transcript on the Spoken lane"
8315 );
8316 }
8317 other => unreachable!("expected BlockAssistant, got {other:?}"),
8318 }
8319 }
8320
8321 #[test]
8322 fn realtime_transcript_append_orders_causally_equivalent_out_of_order_items() {
8323 let mut session = Session::new();
8324
8325 assert!(
8326 session
8327 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
8328 response_id: "resp_assistant".to_string(),
8329 delta_id: "evt_delta_1".to_string(),
8330 item_id: "item_assistant".to_string(),
8331 previous_item_id: Some("item_user".to_string()),
8332 content_index: 0,
8333 delta: "answer".to_string(),
8334 })
8335 .is_inert()
8336 );
8337 assert!(
8338 session
8339 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
8340 response_id: "resp_assistant".to_string(),
8341 stop_reason: StopReason::EndTurn,
8342 usage: Usage::default(),
8343 })
8344 .is_inert()
8345 );
8346
8347 let outcome = session.append_realtime_transcript_event(
8348 RealtimeTranscriptEvent::UserTranscriptFinal {
8349 item_id: "item_user".to_string(),
8350 previous_item_id: None,
8351 content_index: 0,
8352 text: "question".to_string(),
8353 },
8354 );
8355
8356 assert_eq!(outcome.materialized_messages.len(), 2);
8357 assert_eq!(session.messages().len(), 2);
8358 assert!(matches!(
8359 &session.messages()[0],
8360 Message::User(user) if user.text_content() == "question"
8361 ));
8362 assert!(matches!(
8363 &session.messages()[1],
8364 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer"
8365 ));
8366 }
8367
8368 #[test]
8369 fn realtime_transcript_replay_of_seen_provider_items_is_inert() {
8370 let mut session = Session::new();
8371 let events = vec![
8372 RealtimeTranscriptEvent::UserTranscriptFinal {
8373 item_id: "item_user".to_string(),
8374 previous_item_id: None,
8375 content_index: 0,
8376 text: "hello".to_string(),
8377 },
8378 RealtimeTranscriptEvent::AssistantTextDelta {
8379 response_id: "resp_assistant".to_string(),
8380 delta_id: "evt_delta_1".to_string(),
8381 item_id: "item_assistant".to_string(),
8382 previous_item_id: Some("item_user".to_string()),
8383 content_index: 0,
8384 delta: "world".to_string(),
8385 },
8386 RealtimeTranscriptEvent::AssistantTurnCompleted {
8387 response_id: "resp_assistant".to_string(),
8388 stop_reason: StopReason::EndTurn,
8389 usage: Usage::default(),
8390 },
8391 ];
8392
8393 for event in events.iter().cloned() {
8394 let _ = session.append_realtime_transcript_event(event);
8395 }
8396 let first_messages = serde_json::to_value(session.messages()).unwrap();
8397
8398 for event in events {
8399 assert!(session.append_realtime_transcript_event(event).is_inert());
8400 }
8401
8402 assert_eq!(
8403 serde_json::to_value(session.messages()).unwrap(),
8404 first_messages
8405 );
8406 }
8407
8408 #[test]
8409 fn realtime_transcript_user_final_replay_cannot_erase_existing_segment() {
8410 let mut session = Session::new();
8411
8412 let user = RealtimeTranscriptEvent::UserTranscriptFinal {
8413 item_id: "item_user".to_string(),
8414 previous_item_id: None,
8415 content_index: 0,
8416 text: "remember amber lantern".to_string(),
8417 };
8418 assert!(
8419 !session
8420 .append_realtime_transcript_event(user.clone())
8421 .is_inert()
8422 );
8423 let first_messages = serde_json::to_value(session.messages()).unwrap();
8424
8425 assert!(
8426 session
8427 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
8428 item_id: "item_user".to_string(),
8429 previous_item_id: None,
8430 content_index: 0,
8431 text: String::new(),
8432 })
8433 .is_inert()
8434 );
8435 assert!(session.append_realtime_transcript_event(user).is_inert());
8436 assert_eq!(
8437 serde_json::to_value(session.messages()).unwrap(),
8438 first_messages
8439 );
8440 }
8441
8442 #[test]
8443 fn realtime_transcript_empty_user_final_can_be_filled_by_later_nonempty_replay() {
8444 let mut session = Session::new();
8445
8446 assert!(
8447 session
8448 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
8449 item_id: "item_user".to_string(),
8450 previous_item_id: None,
8451 content_index: 0,
8452 text: String::new(),
8453 })
8454 .is_inert()
8455 );
8456 assert!(session.messages().is_empty());
8457
8458 let outcome = session.append_realtime_transcript_event(
8459 RealtimeTranscriptEvent::UserTranscriptFinal {
8460 item_id: "item_user".to_string(),
8461 previous_item_id: None,
8462 content_index: 0,
8463 text: "remember amber lantern".to_string(),
8464 },
8465 );
8466 assert_eq!(outcome.materialized_messages.len(), 1);
8467 assert_eq!(session.messages().len(), 1);
8468 assert!(matches!(
8469 &session.messages()[0],
8470 Message::User(user) if user.text_content() == "remember amber lantern"
8471 ));
8472 }
8473
8474 #[test]
8475 fn realtime_transcript_skipped_provider_items_preserve_causal_order_without_content() {
8476 let mut session = Session::new();
8477
8478 let assistant_delta = RealtimeTranscriptEvent::AssistantTextDelta {
8479 response_id: "resp_assistant".to_string(),
8480 delta_id: "evt_delta_1".to_string(),
8481 item_id: "item_assistant".to_string(),
8482 previous_item_id: Some("item_tool".to_string()),
8483 content_index: 0,
8484 delta: "done".to_string(),
8485 };
8486 assert!(
8487 session
8488 .append_realtime_transcript_event(assistant_delta.clone())
8489 .is_inert()
8490 );
8491 let assistant_complete = RealtimeTranscriptEvent::AssistantTurnCompleted {
8492 response_id: "resp_assistant".to_string(),
8493 stop_reason: StopReason::EndTurn,
8494 usage: Usage::default(),
8495 };
8496 assert!(
8497 session
8498 .append_realtime_transcript_event(assistant_complete.clone())
8499 .is_inert()
8500 );
8501
8502 let skipped = RealtimeTranscriptEvent::ItemSkipped {
8503 item_id: "item_tool".to_string(),
8504 previous_item_id: Some("item_user".to_string()),
8505 };
8506 assert!(
8507 session
8508 .append_realtime_transcript_event(skipped.clone())
8509 .is_inert(),
8510 "a skipped provider item must not append transcript content"
8511 );
8512 assert!(session.messages().is_empty());
8513
8514 let outcome = session.append_realtime_transcript_event(
8515 RealtimeTranscriptEvent::UserTranscriptFinal {
8516 item_id: "item_user".to_string(),
8517 previous_item_id: None,
8518 content_index: 0,
8519 text: "please use the tool".to_string(),
8520 },
8521 );
8522 assert_eq!(outcome.materialized_messages.len(), 2);
8523 assert_eq!(session.messages().len(), 2);
8524 assert!(matches!(
8525 &session.messages()[0],
8526 Message::User(user) if user.text_content() == "please use the tool"
8527 ));
8528 assert!(matches!(
8529 &session.messages()[1],
8530 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "done"
8531 ));
8532
8533 let first_messages = serde_json::to_value(session.messages()).unwrap();
8534 assert!(session.append_realtime_transcript_event(skipped).is_inert());
8535 assert!(
8536 session
8537 .append_realtime_transcript_event(assistant_delta)
8538 .is_inert()
8539 );
8540 assert!(
8541 session
8542 .append_realtime_transcript_event(assistant_complete)
8543 .is_inert()
8544 );
8545 assert_eq!(
8546 serde_json::to_value(session.messages()).unwrap(),
8547 first_messages
8548 );
8549 }
8550
8551 #[test]
8552 fn realtime_transcript_interrupted_assistant_item_unblocks_later_provider_items() {
8553 let mut session = Session::new();
8560
8561 let _ = session.append_realtime_transcript_event(
8562 RealtimeTranscriptEvent::UserTranscriptFinal {
8563 item_id: "item_repeat".to_string(),
8564 previous_item_id: None,
8565 content_index: 0,
8566 text: "repeat until stop".to_string(),
8567 },
8568 );
8569 assert!(
8570 session
8571 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
8572 response_id: "resp_loop".to_string(),
8573 delta_id: "evt_loop_1".to_string(),
8574 item_id: "item_loop".to_string(),
8575 previous_item_id: Some("item_repeat".to_string()),
8576 content_index: 0,
8577 delta: "Looping now".to_string(),
8578 })
8579 .is_inert()
8580 );
8581 assert!(
8582 session
8583 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
8584 item_id: "item_stop".to_string(),
8585 previous_item_id: Some("item_loop".to_string()),
8586 content_index: 0,
8587 text: "Stop.".to_string(),
8588 })
8589 .is_inert(),
8590 "the stop turn waits until the interrupted assistant provider item is resolved"
8591 );
8592
8593 let outcome = session.append_realtime_transcript_event(
8594 RealtimeTranscriptEvent::AssistantTurnInterrupted {
8595 response_id: "resp_loop".to_string(),
8596 },
8597 );
8598
8599 assert_eq!(outcome.materialized_messages.len(), 2);
8602 assert_eq!(session.messages().len(), 3);
8604 assert!(matches!(
8605 &session.messages()[0],
8606 Message::User(user) if user.text_content() == "repeat until stop"
8607 ));
8608 match &session.messages()[1] {
8609 Message::BlockAssistant(assistant) => {
8610 let text = block_assistant_text(assistant);
8611 assert_eq!(text, "Looping now");
8612 }
8613 other => unreachable!(
8614 "Display lane assistant item must be retained on Interrupted, got {other:?}"
8615 ),
8616 }
8617 assert!(matches!(
8618 &session.messages()[2],
8619 Message::User(user) if user.text_content() == "Stop."
8620 ));
8621 }
8622
8623 #[test]
8624 fn realtime_transcript_late_interrupted_assistant_delta_stays_noncanonical() {
8625 let mut session = Session::new();
8626
8627 let _ = session.append_realtime_transcript_event(
8628 RealtimeTranscriptEvent::UserTranscriptFinal {
8629 item_id: "item_repeat".to_string(),
8630 previous_item_id: None,
8631 content_index: 0,
8632 text: "repeat until stop".to_string(),
8633 },
8634 );
8635 assert!(
8636 session
8637 .append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
8638 item_id: "item_loop".to_string(),
8639 previous_item_id: Some("item_repeat".to_string()),
8640 role: RealtimeTranscriptRole::Assistant,
8641 response_id: None,
8642 })
8643 .is_inert(),
8644 "provider can observe an assistant item before the adapter learns its response id"
8645 );
8646 assert!(
8647 session
8648 .append_realtime_transcript_event(
8649 RealtimeTranscriptEvent::AssistantTurnInterrupted {
8650 response_id: "resp_loop".to_string(),
8651 }
8652 )
8653 .is_inert(),
8654 "an interruption can arrive before delayed transcript deltas for the response"
8655 );
8656 assert!(
8657 session
8658 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
8659 item_id: "item_stop".to_string(),
8660 previous_item_id: Some("item_loop".to_string()),
8661 content_index: 0,
8662 text: "Stop.".to_string(),
8663 })
8664 .is_inert(),
8665 "the stop turn waits for the provider's interrupted assistant item anchor"
8666 );
8667
8668 let late_delta_outcome =
8669 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
8670 response_id: "resp_loop".to_string(),
8671 delta_id: "evt_loop_late".to_string(),
8672 item_id: "item_loop".to_string(),
8673 previous_item_id: Some("item_repeat".to_string()),
8674 content_index: 0,
8675 delta: "Looping now".to_string(),
8676 });
8677 assert_eq!(late_delta_outcome.materialized_messages.len(), 1);
8678 assert!(matches!(
8679 &session.messages()[1],
8680 Message::User(user) if user.text_content() == "Stop."
8681 ));
8682 assert!(
8683 session
8684 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
8685 response_id: "resp_loop".to_string(),
8686 stop_reason: StopReason::EndTurn,
8687 usage: Usage::default(),
8688 })
8689 .is_inert(),
8690 "late completion for an interrupted response must not resurrect its deltas"
8691 );
8692 assert!(
8693 session
8694 .messages()
8695 .iter()
8696 .filter_map(|message| match message {
8697 Message::BlockAssistant(assistant) => Some(block_assistant_text(assistant)),
8698 _ => None,
8699 })
8700 .all(|text| !text.contains("Looping now")),
8701 "late interrupted assistant text must remain non-canonical"
8702 );
8703 }
8704
8705 #[test]
8706 fn realtime_transcript_completion_only_finalizes_matching_response() {
8707 let mut session = Session::new();
8708
8709 let _ = session.append_realtime_transcript_event(
8710 RealtimeTranscriptEvent::UserTranscriptFinal {
8711 item_id: "item_user".to_string(),
8712 previous_item_id: None,
8713 content_index: 0,
8714 text: "question".to_string(),
8715 },
8716 );
8717 assert!(
8718 session
8719 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
8720 response_id: "resp_a".to_string(),
8721 delta_id: "evt_a".to_string(),
8722 item_id: "item_a".to_string(),
8723 previous_item_id: Some("item_user".to_string()),
8724 content_index: 0,
8725 delta: "answer a".to_string(),
8726 })
8727 .is_inert()
8728 );
8729
8730 assert!(
8731 session
8732 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
8733 response_id: "resp_b".to_string(),
8734 stop_reason: StopReason::EndTurn,
8735 usage: Usage::default(),
8736 })
8737 .is_inert(),
8738 "a completion for another response must not finalize buffered assistant text"
8739 );
8740 assert_eq!(session.messages().len(), 1);
8741
8742 let outcome = session.append_realtime_transcript_event(
8743 RealtimeTranscriptEvent::AssistantTurnCompleted {
8744 response_id: "resp_a".to_string(),
8745 stop_reason: StopReason::EndTurn,
8746 usage: Usage::default(),
8747 },
8748 );
8749 assert_eq!(outcome.materialized_messages.len(), 1);
8750 assert_eq!(session.messages().len(), 2);
8751 assert!(matches!(
8752 &session.messages()[1],
8753 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer a"
8754 ));
8755 }
8756
8757 #[test]
8758 fn realtime_transcript_completion_before_later_delta_is_response_scoped() {
8759 let mut session = Session::new();
8760
8761 let _ = session.append_realtime_transcript_event(
8762 RealtimeTranscriptEvent::UserTranscriptFinal {
8763 item_id: "item_user".to_string(),
8764 previous_item_id: None,
8765 content_index: 0,
8766 text: "question".to_string(),
8767 },
8768 );
8769 assert!(
8770 session
8771 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
8772 response_id: "resp_a".to_string(),
8773 stop_reason: StopReason::EndTurn,
8774 usage: Usage::default(),
8775 })
8776 .is_inert()
8777 );
8778 assert!(
8779 session
8780 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
8781 response_id: "resp_b".to_string(),
8782 delta_id: "evt_b".to_string(),
8783 item_id: "item_b".to_string(),
8784 previous_item_id: Some("item_user".to_string()),
8785 content_index: 0,
8786 delta: "wrong response".to_string(),
8787 })
8788 .is_inert(),
8789 "a later delta for another response must not be finalized by resp_a's pending completion"
8790 );
8791
8792 let outcome =
8793 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
8794 response_id: "resp_a".to_string(),
8795 delta_id: "evt_a".to_string(),
8796 item_id: "item_a".to_string(),
8797 previous_item_id: Some("item_user".to_string()),
8798 content_index: 0,
8799 delta: "right response".to_string(),
8800 });
8801
8802 assert_eq!(outcome.materialized_messages.len(), 1);
8803 assert_eq!(session.messages().len(), 2);
8804 assert!(matches!(
8805 &session.messages()[1],
8806 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "right response"
8807 ));
8808 }
8809
8810 #[test]
8811 fn realtime_transcript_late_duplicate_completion_cannot_finalize_unrelated_response() {
8812 let mut session = Session::new();
8813
8814 let _ = session.append_realtime_transcript_event(
8815 RealtimeTranscriptEvent::UserTranscriptFinal {
8816 item_id: "item_user".to_string(),
8817 previous_item_id: None,
8818 content_index: 0,
8819 text: "question".to_string(),
8820 },
8821 );
8822 let _ =
8823 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
8824 response_id: "resp_a".to_string(),
8825 delta_id: "evt_a".to_string(),
8826 item_id: "item_a".to_string(),
8827 previous_item_id: Some("item_user".to_string()),
8828 content_index: 0,
8829 delta: "first".to_string(),
8830 });
8831 let _ = session.append_realtime_transcript_event(
8832 RealtimeTranscriptEvent::AssistantTurnCompleted {
8833 response_id: "resp_a".to_string(),
8834 stop_reason: StopReason::EndTurn,
8835 usage: Usage::default(),
8836 },
8837 );
8838 assert_eq!(session.messages().len(), 2);
8839
8840 assert!(
8841 session
8842 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
8843 response_id: "resp_b".to_string(),
8844 delta_id: "evt_b".to_string(),
8845 item_id: "item_b".to_string(),
8846 previous_item_id: Some("item_a".to_string()),
8847 content_index: 0,
8848 delta: "second".to_string(),
8849 })
8850 .is_inert()
8851 );
8852 assert!(
8853 session
8854 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
8855 response_id: "resp_a".to_string(),
8856 stop_reason: StopReason::EndTurn,
8857 usage: Usage::default(),
8858 })
8859 .is_inert(),
8860 "a duplicate late terminal for resp_a must not finalize resp_b"
8861 );
8862 assert_eq!(session.messages().len(), 2);
8863
8864 let outcome = session.append_realtime_transcript_event(
8865 RealtimeTranscriptEvent::AssistantTurnCompleted {
8866 response_id: "resp_b".to_string(),
8867 stop_reason: StopReason::EndTurn,
8868 usage: Usage::default(),
8869 },
8870 );
8871 assert_eq!(outcome.materialized_messages.len(), 1);
8872 assert_eq!(session.messages().len(), 3);
8873 }
8874
8875 #[test]
8876 fn realtime_transcript_interruption_discards_only_matching_response() {
8877 let mut session = Session::new();
8883
8884 let _ = session.append_realtime_transcript_event(
8885 RealtimeTranscriptEvent::UserTranscriptFinal {
8886 item_id: "item_user".to_string(),
8887 previous_item_id: None,
8888 content_index: 0,
8889 text: "question".to_string(),
8890 },
8891 );
8892 let _ =
8893 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
8894 response_id: "resp_a".to_string(),
8895 delta_id: "evt_a".to_string(),
8896 item_id: "item_a".to_string(),
8897 previous_item_id: Some("item_user".to_string()),
8898 content_index: 0,
8899 delta: "interrupted display".to_string(),
8900 });
8901 let _ =
8902 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
8903 response_id: "resp_b".to_string(),
8904 delta_id: "evt_b".to_string(),
8905 item_id: "item_b".to_string(),
8906 previous_item_id: Some("item_user".to_string()),
8907 content_index: 0,
8908 delta: "keep me".to_string(),
8909 });
8910
8911 let interrupt_outcome = session.append_realtime_transcript_event(
8914 RealtimeTranscriptEvent::AssistantTurnInterrupted {
8915 response_id: "resp_a".to_string(),
8916 },
8917 );
8918 assert_eq!(
8919 interrupt_outcome.materialized_messages.len(),
8920 1,
8921 "resp_a's Display item commits on Interrupted"
8922 );
8923
8924 let outcome = session.append_realtime_transcript_event(
8925 RealtimeTranscriptEvent::AssistantTurnCompleted {
8926 response_id: "resp_b".to_string(),
8927 stop_reason: StopReason::EndTurn,
8928 usage: Usage::default(),
8929 },
8930 );
8931 assert_eq!(
8932 outcome.materialized_messages.len(),
8933 1,
8934 "resp_b commits on its TurnCompleted, untouched by resp_a's Interrupted"
8935 );
8936
8937 assert_eq!(session.messages().len(), 3);
8939 assert!(matches!(
8940 &session.messages()[1],
8941 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "interrupted display"
8942 ));
8943 assert!(matches!(
8944 &session.messages()[2],
8945 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "keep me"
8946 ));
8947 }
8948
8949 #[test]
8952 fn test_fork_shares_arc_no_clone() {
8953 let mut session = Session::new();
8954 for i in 0..100 {
8955 session.push(Message::User(UserMessage::text(format!("Message {i}"))));
8956 }
8957
8958 let forked = session.fork();
8960
8961 assert!(Arc::ptr_eq(&session.messages, &forked.messages));
8963 assert_eq!(forked.messages().len(), 100);
8964 }
8965
8966 #[test]
8967 fn test_fork_at_shares_arc_prefix() {
8968 let mut session = Session::new();
8969 for i in 0..100 {
8970 session.push(Message::User(UserMessage::text(format!("Message {i}"))));
8971 }
8972
8973 let forked = session.fork_at(50);
8975 assert_eq!(forked.messages().len(), 50);
8976
8977 assert_eq!(session.messages().len(), 100);
8979 }
8980
8981 #[test]
8982 fn test_fork_at_resets_transcript_history_state_for_branch_identity() {
8983 let mut session = Session::new();
8984 session.push(Message::User(UserMessage::text(
8985 "summarize this".to_string(),
8986 )));
8987 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
8988 vec![AssistantBlock::Text {
8989 text: "long assistant trace".to_string(),
8990 meta: None,
8991 }],
8992 StopReason::EndTurn,
8993 )));
8994 let parent_revision = session.transcript_revision().expect("parent revision");
8995 session
8996 .commit_transcript_rewrite(
8997 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
8998 vec![Message::BlockAssistant(BlockAssistantMessage::new(
8999 vec![AssistantBlock::Text {
9000 text: "compact trace".to_string(),
9001 meta: None,
9002 }],
9003 StopReason::EndTurn,
9004 ))],
9005 TranscriptRewriteReason::new("compaction"),
9006 Some("test".to_string()),
9007 Some(parent_revision),
9008 )
9009 .expect("rewrite should commit");
9010
9011 let source_head = session.transcript_revision().expect("source head");
9012 let mut forked = session.fork_at(1);
9013 assert_ne!(forked.id(), session.id());
9014 assert!(
9015 !forked
9016 .metadata()
9017 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
9018 );
9019 assert_eq!(
9020 forked.transcript_revision().expect("fork head"),
9021 transcript_messages_digest(forked.messages()).expect("fork digest")
9022 );
9023 assert!(
9024 forked
9025 .transcript_revision_messages(&source_head)
9026 .expect("fork history lookup")
9027 .is_none()
9028 );
9029
9030 let fork_parent = forked.transcript_revision().expect("fork parent");
9031 let commit = forked
9032 .commit_transcript_rewrite(
9033 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
9034 vec![Message::User(UserMessage::text(
9035 "branch prompt".to_string(),
9036 ))],
9037 TranscriptRewriteReason::new("branch_edit"),
9038 Some("test".to_string()),
9039 Some(fork_parent.clone()),
9040 )
9041 .expect("fork rewrite should use fork-local parent");
9042 assert_eq!(commit.parent_revision, fork_parent);
9043 }
9044
9045 #[test]
9046 fn test_push_cow_behavior() {
9047 let mut session = Session::new();
9048 session.push(Message::User(UserMessage::text("First".to_string())));
9049
9050 let forked = session.fork();
9052 assert!(Arc::ptr_eq(&session.messages, &forked.messages));
9053
9054 session.push(Message::User(UserMessage::text("Second".to_string())));
9056
9057 assert!(!Arc::ptr_eq(&session.messages, &forked.messages));
9059 assert_eq!(session.messages().len(), 2);
9060 assert_eq!(forked.messages().len(), 1);
9061 }
9062
9063 #[test]
9066 fn test_push_batch_single_timestamp() {
9067 let mut session = Session::new();
9068 let initial_updated = session.updated_at();
9069
9070 session.push_batch(vec![
9072 Message::User(UserMessage::text("First".to_string())),
9073 Message::User(UserMessage::text("Second".to_string())),
9074 Message::User(UserMessage::text("Third".to_string())),
9075 ]);
9076
9077 assert_eq!(session.messages().len(), 3);
9078 assert!(session.updated_at() >= initial_updated);
9080 }
9081
9082 #[test]
9083 fn test_touch_updates_timestamp() {
9084 let mut session = Session::new();
9085 let initial = session.updated_at();
9086
9087 std::thread::sleep(std::time::Duration::from_millis(10));
9088
9089 session.touch();
9091
9092 assert!(session.updated_at() > initial);
9093 }
9094
9095 #[test]
9096 fn test_session_push() {
9097 let mut session = Session::new();
9098 let initial_updated = session.updated_at();
9099
9100 std::thread::sleep(std::time::Duration::from_millis(10));
9102
9103 session.push(Message::User(UserMessage::text("Hello".to_string())));
9104
9105 assert_eq!(session.messages().len(), 1);
9106 assert!(session.updated_at() > initial_updated);
9107 }
9108
9109 #[test]
9110 fn test_session_fork() {
9111 let mut session = Session::new();
9112 session.push(Message::System(SystemMessage::new("System prompt")));
9113 session.push(Message::User(UserMessage::text("Hello".to_string())));
9114 session.push(Message::BlockAssistant(BlockAssistantMessage {
9115 blocks: vec![AssistantBlock::Text {
9116 text: "Hi!".to_string(),
9117 meta: None,
9118 }],
9119 stop_reason: StopReason::EndTurn,
9120 identity: crate::types::TranscriptMessageIdentity::default(),
9121 created_at: crate::types::message_timestamp_now(),
9122 }));
9123
9124 let forked = session.fork_at(2);
9126 assert_eq!(forked.messages().len(), 2);
9127 assert_ne!(forked.id(), session.id());
9128
9129 let full_fork = session.fork();
9131 assert_eq!(full_fork.messages().len(), 3);
9132 }
9133
9134 #[test]
9135 fn test_session_forks_drop_generated_authority_metadata() {
9136 let mut session = Session::new();
9137 session.push(Message::User(UserMessage::text("original")));
9138 session.set_metadata("ordinary", serde_json::json!("keep"));
9139 session
9140 .set_build_state(SessionBuildState::default())
9141 .expect("build state should serialize");
9142 session
9143 .set_system_context_state(SessionSystemContextState::default())
9144 .expect("system-context state should serialize");
9145 session
9146 .set_deferred_turn_state(SessionDeferredTurnState::default())
9147 .expect("deferred-turn state should serialize");
9148 session
9149 .set_tool_visibility_state(
9150 AuthorizedSessionToolVisibilityState::from_generated_authority(
9151 SessionToolVisibilityState::default(),
9152 ),
9153 )
9154 .expect("visibility state should serialize");
9155 let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
9156 item_id: "rt-item".to_string(),
9157 previous_item_id: None,
9158 role: RealtimeTranscriptRole::User,
9159 response_id: None,
9160 });
9161 session.metadata.insert(
9162 crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY.to_string(),
9163 serde_json::json!([{"sealed_projection": "must-not-fork"}]),
9164 );
9165 assert!(
9166 session
9167 .metadata()
9168 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
9169 "test setup should install realtime transcript authority state"
9170 );
9171
9172 let forked_at = session.fork_at(1);
9173 let full_fork = session.fork();
9174 let replaced = session
9175 .fork_replacing(
9176 0,
9177 TranscriptReplacement::Message {
9178 message: Message::User(UserMessage::text("replacement")),
9179 },
9180 )
9181 .expect("replacement fork should succeed");
9182
9183 for forked in [&forked_at, &full_fork, &replaced] {
9184 assert_eq!(forked.metadata().get("ordinary").unwrap(), "keep");
9185 assert!(
9186 !forked.metadata().contains_key(SESSION_BUILD_STATE_KEY),
9187 "forked sessions must not raw-copy durable build-state authority"
9188 );
9189 assert!(
9190 !forked
9191 .metadata()
9192 .contains_key(SESSION_SYSTEM_CONTEXT_STATE_KEY),
9193 "forked sessions must not raw-copy system-context authority state"
9194 );
9195 assert!(
9196 !forked
9197 .metadata()
9198 .contains_key(SESSION_DEFERRED_TURN_STATE_KEY),
9199 "forked sessions must not raw-copy deferred-turn authority state"
9200 );
9201 assert!(
9202 !forked
9203 .metadata()
9204 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY),
9205 "forked sessions must not raw-copy tool-visibility authority state"
9206 );
9207 assert!(
9208 !forked
9209 .metadata()
9210 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
9211 "forked sessions must not raw-copy realtime transcript authority state"
9212 );
9213 assert!(
9214 !forked
9215 .metadata()
9216 .contains_key(crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY),
9217 "forked sessions must not raw-copy compaction outbox authority"
9218 );
9219 }
9220 }
9221
9222 #[test]
9223 fn test_session_metadata() {
9224 let mut session = Session::new();
9225 session.set_metadata("key", serde_json::json!("value"));
9226
9227 assert_eq!(session.metadata().get("key").unwrap(), "value");
9228 }
9229
9230 #[test]
9231 fn session_metadata_realm_id_is_back_read_compatible_string() {
9232 let metadata = SessionMetadata {
9235 schema_version: SESSION_METADATA_SCHEMA_VERSION,
9236 model: "test-model".to_string(),
9237 max_tokens: 1024,
9238 structured_output_retries: 2,
9239 provider: Provider::Other,
9240 self_hosted_server_id: None,
9241 provider_params: None,
9242 tooling: SessionTooling::default(),
9243 keep_alive: false,
9244 comms_name: None,
9245 peer_meta: None,
9246 realm_id: Some(crate::RealmId::parse("env_default").unwrap()),
9247 instance_id: None,
9248 backend: None,
9249 config_generation: None,
9250 auth_binding: None,
9251 mob_member_binding: None,
9252 };
9253 let value = serde_json::to_value(&metadata).unwrap();
9254 assert_eq!(
9255 value.get("realm_id"),
9256 Some(&serde_json::json!("env_default")),
9257 "typed realm_id must serialize as a bare slug string"
9258 );
9259
9260 let legacy = serde_json::json!({
9263 "schema_version": SESSION_METADATA_SCHEMA_VERSION,
9264 "model": "test-model",
9265 "max_tokens": 1024,
9266 "structured_output_retries": 2,
9267 "provider": "other",
9268 "tooling": SessionTooling::default(),
9269 "keep_alive": false,
9270 "comms_name": null,
9271 "realm_id": "legacy_realm",
9272 });
9273 let restored: SessionMetadata = serde_json::from_value(legacy).unwrap();
9274 assert_eq!(
9275 restored.realm_id.as_ref().map(crate::RealmId::as_str),
9276 Some("legacy_realm")
9277 );
9278 }
9279
9280 #[test]
9285 fn session_tooling_tool_access_policy_round_trip_and_absent_default() {
9286 let legacy = serde_json::json!({});
9288 let restored: SessionTooling = serde_json::from_value(legacy).unwrap();
9289 assert_eq!(restored.tool_access_policy, None);
9290
9291 let value = serde_json::to_value(SessionTooling::default()).unwrap();
9293 assert!(
9294 value.get("tool_access_policy").is_none(),
9295 "None policy must not serialize"
9296 );
9297
9298 let tooling = SessionTooling {
9300 tool_access_policy: Some(crate::ops::ToolAccessPolicy::AllowList(
9301 ["read_file", "send_message"].into_iter().collect(),
9302 )),
9303 ..SessionTooling::default()
9304 };
9305 let value = serde_json::to_value(&tooling).unwrap();
9306 let restored: SessionTooling = serde_json::from_value(value).unwrap();
9307 assert_eq!(restored.tool_access_policy, tooling.tool_access_policy);
9308 }
9309
9310 #[test]
9311 fn lifecycle_terminal_typed_round_trip() {
9312 let mut session = Session::new();
9313 assert_eq!(session.lifecycle_terminal(), None);
9314
9315 session
9316 .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
9317 .expect("typed terminal write should serialize");
9318 assert_eq!(
9319 session.lifecycle_terminal(),
9320 Some(SessionLifecycleTerminal::Archived)
9321 );
9322 assert!(
9323 session
9324 .lifecycle_terminal()
9325 .is_some_and(SessionLifecycleTerminal::is_archived)
9326 );
9327 assert_eq!(
9329 session
9330 .metadata()
9331 .get(SESSION_LIFECYCLE_TERMINAL_KEY)
9332 .unwrap(),
9333 &serde_json::json!("archived")
9334 );
9335 }
9336
9337 #[test]
9338 fn lifecycle_terminal_key_rejects_raw_mutation() {
9339 let mut session = Session::new();
9340 assert!(
9341 session
9342 .try_set_metadata(
9343 SESSION_LIFECYCLE_TERMINAL_KEY,
9344 serde_json::json!("archived")
9345 )
9346 .is_err(),
9347 "the typed lifecycle-terminal key is reserved for session authority"
9348 );
9349 }
9350
9351 #[test]
9352 fn test_session_metadata_backfill_preserves_timestamp() {
9353 let mut session = Session::new();
9354 let initial_updated = session.updated_at();
9355
9356 std::thread::sleep(std::time::Duration::from_millis(10));
9357
9358 assert!(session.backfill_metadata_if_absent("key", serde_json::json!("value")));
9359 assert_eq!(session.metadata().get("key").unwrap(), "value");
9360 assert_eq!(session.updated_at(), initial_updated);
9361 assert!(!session.backfill_metadata_if_absent("key", serde_json::json!("other")));
9362 assert_eq!(session.metadata().get("key").unwrap(), "value");
9363 assert_eq!(session.updated_at(), initial_updated);
9364 }
9365
9366 #[test]
9367 fn test_reserved_generated_authority_metadata_rejects_raw_mutation() {
9368 let mut session = Session::new();
9369
9370 assert!(
9371 session
9372 .try_set_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY, serde_json::json!({}))
9373 .is_err()
9374 );
9375 assert!(
9376 session
9377 .try_set_metadata(SESSION_METADATA_KEY, serde_json::json!({}))
9378 .is_err()
9379 );
9380 assert!(
9381 session
9382 .try_set_metadata(SESSION_BUILD_STATE_KEY, serde_json::json!({}))
9383 .is_err()
9384 );
9385 let compaction_intents_key = crate::memory::SESSION_COMPACTION_PROJECTION_INTENTS_KEY;
9386 let sealed_compaction_intents =
9387 serde_json::json!([{"sealed_projection": "typed-owner-only"}]);
9388 session.metadata.insert(
9389 compaction_intents_key.to_string(),
9390 sealed_compaction_intents.clone(),
9391 );
9392 assert!(
9393 session
9394 .try_set_metadata(compaction_intents_key, serde_json::json!([]))
9395 .is_err(),
9396 "raw metadata must not overwrite compaction outbox authority"
9397 );
9398 session.remove_metadata(compaction_intents_key);
9399 assert_eq!(
9400 session.metadata().get(compaction_intents_key),
9401 Some(&sealed_compaction_intents),
9402 "raw metadata removal must not erase compaction outbox authority"
9403 );
9404 let mut absent = Session::new();
9405 assert!(
9406 !absent.backfill_metadata_if_absent(
9407 compaction_intents_key,
9408 serde_json::json!([{"forged_projection": true}])
9409 ),
9410 "compatibility backfill must not fabricate compaction outbox authority"
9411 );
9412 assert!(!absent.metadata().contains_key(compaction_intents_key));
9413 session
9414 .set_session_metadata(SessionMetadata {
9415 schema_version: SESSION_METADATA_SCHEMA_VERSION,
9416 model: "test-model".to_string(),
9417 max_tokens: 1024,
9418 structured_output_retries: 2,
9419 provider: Provider::Other,
9420 self_hosted_server_id: None,
9421 provider_params: None,
9422 tooling: SessionTooling::default(),
9423 keep_alive: false,
9424 comms_name: None,
9425 peer_meta: None,
9426 realm_id: None,
9427 instance_id: None,
9428 backend: None,
9429 config_generation: None,
9430 auth_binding: None,
9431 mob_member_binding: None,
9432 })
9433 .expect("typed metadata setter should route through generated authority");
9434 session
9435 .set_build_state(SessionBuildState::default())
9436 .expect("typed build-state setter should route through generated authority");
9437 session.remove_metadata(SESSION_METADATA_KEY);
9438 session.remove_metadata(SESSION_BUILD_STATE_KEY);
9439 assert!(
9440 session.metadata().contains_key(SESSION_METADATA_KEY),
9441 "raw removal must not delete generated-authority session metadata"
9442 );
9443 assert!(
9444 session.metadata().contains_key(SESSION_BUILD_STATE_KEY),
9445 "raw removal must not delete generated-authority build state"
9446 );
9447 session.set_metadata(SESSION_DEFERRED_TURN_STATE_KEY, serde_json::json!({}));
9448 assert!(
9449 !session
9450 .metadata()
9451 .contains_key(SESSION_DEFERRED_TURN_STATE_KEY)
9452 );
9453 assert!(
9454 !session.backfill_metadata_if_absent(
9455 SESSION_SYSTEM_CONTEXT_STATE_KEY,
9456 serde_json::json!({})
9457 )
9458 );
9459
9460 let state = SessionSystemContextState::default();
9461 session
9462 .set_system_context_state(state.clone())
9463 .expect("typed setter should route through generated authority");
9464 session.remove_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY);
9465 assert_eq!(
9466 session
9467 .try_system_context_state()
9468 .expect("typed state should restore"),
9469 Some(state)
9470 );
9471
9472 session.metadata.insert(
9473 SESSION_SYSTEM_CONTEXT_STATE_KEY.to_string(),
9474 serde_json::json!("not-a-state"),
9475 );
9476 assert!(
9477 session.try_system_context_state().is_err(),
9478 "malformed generated authority state must not decode as absent/default"
9479 );
9480
9481 session.metadata.insert(
9482 SESSION_METADATA_KEY.to_string(),
9483 serde_json::json!("not-metadata"),
9484 );
9485 assert!(
9486 session.try_session_metadata().is_err(),
9487 "malformed session metadata must not decode as absent/default"
9488 );
9489
9490 session.metadata.insert(
9491 SESSION_BUILD_STATE_KEY.to_string(),
9492 serde_json::json!("not-build-state"),
9493 );
9494 assert!(
9495 session.try_build_state().is_err(),
9496 "malformed build state must not decode as absent/default"
9497 );
9498
9499 assert!(
9500 session
9501 .try_set_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY, serde_json::json!({}))
9502 .is_err()
9503 );
9504 session
9505 .set_tool_visibility_state(
9506 AuthorizedSessionToolVisibilityState::from_generated_authority(
9507 SessionToolVisibilityState::default(),
9508 ),
9509 )
9510 .expect("typed visibility setter should route through typed authority handoff");
9511 session.remove_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY);
9512 assert!(
9513 session
9514 .metadata()
9515 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
9516 );
9517 session.clear_tool_visibility_state();
9518 assert!(
9519 !session
9520 .metadata()
9521 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
9522 );
9523 assert!(
9524 session
9525 .try_set_metadata(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, serde_json::json!({}))
9526 .is_err()
9527 );
9528 let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
9529 item_id: "rt-item".to_string(),
9530 previous_item_id: None,
9531 role: RealtimeTranscriptRole::User,
9532 response_id: None,
9533 });
9534 assert!(
9535 session
9536 .metadata()
9537 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
9538 "typed realtime transcript append should retain authority to persist its state"
9539 );
9540 session.metadata.insert(
9541 SESSION_REALTIME_TRANSCRIPT_STATE_KEY.to_string(),
9542 serde_json::json!("not-a-state"),
9543 );
9544 assert!(
9545 session.try_realtime_transcript_state().is_err(),
9546 "malformed realtime generated authority state must not decode as absent/default"
9547 );
9548 }
9549
9550 #[test]
9551 fn test_session_mob_tool_authority_context_persists_projection_without_authority_seal() {
9552 let mut session = Session::new();
9553 session
9554 .set_build_state(SessionBuildState::default())
9555 .expect("session build state should serialize");
9556 let authority = MobToolAuthorityContext::generated_for_test(
9557 crate::service::OpaquePrincipalToken::new("opaque-principal"),
9558 false,
9559 false,
9560 false,
9561 std::collections::BTreeSet::from(["mob-a".to_string()]),
9562 std::collections::BTreeMap::new(),
9563 None,
9564 Some("audit-1".to_string()),
9565 );
9566
9567 session
9568 .set_mob_tool_authority_context(Some(authority))
9569 .expect("authority should serialize");
9570 assert!(session.mob_tool_authority_context().is_none());
9571 let stored = session
9572 .build_state()
9573 .and_then(|state| state.mob_tool_authority_context)
9574 .expect("stored projection should deserialize");
9575 assert!(!stored.is_generated_authority_context());
9576 assert!(!stored.can_manage_mob("mob-a"));
9577
9578 session
9579 .set_mob_tool_authority_context(None)
9580 .expect("authority should clear");
9581 assert!(session.mob_tool_authority_context().is_none());
9582 }
9583
9584 #[test]
9585 fn test_session_build_state_rejects_forged_mob_authority_projection() {
9586 let mut session = Session::new();
9587 let authority = MobToolAuthorityContext::generated_for_test(
9588 crate::service::OpaquePrincipalToken::new("opaque-principal"),
9589 false,
9590 false,
9591 false,
9592 std::collections::BTreeSet::from(["mob-a".to_string()]),
9593 std::collections::BTreeMap::new(),
9594 None,
9595 Some("audit-1".to_string()),
9596 );
9597 let forged_projection: MobToolAuthorityContext =
9598 serde_json::from_value(serde_json::to_value(authority).expect("serialize authority"))
9599 .expect("deserialize projection");
9600 assert!(!forged_projection.is_generated_authority_context());
9601
9602 let err = session
9603 .set_build_state(SessionBuildState {
9604 mob_tool_authority_context: Some(forged_projection),
9605 ..Default::default()
9606 })
9607 .expect_err("forged build state must be rejected by generated authority");
9608 assert!(
9612 err.to_string()
9613 .contains("generated session document authority rejected"),
9614 "unexpected error: {err}"
9615 );
9616 }
9617
9618 #[test]
9619 fn test_session_tool_visibility_state_roundtrip() {
9620 let mut session = Session::new();
9621 let state = SessionToolVisibilityState {
9622 inherited_base_filter: ToolFilter::Allow(["visible".to_string()].into_iter().collect()),
9623 active_filter: ToolFilter::Allow(
9624 ["visible".to_string(), "missing".to_string()]
9625 .into_iter()
9626 .collect(),
9627 ),
9628 staged_filter: ToolFilter::Allow(
9629 ["visible".to_string(), "missing".to_string()]
9630 .into_iter()
9631 .collect(),
9632 ),
9633 active_revision: 1,
9634 staged_revision: 2,
9635 ..Default::default()
9636 };
9637
9638 session
9639 .set_tool_visibility_state(
9640 AuthorizedSessionToolVisibilityState::from_generated_authority(state.clone()),
9641 )
9642 .expect("tool visibility state should serialize");
9643 assert_eq!(session.tool_visibility_state().unwrap(), Some(state));
9644 }
9645
9646 #[test]
9647 fn test_session_tool_visibility_state_malformed_returns_error() {
9648 let mut session = Session::new();
9649 session.metadata.insert(
9650 SESSION_TOOL_VISIBILITY_STATE_KEY.to_string(),
9651 serde_json::json!({
9652 "active_filter": {
9653 "unexpected_filter_kind": ["secret"]
9654 }
9655 }),
9656 );
9657
9658 assert!(
9659 session.tool_visibility_state().is_err(),
9660 "malformed canonical visibility metadata must not decode as absent/default"
9661 );
9662 }
9663
9664 #[test]
9665 fn test_session_serialization() {
9666 let mut session = Session::new();
9667 session.push(Message::User(UserMessage::text("Test".to_string())));
9668
9669 let json = serde_json::to_string(&session).unwrap();
9670 let parsed: Session = serde_json::from_str(&json).unwrap();
9671
9672 assert_eq!(parsed.id(), session.id());
9673 assert_eq!(parsed.messages().len(), 1);
9674 assert_eq!(parsed.version(), SESSION_VERSION);
9675 }
9676
9677 #[test]
9678 fn test_session_meta_from_session() {
9679 let mut session = Session::new();
9680 session.push(Message::User(UserMessage::text("Hello".to_string())));
9681 session.push(Message::BlockAssistant(BlockAssistantMessage {
9682 blocks: vec![AssistantBlock::Text {
9683 text: "Hi!".to_string(),
9684 meta: None,
9685 }],
9686 stop_reason: StopReason::EndTurn,
9687 identity: crate::types::TranscriptMessageIdentity::default(),
9688 created_at: crate::types::message_timestamp_now(),
9689 }));
9690 session.record_usage(Usage {
9691 input_tokens: 10,
9692 output_tokens: 5,
9693 cache_creation_tokens: None,
9694 cache_read_tokens: None,
9695 });
9696
9697 let meta = SessionMeta::from(&session);
9698 assert_eq!(meta.id, *session.id());
9699 assert_eq!(meta.message_count, 2);
9700 assert_eq!(meta.total_tokens, 15);
9701 }
9702
9703 #[test]
9704 fn system_context_state_preserves_applied_runtime_context() {
9705 let accepted_at = SystemTime::UNIX_EPOCH;
9706 let mut state = SessionSystemContextState::default();
9707 state
9708 .stage_append(
9709 &AppendSystemContextRequest {
9710 content: crate::lifecycle::run_primitive::CoreRenderable::text(
9711 "Authoritative peer token is birch seventeen.".to_string(),
9712 ),
9713 source: Some(
9714 "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1"
9715 .to_string(),
9716 ),
9717 idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
9718 source_kind: SystemContextSource::Normal,
9719 peer_response_terminal: None,
9720 },
9721 accepted_at,
9722 )
9723 .expect("append should stage");
9724
9725 state.mark_pending_applied();
9726
9727 assert!(state.pending.is_empty());
9728 assert_eq!(state.applied.len(), 1);
9729 assert_eq!(
9730 state.applied[0].content.render_text(),
9731 "Authoritative peer token is birch seventeen."
9732 );
9733 assert_eq!(
9734 state.applied[0].source.as_deref(),
9735 Some("peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1")
9736 );
9737
9738 let round_tripped: SessionSystemContextState =
9739 serde_json::from_value(serde_json::to_value(&state).expect("serialize state"))
9740 .expect("deserialize state");
9741 assert_eq!(round_tripped.applied, state.applied);
9742 }
9743
9744 #[test]
9745 fn active_turn_system_context_is_discarded_when_not_applied() {
9746 let mut state = SessionSystemContextState::default();
9747 state
9748 .stage_active_turn_append(
9749 &AppendSystemContextRequest {
9750 content: crate::lifecycle::run_primitive::CoreRenderable::text(
9751 "only for the active run".to_string(),
9752 ),
9753 source: Some("runtime:steer:input-1".to_string()),
9754 idempotency_key: Some("runtime:steer:input-1".to_string()),
9755 source_kind: SystemContextSource::RuntimeSteer,
9756 peer_response_terminal: None,
9757 },
9758 SystemTime::UNIX_EPOCH,
9759 )
9760 .expect("active context should stage");
9761
9762 let discarded = state.discard_unapplied_active_turn_pending();
9763
9764 assert_eq!(discarded.len(), 1);
9765 assert!(state.pending.is_empty());
9766 assert!(state.applied.is_empty());
9767 assert!(state.active_turn_pending_keys.is_empty());
9768 assert!(
9769 state.seen.is_empty(),
9770 "discarded active-turn context should not block later idempotency keys"
9771 );
9772 }
9773
9774 #[test]
9775 fn active_turn_system_context_can_roll_back_targeted_keys() {
9776 let mut state = SessionSystemContextState::default();
9777 for key in ["runtime:steer:input-1", "runtime:steer:input-2"] {
9778 state
9779 .stage_active_turn_append(
9780 &AppendSystemContextRequest {
9781 content: crate::lifecycle::run_primitive::CoreRenderable::text(format!(
9782 "context for {key}"
9783 )),
9784 source: Some(key.to_string()),
9785 idempotency_key: Some(key.to_string()),
9786 source_kind: SystemContextSource::RuntimeSteer,
9787 peer_response_terminal: None,
9788 },
9789 SystemTime::UNIX_EPOCH,
9790 )
9791 .expect("active context should stage");
9792 }
9793
9794 let discarded =
9795 state.discard_active_turn_pending_by_keys(&["runtime:steer:input-1".to_string()]);
9796
9797 assert_eq!(discarded.len(), 1);
9798 assert_eq!(
9799 discarded[0].idempotency_key.as_deref(),
9800 Some("runtime:steer:input-1")
9801 );
9802 assert_eq!(state.pending.len(), 1);
9803 assert_eq!(
9804 state.pending[0].idempotency_key.as_deref(),
9805 Some("runtime:steer:input-2")
9806 );
9807 assert!(!state.seen.contains_key("runtime:steer:input-1"));
9808 assert!(state.seen.contains_key("runtime:steer:input-2"));
9809 assert!(
9810 !state
9811 .active_turn_pending_keys
9812 .contains("runtime:steer:input-1")
9813 );
9814 assert!(
9815 state
9816 .active_turn_pending_keys
9817 .contains("runtime:steer:input-2")
9818 );
9819 }
9820
9821 #[test]
9822 fn active_turn_system_context_is_transient_when_boundary_consumes_it() {
9823 let mut state = SessionSystemContextState::default();
9824 state
9825 .stage_active_turn_append(
9826 &AppendSystemContextRequest {
9827 content: crate::lifecycle::run_primitive::CoreRenderable::text(
9828 "visible to this run".to_string(),
9829 ),
9830 source: Some("runtime:steer:input-2".to_string()),
9831 idempotency_key: Some("runtime:steer:input-2".to_string()),
9832 source_kind: SystemContextSource::RuntimeSteer,
9833 peer_response_terminal: None,
9834 },
9835 SystemTime::UNIX_EPOCH,
9836 )
9837 .expect("active context should stage");
9838
9839 state.mark_pending_applied();
9840 let discarded = state.discard_unapplied_active_turn_pending();
9841
9842 assert!(discarded.is_empty());
9843 assert!(state.pending.is_empty());
9844 assert!(state.applied.is_empty());
9845 assert!(state.active_turn_pending_keys.is_empty());
9846 assert_eq!(
9847 state.seen.get("runtime:steer:input-2"),
9848 None,
9849 "consumed active-turn steer context must not become durable state"
9850 );
9851 }
9852
9853 #[test]
9854 fn discard_transient_runtime_steer_context_removes_steer_via_typed_marker() {
9855 let mut session = Session::new();
9856 session.set_system_prompt(format!(
9860 "base{}{}{}{}",
9861 SYSTEM_CONTEXT_SEPARATOR,
9862 render_system_context_block(&PendingSystemContextAppend {
9863 content: crate::lifecycle::run_primitive::CoreRenderable::text(
9864 "old steer".to_string()
9865 ),
9866 source: Some("steer-source-old".to_string()),
9867 idempotency_key: Some("steer-key-old".to_string()),
9868 source_kind: SystemContextSource::RuntimeSteer,
9869 peer_response_terminal: None,
9870 accepted_at: SystemTime::UNIX_EPOCH,
9871 }),
9872 SYSTEM_CONTEXT_SEPARATOR,
9873 render_system_context_block(&PendingSystemContextAppend {
9874 content: crate::lifecycle::run_primitive::CoreRenderable::text(
9875 "durable peer fact".to_string()
9876 ),
9877 source: Some("peer_response_terminal:analyst:req".to_string()),
9878 idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
9879 source_kind: SystemContextSource::Normal,
9880 peer_response_terminal: None,
9881 accepted_at: SystemTime::UNIX_EPOCH,
9882 })
9883 ));
9884 session
9885 .set_system_context_state(SessionSystemContextState {
9886 pending: vec![PendingSystemContextAppend {
9887 content: crate::lifecycle::run_primitive::CoreRenderable::text(
9888 "pending steer".to_string(),
9889 ),
9890 source: Some("steer-source-pending".to_string()),
9891 idempotency_key: Some("steer-key-pending".to_string()),
9892 source_kind: SystemContextSource::RuntimeSteer,
9893 peer_response_terminal: None,
9894 accepted_at: SystemTime::UNIX_EPOCH,
9895 }],
9896 applied: vec![
9897 PendingSystemContextAppend {
9898 content: crate::lifecycle::run_primitive::CoreRenderable::text(
9899 "old steer".to_string(),
9900 ),
9901 source: Some("steer-source-old".to_string()),
9902 idempotency_key: Some("steer-key-old".to_string()),
9903 source_kind: SystemContextSource::RuntimeSteer,
9904 peer_response_terminal: None,
9905 accepted_at: SystemTime::UNIX_EPOCH,
9906 },
9907 PendingSystemContextAppend {
9908 content: crate::lifecycle::run_primitive::CoreRenderable::text(
9909 "durable peer fact".to_string(),
9910 ),
9911 source: Some("peer_response_terminal:analyst:req".to_string()),
9912 idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
9913 source_kind: SystemContextSource::Normal,
9914 peer_response_terminal: None,
9915 accepted_at: SystemTime::UNIX_EPOCH,
9916 },
9917 ],
9918 seen: BTreeMap::from([(
9919 "steer-key-old".to_string(),
9920 SeenSystemContextKey {
9921 content: crate::lifecycle::run_primitive::CoreRenderable::text(
9922 "old steer".to_string(),
9923 ),
9924 source: Some("steer-source-old".to_string()),
9925 source_kind: SystemContextSource::RuntimeSteer,
9926 state: SeenSystemContextState::Applied,
9927 },
9928 )]),
9929 active_turn_pending_keys: BTreeSet::from(["steer-key-pending".to_string()]),
9930 })
9931 .expect("system context state should serialize");
9932
9933 let removed = session.discard_transient_runtime_steer_context();
9934
9935 assert!(removed >= 4);
9936 let system_prompt = match session.messages().first() {
9937 Some(Message::System(system)) => system.content.as_str(),
9938 other => panic!("expected system prompt, got {other:?}"),
9939 };
9940 assert!(!system_prompt.contains("old steer"));
9941 assert!(system_prompt.contains("durable peer fact"));
9942 let state = session.system_context_state().unwrap_or_default();
9943 assert!(state.pending.is_empty());
9944 assert_eq!(state.applied.len(), 1);
9945 assert_eq!(state.applied[0].content.render_text(), "durable peer fact");
9946 assert!(state.seen.is_empty());
9947 assert!(state.active_turn_pending_keys.is_empty());
9948 }
9949
9950 #[test]
9951 fn append_system_context_blocks_records_typed_applied_context() {
9952 let append = PendingSystemContextAppend {
9953 content: crate::lifecycle::run_primitive::CoreRenderable::text(
9954 "Authoritative peer token is birch seventeen.".to_string(),
9955 ),
9956 source: Some(
9957 "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string(),
9958 ),
9959 idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
9960 source_kind: SystemContextSource::Normal,
9961 peer_response_terminal: None,
9962 accepted_at: SystemTime::UNIX_EPOCH,
9963 };
9964 let mut session = Session::new();
9965
9966 session.append_system_context_blocks(std::slice::from_ref(&append));
9967
9968 let state = session
9969 .system_context_state()
9970 .expect("append should persist typed context state");
9971 assert_eq!(state.applied, vec![append]);
9972 }
9973
9974 fn roster_append() -> PendingSystemContextAppend {
9975 PendingSystemContextAppend {
9976 content: crate::lifecycle::run_primitive::CoreRenderable::text(
9977 "peer roster: lead-1, w-1".to_string(),
9978 ),
9979 source: Some("comms:roster".to_string()),
9980 idempotency_key: Some("comms:roster:v1".to_string()),
9981 source_kind: SystemContextSource::Normal,
9982 peer_response_terminal: None,
9983 accepted_at: SystemTime::UNIX_EPOCH,
9984 }
9985 }
9986
9987 fn resumed_session_with_context_appended_prompt(base: &str) -> Session {
9988 let mut session = Session::new();
9989 session.set_system_prompt(base.to_string());
9990 session.push(Message::User(UserMessage::text("hello".to_string())));
9991 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
9992 session
9993 }
9994
9995 #[test]
9996 fn reconcile_resumed_system_prompt_preserves_identical_base() {
9997 let mut session = Session::new();
9998 session.set_system_prompt("base prompt".to_string());
9999 session.push(Message::User(UserMessage::text("hello".to_string())));
10000 let digest_before = transcript_messages_digest(session.messages()).unwrap();
10001
10002 let outcome = session
10003 .reconcile_resumed_system_prompt("base prompt".to_string(), None)
10004 .expect("reconcile");
10005
10006 assert_eq!(
10007 outcome,
10008 ResumedSystemPromptReconciliation::PreservedContinuation
10009 );
10010 assert_eq!(
10011 transcript_messages_digest(session.messages()).unwrap(),
10012 digest_before,
10013 "identical base must leave the transcript revision unchanged"
10014 );
10015 }
10016
10017 #[test]
10018 fn reconcile_resumed_system_prompt_preserves_context_appended_base() {
10019 let mut session = resumed_session_with_context_appended_prompt("base prompt");
10020 let digest_before = transcript_messages_digest(session.messages()).unwrap();
10021
10022 let outcome = session
10023 .reconcile_resumed_system_prompt("base prompt".to_string(), None)
10024 .expect("reconcile");
10025
10026 assert_eq!(
10027 outcome,
10028 ResumedSystemPromptReconciliation::PreservedContinuation
10029 );
10030 assert_eq!(
10031 transcript_messages_digest(session.messages()).unwrap(),
10032 digest_before,
10033 "a base extended only by runtime context appends must stay untouched"
10034 );
10035 let system = match session.messages().first() {
10036 Some(Message::System(system)) => system.clone(),
10037 other => panic!("expected system message, got {other:?}"),
10038 };
10039 assert!(system.content.contains("peer roster: lead-1, w-1"));
10040 assert!(
10041 system.mutation_kind.is_runtime_context_append(),
10042 "the persisted mutation provenance must survive reconciliation"
10043 );
10044 }
10045
10046 #[test]
10047 fn reconcile_resumed_system_prompt_rewrites_changed_base_preserving_tail() {
10048 let mut session = resumed_session_with_context_appended_prompt("base prompt");
10049
10050 let outcome = session
10051 .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
10052 .expect("reconcile");
10053
10054 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
10055 let system_content = match session.messages().first() {
10056 Some(Message::System(system)) => system.content.clone(),
10057 other => panic!("expected system message, got {other:?}"),
10058 };
10059 assert!(
10060 system_content.starts_with("new base prompt"),
10061 "the changed base must be applied: {system_content}"
10062 );
10063 assert!(
10064 system_content.contains("peer roster: lead-1, w-1"),
10065 "the runtime-applied context tail must survive the base change: {system_content}"
10066 );
10067 let state = session
10068 .transcript_history_state()
10069 .expect("history state deserializes")
10070 .expect("rewrite must record transcript history");
10071 assert_eq!(state.commits.len(), 1);
10072 assert_eq!(
10073 state.commits[0].reason.kind,
10074 RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON
10075 );
10076 assert_eq!(
10077 state.head,
10078 transcript_messages_digest(session.messages()).unwrap(),
10079 "the committed head must match the rewritten transcript"
10080 );
10081 }
10082
10083 #[test]
10084 fn reconcile_resumed_system_prompt_inserts_prompt_on_promptless_transcript() {
10085 let mut session = Session::new();
10086 session.push(Message::User(UserMessage::text("hello".to_string())));
10087
10088 let outcome = session
10089 .reconcile_resumed_system_prompt("late prompt".to_string(), None)
10090 .expect("reconcile");
10091
10092 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
10093 assert!(matches!(
10094 session.messages().first(),
10095 Some(Message::System(system)) if system.content == "late prompt"
10096 ));
10097 let state = session
10098 .transcript_history_state()
10099 .expect("history state deserializes")
10100 .expect("insert must record transcript history");
10101 assert_eq!(state.commits.len(), 1);
10102 assert_eq!(
10103 state.commits[0].reason.kind,
10104 RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON
10105 );
10106 }
10107
10108 fn leading_system_content(session: &Session) -> String {
10109 match session.messages().first() {
10110 Some(Message::System(system)) => system.content.clone(),
10111 other => panic!("expected leading system message, got {other:?}"),
10112 }
10113 }
10114
10115 #[test]
10116 fn reconcile_resumed_system_prompt_preserves_full_context_prompt_from_empty_base() {
10117 let mut session = Session::new();
10122 session.push(Message::User(UserMessage::text("hello".to_string())));
10123 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
10124 let all_context_content = leading_system_content(&session);
10125 assert!(all_context_content.contains("peer roster: lead-1, w-1"));
10126
10127 let outcome = session
10128 .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
10129 .expect("reconcile");
10130
10131 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
10132 assert_eq!(
10133 leading_system_content(&session),
10134 format!("new base prompt{SYSTEM_CONTEXT_SEPARATOR}{all_context_content}"),
10135 "the all-context prompt must survive as the runtime tail of the new base"
10136 );
10137 }
10138
10139 #[test]
10140 fn reconcile_resumed_system_prompt_preserves_context_only_prompt_on_empty_base_resume() {
10141 let mut session = Session::new();
10144 session.push(Message::User(UserMessage::text("hello".to_string())));
10145 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
10146 let digest_before = transcript_messages_digest(session.messages()).unwrap();
10147
10148 let outcome = session
10149 .reconcile_resumed_system_prompt(String::new(), None)
10150 .expect("reconcile");
10151
10152 assert_eq!(
10153 outcome,
10154 ResumedSystemPromptReconciliation::PreservedContinuation
10155 );
10156 assert_eq!(
10157 transcript_messages_digest(session.messages()).unwrap(),
10158 digest_before
10159 );
10160 }
10161
10162 #[test]
10163 fn reconcile_resumed_system_prompt_applies_shortened_base_with_recorded_prior() {
10164 let full_base = format!("part one{SYSTEM_CONTEXT_SEPARATOR}part two");
10168 let mut session = Session::new();
10169 session.set_system_prompt(full_base.clone());
10170 session.push(Message::User(UserMessage::text("hello".to_string())));
10171 session
10172 .set_build_state(SessionBuildState {
10173 assembled_system_prompt: Some(full_base),
10174 ..Default::default()
10175 })
10176 .expect("build state");
10177
10178 let outcome = session
10179 .reconcile_resumed_system_prompt("part one".to_string(), None)
10180 .expect("reconcile");
10181
10182 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
10183 assert_eq!(leading_system_content(&session), "part one");
10184 }
10185
10186 #[test]
10187 fn reconcile_resumed_system_prompt_applies_shortened_base_without_context_provenance() {
10188 let full_base = format!("part one{SYSTEM_CONTEXT_SEPARATOR}part two");
10193 let mut session = Session::new();
10194 session.set_system_prompt(full_base);
10195 session.push(Message::User(UserMessage::text("hello".to_string())));
10196
10197 let outcome = session
10198 .reconcile_resumed_system_prompt("part one".to_string(), None)
10199 .expect("reconcile");
10200
10201 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
10202 assert_eq!(leading_system_content(&session), "part one");
10203 }
10204
10205 #[test]
10206 fn reconcile_resumed_system_prompt_preserves_appended_prompt_without_applied_records() {
10207 let mut session = resumed_session_with_context_appended_prompt("base prompt");
10212 session
10213 .set_system_context_state(SessionSystemContextState::default())
10214 .expect("sweep applied records");
10215 let digest_before = transcript_messages_digest(session.messages()).unwrap();
10216
10217 let outcome = session
10218 .reconcile_resumed_system_prompt("base prompt".to_string(), None)
10219 .expect("reconcile");
10220
10221 assert_eq!(
10222 outcome,
10223 ResumedSystemPromptReconciliation::PreservedContinuation
10224 );
10225 assert_eq!(
10226 transcript_messages_digest(session.messages()).unwrap(),
10227 digest_before
10228 );
10229 }
10230
10231 #[test]
10232 fn reconcile_resumed_system_prompt_clears_orphaned_applied_records_on_tail_drop() {
10233 let mut session = resumed_session_with_context_appended_prompt("base prompt");
10234 session.set_system_prompt(format!(
10239 "mutated base{SYSTEM_CONTEXT_SEPARATOR}stale-looking tail"
10240 ));
10241
10242 let outcome = session
10243 .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
10244 .expect("reconcile");
10245
10246 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
10247 assert_eq!(leading_system_content(&session), "new base prompt");
10248 let state = session.system_context_state().unwrap_or_default();
10249 assert!(
10250 state.applied.is_empty(),
10251 "orphaned applied records must be cleared so the context stays restorable"
10252 );
10253 assert!(
10254 state.seen.is_empty(),
10255 "orphaned idempotency keys must be cleared so keyed re-sends re-apply"
10256 );
10257
10258 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
10261 assert!(
10262 leading_system_content(&session).contains("peer roster: lead-1, w-1"),
10263 "re-sent keyed context must re-apply after the drop"
10264 );
10265 }
10266
10267 #[test]
10268 fn append_system_context_blocks_renders_pre_marked_pending_context() {
10269 let accepted_at = SystemTime::UNIX_EPOCH;
10270 let mut state = SessionSystemContextState::default();
10271 state
10272 .stage_append(
10273 &AppendSystemContextRequest {
10274 content: crate::lifecycle::run_primitive::CoreRenderable::text(
10275 "Apply this staged context at the request boundary.".to_string(),
10276 ),
10277 source: Some("rpc/session_inject_context".to_string()),
10278 idempotency_key: Some("ctx-boundary".to_string()),
10279 source_kind: SystemContextSource::Normal,
10280 peer_response_terminal: None,
10281 },
10282 accepted_at,
10283 )
10284 .expect("append should stage");
10285 let pending = state.pending.clone();
10286 state.mark_pending_applied();
10287 let mut session = Session::new();
10288 session
10289 .set_system_context_state(state)
10290 .expect("state should serialize");
10291
10292 session.append_system_context_blocks(&pending);
10293
10294 let system_prompt = session
10295 .messages()
10296 .first()
10297 .and_then(|message| match message {
10298 Message::System(system) => Some(system.content.as_str()),
10299 _ => None,
10300 })
10301 .unwrap_or_default();
10302 assert!(system_prompt.contains("Apply this staged context at the request boundary."));
10303 let state = session
10304 .system_context_state()
10305 .expect("append should persist typed context state");
10306 assert_eq!(state.applied.len(), 1);
10307 assert_eq!(
10308 state.seen["ctx-boundary"].state,
10309 SeenSystemContextState::Applied
10310 );
10311 }
10312
10313 #[test]
10314 fn append_system_context_blocks_renders_pre_marked_context_without_idempotency_key() {
10315 let accepted_at = SystemTime::UNIX_EPOCH;
10316 let mut state = SessionSystemContextState::default();
10317 state
10318 .stage_append(
10319 &AppendSystemContextRequest {
10320 content: crate::lifecycle::run_primitive::CoreRenderable::text(
10321 "Apply this unkeyed staged context at the request boundary.".to_string(),
10322 ),
10323 source: Some("rpc/session_inject_context".to_string()),
10324 idempotency_key: None,
10325 source_kind: SystemContextSource::Normal,
10326 peer_response_terminal: None,
10327 },
10328 accepted_at,
10329 )
10330 .expect("append should stage");
10331 let pending = state.pending.clone();
10332 state.mark_pending_applied();
10333 let mut session = Session::new();
10334 session
10335 .set_system_context_state(state)
10336 .expect("state should serialize");
10337
10338 session.append_system_context_blocks(&pending);
10339
10340 let system_prompt = session
10341 .messages()
10342 .first()
10343 .and_then(|message| match message {
10344 Message::System(system) => Some(system.content.as_str()),
10345 _ => None,
10346 })
10347 .unwrap_or_default();
10348 assert!(
10349 system_prompt.contains("Apply this unkeyed staged context at the request boundary.")
10350 );
10351 }
10352
10353 #[test]
10357 fn staged_system_context_carries_typed_renderable_to_render_seam() {
10358 use crate::lifecycle::run_primitive::CoreRenderable;
10359
10360 let accepted_at = SystemTime::UNIX_EPOCH;
10361 let mut state = SessionSystemContextState::default();
10362 let renderable = CoreRenderable::Json {
10363 value: serde_json::json!({"alert": "disk-full", "severity": 2}),
10364 };
10365 state
10366 .stage_append(
10367 &AppendSystemContextRequest {
10368 content: renderable.clone(),
10369 source: Some("ops/monitor".to_string()),
10370 idempotency_key: Some("alert-1".to_string()),
10371 source_kind: SystemContextSource::Normal,
10372 peer_response_terminal: None,
10373 },
10374 accepted_at,
10375 )
10376 .expect("typed renderable append should stage");
10377
10378 assert_eq!(state.pending.len(), 1);
10381 assert_eq!(state.pending[0].content, renderable);
10382
10383 let rendered = render_system_context_block(&state.pending[0]);
10386 assert!(rendered.starts_with(SYSTEM_CONTEXT_RENDER_LABEL));
10387 assert!(
10388 rendered.contains(renderable.render_text().trim()),
10389 "render seam must lower via CoreRenderable::render_text: {rendered}"
10390 );
10391 }
10392
10393 #[test]
10394 fn append_system_context_blocks_skips_duplicate_idempotency_key() {
10395 let first = PendingSystemContextAppend {
10396 content: crate::lifecycle::run_primitive::CoreRenderable::text(
10397 "Authoritative peer token is birch seventeen.".to_string(),
10398 ),
10399 source: Some("peer_response_terminal:analyst:req-1".to_string()),
10400 idempotency_key: Some("req-1".to_string()),
10401 source_kind: SystemContextSource::Normal,
10402 peer_response_terminal: None,
10403 accepted_at: SystemTime::UNIX_EPOCH,
10404 };
10405 let duplicate = PendingSystemContextAppend {
10406 accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
10407 ..first.clone()
10408 };
10409 let mut session = Session::new();
10410
10411 session.append_system_context_blocks(std::slice::from_ref(&first));
10412 session.append_system_context_blocks(std::slice::from_ref(&duplicate));
10413
10414 let state = session
10415 .system_context_state()
10416 .expect("append should persist typed context state");
10417 assert_eq!(state.applied, vec![first]);
10418 let system_prompt = session
10419 .messages()
10420 .first()
10421 .and_then(|message| match message {
10422 Message::System(system) => Some(system.content.as_str()),
10423 _ => None,
10424 })
10425 .unwrap_or_default();
10426 assert_eq!(
10427 system_prompt
10428 .matches("Authoritative peer token is birch seventeen.")
10429 .count(),
10430 1
10431 );
10432 }
10433
10434 #[test]
10435 fn append_system_context_blocks_skips_conflicting_duplicate_idempotency_key() {
10436 let first = PendingSystemContextAppend {
10437 content: crate::lifecycle::run_primitive::CoreRenderable::text(
10438 "Authoritative peer token is birch seventeen.".to_string(),
10439 ),
10440 source: Some("peer_response_terminal:analyst:req-1".to_string()),
10441 idempotency_key: Some("req-1".to_string()),
10442 source_kind: SystemContextSource::Normal,
10443 peer_response_terminal: None,
10444 accepted_at: SystemTime::UNIX_EPOCH,
10445 };
10446 let conflicting = PendingSystemContextAppend {
10447 content: crate::lifecycle::run_primitive::CoreRenderable::text(
10448 "Conflicting peer token should not reach the prompt.".to_string(),
10449 ),
10450 accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
10451 ..first.clone()
10452 };
10453 let mut session = Session::new();
10454
10455 session.append_system_context_blocks(std::slice::from_ref(&first));
10456 session.append_system_context_blocks(std::slice::from_ref(&conflicting));
10457
10458 let state = session
10459 .system_context_state()
10460 .expect("append should persist typed context state");
10461 assert_eq!(state.applied, vec![first]);
10462 let system_prompt = session
10463 .messages()
10464 .first()
10465 .and_then(|message| match message {
10466 Message::System(system) => Some(system.content.as_str()),
10467 _ => None,
10468 })
10469 .unwrap_or_default();
10470 assert!(system_prompt.contains("Authoritative peer token is birch seventeen."));
10471 assert!(!system_prompt.contains("Conflicting peer token should not reach the prompt."));
10472 }
10473
10474 #[test]
10486 fn realtime_transcript_assistant_transcript_delta_materializes_transcript_block() {
10487 let mut session = Session::new();
10488
10489 let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
10490 response_id: "resp_spoken".to_string(),
10491 delta_id: "evt_delta_spoken_1".to_string(),
10492 item_id: "item_spoken".to_string(),
10493 previous_item_id: None,
10494 content_index: 0,
10495 delta: "I said hi".to_string(),
10496 };
10497 assert!(
10498 session.append_realtime_transcript_event(delta).is_inert(),
10499 "delta alone is inert until turn-completed flushes"
10500 );
10501
10502 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
10503 response_id: "resp_spoken".to_string(),
10504 stop_reason: StopReason::EndTurn,
10505 usage: Usage::default(),
10506 };
10507 let outcome = session.append_realtime_transcript_event(terminal);
10508 assert_eq!(outcome.materialized_messages.len(), 1);
10509
10510 let messages = session.messages();
10512 assert_eq!(messages.len(), 1);
10513 match &messages[0] {
10514 Message::BlockAssistant(assistant) => {
10515 assert_eq!(assistant.blocks.len(), 1);
10516 match &assistant.blocks[0] {
10517 AssistantBlock::Transcript { text, source, .. } => {
10518 assert_eq!(text, "I said hi");
10519 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
10520 }
10521 other => unreachable!(
10522 "AssistantTranscriptDelta must materialize as AssistantBlock::Transcript, got {other:?}"
10523 ),
10524 }
10525 }
10526 other => unreachable!("expected BlockAssistant message, got {other:?}"),
10527 }
10528 }
10529
10530 #[test]
10531 fn round4_cc4_in_flight_response_ids_lists_distinct_unmaterialized_responses() {
10532 let mut session = Session::new();
10538
10539 for (i, response_id) in [
10543 ("resp_a", "resp_a"),
10544 ("resp_a_extra", "resp_a"),
10545 ("resp_b", "resp_b"),
10546 ("resp_c", "resp_c"),
10547 ]
10548 .iter()
10549 .enumerate()
10550 {
10551 let event = RealtimeTranscriptEvent::AssistantTranscriptDelta {
10552 response_id: response_id.1.to_string(),
10553 delta_id: format!("delta_{i}"),
10554 item_id: response_id.0.to_string(),
10555 previous_item_id: None,
10556 content_index: 0,
10557 delta: "x".to_string(),
10558 };
10559 let _ = session.append_realtime_transcript_event(event);
10560 }
10561
10562 let _ = session.append_realtime_transcript_event(
10564 RealtimeTranscriptEvent::AssistantTurnInterrupted {
10565 response_id: "resp_c".to_string(),
10566 },
10567 );
10568
10569 let _ = session.append_realtime_transcript_event(
10572 RealtimeTranscriptEvent::UserTranscriptFinal {
10573 item_id: "u_item".to_string(),
10574 previous_item_id: None,
10575 content_index: 0,
10576 text: "hi".to_string(),
10577 },
10578 );
10579
10580 let in_flight = session.in_flight_realtime_assistant_response_ids();
10581 assert!(in_flight.contains(&"resp_a".to_string()), "{in_flight:?}");
10582 assert!(in_flight.contains(&"resp_b".to_string()), "{in_flight:?}");
10583 assert!(
10584 !in_flight.contains(&"resp_c".to_string()),
10585 "discarded response must not appear in in_flight: {in_flight:?}"
10586 );
10587 assert_eq!(
10589 in_flight.iter().filter(|r| *r == "resp_a").count(),
10590 1,
10591 "distinct response_ids only: {in_flight:?}"
10592 );
10593 }
10594
10595 #[test]
10596 fn round4_cc2_assistant_turn_completed_after_transcript_deltas_materializes_transcript() {
10597 let mut session = Session::new();
10604
10605 let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
10606 response_id: "resp_cc2".to_string(),
10607 delta_id: "delta_cc2_1".to_string(),
10608 item_id: "item_cc2".to_string(),
10609 previous_item_id: None,
10610 content_index: 0,
10611 delta: "hello world".to_string(),
10612 };
10613 assert!(session.append_realtime_transcript_event(delta).is_inert());
10614
10615 assert_eq!(
10617 session.in_flight_realtime_assistant_response_ids(),
10618 vec!["resp_cc2".to_string()]
10619 );
10620
10621 let outcome = session.append_realtime_transcript_event(
10622 RealtimeTranscriptEvent::AssistantTurnCompleted {
10623 response_id: "resp_cc2".to_string(),
10624 stop_reason: StopReason::EndTurn,
10625 usage: Usage::default(),
10626 },
10627 );
10628 assert_eq!(outcome.materialized_messages.len(), 1);
10629
10630 assert!(
10632 session
10633 .in_flight_realtime_assistant_response_ids()
10634 .is_empty(),
10635 "materialized items must not appear in in_flight_realtime_assistant_response_ids"
10636 );
10637
10638 let messages = session.messages();
10639 let assistant = messages.iter().find_map(|m| match m {
10640 Message::BlockAssistant(a) => Some(a),
10641 _ => None,
10642 });
10643 let assistant = assistant.expect("assistant block message expected");
10644 assert_eq!(assistant.blocks.len(), 1);
10645 assert!(matches!(
10646 &assistant.blocks[0],
10647 AssistantBlock::Transcript {
10648 source: crate::types::TranscriptSource::Spoken,
10649 ..
10650 }
10651 ));
10652 }
10653
10654 #[test]
10655 fn realtime_transcript_assistant_text_delta_still_materializes_text_block() {
10656 let mut session = Session::new();
10660
10661 let delta = RealtimeTranscriptEvent::AssistantTextDelta {
10662 response_id: "resp_display".to_string(),
10663 delta_id: "evt_delta_display_1".to_string(),
10664 item_id: "item_display".to_string(),
10665 previous_item_id: None,
10666 content_index: 0,
10667 delta: "I wrote".to_string(),
10668 };
10669 let _ = session.append_realtime_transcript_event(delta);
10670
10671 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
10672 response_id: "resp_display".to_string(),
10673 stop_reason: StopReason::EndTurn,
10674 usage: Usage::default(),
10675 };
10676 let outcome = session.append_realtime_transcript_event(terminal);
10677 assert_eq!(outcome.materialized_messages.len(), 1);
10678
10679 let messages = session.messages();
10680 match &messages[0] {
10681 Message::BlockAssistant(assistant) => match &assistant.blocks[0] {
10682 AssistantBlock::Text { text, .. } => assert_eq!(text, "I wrote"),
10683 other => unreachable!(
10684 "AssistantTextDelta must keep materializing AssistantBlock::Text, got {other:?}"
10685 ),
10686 },
10687 other => unreachable!("expected BlockAssistant message, got {other:?}"),
10688 }
10689 }
10690
10691 #[test]
10692 fn round4_cc7_mixed_response_persists_text_and_transcript_in_order() {
10693 let mut session = Session::new();
10711
10712 let display_a = RealtimeTranscriptEvent::AssistantTextDelta {
10714 response_id: "resp_mixed_1".to_string(),
10715 delta_id: "delta_disp_1".to_string(),
10716 item_id: "item_display".to_string(),
10717 previous_item_id: None,
10718 content_index: 0,
10719 delta: "Here's the report:".to_string(),
10720 };
10721 assert!(
10722 session
10723 .append_realtime_transcript_event(display_a)
10724 .is_inert()
10725 );
10726
10727 let display_b = RealtimeTranscriptEvent::AssistantTextDelta {
10728 response_id: "resp_mixed_1".to_string(),
10729 delta_id: "delta_disp_2".to_string(),
10730 item_id: "item_display".to_string(),
10731 previous_item_id: None,
10732 content_index: 0,
10733 delta: " (still writing)".to_string(),
10734 };
10735 assert!(
10736 session
10737 .append_realtime_transcript_event(display_b)
10738 .is_inert()
10739 );
10740
10741 let spoken_a = RealtimeTranscriptEvent::AssistantTranscriptDelta {
10746 response_id: "resp_mixed_1".to_string(),
10747 delta_id: "delta_spoken_1".to_string(),
10748 item_id: "item_spoken".to_string(),
10749 previous_item_id: Some("item_display".to_string()),
10750 content_index: 0,
10751 delta: "I'm reading the report aloud:".to_string(),
10752 };
10753 assert!(
10754 session
10755 .append_realtime_transcript_event(spoken_a)
10756 .is_inert()
10757 );
10758
10759 let spoken_b = RealtimeTranscriptEvent::AssistantTranscriptDelta {
10760 response_id: "resp_mixed_1".to_string(),
10761 delta_id: "delta_spoken_2".to_string(),
10762 item_id: "item_spoken".to_string(),
10763 previous_item_id: Some("item_display".to_string()),
10764 content_index: 0,
10765 delta: " sentence two.".to_string(),
10766 };
10767 assert!(
10768 session
10769 .append_realtime_transcript_event(spoken_b)
10770 .is_inert()
10771 );
10772
10773 let outcome = session.append_realtime_transcript_event(
10776 RealtimeTranscriptEvent::AssistantTurnCompleted {
10777 response_id: "resp_mixed_1".to_string(),
10778 stop_reason: StopReason::EndTurn,
10779 usage: Usage {
10780 input_tokens: 11,
10781 output_tokens: 22,
10782 cache_creation_tokens: None,
10783 cache_read_tokens: None,
10784 },
10785 },
10786 );
10787 assert_eq!(outcome.materialized_messages.len(), 2);
10789
10790 let messages = session.messages();
10793 let assistants: Vec<&BlockAssistantMessage> = messages
10794 .iter()
10795 .filter_map(|m| match m {
10796 Message::BlockAssistant(a) => Some(a),
10797 _ => None,
10798 })
10799 .collect();
10800 assert_eq!(
10801 assistants.len(),
10802 1,
10803 "mixed display+spoken response under one response_id must produce exactly ONE BlockAssistant message, got: {assistants:?}"
10804 );
10805 let assistant = assistants[0];
10806 assert_eq!(
10807 assistant.blocks.len(),
10808 2,
10809 "mixed response message must carry both blocks: {:?}",
10810 assistant.blocks
10811 );
10812
10813 match &assistant.blocks[0] {
10815 AssistantBlock::Text { text, .. } => {
10816 assert_eq!(text, "Here's the report: (still writing)");
10817 }
10818 other => unreachable!(
10819 "first block must be AssistantBlock::Text (display lane), got {other:?}"
10820 ),
10821 }
10822 match &assistant.blocks[1] {
10824 AssistantBlock::Transcript { text, source, .. } => {
10825 assert_eq!(text, "I'm reading the report aloud: sentence two.");
10826 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
10827 }
10828 other => unreachable!(
10829 "second block must be AssistantBlock::Transcript {{ source: Spoken }}, got {other:?}"
10830 ),
10831 }
10832
10833 assert_eq!(session.usage.input_tokens, 11);
10835 assert_eq!(session.usage.output_tokens, 22);
10836 }
10837
10838 #[test]
10839 fn round5_r55_mixed_response_barge_in_preserves_display_drops_spoken() {
10840 let mut session = Session::new();
10856
10857 let display = RealtimeTranscriptEvent::AssistantTextDelta {
10858 response_id: "resp_mixed_2".to_string(),
10859 delta_id: "delta_disp_1".to_string(),
10860 item_id: "item_display_2".to_string(),
10861 previous_item_id: None,
10862 content_index: 0,
10863 delta: "Working on the report...".to_string(),
10864 };
10865 let _ = session.append_realtime_transcript_event(display);
10866
10867 let spoken = RealtimeTranscriptEvent::AssistantTranscriptDelta {
10868 response_id: "resp_mixed_2".to_string(),
10869 delta_id: "delta_spoken_1".to_string(),
10870 item_id: "item_spoken_2".to_string(),
10871 previous_item_id: Some("item_display_2".to_string()),
10872 content_index: 0,
10873 delta: "I'm reading the report".to_string(),
10874 };
10875 let _ = session.append_realtime_transcript_event(spoken);
10876
10877 let outcome = session.append_realtime_transcript_event(
10881 RealtimeTranscriptEvent::AssistantTurnInterrupted {
10882 response_id: "resp_mixed_2".to_string(),
10883 },
10884 );
10885 assert_eq!(
10886 outcome.materialized_messages.len(),
10887 1,
10888 "Display lane item must materialize on Interrupted: {outcome:?}"
10889 );
10890
10891 let late_completion = session.append_realtime_transcript_event(
10895 RealtimeTranscriptEvent::AssistantTurnCompleted {
10896 response_id: "resp_mixed_2".to_string(),
10897 stop_reason: StopReason::Cancelled,
10898 usage: Usage::default(),
10899 },
10900 );
10901 assert_eq!(
10902 late_completion.materialized_messages.len(),
10903 0,
10904 "post-barge-in TurnCompleted must not resurrect anything"
10905 );
10906
10907 let messages = session.messages();
10910 let assistants: Vec<&BlockAssistantMessage> = messages
10911 .iter()
10912 .filter_map(|m| match m {
10913 Message::BlockAssistant(a) => Some(a),
10914 _ => None,
10915 })
10916 .collect();
10917 assert_eq!(
10918 assistants.len(),
10919 1,
10920 "barge-in must commit exactly one BlockAssistant containing the Display lane: {assistants:?}"
10921 );
10922 let assistant = assistants[0];
10923 assert_eq!(assistant.blocks.len(), 1, "blocks: {:?}", assistant.blocks);
10924 match &assistant.blocks[0] {
10925 AssistantBlock::Text { text, .. } => {
10926 assert_eq!(text, "Working on the report...");
10927 }
10928 other => {
10929 unreachable!("Display lane must materialize as AssistantBlock::Text, got {other:?}")
10930 }
10931 }
10932 assert!(
10934 !assistant
10935 .blocks
10936 .iter()
10937 .any(|b| matches!(b, AssistantBlock::Transcript { .. })),
10938 "Spoken lane must be dropped on barge-in"
10939 );
10940
10941 assert!(
10944 !session
10945 .in_flight_realtime_assistant_response_ids()
10946 .contains(&"resp_mixed_2".to_string()),
10947 "barged-in response must not appear in in_flight_realtime_assistant_response_ids"
10948 );
10949 }
10950
10951 #[test]
10952 fn round5_r55_barge_in_preserves_display_lane_drops_spoken() {
10953 let mut session = Session::new();
10957
10958 let _ =
10959 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10960 response_id: "resp_a".to_string(),
10961 delta_id: "delta_d_1".to_string(),
10962 item_id: "item_display".to_string(),
10963 previous_item_id: None,
10964 content_index: 0,
10965 delta: "display-text".to_string(),
10966 });
10967 let _ = session.append_realtime_transcript_event(
10968 RealtimeTranscriptEvent::AssistantTranscriptDelta {
10969 response_id: "resp_a".to_string(),
10970 delta_id: "delta_s_1".to_string(),
10971 item_id: "item_spoken".to_string(),
10972 previous_item_id: None,
10973 content_index: 0,
10974 delta: "spoken-transcript".to_string(),
10975 },
10976 );
10977
10978 let outcome = session.append_realtime_transcript_event(
10979 RealtimeTranscriptEvent::AssistantTurnInterrupted {
10980 response_id: "resp_a".to_string(),
10981 },
10982 );
10983 assert_eq!(outcome.materialized_messages.len(), 1);
10985
10986 let messages = session.messages();
10987 let assistants: Vec<&BlockAssistantMessage> = messages
10988 .iter()
10989 .filter_map(|m| match m {
10990 Message::BlockAssistant(a) => Some(a),
10991 _ => None,
10992 })
10993 .collect();
10994 assert_eq!(assistants.len(), 1);
10995 assert_eq!(assistants[0].blocks.len(), 1);
10997 match &assistants[0].blocks[0] {
10998 AssistantBlock::Text { text, .. } => assert_eq!(text, "display-text"),
10999 other => unreachable!("expected Text, got {other:?}"),
11000 }
11001 }
11002
11003 #[test]
11004 fn round5_r55_barge_in_finalizes_retained_display_into_committed_block() {
11005 let mut session = Session::new();
11010
11011 let _ =
11012 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
11013 response_id: "resp_a".to_string(),
11014 delta_id: "delta_d_1".to_string(),
11015 item_id: "item_display".to_string(),
11016 previous_item_id: None,
11017 content_index: 0,
11018 delta: "committed-display-text".to_string(),
11019 });
11020
11021 assert!(session.messages().is_empty());
11023
11024 let outcome = session.append_realtime_transcript_event(
11025 RealtimeTranscriptEvent::AssistantTurnInterrupted {
11026 response_id: "resp_a".to_string(),
11027 },
11028 );
11029 assert_eq!(
11030 outcome.materialized_messages.len(),
11031 1,
11032 "Interrupted must finalize retained Display lane immediately"
11033 );
11034
11035 let messages = session.messages();
11037 assert_eq!(messages.len(), 1);
11038 match &messages[0] {
11039 Message::BlockAssistant(assistant) => {
11040 assert_eq!(assistant.blocks.len(), 1);
11041 match &assistant.blocks[0] {
11042 AssistantBlock::Text { text, .. } => {
11043 assert_eq!(text, "committed-display-text");
11044 }
11045 other => unreachable!("expected Text, got {other:?}"),
11046 }
11047 }
11048 other => unreachable!("expected BlockAssistant, got {other:?}"),
11049 }
11050 }
11051
11052 #[test]
11053 fn round5_r56_truncation_promotes_default_lane_item_to_spoken() {
11054 let mut session = Session::new();
11061
11062 let _ = session.append_realtime_transcript_event(
11063 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
11064 response_id: "resp_a".to_string(),
11065 item_id: "item_a".to_string(),
11066 content_index: 0,
11067 text: "what was actually heard".to_string(),
11068 },
11069 );
11070
11071 let outcome = session.append_realtime_transcript_event(
11072 RealtimeTranscriptEvent::AssistantTurnCompleted {
11073 response_id: "resp_a".to_string(),
11074 stop_reason: StopReason::EndTurn,
11075 usage: Usage::default(),
11076 },
11077 );
11078 assert_eq!(outcome.materialized_messages.len(), 1);
11079
11080 assert_eq!(session.messages().len(), 1);
11081 match &session.messages()[0] {
11082 Message::BlockAssistant(assistant) => {
11083 assert_eq!(assistant.blocks.len(), 1);
11084 match &assistant.blocks[0] {
11085 AssistantBlock::Transcript { text, source, .. } => {
11086 assert_eq!(text, "what was actually heard");
11087 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
11088 }
11089 other => unreachable!(
11090 "truncation-only path must materialize as AssistantBlock::Transcript, got {other:?}"
11091 ),
11092 }
11093 }
11094 other => unreachable!("expected BlockAssistant, got {other:?}"),
11095 }
11096 }
11097
11098 #[test]
11099 fn round5_r56_truncation_after_display_delta_is_no_op_keeping_display_content() {
11100 let mut session = Session::new();
11108
11109 let _ =
11110 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
11111 response_id: "resp_a".to_string(),
11112 delta_id: "delta_d_1".to_string(),
11113 item_id: "item_a".to_string(),
11114 previous_item_id: None,
11115 content_index: 0,
11116 delta: "display-text-from-delta".to_string(),
11117 });
11118
11119 let _ = session.append_realtime_transcript_event(
11120 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
11121 response_id: "resp_a".to_string(),
11122 item_id: "item_a".to_string(),
11123 content_index: 0,
11124 text: "spoken-truncation-text".to_string(),
11125 },
11126 );
11127
11128 let _ = session.append_realtime_transcript_event(
11129 RealtimeTranscriptEvent::AssistantTurnCompleted {
11130 response_id: "resp_a".to_string(),
11131 stop_reason: StopReason::EndTurn,
11132 usage: Usage::default(),
11133 },
11134 );
11135
11136 assert_eq!(session.messages().len(), 1);
11139 match &session.messages()[0] {
11140 Message::BlockAssistant(assistant) => {
11141 assert_eq!(assistant.blocks.len(), 1);
11142 match &assistant.blocks[0] {
11143 AssistantBlock::Text { text, .. } => {
11144 assert_eq!(text, "display-text-from-delta");
11145 }
11146 other => unreachable!(
11147 "Display content must survive misrouted truncation, got {other:?}"
11148 ),
11149 }
11150 }
11151 other => unreachable!("expected BlockAssistant, got {other:?}"),
11152 }
11153 }
11154
11155 #[test]
11163 fn round5_r56_sibling_display_delta_skipped_on_spoken_item() {
11164 let mut session = Session::new();
11165
11166 let _ = session.append_realtime_transcript_event(
11168 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
11169 response_id: "resp_a".to_string(),
11170 item_id: "item_a".to_string(),
11171 content_index: 0,
11172 text: "what was actually heard".to_string(),
11173 },
11174 );
11175
11176 let _ =
11179 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
11180 response_id: "resp_a".to_string(),
11181 delta_id: "delta_d_1".to_string(),
11182 item_id: "item_a".to_string(),
11183 previous_item_id: None,
11184 content_index: 0,
11185 delta: "should-not-appear".to_string(),
11186 });
11187
11188 let _ = session.append_realtime_transcript_event(
11189 RealtimeTranscriptEvent::AssistantTurnCompleted {
11190 response_id: "resp_a".to_string(),
11191 stop_reason: StopReason::EndTurn,
11192 usage: Usage::default(),
11193 },
11194 );
11195
11196 assert_eq!(session.messages().len(), 1);
11199 match &session.messages()[0] {
11200 Message::BlockAssistant(assistant) => {
11201 assert_eq!(assistant.blocks.len(), 1);
11202 match &assistant.blocks[0] {
11203 AssistantBlock::Transcript { text, source, .. } => {
11204 assert_eq!(text, "what was actually heard");
11205 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
11206 }
11207 other => unreachable!(
11208 "Spoken-locked item must materialize as Transcript, got {other:?}"
11209 ),
11210 }
11211 }
11212 other => unreachable!("expected BlockAssistant, got {other:?}"),
11213 }
11214 }
11215
11216 #[test]
11223 fn round5_r56_sibling_spoken_delta_skipped_on_display_item() {
11224 let mut session = Session::new();
11225
11226 let _ =
11228 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
11229 response_id: "resp_a".to_string(),
11230 delta_id: "delta_d_1".to_string(),
11231 item_id: "item_a".to_string(),
11232 previous_item_id: None,
11233 content_index: 0,
11234 delta: "display-locked-text".to_string(),
11235 });
11236
11237 let _ = session.append_realtime_transcript_event(
11240 RealtimeTranscriptEvent::AssistantTranscriptDelta {
11241 response_id: "resp_a".to_string(),
11242 delta_id: "delta_s_1".to_string(),
11243 item_id: "item_a".to_string(),
11244 previous_item_id: None,
11245 content_index: 0,
11246 delta: "should-not-appear".to_string(),
11247 },
11248 );
11249
11250 let _ = session.append_realtime_transcript_event(
11251 RealtimeTranscriptEvent::AssistantTurnCompleted {
11252 response_id: "resp_a".to_string(),
11253 stop_reason: StopReason::EndTurn,
11254 usage: Usage::default(),
11255 },
11256 );
11257
11258 assert_eq!(session.messages().len(), 1);
11260 match &session.messages()[0] {
11261 Message::BlockAssistant(assistant) => {
11262 assert_eq!(assistant.blocks.len(), 1);
11263 match &assistant.blocks[0] {
11264 AssistantBlock::Text { text, .. } => {
11265 assert_eq!(text, "display-locked-text");
11266 }
11267 other => {
11268 unreachable!("Display-locked item must materialize as Text, got {other:?}")
11269 }
11270 }
11271 }
11272 other => unreachable!("expected BlockAssistant, got {other:?}"),
11273 }
11274 }
11275
11276 #[test]
11284 fn round5_r57_late_final_text_after_turn_completed_warns_and_skips() {
11285 let mut session = Session::new();
11286
11287 let _ = session.append_realtime_transcript_event(
11289 RealtimeTranscriptEvent::AssistantTranscriptDelta {
11290 response_id: "resp_a".to_string(),
11291 delta_id: "delta_s_1".to_string(),
11292 item_id: "item_a".to_string(),
11293 previous_item_id: None,
11294 content_index: 0,
11295 delta: "delta-accumulated".to_string(),
11296 },
11297 );
11298
11299 let commit_outcome = session.append_realtime_transcript_event(
11301 RealtimeTranscriptEvent::AssistantTurnCompleted {
11302 response_id: "resp_a".to_string(),
11303 stop_reason: StopReason::EndTurn,
11304 usage: Usage::default(),
11305 },
11306 );
11307 assert_eq!(commit_outcome.materialized_messages.len(), 1);
11308
11309 let late_outcome = session.append_realtime_transcript_event(
11313 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
11314 response_id: "resp_a".to_string(),
11315 item_id: "item_a".to_string(),
11316 content_index: 0,
11317 text: "authoritative-final-that-must-not-land".to_string(),
11318 },
11319 );
11320 assert!(
11321 late_outcome.is_inert(),
11322 "late FinalText after materialization must produce inert outcome"
11323 );
11324
11325 assert_eq!(session.messages().len(), 1);
11328 match &session.messages()[0] {
11329 Message::BlockAssistant(assistant) => {
11330 assert_eq!(assistant.blocks.len(), 1);
11331 match &assistant.blocks[0] {
11332 AssistantBlock::Transcript { text, .. } => {
11333 assert_eq!(
11334 text, "delta-accumulated",
11335 "canonical message must preserve delta-accumulated text; \
11336 append-only history forbids late FinalText repair"
11337 );
11338 }
11339 other => unreachable!("expected Transcript, got {other:?}"),
11340 }
11341 }
11342 other => unreachable!("expected BlockAssistant, got {other:?}"),
11343 }
11344 }
11345
11346 fn metadata_seam_session_metadata() -> SessionMetadata {
11347 SessionMetadata {
11348 schema_version: SESSION_METADATA_SCHEMA_VERSION,
11349 model: "test-model".to_string(),
11350 max_tokens: 1024,
11351 structured_output_retries: 2,
11352 provider: Provider::Anthropic,
11353 self_hosted_server_id: None,
11354 provider_params: None,
11355 tooling: SessionTooling::default(),
11356 keep_alive: false,
11357 comms_name: Some("team/reviewer/alice".to_string()),
11358 peer_meta: None,
11359 realm_id: None,
11360 instance_id: None,
11361 backend: None,
11362 config_generation: None,
11363 auth_binding: None,
11364 mob_member_binding: Some(crate::MobMemberBinding {
11365 mob_id: "team".to_string(),
11366 role: "reviewer".to_string(),
11367 member: "alice".to_string(),
11368 }),
11369 }
11370 }
11371
11372 #[test]
11377 fn session_metadata_document_lockstep_with_full_envelope() {
11378 let mut session = Session::new();
11379 session.push(Message::User(UserMessage::text("hello".to_string())));
11380 session
11381 .set_session_metadata(metadata_seam_session_metadata())
11382 .expect("session metadata should persist");
11383 session
11384 .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
11385 .expect("lifecycle terminal should persist");
11386
11387 let bytes = serde_json::to_vec(&session).expect("session should serialize");
11388 let document = session_metadata_document_from_slice(&bytes)
11389 .expect("partial decode must accept the canonical envelope");
11390
11391 assert_eq!(document.session_id(), session.id());
11392 assert_eq!(
11393 document.session_metadata_value(),
11394 session.metadata().get(SESSION_METADATA_KEY),
11395 "partial decode must project the identical raw session-metadata value"
11396 );
11397 assert_eq!(
11398 document.lifecycle_terminal_value(),
11399 session.metadata().get(SESSION_LIFECYCLE_TERMINAL_KEY),
11400 "partial decode must project the identical raw lifecycle-terminal value"
11401 );
11402
11403 let view = document
11404 .try_into_view()
11405 .expect("typed view must decode from the partial document");
11406 let full_view =
11407 PersistedSessionMetadataView::try_from_session(&session).expect("full-session view");
11408 assert_eq!(view.session_id, full_view.session_id);
11409 assert_eq!(
11410 view.session_metadata.as_ref().map(|m| m.model.clone()),
11411 full_view.session_metadata.as_ref().map(|m| m.model.clone())
11412 );
11413 assert_eq!(
11414 view.mob_member_binding(),
11415 full_view.mob_member_binding(),
11416 "typed binding must be identical across the two decode paths"
11417 );
11418 assert_eq!(
11419 view.lifecycle_terminal,
11420 Some(SessionLifecycleTerminal::Archived)
11421 );
11422 assert_eq!(
11423 full_view.lifecycle_terminal,
11424 Some(SessionLifecycleTerminal::Archived)
11425 );
11426 }
11427
11428 #[test]
11431 fn session_metadata_document_fails_closed_on_envelope_version() {
11432 let session = Session::new();
11433 let mut value = serde_json::to_value(&session).expect("session should serialize");
11434 value["version"] = serde_json::json!(SESSION_VERSION + 999);
11435 let bytes = serde_json::to_vec(&value).expect("mangled envelope should serialize");
11436
11437 session_metadata_document_from_slice(&bytes)
11438 .expect_err("an unsupported envelope version must fail the partial decode closed");
11439 }
11440
11441 #[test]
11444 fn persisted_session_metadata_view_fails_closed_on_corrupt_values() {
11445 let session_id = SessionId::new();
11446
11447 let mut corrupt_metadata = serde_json::Map::new();
11448 corrupt_metadata.insert(SESSION_METADATA_KEY.to_string(), serde_json::json!(42));
11449 PersistedSessionMetadataView::try_from_metadata_map(session_id.clone(), &corrupt_metadata)
11450 .expect_err("corrupt session_metadata must fail the view decode closed");
11451
11452 let mut corrupt_terminal = serde_json::Map::new();
11453 corrupt_terminal.insert(
11454 SESSION_LIFECYCLE_TERMINAL_KEY.to_string(),
11455 serde_json::json!("definitely-not-a-terminal"),
11456 );
11457 PersistedSessionMetadataView::try_from_metadata_map(session_id, &corrupt_terminal)
11458 .expect_err("corrupt lifecycle terminal must fail the view decode closed");
11459 }
11460
11461 #[test]
11463 fn persisted_session_metadata_view_reads_absent_facts_as_none() {
11464 let view = PersistedSessionMetadataView::try_from_metadata_map(
11465 SessionId::new(),
11466 &serde_json::Map::new(),
11467 )
11468 .expect("empty metadata map must decode");
11469 assert!(view.session_metadata.is_none());
11470 assert!(view.lifecycle_terminal.is_none());
11471 assert!(view.mob_member_binding().is_none());
11472 }
11473}