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 },
95}
96
97impl TranscriptRewriteSelection {
98 fn bounds(&self) -> (usize, usize) {
99 match self {
100 Self::MessageRange { start, end } => (*start, *end),
101 }
102 }
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
111#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
112#[serde(rename_all = "snake_case")]
113pub struct TranscriptRewriteReason {
114 pub kind: String,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub note: Option<String>,
117}
118
119impl TranscriptRewriteReason {
120 pub fn new(kind: impl Into<String>) -> Self {
121 Self {
122 kind: kind.into(),
123 note: None,
124 }
125 }
126}
127
128pub const RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON: &str = "resume-system-prompt-refresh";
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum ResumedSystemPromptReconciliation {
135 PreservedContinuation,
140 RewrittenBase,
144 NoChange,
147}
148
149impl std::fmt::Display for TranscriptRewriteReason {
150 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 match &self.note {
154 Some(note) => write!(f, "{}: {note}", self.kind),
155 None => f.write_str(&self.kind),
156 }
157 }
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
162#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
163#[serde(rename_all = "snake_case")]
164pub struct TranscriptRewriteCommit {
165 pub parent_revision: String,
166 pub revision: String,
167 pub selection: TranscriptRewriteSelection,
168 pub original_span_digest: String,
169 pub replacement_digest: String,
170 pub messages_before: usize,
171 pub messages_after: usize,
172 pub reason: TranscriptRewriteReason,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub actor: Option<String>,
175 #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
176 pub committed_at: SystemTime,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
181#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
182#[serde(rename_all = "snake_case")]
183pub struct TranscriptRevisionBody {
184 pub revision: String,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub parent_revision: Option<String>,
187 #[cfg_attr(feature = "schema", schemars(with = "Vec<serde_json::Value>"))]
188 pub messages: Vec<Message>,
189 #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
190 pub created_at: SystemTime,
191}
192
193#[cfg(feature = "schema")]
194#[allow(dead_code)]
195#[derive(schemars::JsonSchema)]
196#[schemars(rename = "SystemTime")]
197struct SchemaSystemTime {
198 secs_since_epoch: u64,
199 nanos_since_epoch: u32,
200}
201
202#[derive(Debug, Clone, Serialize)]
204#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
205#[serde(rename_all = "snake_case")]
206pub struct TranscriptRewriteRecord {
207 pub commit: TranscriptRewriteCommit,
208 pub parent_body: TranscriptRevisionBody,
209 pub revision_body: TranscriptRevisionBody,
210}
211
212impl<'de> Deserialize<'de> for TranscriptRewriteRecord {
213 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
214 where
215 D: Deserializer<'de>,
216 {
217 #[derive(Deserialize)]
218 #[serde(rename_all = "snake_case")]
219 struct Wire {
220 commit: TranscriptRewriteCommit,
221 parent_body: TranscriptRevisionBody,
222 revision_body: TranscriptRevisionBody,
223 }
224 let wire = Wire::deserialize(deserializer)?;
225 let mut revisions = vec![wire.parent_body, wire.revision_body];
226 let mut commits = vec![wire.commit];
227 heal_legacy_revision_strings(&mut revisions, &mut commits, None)
228 .map_err(serde::de::Error::custom)?;
229 let mut revisions = revisions.into_iter();
230 let parent_body = revisions
231 .next()
232 .ok_or_else(|| serde::de::Error::custom("rewrite record lost its parent body"))?;
233 let revision_body = revisions
234 .next()
235 .ok_or_else(|| serde::de::Error::custom("rewrite record lost its revision body"))?;
236 let commit = commits
237 .into_iter()
238 .next()
239 .ok_or_else(|| serde::de::Error::custom("rewrite record lost its commit"))?;
240 Ok(Self {
241 commit,
242 parent_body,
243 revision_body,
244 })
245 }
246}
247
248impl TranscriptRewriteRecord {
249 pub fn new(
250 commit: TranscriptRewriteCommit,
251 parent_body: TranscriptRevisionBody,
252 revision_body: TranscriptRevisionBody,
253 ) -> Result<Self, TranscriptEditError> {
254 validate_transcript_rewrite_record(&commit, &parent_body, &revision_body)?;
255 Ok(Self {
256 commit,
257 parent_body,
258 revision_body,
259 })
260 }
261}
262
263#[derive(Debug, Clone, Serialize)]
265#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
266#[serde(rename_all = "snake_case")]
267pub struct TranscriptHistoryState {
268 pub head: String,
269 #[serde(default, skip_serializing_if = "Vec::is_empty")]
270 pub commits: Vec<TranscriptRewriteCommit>,
271 #[serde(default, skip_serializing_if = "Vec::is_empty")]
272 pub revisions: Vec<TranscriptRevisionBody>,
273}
274
275impl<'de> Deserialize<'de> for TranscriptHistoryState {
276 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
277 where
278 D: Deserializer<'de>,
279 {
280 #[derive(Deserialize)]
281 #[serde(rename_all = "snake_case")]
282 struct Wire {
283 head: String,
284 #[serde(default)]
285 commits: Vec<TranscriptRewriteCommit>,
286 #[serde(default)]
287 revisions: Vec<TranscriptRevisionBody>,
288 }
289 let wire = Wire::deserialize(deserializer)?;
290 let mut state = TranscriptHistoryState {
291 head: wire.head,
292 commits: wire.commits,
293 revisions: wire.revisions,
294 };
295 let head_is_current = match state
298 .revisions
299 .iter()
300 .find(|body| body.revision == state.head)
301 {
302 Some(head_body) => {
303 transcript_messages_digest(&head_body.messages).map_err(serde::de::Error::custom)?
304 == state.head
305 }
306 None => true,
307 };
308 if !head_is_current {
309 let TranscriptHistoryState {
310 head,
311 commits,
312 revisions,
313 } = &mut state;
314 heal_legacy_revision_strings(revisions, commits, Some(head))
315 .map_err(serde::de::Error::custom)?;
316 }
317 Ok(state)
318 }
319}
320
321fn heal_legacy_revision_strings(
330 revisions: &mut [TranscriptRevisionBody],
331 commits: &mut [TranscriptRewriteCommit],
332 head: Option<&mut String>,
333) -> Result<(), serde_json::Error> {
334 let mut remap: BTreeMap<String, String> = BTreeMap::new();
335 for body in revisions.iter() {
336 let content = transcript_messages_digest(&body.messages)?;
337 if body.revision == content {
338 continue;
339 }
340 if body.revision == legacy_transcript_messages_digest(&body.messages)? {
341 remap.insert(body.revision.clone(), content);
342 }
343 }
344 if remap.is_empty() {
345 return Ok(());
346 }
347 for body in revisions.iter_mut() {
348 if let Some(current) = remap.get(&body.revision) {
349 body.revision = current.clone();
350 }
351 if let Some(parent) = body.parent_revision.as_ref()
352 && let Some(current) = remap.get(parent)
353 {
354 body.parent_revision = Some(current.clone());
355 }
356 }
357 for commit in commits.iter_mut() {
358 if let Some(current) = remap.get(&commit.parent_revision) {
359 commit.parent_revision = current.clone();
360 }
361 if let Some(current) = remap.get(&commit.revision) {
362 commit.revision = current.clone();
363 }
364 heal_legacy_commit_span_digests(commit, revisions)?;
365 }
366 if let Some(head) = head
367 && let Some(current) = remap.get(head.as_str())
368 {
369 *head = current.clone();
370 }
371 Ok(())
372}
373
374fn heal_legacy_commit_span_digests(
380 commit: &mut TranscriptRewriteCommit,
381 revisions: &[TranscriptRevisionBody],
382) -> Result<(), serde_json::Error> {
383 let Some(parent_body) = revisions
384 .iter()
385 .find(|body| body.revision == commit.parent_revision)
386 else {
387 return Ok(());
388 };
389 let Some(revision_body) = revisions
390 .iter()
391 .find(|body| body.revision == commit.revision)
392 else {
393 return Ok(());
394 };
395 let (start, end) = commit.selection.bounds();
396 if start > end || end > parent_body.messages.len() {
397 return Ok(());
398 }
399 let removed_len = end - start;
400 let Some(retained_len) = commit.messages_before.checked_sub(removed_len) else {
401 return Ok(());
402 };
403 let Some(replacement_len) = commit.messages_after.checked_sub(retained_len) else {
404 return Ok(());
405 };
406 let Some(replacement_end) = start.checked_add(replacement_len) else {
407 return Ok(());
408 };
409 if replacement_end > revision_body.messages.len() {
410 return Ok(());
411 }
412 let original_span = &parent_body.messages[start..end];
413 if commit.original_span_digest == legacy_transcript_messages_digest(original_span)? {
414 commit.original_span_digest = transcript_messages_digest(original_span)?;
415 }
416 let replacement_span = &revision_body.messages[start..replacement_end];
417 if commit.replacement_digest == legacy_transcript_messages_digest(replacement_span)? {
418 commit.replacement_digest = transcript_messages_digest(replacement_span)?;
419 }
420 Ok(())
421}
422
423impl TranscriptHistoryState {
424 pub fn from_rewrite_records<I>(records: I) -> Result<Option<Self>, TranscriptEditError>
426 where
427 I: IntoIterator<Item = TranscriptRewriteRecord>,
428 {
429 let mut state: Option<Self> = None;
430 for record in records {
431 validate_transcript_rewrite_record(
432 &record.commit,
433 &record.parent_body,
434 &record.revision_body,
435 )?;
436 let state = state.get_or_insert_with(|| Self {
437 head: record.commit.parent_revision.clone(),
438 commits: Vec::new(),
439 revisions: Vec::new(),
440 });
441 if record.commit.parent_revision != state.head {
442 if revision_body_extends_head(&record.parent_body, &state.revisions, &state.head)? {
443 state.head = record.commit.parent_revision.clone();
444 } else {
445 return Err(TranscriptEditError::HistoryStateMalformed(format!(
446 "rewrite record parent {} does not extend transcript head {}",
447 record.commit.parent_revision, state.head
448 )));
449 }
450 }
451 if !state
452 .revisions
453 .iter()
454 .any(|body| body.revision == record.parent_body.revision)
455 {
456 state.revisions.push(record.parent_body);
457 }
458 if !state
459 .revisions
460 .iter()
461 .any(|body| body.revision == record.revision_body.revision)
462 {
463 state.revisions.push(record.revision_body);
464 }
465 state.head = record.commit.revision.clone();
466 state.commits.push(record.commit);
467 }
468 Ok(state)
469 }
470}
471
472#[derive(Debug, Clone, thiserror::Error)]
474pub enum TranscriptEditError {
475 #[error("message index {message_index} out of bounds for {message_count} messages")]
476 MessageIndexOutOfBounds {
477 message_index: usize,
478 message_count: usize,
479 },
480 #[error("{block_kind} index {block_index} out of bounds for {block_count} blocks")]
481 BlockIndexOutOfBounds {
482 block_kind: &'static str,
483 block_index: usize,
484 block_count: usize,
485 },
486 #[error("replacement expected {expected} at message index {message_index}, found {actual}")]
487 MessageRoleMismatch {
488 message_index: usize,
489 expected: &'static str,
490 actual: &'static str,
491 },
492 #[error("invalid transcript rewrite range {start}..{end} for {message_count} messages")]
493 InvalidRewriteRange {
494 start: usize,
495 end: usize,
496 message_count: usize,
497 },
498 #[error("transcript rewrite does not change transcript revision {revision}")]
499 NoOpRewrite { revision: String },
500 #[error("transcript rewrite parent revision mismatch: expected {expected}, actual {actual}")]
501 RevisionConflict { expected: String, actual: String },
502 #[error("transcript history state is malformed: {0}")]
503 HistoryStateMalformed(String),
504 #[error("invalid transcript shape after rewrite: {0}")]
505 InvalidTranscriptShape(String),
506}
507
508fn message_role_name(message: &Message) -> &'static str {
509 match message {
510 Message::System(_) => "system",
511 Message::SystemNotice(_) => "system_notice",
512 Message::User(_) => "user",
513 Message::BlockAssistant(_) => "block_assistant",
514 Message::ToolResults { .. } => "tool_results",
515 }
516}
517
518fn assistant_tool_use_ids(message: &Message) -> Vec<&str> {
519 match message {
520 Message::BlockAssistant(assistant) => assistant
521 .blocks
522 .iter()
523 .filter_map(|block| match block {
524 AssistantBlock::ToolUse { id, .. } => Some(id.as_str()),
525 _ => None,
526 })
527 .collect(),
528 _ => Vec::new(),
529 }
530}
531
532fn validate_transcript_tool_result_shape(messages: &[Message]) -> Result<(), TranscriptEditError> {
533 for (index, message) in messages.iter().enumerate() {
534 if let Message::ToolResults { results, .. } = message {
535 let Some(previous) = index
536 .checked_sub(1)
537 .and_then(|previous| messages.get(previous))
538 else {
539 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
540 "tool_results at message {index} has no preceding assistant tool-use message"
541 )));
542 };
543 let expected = assistant_tool_use_ids(previous);
544 if expected.is_empty() {
545 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
546 "tool_results at message {index} follows {}, not an assistant tool-use message",
547 message_role_name(previous)
548 )));
549 }
550 let actual = results
551 .iter()
552 .map(|result| result.tool_use_id.as_str())
553 .collect::<Vec<_>>();
554 let actual_set = actual.iter().copied().collect::<BTreeSet<_>>();
555 let expected_set = expected.iter().copied().collect::<BTreeSet<_>>();
556 if actual.len() != actual_set.len() {
557 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
558 "tool_results at message {index} contains duplicate tool ids"
559 )));
560 }
561 if expected.len() != expected_set.len() {
562 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
563 "assistant tool-use message before tool_results at message {index} contains duplicate tool ids"
564 )));
565 }
566 if actual_set != expected_set {
567 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
568 "tool_results at message {index} resolve tool ids {actual_set:?}, expected {expected_set:?}"
569 )));
570 }
571 }
572
573 let tool_use_ids = assistant_tool_use_ids(message);
574 if tool_use_ids.is_empty() {
575 continue;
576 }
577 let Some(next) = messages.get(index + 1) else {
578 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
579 "assistant tool-use message {index} has no following tool_results"
580 )));
581 };
582 if !matches!(next, Message::ToolResults { .. }) {
583 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
584 "assistant tool-use message {index} is followed by {}, not tool_results",
585 message_role_name(next)
586 )));
587 }
588 }
589 Ok(())
590}
591
592fn canonicalize_digest_image_blocks(blocks: &mut [crate::types::ContentBlock]) {
593 for block in blocks.iter_mut() {
594 if let crate::types::ContentBlock::Image {
595 media_type,
596 data: crate::types::ImageData::Inline { data },
597 } = block
598 {
599 let blob_id = crate::blob::content_blob_id(media_type, data);
602 *block = crate::types::ContentBlock::Image {
603 media_type: media_type.clone(),
604 data: crate::types::ImageData::Blob { blob_id },
605 };
606 }
607 }
608}
609
610fn canonicalize_message_images_for_digest(messages: &[Message]) -> Vec<Message> {
619 let mut canonical = messages.to_vec();
620 for message in &mut canonical {
621 match message {
622 Message::User(user) => canonicalize_digest_image_blocks(&mut user.content),
623 Message::ToolResults { results, .. } => {
624 for result in results.iter_mut() {
625 canonicalize_digest_image_blocks(&mut result.content);
626 }
627 }
628 Message::SystemNotice(notice) => {
629 for block in &mut notice.blocks {
630 match block {
631 crate::types::SystemNoticeBlock::Comms { content, .. }
632 | crate::types::SystemNoticeBlock::ExternalEvent { content, .. } => {
633 canonicalize_digest_image_blocks(content);
634 }
635 _ => {}
636 }
637 }
638 }
639 _ => {}
640 }
641 }
642 canonical
643}
644
645fn digest_timestamp_sentinel() -> crate::types::MessageTimestamp {
649 chrono::DateTime::<chrono::Utc>::UNIX_EPOCH
650}
651
652fn canonicalize_messages_for_digest(messages: &[Message]) -> Vec<Message> {
670 let mut canonical = canonicalize_message_images_for_digest(messages);
671 for message in &mut canonical {
672 match message {
673 Message::System(system) => {
674 system.created_at = digest_timestamp_sentinel();
675 }
676 Message::SystemNotice(notice) => {
677 notice.created_at = digest_timestamp_sentinel();
678 }
679 Message::User(user) => {
680 user.identity = crate::types::TranscriptMessageIdentity::default();
681 user.created_at = digest_timestamp_sentinel();
682 }
683 Message::BlockAssistant(assistant) => {
684 assistant.identity = crate::types::TranscriptMessageIdentity::default();
685 assistant.created_at = digest_timestamp_sentinel();
686 }
687 Message::ToolResults { created_at, .. } => {
688 *created_at = digest_timestamp_sentinel();
689 }
690 }
691 }
692 canonical
693}
694
695pub fn transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
696 sha256_json_digest(&canonicalize_messages_for_digest(messages))
697}
698
699fn legacy_transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
708 sha256_json_digest(&canonicalize_message_images_for_digest(messages))
709}
710
711fn validate_transcript_rewrite_record(
712 commit: &TranscriptRewriteCommit,
713 parent_body: &TranscriptRevisionBody,
714 revision_body: &TranscriptRevisionBody,
715) -> Result<(), TranscriptEditError> {
716 if parent_body.revision != commit.parent_revision {
717 return Err(TranscriptEditError::HistoryStateMalformed(format!(
718 "parent body revision {} does not match commit parent {}",
719 parent_body.revision, commit.parent_revision
720 )));
721 }
722 if revision_body.revision != commit.revision {
723 return Err(TranscriptEditError::HistoryStateMalformed(format!(
724 "revision body {} does not match commit revision {}",
725 revision_body.revision, commit.revision
726 )));
727 }
728 if commit.parent_revision == commit.revision {
729 return Err(TranscriptEditError::NoOpRewrite {
730 revision: commit.revision.clone(),
731 });
732 }
733 let parent_digest = transcript_messages_digest(&parent_body.messages)
734 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
735 if parent_digest != commit.parent_revision {
736 return Err(TranscriptEditError::HistoryStateMalformed(format!(
737 "parent body digest {parent_digest} does not match commit parent {}",
738 commit.parent_revision
739 )));
740 }
741 let revision_digest = transcript_messages_digest(&revision_body.messages)
742 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
743 if revision_digest != commit.revision {
744 return Err(TranscriptEditError::HistoryStateMalformed(format!(
745 "revision body digest {revision_digest} does not match commit revision {}",
746 commit.revision
747 )));
748 }
749 let (start, end) = commit.selection.bounds();
750 if start > end || end > parent_body.messages.len() {
751 return Err(TranscriptEditError::InvalidRewriteRange {
752 start,
753 end,
754 message_count: parent_body.messages.len(),
755 });
756 }
757 if commit.messages_before != parent_body.messages.len()
758 || commit.messages_after != revision_body.messages.len()
759 {
760 return Err(TranscriptEditError::HistoryStateMalformed(format!(
761 "commit message counts {} -> {} do not match revision bodies {} -> {}",
762 commit.messages_before,
763 commit.messages_after,
764 parent_body.messages.len(),
765 revision_body.messages.len()
766 )));
767 }
768 let original_span_digest = transcript_messages_digest(&parent_body.messages[start..end])
769 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
770 if original_span_digest != commit.original_span_digest {
771 return Err(TranscriptEditError::HistoryStateMalformed(format!(
772 "original span digest {original_span_digest} does not match commit digest {}",
773 commit.original_span_digest
774 )));
775 }
776 let removed_len = end - start;
777 let retained_len = commit
778 .messages_before
779 .checked_sub(removed_len)
780 .ok_or_else(|| {
781 TranscriptEditError::HistoryStateMalformed(
782 "commit removed more messages than it recorded before rewrite".to_string(),
783 )
784 })?;
785 let replacement_len = commit
786 .messages_after
787 .checked_sub(retained_len)
788 .ok_or_else(|| {
789 TranscriptEditError::HistoryStateMalformed(
790 "commit message counts cannot describe a replacement span".to_string(),
791 )
792 })?;
793 let replacement_end = start.checked_add(replacement_len).ok_or_else(|| {
794 TranscriptEditError::HistoryStateMalformed("replacement span end overflowed".to_string())
795 })?;
796 if replacement_end > revision_body.messages.len() {
797 return Err(TranscriptEditError::InvalidRewriteRange {
798 start,
799 end: replacement_end,
800 message_count: revision_body.messages.len(),
801 });
802 }
803 let parent_prefix_digest = transcript_messages_digest(&parent_body.messages[..start])
804 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
805 let revision_prefix_digest = transcript_messages_digest(&revision_body.messages[..start])
806 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
807 if parent_prefix_digest != revision_prefix_digest {
808 return Err(TranscriptEditError::HistoryStateMalformed(
809 "rewrite revision changed messages before the selected span".to_string(),
810 ));
811 }
812 let parent_suffix_digest = transcript_messages_digest(&parent_body.messages[end..])
813 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
814 let revision_suffix_digest =
815 transcript_messages_digest(&revision_body.messages[replacement_end..])
816 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
817 if parent_suffix_digest != revision_suffix_digest {
818 return Err(TranscriptEditError::HistoryStateMalformed(
819 "rewrite revision changed messages after the selected span".to_string(),
820 ));
821 }
822 let replacement_digest =
823 transcript_messages_digest(&revision_body.messages[start..replacement_end])
824 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
825 if replacement_digest != commit.replacement_digest {
826 return Err(TranscriptEditError::HistoryStateMalformed(format!(
827 "replacement span digest {replacement_digest} does not match commit digest {}",
828 commit.replacement_digest
829 )));
830 }
831 Ok(())
832}
833
834fn validate_transcript_history_state(
835 state: &TranscriptHistoryState,
836) -> Result<(), TranscriptEditError> {
837 if state
838 .revisions
839 .iter()
840 .all(|body| body.revision != state.head)
841 {
842 return Err(TranscriptEditError::HistoryStateMalformed(format!(
843 "missing transcript head body {}",
844 state.head
845 )));
846 }
847 for body in &state.revisions {
848 let digest = transcript_messages_digest(&body.messages)
849 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
850 if digest != body.revision {
851 return Err(TranscriptEditError::HistoryStateMalformed(format!(
852 "transcript revision body {} has digest {digest}",
853 body.revision
854 )));
855 }
856 }
857 for commit in &state.commits {
858 let parent_body = state
859 .revisions
860 .iter()
861 .find(|body| body.revision == commit.parent_revision)
862 .ok_or_else(|| {
863 TranscriptEditError::HistoryStateMalformed(format!(
864 "missing parent transcript body {}",
865 commit.parent_revision
866 ))
867 })?;
868 let revision_body = state
869 .revisions
870 .iter()
871 .find(|body| body.revision == commit.revision)
872 .ok_or_else(|| {
873 TranscriptEditError::HistoryStateMalformed(format!(
874 "missing transcript revision body {}",
875 commit.revision
876 ))
877 })?;
878 validate_transcript_rewrite_record(commit, parent_body, revision_body)?;
879 }
880 let Some(first_commit) = state.commits.first() else {
881 return Ok(());
882 };
883 let mut expected_head = first_commit.parent_revision.clone();
884 for commit in &state.commits {
885 let parent_body = state
886 .revisions
887 .iter()
888 .find(|body| body.revision == commit.parent_revision)
889 .ok_or_else(|| {
890 TranscriptEditError::HistoryStateMalformed(format!(
891 "missing parent transcript body {}",
892 commit.parent_revision
893 ))
894 })?;
895 if commit.parent_revision != expected_head
896 && !revision_body_extends_head(parent_body, &state.revisions, &expected_head)?
897 {
898 return Err(TranscriptEditError::HistoryStateMalformed(format!(
899 "rewrite commit parent {} does not extend transcript head {}",
900 commit.parent_revision, expected_head
901 )));
902 }
903 expected_head = commit.revision.clone();
904 }
905 let mut cursor = state.head.clone();
906 while cursor != expected_head {
907 let Some(head_body) = state.revisions.iter().find(|body| body.revision == cursor) else {
908 break;
909 };
910 match head_body.parent_revision.as_deref() {
911 Some(parent) => cursor = parent.to_string(),
912 None => break,
913 }
914 }
915 if cursor != expected_head {
916 return Err(TranscriptEditError::HistoryStateMalformed(format!(
917 "transcript head {} does not extend the rewrite chain",
918 state.head
919 )));
920 }
921 Ok(())
922}
923
924fn revision_body_extends_head(
925 candidate: &TranscriptRevisionBody,
926 revisions: &[TranscriptRevisionBody],
927 head: &str,
928) -> Result<bool, TranscriptEditError> {
929 if candidate.parent_revision.as_deref() == Some(head) {
930 return Ok(true);
931 }
932 let Some(head_body) = revisions.iter().find(|body| body.revision == head) else {
933 return Ok(false);
934 };
935 if candidate.messages.len() < head_body.messages.len() {
936 return Ok(false);
937 }
938 let prefix_digest = transcript_messages_digest(&candidate.messages[..head_body.messages.len()])
939 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
940 Ok(prefix_digest == head)
941}
942
943fn sha256_json_digest<T: Serialize + ?Sized>(value: &T) -> Result<String, serde_json::Error> {
944 let bytes = serde_json::to_vec(value)?;
945 let digest = Sha256::digest(bytes);
946 let mut out = String::with_capacity(digest.len() * 2);
947 const HEX: &[u8; 16] = b"0123456789abcdef";
948 for byte in digest {
949 out.push(HEX[(byte >> 4) as usize] as char);
950 out.push(HEX[(byte & 0x0f) as usize] as char);
951 }
952 Ok(format!("sha256:{out}"))
953}
954
955#[derive(Debug, Clone)]
959pub struct Session {
960 version: u32,
963 id: SessionId,
965 pub(crate) messages: Arc<Vec<Message>>,
967 created_at: SystemTime,
969 updated_at: SystemTime,
971 metadata: serde_json::Map<String, serde_json::Value>,
973 usage: Usage,
975}
976
977#[derive(Serialize, Deserialize)]
979#[serde(rename_all = "snake_case")]
980struct SessionSerde {
981 version: u32,
982 id: SessionId,
983 messages: Vec<Message>,
984 created_at: SystemTime,
985 updated_at: SystemTime,
986 #[serde(default)]
987 metadata: serde_json::Map<String, serde_json::Value>,
988 #[serde(default)]
989 usage: Usage,
990}
991
992impl Serialize for Session {
993 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
994 where
995 S: Serializer,
996 {
997 let serde_repr = SessionSerde {
998 version: self.version,
999 id: self.id.clone(),
1000 messages: (*self.messages).clone(),
1001 created_at: self.created_at,
1002 updated_at: self.updated_at,
1003 metadata: self.metadata.clone(),
1004 usage: self.usage.clone(),
1005 };
1006 serde_repr.serialize(serializer)
1007 }
1008}
1009
1010impl<'de> Deserialize<'de> for Session {
1011 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1012 where
1013 D: Deserializer<'de>,
1014 {
1015 let serde_repr = SessionSerde::deserialize(deserializer)?;
1016 let version = session_persistence_version_authority::restore_session_envelope_version(
1017 serde_repr.version,
1018 )
1019 .map_err(<D::Error as serde::de::Error>::custom)?;
1020 Ok(Session {
1021 version,
1022 id: serde_repr.id,
1023 messages: Arc::new(serde_repr.messages),
1024 created_at: serde_repr.created_at,
1025 updated_at: serde_repr.updated_at,
1026 metadata: serde_repr.metadata,
1027 usage: serde_repr.usage,
1028 })
1029 }
1030}
1031
1032#[derive(Deserialize)]
1041#[serde(rename_all = "snake_case")]
1042struct SessionMetadataDocumentSerde {
1043 version: u32,
1044 id: SessionId,
1045 #[serde(default)]
1046 metadata: serde_json::Map<String, serde_json::Value>,
1047}
1048
1049#[derive(Debug, Clone)]
1058pub struct SessionMetadataDocument {
1059 session_id: SessionId,
1060 metadata: serde_json::Map<String, serde_json::Value>,
1061}
1062
1063impl SessionMetadataDocument {
1064 pub fn session_id(&self) -> &SessionId {
1066 &self.session_id
1067 }
1068
1069 pub fn session_metadata_value(&self) -> Option<&serde_json::Value> {
1072 self.metadata.get(SESSION_METADATA_KEY)
1073 }
1074
1075 pub fn lifecycle_terminal_value(&self) -> Option<&serde_json::Value> {
1078 self.metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY)
1079 }
1080
1081 pub fn try_into_view(self) -> Result<PersistedSessionMetadataView, serde_json::Error> {
1084 PersistedSessionMetadataView::try_from_metadata_map(self.session_id, &self.metadata)
1085 }
1086}
1087
1088pub fn session_metadata_document_from_slice(
1095 bytes: &[u8],
1096) -> Result<SessionMetadataDocument, serde_json::Error> {
1097 let serde_repr: SessionMetadataDocumentSerde = serde_json::from_slice(bytes)?;
1098 session_persistence_version_authority::restore_session_envelope_version(serde_repr.version)
1099 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1100 Ok(SessionMetadataDocument {
1101 session_id: serde_repr.id,
1102 metadata: serde_repr.metadata,
1103 })
1104}
1105
1106impl Session {
1107 pub(crate) fn from_head_parts(
1115 version: u32,
1116 id: SessionId,
1117 messages: Vec<Message>,
1118 created_at: SystemTime,
1119 updated_at: SystemTime,
1120 metadata: serde_json::Map<String, serde_json::Value>,
1121 usage: Usage,
1122 ) -> Result<Self, String> {
1123 let version =
1124 session_persistence_version_authority::restore_session_envelope_version(version)
1125 .map_err(|err| err.to_string())?;
1126 Ok(Self {
1127 version,
1128 id,
1129 messages: Arc::new(messages),
1130 created_at,
1131 updated_at,
1132 metadata,
1133 usage,
1134 })
1135 }
1136}
1137
1138pub const SESSION_SYSTEM_CONTEXT_STATE_KEY: &str = "session_system_context_state";
1140
1141pub const SESSION_DEFERRED_TURN_STATE_KEY: &str = "session_deferred_turn_state";
1143
1144pub const SESSION_BUILD_STATE_KEY: &str = "session_build_state";
1146
1147pub const SESSION_TOOL_VISIBILITY_STATE_KEY: &str = "session_tool_visibility_state_v1";
1149
1150pub const SESSION_LIFECYCLE_TERMINAL_KEY: &str = "session_lifecycle_terminal";
1152
1153pub const SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY: &str =
1161 "session_runtime_checkpoint_provenance_v1";
1162
1163pub const VIEW_IMAGE_TOOL_NAME: &str = "view_image";
1165
1166pub const SYSTEM_CONTEXT_SEPARATOR: &str = "\n\n---\n\n";
1168
1169#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1170#[error("metadata key `{key}` is reserved for session authority")]
1171pub struct ReservedSessionMetadataKey {
1172 key: String,
1173}
1174
1175impl ReservedSessionMetadataKey {
1176 fn new(key: &str) -> Self {
1177 Self {
1178 key: key.to_string(),
1179 }
1180 }
1181}
1182
1183fn is_session_authority_metadata_key(key: &str) -> bool {
1184 crate::surface_metadata::ReservedMetadataKey::is_session_authority(key)
1187}
1188
1189#[allow(clippy::panic)]
1190fn fail_closed_generated_restore(authority: &'static str, err: serde_json::Error) -> ! {
1191 tracing::error!(
1192 authority,
1193 error = %err,
1194 "generated authority rejected durable restore"
1195 );
1196 panic!("generated {authority} authority rejected durable restore: {err}");
1197}
1198
1199#[derive(Clone)]
1205pub struct SystemContextStateHandle {
1206 inner: Arc<std::sync::Mutex<SessionSystemContextState>>,
1207}
1208
1209impl std::fmt::Debug for SystemContextStateHandle {
1210 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1211 f.debug_struct("SystemContextStateHandle")
1212 .field("inner", &"<Arc<Mutex<SessionSystemContextState>>>")
1213 .finish()
1214 }
1215}
1216
1217impl SystemContextStateHandle {
1218 pub fn new(state: SessionSystemContextState) -> Result<Self, serde_json::Error> {
1219 let state = system_context_authority::restore_system_context_state(state)
1220 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1221 Ok(Self {
1222 inner: Arc::new(std::sync::Mutex::new(state)),
1223 })
1224 }
1225
1226 pub fn from_shared_authority_state(
1227 inner: Arc<std::sync::Mutex<SessionSystemContextState>>,
1228 ) -> Self {
1229 Self { inner }
1230 }
1231
1232 pub fn snapshot(&self) -> SessionSystemContextState {
1233 match self.inner.lock() {
1234 Ok(guard) => guard.clone(),
1235 Err(poisoned) => {
1236 tracing::warn!("system-context state lock poisoned while reading snapshot");
1237 poisoned.into_inner().clone()
1238 }
1239 }
1240 }
1241
1242 pub fn replace_from_generated_restore(
1243 &self,
1244 state: SessionSystemContextState,
1245 ) -> Result<(), serde_json::Error> {
1246 let state = system_context_authority::restore_system_context_state(state)
1247 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1248 match self.inner.lock() {
1249 Ok(mut guard) => {
1250 *guard = state;
1251 }
1252 Err(poisoned) => {
1253 tracing::warn!("system-context state lock poisoned while restoring state");
1254 *poisoned.into_inner() = state;
1255 }
1256 }
1257 Ok(())
1258 }
1259
1260 pub fn replace_from_generated_restore_if_changed(
1261 &self,
1262 state: SessionSystemContextState,
1263 ) -> Result<bool, serde_json::Error> {
1264 let state = system_context_authority::restore_system_context_state(state)
1265 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1266 let mut guard = match self.inner.lock() {
1267 Ok(guard) => guard,
1268 Err(poisoned) => {
1269 tracing::warn!(
1270 "system-context state lock poisoned while replacing generated-restored state"
1271 );
1272 poisoned.into_inner()
1273 }
1274 };
1275 if *guard == state {
1276 return Ok(false);
1277 }
1278 *guard = state;
1279 Ok(true)
1280 }
1281
1282 pub fn replace_from_generated_restore_if_current(
1283 &self,
1284 current: &SessionSystemContextState,
1285 replacement: SessionSystemContextState,
1286 ) -> Result<bool, serde_json::Error> {
1287 let replacement = system_context_authority::restore_system_context_state(replacement)
1288 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1289 let mut guard = match self.inner.lock() {
1290 Ok(guard) => guard,
1291 Err(poisoned) => {
1292 tracing::warn!(
1293 "system-context state lock poisoned while conditionally replacing generated-restored state"
1294 );
1295 poisoned.into_inner()
1296 }
1297 };
1298 if *guard != *current {
1299 return Ok(false);
1300 }
1301 *guard = replacement;
1302 Ok(true)
1303 }
1304
1305 pub fn stage_append_with_snapshot(
1306 &self,
1307 req: &AppendSystemContextRequest,
1308 accepted_at: SystemTime,
1309 ) -> Result<
1310 (
1311 crate::service::AppendSystemContextStatus,
1312 SessionSystemContextState,
1313 SessionSystemContextState,
1314 ),
1315 SystemContextStageError,
1316 > {
1317 let mut guard = match self.inner.lock() {
1318 Ok(guard) => guard,
1319 Err(poisoned) => {
1320 tracing::warn!("system-context state lock poisoned while staging append");
1321 poisoned.into_inner()
1322 }
1323 };
1324 let snapshot = guard.clone();
1325 let status = guard.stage_append(req, accepted_at)?;
1326 let staged = guard.clone();
1327 Ok((status, snapshot, staged))
1328 }
1329
1330 pub fn stage_active_turn_appends_with_snapshot(
1331 &self,
1332 appends: Vec<(AppendSystemContextRequest, SystemTime)>,
1333 ) -> Result<(SessionSystemContextState, SessionSystemContextState), SystemContextStageError>
1334 {
1335 let mut guard = match self.inner.lock() {
1336 Ok(guard) => guard,
1337 Err(poisoned) => {
1338 tracing::warn!(
1339 "system-context state lock poisoned while staging active-turn appends"
1340 );
1341 poisoned.into_inner()
1342 }
1343 };
1344 let snapshot = guard.clone();
1345 let mut candidate = snapshot.clone();
1346 for (req, accepted_at) in appends {
1347 candidate.stage_active_turn_append(&req, accepted_at)?;
1348 }
1349 *guard = candidate.clone();
1350 let staged = candidate;
1351 Ok((snapshot, staged))
1352 }
1353
1354 pub fn discard_unapplied_active_turn_pending(&self) -> usize {
1355 let discarded = match self.inner.lock() {
1356 Ok(mut guard) => guard.discard_unapplied_active_turn_pending(),
1357 Err(poisoned) => {
1358 tracing::warn!(
1359 "system-context state lock poisoned while discarding active-turn context"
1360 );
1361 poisoned
1362 .into_inner()
1363 .discard_unapplied_active_turn_pending()
1364 }
1365 };
1366 discarded.len()
1367 }
1368
1369 pub fn discard_active_turn_pending_by_keys(
1370 &self,
1371 idempotency_keys: &[String],
1372 ) -> Vec<PendingSystemContextAppend> {
1373 match self.inner.lock() {
1374 Ok(mut guard) => guard.discard_active_turn_pending_by_keys(idempotency_keys),
1375 Err(poisoned) => {
1376 tracing::warn!(
1377 "system-context state lock poisoned while discarding active-turn pending appends"
1378 );
1379 poisoned
1380 .into_inner()
1381 .discard_active_turn_pending_by_keys(idempotency_keys)
1382 }
1383 }
1384 }
1385
1386 pub fn stage_active_turn_append(
1387 &self,
1388 req: &AppendSystemContextRequest,
1389 accepted_at: SystemTime,
1390 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
1391 match self.inner.lock() {
1392 Ok(mut guard) => guard.stage_active_turn_append(req, accepted_at),
1393 Err(poisoned) => {
1394 tracing::warn!(
1395 "system-context state lock poisoned while staging active-turn context"
1396 );
1397 poisoned
1398 .into_inner()
1399 .stage_active_turn_append(req, accepted_at)
1400 }
1401 }
1402 }
1403}
1404
1405#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
1409#[serde(rename_all = "snake_case")]
1410pub struct SessionSystemContextState {
1411 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1412 pub(crate) pending: Vec<PendingSystemContextAppend>,
1413 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1414 pub(crate) applied: Vec<PendingSystemContextAppend>,
1415 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1416 pub(crate) seen: std::collections::BTreeMap<String, SeenSystemContextKey>,
1417 #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
1418 pub(crate) active_turn_pending_keys: std::collections::BTreeSet<String>,
1419}
1420
1421#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1431#[serde(rename_all = "snake_case")]
1432pub enum SystemContextSource {
1433 #[default]
1435 Normal,
1436 RuntimeSteer,
1439}
1440
1441impl From<SystemContextSource> for session_document::SystemContextSource {
1442 fn from(value: SystemContextSource) -> Self {
1443 match value {
1444 SystemContextSource::Normal => Self::Normal,
1445 SystemContextSource::RuntimeSteer => Self::RuntimeSteer,
1446 }
1447 }
1448}
1449
1450impl SystemContextSource {
1451 #[must_use]
1454 pub fn is_normal(&self) -> bool {
1455 matches!(self, Self::Normal)
1456 }
1457
1458 #[must_use]
1460 pub fn is_runtime_steer(&self) -> bool {
1461 matches!(self, Self::RuntimeSteer)
1462 }
1463}
1464
1465#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1469#[serde(rename_all = "snake_case")]
1470pub struct PendingSystemContextAppend {
1471 pub content: crate::lifecycle::run_primitive::CoreRenderable,
1479 #[serde(default, skip_serializing_if = "Option::is_none")]
1480 pub source: Option<String>,
1481 #[serde(default, skip_serializing_if = "Option::is_none")]
1482 pub idempotency_key: Option<String>,
1483 #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
1485 pub source_kind: SystemContextSource,
1486 #[serde(default, skip_serializing_if = "Option::is_none")]
1494 pub peer_response_terminal: Option<crate::handles::PeerResponseTerminalFact>,
1495 pub accepted_at: SystemTime,
1496}
1497
1498#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1513#[serde(rename_all = "snake_case")]
1514pub enum SessionLifecycleTerminal {
1515 Active,
1517 Archived,
1519}
1520
1521impl SessionLifecycleTerminal {
1522 #[must_use]
1524 pub fn is_archived(self) -> bool {
1525 matches!(self, Self::Archived)
1526 }
1527}
1528
1529impl From<SessionLifecycleTerminal> for session_document::SessionDocumentLifecycle {
1530 fn from(value: SessionLifecycleTerminal) -> Self {
1531 match value {
1532 SessionLifecycleTerminal::Active => Self::Active,
1533 SessionLifecycleTerminal::Archived => Self::Archived,
1534 }
1535 }
1536}
1537
1538impl From<session_document::SessionDocumentLifecycle> for SessionLifecycleTerminal {
1539 fn from(value: session_document::SessionDocumentLifecycle) -> Self {
1540 match value {
1541 session_document::SessionDocumentLifecycle::Active => Self::Active,
1542 session_document::SessionDocumentLifecycle::Archived => Self::Archived,
1543 }
1544 }
1545}
1546
1547#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
1549#[serde(rename_all = "snake_case")]
1550pub struct SessionDeferredTurnState {
1551 #[serde(default, skip_serializing_if = "DeferredFirstTurnPhase::is_inactive")]
1552 pub(crate) first_turn_phase: DeferredFirstTurnPhase,
1553 #[serde(default, skip_serializing_if = "Option::is_none")]
1554 pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
1555 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1556 pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
1557}
1558
1559#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
1561#[serde(rename_all = "snake_case")]
1562pub enum DeferredFirstTurnPhase {
1563 #[default]
1565 Inactive,
1566 Pending,
1568 Consumed,
1570}
1571
1572impl DeferredFirstTurnPhase {
1573 pub fn is_inactive(&self) -> bool {
1574 matches!(self, Self::Inactive)
1575 }
1576}
1577
1578impl From<DeferredFirstTurnPhase> for session_document::SessionFirstTurnPhase {
1579 fn from(value: DeferredFirstTurnPhase) -> Self {
1580 match value {
1581 DeferredFirstTurnPhase::Inactive => Self::Inactive,
1582 DeferredFirstTurnPhase::Pending => Self::Pending,
1583 DeferredFirstTurnPhase::Consumed => Self::Consumed,
1584 }
1585 }
1586}
1587
1588impl From<session_document::SessionFirstTurnPhase> for DeferredFirstTurnPhase {
1589 fn from(value: session_document::SessionFirstTurnPhase) -> Self {
1590 match value {
1591 session_document::SessionFirstTurnPhase::Inactive => Self::Inactive,
1592 session_document::SessionFirstTurnPhase::Pending => Self::Pending,
1593 session_document::SessionFirstTurnPhase::Consumed => Self::Consumed,
1594 }
1595 }
1596}
1597
1598fn is_default_hook_run_overrides(value: &crate::HookRunOverrides) -> bool {
1599 value == &crate::HookRunOverrides::default()
1600}
1601
1602fn is_default_call_timeout_override(value: &crate::CallTimeoutOverride) -> bool {
1603 value == &crate::CallTimeoutOverride::default()
1604}
1605
1606fn is_tool_filter_all(value: &ToolFilter) -> bool {
1607 matches!(value, ToolFilter::All)
1608}
1609
1610fn is_zero(value: &u64) -> bool {
1611 *value == 0
1612}
1613
1614pub fn capability_base_filter_for_image_tool_results(image_tool_results: bool) -> ToolFilter {
1616 if image_tool_results {
1617 ToolFilter::All
1618 } else {
1619 ToolFilter::Deny([VIEW_IMAGE_TOOL_NAME.to_string()].into_iter().collect())
1620 }
1621}
1622
1623#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1630#[serde(rename_all = "snake_case")]
1631pub struct ToolVisibilityWitness {
1632 #[serde(default, skip_serializing_if = "Option::is_none")]
1633 pub last_seen_provenance: Option<ToolProvenance>,
1634}
1635
1636impl ToolVisibilityWitness {
1637 pub fn has_identity_witness(&self) -> bool {
1638 self.last_seen_provenance.is_some()
1639 }
1640}
1641
1642#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1648#[serde(rename_all = "snake_case")]
1649pub struct DeferredToolLoadAuthority {
1650 pub name: ToolName,
1651 pub witness: ToolVisibilityWitness,
1652}
1653
1654impl DeferredToolLoadAuthority {
1655 pub fn new(name: impl Into<ToolName>, witness: ToolVisibilityWitness) -> Self {
1656 Self {
1657 name: name.into(),
1658 witness,
1659 }
1660 }
1661
1662 pub fn into_parts(self) -> (ToolName, ToolVisibilityWitness) {
1663 (self.name, self.witness)
1664 }
1665}
1666
1667#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1670#[serde(rename_all = "snake_case")]
1671pub struct WitnessedToolFilter {
1672 pub filter: ToolFilter,
1673 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1674 pub witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1675}
1676
1677impl WitnessedToolFilter {
1678 pub fn new(filter: ToolFilter, witnesses: BTreeMap<ToolName, ToolVisibilityWitness>) -> Self {
1679 Self { filter, witnesses }
1680 }
1681
1682 pub fn into_parts(self) -> (ToolFilter, BTreeMap<ToolName, ToolVisibilityWitness>) {
1683 (self.filter, self.witnesses)
1684 }
1685}
1686
1687#[derive(Debug, Clone, PartialEq, Eq)]
1694pub struct InheritedToolVisibilityAuthority {
1695 filter: ToolFilter,
1696 witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1697}
1698
1699impl InheritedToolVisibilityAuthority {
1700 pub(crate) fn from_generated_composition_authority(
1701 filter: ToolFilter,
1702 witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1703 ) -> Self {
1704 Self { filter, witnesses }
1705 }
1706
1707 pub fn filter(&self) -> &ToolFilter {
1708 &self.filter
1709 }
1710
1711 pub fn witnesses(&self) -> &BTreeMap<ToolName, ToolVisibilityWitness> {
1712 &self.witnesses
1713 }
1714
1715 pub(crate) fn into_initial_visibility_state(self) -> SessionToolVisibilityState {
1716 SessionToolVisibilityState {
1717 inherited_base_filter: self.filter,
1718 filter_witnesses: self.witnesses,
1719 ..Default::default()
1720 }
1721 }
1722}
1723
1724#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1726#[serde(rename_all = "snake_case")]
1727pub struct SessionToolVisibilityState {
1728 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
1729 pub capability_base_filter: ToolFilter,
1730 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
1731 pub inherited_base_filter: ToolFilter,
1732 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
1733 pub active_filter: ToolFilter,
1734 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
1735 pub staged_filter: ToolFilter,
1736 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1737 pub active_requested_deferred_names: BTreeSet<ToolName>,
1738 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1739 pub staged_requested_deferred_names: BTreeSet<ToolName>,
1740 #[serde(default, skip_serializing_if = "is_zero")]
1741 pub active_revision: u64,
1742 #[serde(default, skip_serializing_if = "is_zero")]
1743 pub staged_revision: u64,
1744 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1745 pub requested_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1746 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1747 pub filter_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1748}
1749
1750#[derive(Debug, Clone, PartialEq, Eq)]
1756pub struct AuthorizedSessionToolVisibilityState {
1757 state: SessionToolVisibilityState,
1758}
1759
1760impl AuthorizedSessionToolVisibilityState {
1761 pub(crate) fn from_generated_authority(state: SessionToolVisibilityState) -> Self {
1762 Self { state }
1763 }
1764
1765 pub fn as_state(&self) -> &SessionToolVisibilityState {
1766 &self.state
1767 }
1768
1769 pub fn into_state(self) -> SessionToolVisibilityState {
1770 self.state
1771 }
1772}
1773
1774#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1777#[serde(rename_all = "snake_case")]
1778pub struct SessionBuildState {
1779 #[serde(
1780 default,
1781 skip_serializing_if = "crate::config::SystemPromptOverride::is_inherit"
1782 )]
1783 pub system_prompt: crate::config::SystemPromptOverride,
1784 #[serde(default, skip_serializing_if = "Option::is_none")]
1785 pub output_schema: Option<crate::OutputSchema>,
1786 #[serde(default, skip_serializing_if = "is_default_hook_run_overrides")]
1787 pub hooks_override: crate::HookRunOverrides,
1788 #[serde(default, skip_serializing_if = "Option::is_none")]
1789 pub budget_limits: Option<crate::BudgetLimits>,
1790 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1791 pub recoverable_tool_defs: Vec<ToolDef>,
1792 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1793 pub silent_comms_intents: Vec<String>,
1794 #[serde(default, skip_serializing_if = "Option::is_none")]
1795 pub max_inline_peer_notifications: Option<i32>,
1796 #[serde(default, skip_serializing_if = "Option::is_none")]
1797 pub app_context: Option<serde_json::Value>,
1798 #[serde(default, skip_serializing_if = "Option::is_none")]
1799 pub additional_instructions: Option<Vec<String>>,
1800 #[serde(default, skip_serializing_if = "Option::is_none")]
1801 pub shell_env: Option<HashMap<String, String>>,
1802 #[serde(default, skip_serializing_if = "Option::is_none")]
1808 pub mob_tool_authority_context: Option<MobToolAuthorityContext>,
1809 #[serde(default, skip_serializing_if = "is_default_call_timeout_override")]
1810 pub call_timeout_override: crate::CallTimeoutOverride,
1811 #[serde(default, skip_serializing_if = "Option::is_none")]
1818 pub assembled_system_prompt: Option<String>,
1819}
1820
1821#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1823#[serde(rename_all = "snake_case")]
1824pub struct PendingDeferredPrompt {
1825 pub prompt: ContentInput,
1826 pub accepted_at: SystemTime,
1827}
1828
1829#[derive(Debug, Clone, Serialize, Deserialize)]
1831#[serde(rename_all = "snake_case")]
1832pub struct PendingToolResultsMessage {
1833 pub results: Vec<ToolResult>,
1834 pub accepted_at: SystemTime,
1835}
1836
1837impl PartialEq for PendingToolResultsMessage {
1838 fn eq(&self, other: &Self) -> bool {
1839 self.accepted_at == other.accepted_at
1840 && serde_json::to_value(&self.results).ok() == serde_json::to_value(&other.results).ok()
1841 }
1842}
1843
1844#[derive(Debug, Clone, Default, PartialEq)]
1846pub struct ConsumedDeferredTurnInputs {
1847 pub(crate) restore_first_turn_pending: bool,
1848 pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
1849 pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
1850}
1851
1852impl ConsumedDeferredTurnInputs {
1853 pub fn is_empty(&self) -> bool {
1854 !self.restore_first_turn_pending
1855 && self.pending_initial_prompt.is_none()
1856 && self.pending_tool_results.is_empty()
1857 }
1858
1859 pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
1860 self.pending_initial_prompt.as_ref()
1861 }
1862
1863 pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
1864 &self.pending_tool_results
1865 }
1866}
1867
1868#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1870#[serde(rename_all = "snake_case")]
1871pub struct SeenSystemContextKey {
1872 pub content: crate::lifecycle::run_primitive::CoreRenderable,
1874 #[serde(default, skip_serializing_if = "Option::is_none")]
1875 pub source: Option<String>,
1876 #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
1879 pub source_kind: SystemContextSource,
1880 pub state: SeenSystemContextState,
1881}
1882
1883#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1885#[serde(rename_all = "snake_case")]
1886pub enum SeenSystemContextState {
1887 Pending,
1888 Applied,
1889}
1890
1891impl SessionSystemContextState {
1892 pub fn pending(&self) -> &[PendingSystemContextAppend] {
1893 &self.pending
1894 }
1895
1896 pub fn applied(&self) -> &[PendingSystemContextAppend] {
1897 &self.applied
1898 }
1899
1900 pub fn seen(&self) -> &BTreeMap<String, SeenSystemContextKey> {
1901 &self.seen
1902 }
1903
1904 pub fn active_turn_pending_keys(&self) -> &BTreeSet<String> {
1905 &self.active_turn_pending_keys
1906 }
1907
1908 pub fn pending_len(&self) -> usize {
1909 self.pending.len()
1910 }
1911
1912 pub fn applied_len(&self) -> usize {
1913 self.applied.len()
1914 }
1915
1916 pub fn active_turn_pending_len(&self) -> usize {
1917 self.active_turn_pending_keys.len()
1918 }
1919
1920 pub fn realtime_projection_appends(&self) -> Vec<PendingSystemContextAppend> {
1921 self.applied
1922 .iter()
1923 .chain(self.pending.iter())
1924 .cloned()
1925 .collect()
1926 }
1927
1928 pub fn stage_append(
1930 &mut self,
1931 req: &AppendSystemContextRequest,
1932 accepted_at: SystemTime,
1933 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
1934 system_context_authority::stage_append(self, req, accepted_at, false)
1935 }
1936
1937 fn stage_append_with_generated_authority(
1938 &mut self,
1939 req: &AppendSystemContextRequest,
1940 accepted_at: SystemTime,
1941 active_turn_scoped: bool,
1942 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
1943 system_context_authority::stage_append(self, req, accepted_at, active_turn_scoped)
1944 }
1945
1946 pub fn stage_active_turn_append(
1953 &mut self,
1954 req: &AppendSystemContextRequest,
1955 accepted_at: SystemTime,
1956 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
1957 self.stage_append_with_generated_authority(req, accepted_at, true)
1958 }
1959
1960 pub fn mark_pending_applied(&mut self) {
1962 system_context_authority::mark_pending_applied(self);
1963 }
1964
1965 pub fn discard_unapplied_active_turn_pending(&mut self) -> Vec<PendingSystemContextAppend> {
1968 system_context_authority::discard_unapplied_active_turn_pending(self)
1969 }
1970
1971 pub fn discard_active_turn_pending_by_keys(
1978 &mut self,
1979 idempotency_keys: &[String],
1980 ) -> Vec<PendingSystemContextAppend> {
1981 system_context_authority::discard_active_turn_pending_by_keys(self, idempotency_keys)
1982 }
1983
1984 pub fn restore_from_snapshot(self) -> Result<Self, SystemContextStageError> {
1988 system_context_authority::restore_system_context_state(self)
1989 }
1990
1991 pub fn record_applied_blocks(
1995 &mut self,
1996 appends: &[PendingSystemContextAppend],
1997 current_system_prompt: &str,
1998 ) -> Vec<PendingSystemContextAppend> {
1999 system_context_authority::record_applied_system_context_blocks(
2000 self,
2001 appends,
2002 current_system_prompt,
2003 )
2004 }
2005}
2006
2007const SESSION_DOCUMENT_FIRST_TURN_KEY: &str = "first_turn";
2012
2013fn usize_to_u64(value: usize) -> u64 {
2014 u64::try_from(value).unwrap_or(u64::MAX)
2015}
2016
2017fn validate_deferred_turn_snapshot(
2025 state: SessionDeferredTurnState,
2026) -> Result<SessionDeferredTurnState, session_document::SessionDocumentError> {
2027 let mut authority = session_document::SessionDocumentMachineAuthority::new();
2028 let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
2029 authority.recover_session_first_turn_phase(
2033 key,
2034 state.first_turn_phase.into(),
2035 state.pending_initial_prompt.is_some(),
2036 usize_to_u64(state.pending_tool_results.len()),
2037 )?;
2038 Ok(state)
2039}
2040
2041impl SessionDeferredTurnState {
2042 pub fn first_turn_phase(&self) -> DeferredFirstTurnPhase {
2043 self.first_turn_phase
2044 }
2045
2046 pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
2047 self.pending_initial_prompt.as_ref()
2048 }
2049
2050 pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
2051 &self.pending_tool_results
2052 }
2053
2054 pub fn pending_tool_results_len(&self) -> usize {
2055 self.pending_tool_results.len()
2056 }
2057
2058 pub(crate) fn pending_initial_prompt_mut_for_blob_rewrite(
2059 &mut self,
2060 ) -> Option<&mut PendingDeferredPrompt> {
2061 self.pending_initial_prompt.as_mut()
2062 }
2063
2064 pub(crate) fn pending_tool_results_mut_for_blob_rewrite(
2065 &mut self,
2066 ) -> &mut [PendingToolResultsMessage] {
2067 &mut self.pending_tool_results
2068 }
2069
2070 fn document_authority(
2080 &self,
2081 ) -> (
2082 session_document::SessionDocumentMachineAuthority,
2083 session_document::SessionDocumentKey,
2084 ) {
2085 let mut authority = session_document::SessionDocumentMachineAuthority::new();
2086 let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
2087 if let Err(err) = authority.recover_session_first_turn_phase(
2088 key.clone(),
2089 self.first_turn_phase.into(),
2090 self.pending_initial_prompt.is_some(),
2091 usize_to_u64(self.pending_tool_results.len()),
2092 ) {
2093 tracing::warn!(
2094 error = %err,
2095 "generated session document authority rejected first-turn recovery"
2096 );
2097 }
2098 (authority, key)
2099 }
2100
2101 fn mirror_first_turn_phase(
2104 &mut self,
2105 effects: &[session_document::SessionDocumentEffect],
2106 ) -> Option<bool> {
2107 for effect in effects {
2108 if let session_document::SessionDocumentEffect::SessionFirstTurnPhaseResolved {
2109 phase,
2110 was_pending,
2111 } = effect
2112 {
2113 self.first_turn_phase = (*phase).into();
2114 return Some(*was_pending);
2115 }
2116 }
2117 None
2118 }
2119
2120 pub fn mark_initial_turn_pending(&mut self) {
2122 let (mut authority, key) = self.document_authority();
2123 match authority.mark_session_initial_turn_pending(key) {
2124 Ok(effects) => {
2125 self.mirror_first_turn_phase(&effects);
2126 }
2127 Err(err) => tracing::warn!(
2128 error = %err,
2129 "generated session document authority rejected pending mark"
2130 ),
2131 }
2132 }
2133
2134 pub fn mark_initial_turn_started(&mut self) -> bool {
2138 let (mut authority, key) = self.document_authority();
2139 match authority.start_session_initial_turn(key) {
2140 Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
2141 Err(err) => {
2142 tracing::warn!(
2143 error = %err,
2144 "generated session document authority rejected first-turn start"
2145 );
2146 false
2147 }
2148 }
2149 }
2150
2151 pub fn restore_initial_turn_pending(&mut self) {
2153 let (mut authority, key) = self.document_authority();
2158 match authority.restore_session_consumed_inputs(
2159 key.clone(),
2160 true,
2161 self.pending_initial_prompt.is_some(),
2162 usize_to_u64(self.pending_tool_results.len()),
2163 ) {
2164 Ok(_) => {
2165 if let Some(phase) = authority.session_first_turn_phase_for(&key) {
2168 self.first_turn_phase = phase.into();
2169 }
2170 }
2171 Err(err) => tracing::warn!(
2172 error = %err,
2173 "generated session document authority rejected pending restore"
2174 ),
2175 }
2176 }
2177
2178 pub fn allows_initial_turn_overrides(&self) -> bool {
2180 let (mut authority, key) = self.document_authority();
2181 match authority.resolve_session_first_turn_overrides_allowed(key) {
2182 Ok(effects) => effects
2183 .iter()
2184 .find_map(|effect| {
2185 match effect {
2186 session_document::SessionDocumentEffect::SessionFirstTurnOverridesResolved {
2187 allowed,
2188 } => Some(*allowed),
2189 _ => None,
2190 }
2191 })
2192 .unwrap_or(false),
2193 Err(err) => {
2194 tracing::warn!(
2195 error = %err,
2196 "generated session document authority rejected override resolution"
2197 );
2198 false
2199 }
2200 }
2201 }
2202
2203 pub fn stage_initial_prompt(&mut self, prompt: ContentInput, accepted_at: SystemTime) {
2205 let prompt_has_content = prompt.has_images() || !prompt.text_content().trim().is_empty();
2206 let (mut authority, key) = self.document_authority();
2207 match authority.stage_session_initial_prompt(key, prompt_has_content) {
2208 Ok(effects) => {
2209 let decision = effects.iter().find_map(|effect| {
2210 match effect {
2211 session_document::SessionDocumentEffect::SessionInitialPromptStageResolved {
2212 decision,
2213 } => Some(*decision),
2214 _ => None,
2215 }
2216 });
2217 match decision {
2218 Some(session_document::SessionInitialPromptStageDecision::Store) => {
2219 self.pending_initial_prompt = Some(PendingDeferredPrompt {
2220 prompt,
2221 accepted_at,
2222 });
2223 }
2224 Some(session_document::SessionInitialPromptStageDecision::Clear) => {
2225 self.pending_initial_prompt = None;
2226 }
2227 None => tracing::warn!(
2228 "generated session document authority returned no prompt-stage decision"
2229 ),
2230 }
2231 }
2232 Err(err) => tracing::warn!(
2233 error = %err,
2234 "generated session document authority rejected initial prompt stage"
2235 ),
2236 }
2237 }
2238
2239 pub fn stage_tool_results(
2241 &mut self,
2242 results: Vec<ToolResult>,
2243 accepted_at: SystemTime,
2244 ) -> usize {
2245 let (mut authority, key) = self.document_authority();
2246 let accepted = match authority.stage_session_tool_results(key, usize_to_u64(results.len()))
2247 {
2248 Ok(effects) => effects.iter().find_map(|effect| match effect {
2249 session_document::SessionDocumentEffect::SessionToolResultsStageResolved {
2250 accepted_count,
2251 } => Some(*accepted_count),
2252 _ => None,
2253 }),
2254 Err(err) => {
2255 tracing::warn!(
2256 error = %err,
2257 "generated session document authority rejected tool-results stage"
2258 );
2259 return 0;
2260 }
2261 };
2262 let Some(accepted) = accepted else {
2263 tracing::warn!(
2264 "generated session document authority returned no tool-results decision"
2265 );
2266 return 0;
2267 };
2268 if accepted == 0 {
2269 return 0;
2270 }
2271 let accepted = usize::try_from(accepted).unwrap_or(usize::MAX);
2272 self.pending_tool_results.push(PendingToolResultsMessage {
2273 results,
2274 accepted_at,
2275 });
2276 accepted
2277 }
2278
2279 pub fn has_pending_tool_results(&self) -> bool {
2281 !self.pending_tool_results.is_empty()
2282 }
2283
2284 pub fn consume_for_started_turn(&mut self) -> ConsumedDeferredTurnInputs {
2286 let (mut authority, key) = self.document_authority();
2287 let was_pending = match authority.consume_session_deferred_inputs(key) {
2288 Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
2289 Err(err) => {
2290 tracing::warn!(
2291 error = %err,
2292 "generated session document authority rejected started-turn consumption"
2293 );
2294 return ConsumedDeferredTurnInputs::default();
2295 }
2296 };
2297 ConsumedDeferredTurnInputs {
2298 restore_first_turn_pending: was_pending,
2299 pending_initial_prompt: self.pending_initial_prompt.take(),
2300 pending_tool_results: std::mem::take(&mut self.pending_tool_results),
2301 }
2302 }
2303
2304 pub fn restore_consumed_turn_inputs(&mut self, consumed: ConsumedDeferredTurnInputs) {
2306 if consumed.is_empty() {
2307 return;
2308 }
2309 let (mut authority, key) = self.document_authority();
2310 let effects = match authority.restore_session_consumed_inputs(
2311 key,
2312 consumed.restore_first_turn_pending,
2313 consumed.pending_initial_prompt.is_some(),
2314 usize_to_u64(consumed.pending_tool_results.len()),
2315 ) {
2316 Ok(effects) => effects,
2317 Err(err) => {
2318 tracing::warn!(
2319 error = %err,
2320 "generated session document authority rejected consumed input restore"
2321 );
2322 return;
2323 }
2324 };
2325 let Some((restore_first_turn_pending, restore_initial_prompt, restore_tool_results)) =
2326 effects.iter().find_map(|effect| match effect {
2327 session_document::SessionDocumentEffect::SessionConsumedInputsRestoreResolved {
2328 restore_first_turn_pending,
2329 restore_initial_prompt,
2330 restore_tool_results,
2331 } => Some((
2332 *restore_first_turn_pending,
2333 *restore_initial_prompt,
2334 *restore_tool_results,
2335 )),
2336 _ => None,
2337 })
2338 else {
2339 tracing::warn!(
2340 "generated session document authority returned no consumed-input restore decision"
2341 );
2342 return;
2343 };
2344 if restore_first_turn_pending {
2345 self.restore_initial_turn_pending();
2346 }
2347 if restore_initial_prompt && self.pending_initial_prompt.is_none() {
2348 self.pending_initial_prompt = consumed.pending_initial_prompt;
2349 }
2350 if restore_tool_results {
2351 let mut restored = consumed.pending_tool_results;
2352 restored.extend(std::mem::take(&mut self.pending_tool_results));
2353 self.pending_tool_results = restored;
2354 }
2355 }
2356}
2357
2358#[derive(Debug, Clone, PartialEq, Eq)]
2360pub enum SystemContextStageError {
2361 InvalidRequest(String),
2362 Conflict {
2363 key: String,
2364 existing_text: String,
2365 existing_source: Option<String>,
2366 },
2367}
2368
2369impl std::fmt::Display for SystemContextStageError {
2370 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2371 match self {
2372 Self::InvalidRequest(message) => {
2373 write!(f, "invalid system-context append request: {message}")
2374 }
2375 Self::Conflict { key, .. } => {
2376 write!(
2377 f,
2378 "system-context append conflict for idempotency key `{key}`"
2379 )
2380 }
2381 }
2382 }
2383}
2384
2385impl std::error::Error for SystemContextStageError {}
2386
2387fn render_system_context_block(append: &PendingSystemContextAppend) -> String {
2395 let mut rendered = String::from(SYSTEM_CONTEXT_RENDER_LABEL);
2396 if let Some(source) = &append.source {
2397 rendered.push_str("\nsource: ");
2398 rendered.push_str(source);
2399 }
2400 rendered.push_str("\n\n");
2401 rendered.push_str(append.content.render_text().trim());
2404 rendered
2405}
2406
2407const SYSTEM_CONTEXT_RENDER_LABEL: &str = "[Runtime System Context]";
2412
2413fn render_system_context_blocks_joined(appends: &[PendingSystemContextAppend]) -> String {
2419 appends
2420 .iter()
2421 .map(render_system_context_block)
2422 .collect::<Vec<_>>()
2423 .join(SYSTEM_CONTEXT_SEPARATOR)
2424}
2425
2426fn compose_system_prompt_with_context_tail(base: &str, tail: &str) -> String {
2431 if tail.is_empty() {
2432 return base.to_string();
2433 }
2434 if base.is_empty() {
2435 return tail
2436 .strip_prefix(SYSTEM_CONTEXT_SEPARATOR)
2437 .unwrap_or(tail)
2438 .to_string();
2439 }
2440 format!("{base}{tail}")
2441}
2442
2443fn persisted_prompt_is_admitted_context_append_continuation(
2454 assembled_base: &str,
2455 persisted_content: &str,
2456 persisted_mutation_kind: crate::types::SystemPromptMutationKind,
2457) -> bool {
2458 let content_identical = persisted_content == assembled_base;
2459 let content_extends = persisted_content.starts_with(assembled_base);
2460 let appended_starts_with_separator = content_extends
2461 && persisted_content[assembled_base.len()..].starts_with(SYSTEM_CONTEXT_SEPARATOR);
2462 let mut authority = session_document::SessionDocumentMachineAuthority::new();
2463 match authority.resolve_system_context_persist_append_admission(
2464 true,
2465 content_identical,
2466 content_extends,
2467 appended_starts_with_separator,
2468 persisted_mutation_kind.is_runtime_context_append(),
2469 ) {
2470 Ok(effects) => effects.into_iter().any(|effect| {
2471 matches!(
2472 effect,
2473 session_document::SessionDocumentEffect::SystemContextPersistAppendAdmissionResolved {
2474 admission: session_document::SystemContextPersistAppendAdmission::Admit,
2475 }
2476 )
2477 }),
2478 Err(error) => {
2479 tracing::warn!(
2480 error = %error,
2481 "session document authority refused resume prompt continuation admission; \
2482 falling back to audited rewrite"
2483 );
2484 false
2485 }
2486 }
2487}
2488
2489mod system_context_authority {
2500 use super::{
2501 AppendSystemContextRequest, BTreeSet, PendingSystemContextAppend, SeenSystemContextKey,
2502 SeenSystemContextState, SessionSystemContextState, SystemContextSource,
2503 SystemContextStageError, SystemTime, render_system_context_block, session_document,
2504 usize_to_u64,
2505 };
2506 use crate::service::AppendSystemContextStatus;
2507
2508 fn document_authority() -> session_document::SessionDocumentMachineAuthority {
2509 session_document::SessionDocumentMachineAuthority::new()
2510 }
2511
2512 fn resolve_append_decision(
2514 trimmed_text_byte_count: u64,
2515 idempotency_key_present: bool,
2516 existing_key_matches: bool,
2517 existing_key_conflicts: bool,
2518 active_turn_scoped: bool,
2519 ) -> Result<session_document::SystemContextAppendDecision, SystemContextStageError> {
2520 let mut authority = document_authority();
2521 let effects = authority
2522 .resolve_system_context_append(
2523 trimmed_text_byte_count,
2524 idempotency_key_present,
2525 existing_key_matches,
2526 existing_key_conflicts,
2527 active_turn_scoped,
2528 )
2529 .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
2530 effects
2531 .into_iter()
2532 .find_map(|effect| match effect {
2533 session_document::SessionDocumentEffect::SystemContextAppendResolved {
2534 decision,
2535 ..
2536 } => Some(decision),
2537 _ => None,
2538 })
2539 .ok_or_else(|| {
2540 SystemContextStageError::InvalidRequest(
2541 "generated session document authority returned no append decision".to_string(),
2542 )
2543 })
2544 }
2545
2546 fn pending_apply_item(source_kind: SystemContextSource) -> Option<(bool, bool, bool)> {
2549 let mut authority = document_authority();
2550 match authority.resolve_system_context_pending_apply_item(source_kind.into()) {
2551 Ok(effects) => effects.into_iter().find_map(|effect| {
2552 match effect {
2553 session_document::SessionDocumentEffect::SystemContextPendingApplyItemResolved {
2554 promote_to_applied,
2555 mark_seen_applied,
2556 remove_seen,
2557 } => Some((promote_to_applied, mark_seen_applied, remove_seen)),
2558 _ => None,
2559 }
2560 }),
2561 Err(err) => {
2562 tracing::warn!(
2563 error = %err,
2564 "generated session document authority rejected system-context apply item"
2565 );
2566 None
2567 }
2568 }
2569 }
2570
2571 fn steer_cleanup_discards(source_kind: SystemContextSource) -> bool {
2574 let mut authority = document_authority();
2575 match authority.resolve_system_context_steer_cleanup_item(source_kind.into()) {
2576 Ok(effects) => effects
2577 .into_iter()
2578 .find_map(|effect| {
2579 match effect {
2580 session_document::SessionDocumentEffect::SystemContextSteerCleanupItemResolved {
2581 discard,
2582 } => Some(discard),
2583 _ => None,
2584 }
2585 })
2586 .unwrap_or(false),
2587 Err(err) => {
2588 tracing::warn!(
2589 error = %err,
2590 "generated session document authority rejected system-context steer cleanup item"
2591 );
2592 false
2593 }
2594 }
2595 }
2596
2597 pub(super) fn restore_system_context_state(
2598 state: SessionSystemContextState,
2599 ) -> Result<SessionSystemContextState, SystemContextStageError> {
2600 let active_keys_have_known_pending_or_seen =
2601 state.active_turn_pending_keys.iter().all(|key| {
2602 state.seen.contains_key(key)
2603 || state
2604 .pending
2605 .iter()
2606 .any(|append| append.idempotency_key.as_ref() == Some(key))
2607 });
2608 let seen_keys_match_known_appends = state.seen.iter().all(|(key, seen)| {
2609 state
2610 .pending
2611 .iter()
2612 .chain(state.applied.iter())
2613 .any(|append| {
2614 append.idempotency_key.as_ref() == Some(key)
2615 && seen.content == append.content
2616 && seen.source.as_deref() == append.source.as_deref()
2617 })
2618 });
2619 let mut authority = document_authority();
2620 authority
2621 .restore_system_context_snapshot(
2622 active_keys_have_known_pending_or_seen,
2623 seen_keys_match_known_appends,
2624 )
2625 .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
2626 Ok(state)
2627 }
2628
2629 pub(super) fn stage_append(
2630 state: &mut SessionSystemContextState,
2631 req: &AppendSystemContextRequest,
2632 accepted_at: SystemTime,
2633 active_turn_scoped: bool,
2634 ) -> Result<AppendSystemContextStatus, SystemContextStageError> {
2635 let rendered_text = req.content.render_text();
2639 let rendered_len = rendered_text.trim().len();
2640 let existing = req
2641 .idempotency_key
2642 .as_ref()
2643 .and_then(|key| state.seen.get(key));
2644 let existing_key_matches = existing.is_some_and(|existing| {
2645 existing.content == req.content && existing.source.as_deref() == req.source.as_deref()
2646 });
2647 let existing_key_conflicts = existing.is_some() && !existing_key_matches;
2648 let decision = resolve_append_decision(
2649 usize_to_u64(rendered_len),
2650 req.idempotency_key.is_some(),
2651 existing_key_matches,
2652 existing_key_conflicts,
2653 active_turn_scoped,
2654 )?;
2655
2656 match decision {
2657 session_document::SystemContextAppendDecision::RejectEmpty => {
2658 return Err(SystemContextStageError::InvalidRequest(
2659 "system context text must not be empty".to_string(),
2660 ));
2661 }
2662 session_document::SystemContextAppendDecision::RejectConflict => {
2663 let Some(key) = req.idempotency_key.as_ref() else {
2664 return Err(SystemContextStageError::InvalidRequest(
2665 "generated system-context authority rejected append without a key"
2666 .to_string(),
2667 ));
2668 };
2669 let Some(existing) = existing else {
2670 return Err(SystemContextStageError::InvalidRequest(
2671 "generated system-context authority rejected append without a conflict"
2672 .to_string(),
2673 ));
2674 };
2675 return Err(SystemContextStageError::Conflict {
2676 key: key.clone(),
2677 existing_text: existing.content.render_text(),
2678 existing_source: existing.source.clone(),
2679 });
2680 }
2681 session_document::SystemContextAppendDecision::Duplicate => {
2682 return Ok(AppendSystemContextStatus::Duplicate);
2683 }
2684 session_document::SystemContextAppendDecision::Staged => {}
2685 }
2686
2687 let append = PendingSystemContextAppend {
2688 content: req.content.clone(),
2689 source: req.source.clone(),
2690 idempotency_key: req.idempotency_key.clone(),
2691 source_kind: req.source_kind,
2692 peer_response_terminal: req.peer_response_terminal.clone(),
2696 accepted_at,
2697 };
2698 if let Some(key) = req.idempotency_key.as_ref() {
2699 state.seen.insert(
2700 key.clone(),
2701 SeenSystemContextKey {
2702 content: append.content.clone(),
2703 source: append.source.clone(),
2704 source_kind: append.source_kind,
2705 state: SeenSystemContextState::Pending,
2706 },
2707 );
2708 }
2709 if active_turn_scoped && let Some(key) = req.idempotency_key.as_ref() {
2710 state.active_turn_pending_keys.insert(key.clone());
2711 }
2712 state.pending.push(append);
2713 Ok(AppendSystemContextStatus::Staged)
2714 }
2715
2716 pub(super) fn mark_pending_applied(state: &mut SessionSystemContextState) {
2717 let pending = std::mem::take(&mut state.pending);
2720 let mut seen_to_remove = Vec::new();
2721 for append in &pending {
2722 let Some((promote_to_applied, mark_seen_applied, remove_seen)) =
2723 pending_apply_item(append.source_kind)
2724 else {
2725 continue;
2726 };
2727 if promote_to_applied && !state.applied.contains(append) {
2728 state.applied.push(append.clone());
2729 }
2730 if let Some(key) = append.idempotency_key.as_ref() {
2731 if remove_seen {
2732 seen_to_remove.push(key.clone());
2733 } else if mark_seen_applied && let Some(seen) = state.seen.get_mut(key) {
2734 seen.state = SeenSystemContextState::Applied;
2735 }
2736 }
2737 }
2738 for key in seen_to_remove {
2739 state.seen.remove(&key);
2740 }
2741 state.active_turn_pending_keys.clear();
2742 }
2743
2744 pub(super) fn discard_unapplied_active_turn_pending(
2745 state: &mut SessionSystemContextState,
2746 ) -> Vec<PendingSystemContextAppend> {
2747 if state.active_turn_pending_keys.is_empty() {
2748 return Vec::new();
2749 }
2750 let active_keys = std::mem::take(&mut state.active_turn_pending_keys);
2751 let mut discarded = Vec::new();
2752 state.pending.retain(|append| {
2753 let should_discard = append
2754 .idempotency_key
2755 .as_ref()
2756 .is_some_and(|key| active_keys.contains(key));
2757 if should_discard {
2758 discarded.push(append.clone());
2759 }
2760 !should_discard
2761 });
2762
2763 for append in &discarded {
2764 if let Some(key) = append.idempotency_key.as_ref()
2765 && state
2766 .seen
2767 .get(key)
2768 .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
2769 {
2770 state.seen.remove(key);
2771 }
2772 }
2773
2774 discarded
2775 }
2776
2777 pub(super) fn discard_active_turn_pending_by_keys(
2778 state: &mut SessionSystemContextState,
2779 idempotency_keys: &[String],
2780 ) -> Vec<PendingSystemContextAppend> {
2781 if idempotency_keys.is_empty() || state.active_turn_pending_keys.is_empty() {
2782 return Vec::new();
2783 }
2784 let requested_keys: BTreeSet<&str> = idempotency_keys.iter().map(String::as_str).collect();
2785 let mut discarded = Vec::new();
2786 let mut discarded_keys = Vec::new();
2787 state.pending.retain(|append| {
2788 let should_discard = append.idempotency_key.as_ref().is_some_and(|key| {
2789 requested_keys.contains(key.as_str())
2790 && state.active_turn_pending_keys.contains(key)
2791 });
2792 if should_discard {
2793 if let Some(key) = append.idempotency_key.as_ref() {
2794 discarded_keys.push(key.clone());
2795 }
2796 discarded.push(append.clone());
2797 }
2798 !should_discard
2799 });
2800
2801 for key in discarded_keys {
2802 state.active_turn_pending_keys.remove(&key);
2803 if state
2804 .seen
2805 .get(&key)
2806 .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
2807 {
2808 state.seen.remove(&key);
2809 }
2810 }
2811
2812 discarded
2813 }
2814
2815 pub(super) fn discard_transient_runtime_steer_state(
2816 state: &mut SessionSystemContextState,
2817 ) -> usize {
2818 let mut removed = 0usize;
2819
2820 let before_pending = state.pending.len();
2821 state
2822 .pending
2823 .retain(|append| !steer_cleanup_discards(append.source_kind));
2824 removed += before_pending.saturating_sub(state.pending.len());
2825
2826 let before_applied = state.applied.len();
2827 state
2828 .applied
2829 .retain(|append| !steer_cleanup_discards(append.source_kind));
2830 removed += before_applied.saturating_sub(state.applied.len());
2831
2832 let before_seen = state.seen.len();
2833 state
2834 .seen
2835 .retain(|_key, seen| !steer_cleanup_discards(seen.source_kind));
2836 removed += before_seen.saturating_sub(state.seen.len());
2837
2838 let before_active = state.active_turn_pending_keys.len();
2842 let steer_keys: BTreeSet<String> = state
2843 .seen
2844 .iter()
2845 .filter(|(_key, seen)| steer_cleanup_discards(seen.source_kind))
2846 .map(|(key, _seen)| key.clone())
2847 .collect();
2848 state
2852 .active_turn_pending_keys
2853 .retain(|key| state.seen.contains_key(key) && !steer_keys.contains(key));
2854 removed += before_active.saturating_sub(state.active_turn_pending_keys.len());
2855
2856 removed
2857 }
2858
2859 pub(super) fn remove_runtime_steer_blocks_for_rendered(
2860 system_prompt: &str,
2861 runtime_steer_appends: &[PendingSystemContextAppend],
2862 ) -> (String, usize) {
2863 if runtime_steer_appends.is_empty() {
2864 return (system_prompt.to_string(), 0);
2865 }
2866 let steer_blocks: BTreeSet<String> = runtime_steer_appends
2870 .iter()
2871 .map(render_system_context_block)
2872 .collect();
2873 let parts = system_prompt
2874 .split(super::SYSTEM_CONTEXT_SEPARATOR)
2875 .map(str::to_string)
2876 .collect::<Vec<_>>();
2877 let original_len = parts.len();
2878 let retained = parts
2879 .into_iter()
2880 .filter(|part| !steer_blocks.contains(part))
2881 .collect::<Vec<_>>();
2882 let removed = original_len.saturating_sub(retained.len());
2883 (retained.join(super::SYSTEM_CONTEXT_SEPARATOR), removed)
2884 }
2885
2886 pub(super) fn record_applied_system_context_blocks(
2887 state: &mut SessionSystemContextState,
2888 appends: &[PendingSystemContextAppend],
2889 current_system_prompt: &str,
2890 ) -> Vec<PendingSystemContextAppend> {
2891 let mut new_appends: Vec<PendingSystemContextAppend> = Vec::new();
2892 for append in appends {
2893 if append.content.render_text().trim().is_empty() {
2894 continue;
2895 }
2896 let rendered = render_system_context_block(append);
2897 if let Some(key) = append.idempotency_key.as_ref() {
2898 if let Some(existing) = state.seen.get(key)
2899 && !seen_system_context_matches(existing, append)
2900 {
2901 tracing::warn!(
2902 idempotency_key = %key,
2903 "skipping conflicting runtime system-context append"
2904 );
2905 continue;
2906 }
2907 if let Some(existing) = state
2908 .applied
2909 .iter()
2910 .find(|applied| applied.idempotency_key.as_ref() == Some(key))
2911 && !pending_system_context_matches(existing, append)
2912 {
2913 tracing::warn!(
2914 idempotency_key = %key,
2915 "skipping conflicting runtime system-context append"
2916 );
2917 continue;
2918 }
2919 if let Some(existing) = new_appends
2920 .iter()
2921 .find(|pending| pending.idempotency_key.as_ref() == Some(key))
2922 {
2923 if !pending_system_context_matches(existing, append) {
2924 tracing::warn!(
2925 idempotency_key = %key,
2926 "skipping conflicting runtime system-context append"
2927 );
2928 }
2929 continue;
2930 }
2931 if current_system_prompt.contains(&rendered) {
2932 record_applied_append(state, append);
2933 continue;
2934 }
2935 } else if new_appends.contains(append) || current_system_prompt.contains(&rendered) {
2936 continue;
2937 }
2938 record_applied_append(state, append);
2939 new_appends.push(append.clone());
2940 }
2941 new_appends
2942 }
2943
2944 fn record_applied_append(
2945 state: &mut SessionSystemContextState,
2946 append: &PendingSystemContextAppend,
2947 ) {
2948 if let Some(key) = append.idempotency_key.as_ref() {
2949 state.seen.insert(
2950 key.clone(),
2951 SeenSystemContextKey {
2952 content: append.content.clone(),
2953 source: append.source.clone(),
2954 source_kind: append.source_kind,
2955 state: SeenSystemContextState::Applied,
2956 },
2957 );
2958 if state
2959 .applied
2960 .iter()
2961 .any(|applied| applied.idempotency_key.as_ref() == Some(key))
2962 {
2963 return;
2964 }
2965 } else if state.applied.contains(append) {
2966 return;
2967 }
2968 state.applied.push(append.clone());
2969 }
2970
2971 fn seen_system_context_matches(
2972 seen: &SeenSystemContextKey,
2973 append: &PendingSystemContextAppend,
2974 ) -> bool {
2975 seen.content == append.content && seen.source.as_deref() == append.source.as_deref()
2976 }
2977
2978 fn pending_system_context_matches(
2979 existing: &PendingSystemContextAppend,
2980 append: &PendingSystemContextAppend,
2981 ) -> bool {
2982 existing.content == append.content && existing.source.as_deref() == append.source.as_deref()
2983 }
2984}
2985
2986impl Session {
2987 pub fn new() -> Self {
2989 let now = SystemTime::now();
2990 Self {
2991 version: session_version(),
2992 id: SessionId::new(),
2993 messages: Arc::new(Vec::new()),
2994 created_at: now,
2995 updated_at: now,
2996 metadata: serde_json::Map::new(),
2997 usage: Usage::default(),
2998 }
2999 }
3000
3001 pub fn with_id(id: SessionId) -> Self {
3003 let mut session = Self::new();
3004 session.id = id;
3005 session
3006 }
3007
3008 pub fn id(&self) -> &SessionId {
3010 &self.id
3011 }
3012
3013 pub fn version(&self) -> u32 {
3015 self.version
3016 }
3017
3018 pub fn messages(&self) -> &[Message] {
3020 &self.messages
3021 }
3022
3023 pub(crate) fn replace_messages_internal(
3029 &mut self,
3030 messages: Vec<Message>,
3031 reason: TranscriptRewriteReason,
3032 ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError> {
3033 if transcript_messages_digest(self.messages()).ok()
3034 == transcript_messages_digest(&messages).ok()
3035 {
3036 return Ok(None);
3037 }
3038 let commit = self.commit_transcript_rewrite(
3039 TranscriptRewriteSelection::MessageRange {
3040 start: 0,
3041 end: self.messages.len(),
3042 },
3043 messages,
3044 reason,
3045 Some("meerkat-core".to_string()),
3046 None,
3047 )?;
3048 Ok(Some(commit))
3049 }
3050
3051 pub(crate) fn retain_messages_internal<F>(
3053 &mut self,
3054 mut retain: F,
3055 reason: TranscriptRewriteReason,
3056 ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError>
3057 where
3058 F: FnMut(&Message) -> bool,
3059 {
3060 let retained = self
3061 .messages
3062 .iter()
3063 .filter(|message| retain(message))
3064 .cloned()
3065 .collect::<Vec<_>>();
3066 if retained.len() == self.messages.len()
3067 && transcript_messages_digest(self.messages()).ok()
3068 == transcript_messages_digest(&retained).ok()
3069 {
3070 return Ok(None);
3071 }
3072 self.replace_messages_internal(retained, reason)
3073 }
3074
3075 pub fn replace_synthetic_notices(
3085 &mut self,
3086 kind: crate::types::SystemNoticeKind,
3087 replacements: Vec<Message>,
3088 ) -> Result<(), TranscriptEditError> {
3089 for (index, message) in replacements.iter().enumerate() {
3090 let matches_kind =
3091 matches!(message, Message::SystemNotice(notice) if notice.kind == kind);
3092 if !matches_kind {
3093 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
3094 "replacement {index} for synthetic notice kind {kind:?} is not a system notice of that kind"
3095 )));
3096 }
3097 }
3098 self.retain_messages_internal(
3099 |message| !matches!(message, Message::SystemNotice(notice) if notice.kind == kind),
3100 TranscriptRewriteReason::new("synthetic_notice_cleanup"),
3101 )?;
3102 for message in replacements {
3103 self.push(message);
3104 }
3105 Ok(())
3106 }
3107
3108 pub fn created_at(&self) -> SystemTime {
3110 self.created_at
3111 }
3112
3113 pub fn updated_at(&self) -> SystemTime {
3115 self.updated_at
3116 }
3117
3118 pub fn push(&mut self, message: Message) {
3122 Arc::make_mut(&mut self.messages).push(message);
3123 self.updated_at = SystemTime::now();
3124 self.refresh_transcript_head_after_message_mutation();
3125 }
3126
3127 pub fn push_batch(&mut self, messages: Vec<Message>) {
3131 if messages.is_empty() {
3132 return;
3133 }
3134 let inner = Arc::make_mut(&mut self.messages);
3135 inner.extend(messages);
3136 self.updated_at = SystemTime::now();
3137 self.refresh_transcript_head_after_message_mutation();
3138 }
3139
3140 pub async fn externalize_media(
3151 &mut self,
3152 blob_store: &dyn crate::BlobStore,
3153 start: usize,
3154 ) -> Result<(), crate::blob::BlobStoreError> {
3155 let previous_digest = if self
3156 .metadata
3157 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
3158 {
3159 transcript_messages_digest(self.messages()).ok()
3160 } else {
3161 None
3162 };
3163 let messages = Arc::make_mut(&mut self.messages);
3164 crate::image_content::externalize_messages_from(blob_store, messages, start).await?;
3165 if let Some(previous_digest) = previous_digest
3166 && transcript_messages_digest(self.messages()).ok().as_ref() != Some(&previous_digest)
3167 {
3168 self.refresh_transcript_head_after_message_mutation();
3169 }
3170 Ok(())
3171 }
3172
3173 pub async fn hydrate_realtime_user_images(
3181 &mut self,
3182 blob_store: &dyn crate::BlobStore,
3183 max_decoded_bytes: usize,
3184 ) -> Result<(), crate::image_content::RealtimeUserImageHydrationError> {
3185 self.hydrate_realtime_user_images_with_usage(blob_store, max_decoded_bytes)
3186 .await
3187 .map(|_| ())
3188 }
3189
3190 pub async fn hydrate_realtime_user_images_with_usage(
3193 &mut self,
3194 blob_store: &dyn crate::BlobStore,
3195 max_decoded_bytes: usize,
3196 ) -> Result<usize, crate::image_content::RealtimeUserImageHydrationError> {
3197 let previous_digest = if self
3198 .metadata
3199 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
3200 {
3201 transcript_messages_digest(self.messages()).ok()
3202 } else {
3203 None
3204 };
3205 let messages = Arc::make_mut(&mut self.messages);
3206 let decoded_total =
3207 crate::image_content::hydrate_user_images_for_realtime_projection_with_usage(
3208 blob_store,
3209 messages,
3210 max_decoded_bytes,
3211 )
3212 .await?;
3213 if let Some(previous_digest) = previous_digest
3214 && transcript_messages_digest(self.messages()).ok().as_ref() != Some(&previous_digest)
3215 {
3216 self.refresh_transcript_head_after_message_mutation();
3217 }
3218 Ok(decoded_total)
3219 }
3220
3221 pub fn touch(&mut self) {
3225 self.updated_at = SystemTime::now();
3226 }
3227
3228 pub fn last_n(&self, n: usize) -> &[Message] {
3230 let start = self.messages.len().saturating_sub(n);
3231 &self.messages[start..]
3232 }
3233
3234 pub fn total_tokens(&self) -> u64 {
3236 self.usage.total_tokens()
3237 }
3238
3239 pub fn total_usage(&self) -> Usage {
3241 self.usage.clone()
3242 }
3243
3244 pub fn record_usage(&mut self, turn_usage: Usage) {
3246 self.usage.add(&turn_usage);
3247 self.updated_at = SystemTime::now();
3248 }
3249
3250 pub fn append_external_user_content(&mut self, content: ContentInput) {
3252 self.push(Message::User(UserMessage::with_blocks(
3253 content.into_blocks(),
3254 )));
3255 }
3256
3257 pub fn append_external_assistant_blocks(
3259 &mut self,
3260 blocks: Vec<AssistantBlock>,
3261 stop_reason: StopReason,
3262 usage: Usage,
3263 ) {
3264 if !blocks.is_empty() {
3265 self.push(Message::BlockAssistant(BlockAssistantMessage::new(
3266 blocks,
3267 stop_reason,
3268 )));
3269 }
3270 if usage != Usage::default() {
3271 self.record_usage(usage);
3272 }
3273 }
3274
3275 pub fn append_realtime_transcript_event(
3283 &mut self,
3284 event: RealtimeTranscriptEvent,
3285 ) -> RealtimeTranscriptApplyOutcome {
3286 let mut state = self.realtime_transcript_state();
3287 let commit =
3288 realtime_transcript_revision::apply_realtime_transcript_event(&mut state, event)
3289 .unwrap_or_else(|err| {
3290 fail_closed_generated_restore(
3291 "realtime-transcript",
3292 <serde_json::Error as serde::de::Error>::custom(err),
3293 )
3294 });
3295 self.store_realtime_transcript_state(&state);
3296 self.push_batch(commit.messages);
3297 if commit.usage != Usage::default() {
3298 self.record_usage(commit.usage);
3299 }
3300 commit.outcome
3301 }
3302
3303 #[must_use]
3306 pub fn preflight_realtime_user_content_event(
3307 &self,
3308 event: &RealtimeTranscriptEvent,
3309 ) -> Option<crate::RealtimeUserContentApplyOutcome> {
3310 let state = self.realtime_transcript_state();
3311 realtime_transcript_revision::preflight_realtime_user_content_event(&state, event)
3312 .unwrap_or_else(|err| {
3313 fail_closed_generated_restore(
3314 "realtime-user-content-preflight",
3315 <serde_json::Error as serde::de::Error>::custom(err),
3316 )
3317 })
3318 }
3319
3320 #[must_use]
3339 pub fn in_flight_realtime_assistant_response_ids(&self) -> Vec<String> {
3340 let state = self.realtime_transcript_state();
3341 realtime_transcript_revision::in_flight_realtime_assistant_response_ids(&state)
3342 }
3343
3344 #[must_use]
3347 pub fn realtime_user_content_identities(&self) -> Vec<RealtimeUserContentIdentity> {
3348 let state = self.realtime_transcript_state();
3349 realtime_transcript_revision::realtime_user_content_identities(&state)
3350 }
3351
3352 #[must_use]
3355 pub fn pending_realtime_user_content_blob(
3356 &self,
3357 ) -> Option<crate::PendingRealtimeUserContentBlob> {
3358 let state = self.realtime_transcript_state();
3359 realtime_transcript_revision::pending_realtime_user_content_blob(&state)
3360 }
3361
3362 pub fn stage_pending_realtime_user_content_blob(
3365 &mut self,
3366 pending: crate::PendingRealtimeUserContentBlob,
3367 ) -> Result<
3368 crate::generated::session_document::RealtimeUserContentBlobStageDisposition,
3369 realtime_transcript_revision::RealtimeTranscriptShellError,
3370 > {
3371 let mut state = self.realtime_transcript_state();
3372 let disposition = realtime_transcript_revision::stage_pending_realtime_user_content_blob(
3373 &mut state, pending,
3374 )?;
3375 self.store_realtime_transcript_state(&state);
3376 Ok(disposition)
3377 }
3378
3379 pub fn resolve_pending_realtime_user_content_blob_recovery(
3380 &self,
3381 request: Option<&crate::PendingRealtimeUserContentBlob>,
3382 pending_blob_valid: bool,
3383 ) -> Result<
3384 crate::generated::session_document::RealtimeUserContentBlobRecoveryDisposition,
3385 realtime_transcript_revision::RealtimeTranscriptShellError,
3386 > {
3387 let state = self.realtime_transcript_state();
3388 realtime_transcript_revision::resolve_pending_realtime_user_content_blob_recovery(
3389 &state,
3390 request,
3391 pending_blob_valid,
3392 )
3393 }
3394
3395 pub fn clear_invalid_pending_realtime_user_content_blob(
3398 &mut self,
3399 request: Option<&crate::PendingRealtimeUserContentBlob>,
3400 ) -> Result<(), realtime_transcript_revision::RealtimeTranscriptShellError> {
3401 let mut state = self.realtime_transcript_state();
3402 realtime_transcript_revision::clear_invalid_pending_realtime_user_content_blob(
3403 &mut state, request,
3404 )?;
3405 self.store_realtime_transcript_state(&state);
3406 Ok(())
3407 }
3408
3409 #[must_use]
3413 pub fn realtime_user_content_tombstones(
3414 &self,
3415 ) -> Vec<crate::realtime_transcript::RealtimeUserContentTombstone> {
3416 let state = self.realtime_transcript_state();
3417 realtime_transcript_revision::realtime_user_content_tombstones(&state)
3418 }
3419
3420 fn realtime_transcript_state(&self) -> SessionRealtimeTranscriptState {
3421 match self.try_realtime_transcript_state() {
3422 Ok(Some(state)) => state,
3423 Ok(None) => SessionRealtimeTranscriptState::default(),
3424 Err(err) => fail_closed_generated_restore("realtime-transcript", err),
3425 }
3426 }
3427
3428 fn try_realtime_transcript_state(
3429 &self,
3430 ) -> Result<Option<SessionRealtimeTranscriptState>, serde_json::Error> {
3431 self.metadata
3432 .get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY)
3433 .map(|value| {
3434 let state = serde_json::from_value(value.clone())?;
3435 realtime_transcript_revision::restore_realtime_transcript_state(state)
3436 .map_err(<serde_json::Error as serde::de::Error>::custom)
3437 })
3438 .transpose()
3439 }
3440
3441 fn store_realtime_transcript_state(&mut self, state: &SessionRealtimeTranscriptState) {
3442 match serde_json::to_value(state) {
3443 Ok(value) => self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value),
3444 Err(error) => {
3445 tracing::warn!(error = %error, "failed to serialize realtime transcript state");
3446 }
3447 }
3448 }
3449
3450 fn reconciled_realtime_transcript_metadata_after_rewrite(
3451 &self,
3452 messages: &[Message],
3453 ) -> Result<Option<serde_json::Value>, TranscriptEditError> {
3454 let Some(state) = self
3455 .try_realtime_transcript_state()
3456 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
3457 else {
3458 return Ok(None);
3459 };
3460 let state =
3461 realtime_transcript_revision::reconcile_realtime_transcript_state_after_rewrite(
3462 state, messages,
3463 )
3464 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
3465 serde_json::to_value(state)
3466 .map(Some)
3467 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))
3468 }
3469
3470 fn apply_authorized_system_prompt(
3471 &mut self,
3472 prompt: session_durable_config_authority::AuthorizedSystemPrompt,
3473 ) {
3474 use crate::types::SystemMessage;
3475
3476 let mutation_kind = prompt.mutation_kind();
3481 let (prompt, _replacing_existing) = prompt.into_parts();
3482 let message = SystemMessage::with_mutation_kind(prompt, mutation_kind);
3483 let inner = Arc::make_mut(&mut self.messages);
3484 if let Some(Message::System(_)) = inner.first() {
3486 inner[0] = Message::System(message);
3487 } else {
3488 inner.insert(0, Message::System(message));
3489 }
3490 self.updated_at = SystemTime::now();
3491 self.refresh_transcript_head_after_message_mutation();
3492 }
3493
3494 pub fn set_system_prompt_with_source(
3496 &mut self,
3497 prompt: String,
3498 source: session_durable_config_authority::SessionSystemPromptSource,
3499 ) -> Result<(), session_durable_config_authority::SessionDurableConfigAuthorityError> {
3500 let replacing_existing = matches!(self.messages.first(), Some(Message::System(_)));
3501 let prompt = session_durable_config_authority::authorize_system_prompt_mutation(
3502 prompt,
3503 source,
3504 replacing_existing,
3505 )?;
3506 self.apply_authorized_system_prompt(prompt);
3507 Ok(())
3508 }
3509
3510 pub fn set_system_prompt(&mut self, prompt: String) {
3512 if let Err(err) = self.set_system_prompt_with_source(
3513 prompt,
3514 session_durable_config_authority::SessionSystemPromptSource::DirectMutation,
3515 ) {
3516 tracing::warn!(error = %err, "generated session durable-config authority rejected system prompt mutation");
3517 }
3518 }
3519
3520 pub fn discard_transient_runtime_steer_context(&mut self) -> usize {
3526 let mut removed = 0usize;
3527
3528 let mut state = match self.try_system_context_state() {
3529 Ok(state) => state.unwrap_or_default(),
3530 Err(err) => {
3531 tracing::warn!(
3532 error = %err,
3533 "generated system-context authority rejected runtime steer cleanup state"
3534 );
3535 return removed;
3536 }
3537 };
3538
3539 let runtime_steer_appends = state
3544 .pending
3545 .iter()
3546 .chain(state.applied.iter())
3547 .filter(|append| append.source_kind.is_runtime_steer())
3548 .cloned()
3549 .collect::<Vec<_>>();
3550 if let Some(Message::System(system)) = self.messages.first() {
3551 let (retained_prompt, removed_blocks) =
3552 system_context_authority::remove_runtime_steer_blocks_for_rendered(
3553 &system.content,
3554 &runtime_steer_appends,
3555 );
3556 if removed_blocks > 0 {
3557 removed += removed_blocks;
3558 if let Err(err) = self.set_system_prompt_with_source(
3559 retained_prompt,
3560 session_durable_config_authority::SessionSystemPromptSource::RuntimeSteerCleanup,
3561 ) {
3562 tracing::warn!(
3563 error = %err,
3564 "generated session durable-config authority rejected runtime steer prompt cleanup"
3565 );
3566 }
3567 }
3568 }
3569
3570 removed += system_context_authority::discard_transient_runtime_steer_state(&mut state);
3571
3572 if removed > 0
3573 && let Err(err) = self.set_system_context_state(state)
3574 {
3575 tracing::warn!(
3576 error = %err,
3577 "failed to persist runtime steer context cleanup"
3578 );
3579 }
3580
3581 removed
3582 }
3583
3584 pub fn append_system_context_blocks(&mut self, appends: &[PendingSystemContextAppend]) {
3586 if appends.is_empty() {
3587 return;
3588 }
3589
3590 let current_system_prompt = self
3591 .messages
3592 .first()
3593 .and_then(|message| match message {
3594 Message::System(system) => Some(system.content.as_str()),
3595 _ => None,
3596 })
3597 .unwrap_or_default();
3598 let mut state = match self.try_system_context_state() {
3599 Ok(state) => state.unwrap_or_default(),
3600 Err(err) => {
3601 tracing::warn!(
3602 error = %err,
3603 "generated system-context authority rejected applied context state"
3604 );
3605 return;
3606 }
3607 };
3608 let new_appends = system_context_authority::record_applied_system_context_blocks(
3609 &mut state,
3610 appends,
3611 current_system_prompt,
3612 );
3613 if new_appends.is_empty() {
3614 if let Err(err) = self.set_system_context_state(state) {
3615 tracing::warn!(error = %err, "failed to persist applied system-context state");
3616 }
3617 return;
3618 }
3619
3620 let rendered = render_system_context_blocks_joined(&new_appends);
3621
3622 let next = match self.messages.first() {
3623 Some(Message::System(sys)) if !sys.content.is_empty() => {
3624 format!("{}{}{}", sys.content, SYSTEM_CONTEXT_SEPARATOR, rendered)
3625 }
3626 _ => rendered,
3627 };
3628 if let Err(err) = self.set_system_prompt_with_source(
3629 next,
3630 session_durable_config_authority::SessionSystemPromptSource::RuntimeContextAppend,
3631 ) {
3632 tracing::warn!(
3633 error = %err,
3634 "generated session durable-config authority rejected system-context prompt append"
3635 );
3636 return;
3637 }
3638 if let Err(err) = self.set_system_context_state(state) {
3639 tracing::warn!(error = %err, "failed to persist applied system-context state");
3640 }
3641 }
3642
3643 pub fn reconcile_resumed_system_prompt(
3669 &mut self,
3670 assembled_base: String,
3671 actor: Option<String>,
3672 ) -> Result<ResumedSystemPromptReconciliation, TranscriptEditError> {
3673 let persisted = match self.messages.first() {
3674 Some(Message::System(system)) => Some((system.content.clone(), system.mutation_kind)),
3675 _ => None,
3676 };
3677
3678 let Some((persisted_content, persisted_mutation_kind)) = persisted else {
3679 if assembled_base.is_empty() {
3680 return Ok(ResumedSystemPromptReconciliation::NoChange);
3681 }
3682 self.commit_resume_system_prompt_rewrite(assembled_base, false, actor)?;
3686 return Ok(ResumedSystemPromptReconciliation::RewrittenBase);
3687 };
3688
3689 if persisted_content == assembled_base {
3690 return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
3691 }
3692
3693 if let Some(tail) = self.verified_runtime_context_tail(&persisted_content) {
3702 let expected = compose_system_prompt_with_context_tail(&assembled_base, &tail);
3703 if expected == persisted_content {
3704 return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
3705 }
3706 self.commit_resume_system_prompt_rewrite(expected, true, actor)?;
3707 return Ok(ResumedSystemPromptReconciliation::RewrittenBase);
3708 }
3709
3710 if persisted_prompt_is_admitted_context_append_continuation(
3718 &assembled_base,
3719 &persisted_content,
3720 persisted_mutation_kind,
3721 ) {
3722 return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
3723 }
3724
3725 let dropping_applied_context = persisted_mutation_kind.is_runtime_context_append()
3732 || self
3733 .system_context_state()
3734 .is_some_and(|state| !state.applied.is_empty());
3735 self.commit_resume_system_prompt_rewrite(assembled_base, true, actor)?;
3736 if dropping_applied_context {
3737 tracing::warn!(
3738 session_id = %self.id,
3739 "resume base-prompt refresh dropped an unverifiable runtime system-context tail; \
3740 clearing applied-append records so keyed re-sends can restore the context"
3741 );
3742 self.clear_applied_system_context_records();
3743 }
3744 Ok(ResumedSystemPromptReconciliation::RewrittenBase)
3745 }
3746
3747 fn verified_runtime_context_tail(&self, persisted_content: &str) -> Option<String> {
3757 if let Some(prior_base) = self
3758 .build_state()
3759 .and_then(|state| state.assembled_system_prompt)
3760 {
3761 if persisted_content == prior_base {
3762 return Some(String::new());
3763 }
3764 if let Some(appended) = persisted_content.strip_prefix(prior_base.as_str())
3765 && appended.starts_with(SYSTEM_CONTEXT_SEPARATOR)
3766 {
3767 return Some(appended.to_string());
3768 }
3769 }
3772 let rendered_tail = self
3773 .system_context_state()
3774 .map(|state| render_system_context_blocks_joined(&state.applied))
3775 .unwrap_or_default();
3776 if rendered_tail.is_empty() {
3777 return None;
3778 }
3779 if persisted_content == rendered_tail {
3780 return Some(format!("{SYSTEM_CONTEXT_SEPARATOR}{rendered_tail}"));
3784 }
3785 let with_separator = format!("{SYSTEM_CONTEXT_SEPARATOR}{rendered_tail}");
3786 persisted_content
3787 .ends_with(&with_separator)
3788 .then_some(with_separator)
3789 }
3790
3791 fn commit_resume_system_prompt_rewrite(
3794 &mut self,
3795 content: String,
3796 replacing_existing: bool,
3797 actor: Option<String>,
3798 ) -> Result<(), TranscriptEditError> {
3799 let authorized = session_durable_config_authority::authorize_system_prompt_mutation(
3800 content,
3801 session_durable_config_authority::SessionSystemPromptSource::ExplicitBuild,
3802 replacing_existing,
3803 )
3804 .map_err(|err| {
3805 TranscriptEditError::HistoryStateMalformed(format!(
3806 "generated session durable-config authority rejected resume system prompt refresh: {err}"
3807 ))
3808 })?;
3809 let mutation_kind = authorized.mutation_kind();
3810 let (content, _replacing_existing) = authorized.into_parts();
3811 let replacement = Message::System(crate::types::SystemMessage::with_mutation_kind(
3812 content,
3813 mutation_kind,
3814 ));
3815 let end = usize::from(replacing_existing);
3816 self.commit_transcript_rewrite(
3817 TranscriptRewriteSelection::MessageRange { start: 0, end },
3818 vec![replacement],
3819 TranscriptRewriteReason::new(RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON),
3820 actor,
3821 None,
3822 )?;
3823 Ok(())
3824 }
3825
3826 fn clear_applied_system_context_records(&mut self) {
3830 let mut state = match self.try_system_context_state() {
3831 Ok(Some(state)) => state,
3832 Ok(None) => return,
3833 Err(error) => {
3834 tracing::warn!(
3835 session_id = %self.id,
3836 error = %error,
3837 "failed to read system-context state while clearing orphaned applied records"
3838 );
3839 return;
3840 }
3841 };
3842 if state.applied.is_empty() {
3843 return;
3844 }
3845 let dropped_keys: Vec<String> = state
3846 .applied
3847 .iter()
3848 .filter_map(|append| append.idempotency_key.clone())
3849 .collect();
3850 state.applied.clear();
3851 for key in &dropped_keys {
3852 state.seen.remove(key);
3853 }
3854 if let Err(error) = self.set_system_context_state(state) {
3855 tracing::warn!(
3856 session_id = %self.id,
3857 error = %error,
3858 "failed to persist cleared applied system-context records after resume prompt refresh"
3859 );
3860 }
3861 }
3862
3863 pub fn last_assistant_text(&self) -> Option<String> {
3870 self.messages.iter().rev().find_map(|m| match m {
3871 Message::BlockAssistant(a) => {
3872 let mut buf = String::new();
3873 for block in &a.blocks {
3874 match block {
3875 crate::types::AssistantBlock::Text { text, .. }
3876 | crate::types::AssistantBlock::Transcript { text, .. } => {
3877 buf.push_str(text);
3878 }
3879 _ => {}
3880 }
3881 }
3882 if buf.is_empty() { None } else { Some(buf) }
3883 }
3884 _ => None,
3885 })
3886 }
3887
3888 pub fn tool_call_count(&self) -> usize {
3890 self.messages
3891 .iter()
3892 .filter_map(|m| match m {
3893 Message::BlockAssistant(a) => Some(
3894 a.blocks
3895 .iter()
3896 .filter(|b| matches!(b, crate::types::AssistantBlock::ToolUse { .. }))
3897 .count(),
3898 ),
3899 _ => None,
3900 })
3901 .sum()
3902 }
3903
3904 pub fn metadata(&self) -> &serde_json::Map<String, serde_json::Value> {
3906 &self.metadata
3907 }
3908
3909 fn set_metadata_unchecked(&mut self, key: &str, value: serde_json::Value) {
3910 self.metadata.insert(key.to_string(), value);
3911 self.updated_at = SystemTime::now();
3912 }
3913
3914 #[cfg(test)]
3915 pub(crate) fn set_metadata_unchecked_for_test(&mut self, key: &str, value: serde_json::Value) {
3916 self.set_metadata_unchecked(key, value);
3917 }
3918
3919 fn fork_metadata_projection(&self) -> serde_json::Map<String, serde_json::Value> {
3920 let mut metadata = self.metadata.clone();
3921 metadata.retain(|key, _| !is_session_authority_metadata_key(key));
3922 metadata
3923 }
3924
3925 fn remove_metadata_unchecked(&mut self, key: &str) {
3926 self.metadata.remove(key);
3927 self.updated_at = SystemTime::now();
3928 }
3929
3930 pub fn try_set_metadata(
3932 &mut self,
3933 key: &str,
3934 value: serde_json::Value,
3935 ) -> Result<(), ReservedSessionMetadataKey> {
3936 if is_session_authority_metadata_key(key) {
3937 return Err(ReservedSessionMetadataKey::new(key));
3938 }
3939 self.set_metadata_unchecked(key, value);
3940 Ok(())
3941 }
3942
3943 pub fn set_metadata(&mut self, key: &str, value: serde_json::Value) {
3948 if let Err(err) = self.try_set_metadata(key, value) {
3949 tracing::warn!(error = %err, "rejected raw session metadata mutation");
3950 }
3951 }
3952
3953 pub fn backfill_metadata_if_absent(&mut self, key: &str, value: serde_json::Value) -> bool {
3959 if is_session_authority_metadata_key(key) {
3960 tracing::warn!(
3961 metadata_key = key,
3962 "rejected raw session metadata backfill for authority key"
3963 );
3964 return false;
3965 }
3966 if self.metadata.contains_key(key) {
3967 false
3968 } else {
3969 self.metadata.insert(key.to_string(), value);
3970 true
3971 }
3972 }
3973
3974 pub fn remove_metadata(&mut self, key: &str) {
3976 if is_session_authority_metadata_key(key) {
3977 tracing::warn!(
3978 metadata_key = key,
3979 "rejected raw session metadata removal for authority key"
3980 );
3981 return;
3982 }
3983 self.metadata.remove(key);
3984 self.updated_at = SystemTime::now();
3985 }
3986
3987 pub fn set_session_metadata(
3989 &mut self,
3990 metadata: SessionMetadata,
3991 ) -> Result<(), serde_json::Error> {
3992 let metadata =
3993 session_durable_config_authority::authorize_session_metadata_persist(metadata)
3994 .map_err(<serde_json::Error as serde::ser::Error>::custom)?
3995 .into_metadata();
3996 let value = serde_json::to_value(metadata)?;
3997 self.set_metadata_unchecked(SESSION_METADATA_KEY, value);
3998 Ok(())
3999 }
4000
4001 pub fn session_metadata(&self) -> Option<SessionMetadata> {
4006 match self.try_session_metadata() {
4007 Ok(metadata) => metadata,
4008 Err(err) => fail_closed_generated_restore("session-metadata", err),
4009 }
4010 }
4011
4012 pub fn try_session_metadata(&self) -> Result<Option<SessionMetadata>, serde_json::Error> {
4014 try_session_metadata_from_map(&self.metadata)
4015 }
4016
4017 pub fn set_system_context_state(
4019 &mut self,
4020 state: SessionSystemContextState,
4021 ) -> Result<(), serde_json::Error> {
4022 let state = system_context_authority::restore_system_context_state(state)
4023 .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
4024 let value = serde_json::to_value(state)?;
4025 self.set_metadata_unchecked(SESSION_SYSTEM_CONTEXT_STATE_KEY, value);
4026 Ok(())
4027 }
4028
4029 pub fn try_system_context_state(
4031 &self,
4032 ) -> Result<Option<SessionSystemContextState>, serde_json::Error> {
4033 self.metadata
4034 .get(SESSION_SYSTEM_CONTEXT_STATE_KEY)
4035 .map(|value| {
4036 let state = serde_json::from_value(value.clone())?;
4037 system_context_authority::restore_system_context_state(state)
4038 .map_err(<serde_json::Error as serde::de::Error>::custom)
4039 })
4040 .transpose()
4041 }
4042
4043 pub fn system_context_state(&self) -> Option<SessionSystemContextState> {
4049 match self.try_system_context_state() {
4050 Ok(state) => state,
4051 Err(err) => fail_closed_generated_restore("system-context", err),
4052 }
4053 }
4054
4055 pub fn set_deferred_turn_state(
4057 &mut self,
4058 state: SessionDeferredTurnState,
4059 ) -> Result<(), serde_json::Error> {
4060 let state = validate_deferred_turn_snapshot(state)
4061 .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
4062 let value = serde_json::to_value(state)?;
4063 self.set_metadata_unchecked(SESSION_DEFERRED_TURN_STATE_KEY, value);
4064 Ok(())
4065 }
4066
4067 pub fn try_deferred_turn_state(
4069 &self,
4070 ) -> Result<Option<SessionDeferredTurnState>, serde_json::Error> {
4071 self.metadata
4072 .get(SESSION_DEFERRED_TURN_STATE_KEY)
4073 .map(|value| {
4074 let state = serde_json::from_value(value.clone())?;
4075 validate_deferred_turn_snapshot(state)
4076 .map_err(<serde_json::Error as serde::de::Error>::custom)
4077 })
4078 .transpose()
4079 }
4080
4081 pub fn deferred_turn_state(&self) -> Option<SessionDeferredTurnState> {
4087 match self.try_deferred_turn_state() {
4088 Ok(state) => state,
4089 Err(err) => fail_closed_generated_restore("deferred-turn", err),
4090 }
4091 }
4092
4093 pub fn set_lifecycle_terminal(
4102 &mut self,
4103 terminal: SessionLifecycleTerminal,
4104 ) -> Result<(), serde_json::Error> {
4105 let value = serde_json::to_value(terminal)?;
4106 self.set_metadata_unchecked(SESSION_LIFECYCLE_TERMINAL_KEY, value);
4107 Ok(())
4108 }
4109
4110 pub fn try_lifecycle_terminal(
4115 &self,
4116 ) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
4117 try_lifecycle_terminal_from_map(&self.metadata)
4118 }
4119
4120 pub fn lifecycle_terminal(&self) -> Option<SessionLifecycleTerminal> {
4126 match self.try_lifecycle_terminal() {
4127 Ok(state) => state,
4128 Err(err) => fail_closed_generated_restore("session-lifecycle-terminal", err),
4129 }
4130 }
4131
4132 pub fn set_build_state(&mut self, state: SessionBuildState) -> Result<(), serde_json::Error> {
4134 let state = session_durable_config_authority::authorize_session_build_state_persist(state)
4135 .map_err(<serde_json::Error as serde::ser::Error>::custom)?
4136 .into_state();
4137 let value = serde_json::to_value(state)?;
4138 self.set_metadata_unchecked(SESSION_BUILD_STATE_KEY, value);
4139 Ok(())
4140 }
4141
4142 pub fn build_state(&self) -> Option<SessionBuildState> {
4147 match self.try_build_state() {
4148 Ok(state) => state,
4149 Err(err) => fail_closed_generated_restore("session-build-state", err),
4150 }
4151 }
4152
4153 pub fn try_build_state(&self) -> Result<Option<SessionBuildState>, serde_json::Error> {
4155 let Some(value) = self.metadata.get(SESSION_BUILD_STATE_KEY) else {
4156 return Ok(None);
4157 };
4158 let state = serde_json::from_value::<SessionBuildState>(value.clone())?;
4159 session_durable_config_authority::restore_session_build_state(state)
4160 .map(Some)
4161 .map_err(<serde_json::Error as serde::de::Error>::custom)
4162 }
4163
4164 pub fn set_tool_visibility_state(
4166 &mut self,
4167 state: AuthorizedSessionToolVisibilityState,
4168 ) -> Result<(), serde_json::Error> {
4169 let value = serde_json::to_value(state.into_state())?;
4170 self.set_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY, value);
4171 Ok(())
4172 }
4173
4174 #[cfg(test)]
4179 pub(crate) fn clear_tool_visibility_state(&mut self) {
4180 self.remove_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY);
4181 }
4182
4183 pub fn tool_visibility_state(
4185 &self,
4186 ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
4187 self.try_tool_visibility_state()
4188 }
4189
4190 pub fn try_tool_visibility_state(
4193 &self,
4194 ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
4195 self.metadata
4196 .get(SESSION_TOOL_VISIBILITY_STATE_KEY)
4197 .map(|value| serde_json::from_value(value.clone()))
4198 .transpose()
4199 }
4200
4201 pub fn transcript_history_state(
4203 &self,
4204 ) -> Result<Option<TranscriptHistoryState>, serde_json::Error> {
4205 self.metadata
4206 .get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
4207 .map(|value| serde_json::from_value(value.clone()))
4208 .transpose()
4209 }
4210
4211 pub fn validate_transcript_history_state(&self) -> Result<(), TranscriptEditError> {
4213 let Some(state) = self
4214 .transcript_history_state()
4215 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
4216 else {
4217 return Ok(());
4218 };
4219 validate_transcript_history_state(&state)
4220 }
4221
4222 pub fn clear_transcript_history_state(&mut self) {
4225 self.remove_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY);
4226 }
4227
4228 pub fn set_runtime_checkpoint_provenance(&mut self) {
4234 self.set_metadata_unchecked(
4235 SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY,
4236 serde_json::Value::Bool(true),
4237 );
4238 }
4239
4240 pub fn clear_runtime_checkpoint_provenance(&mut self) {
4244 self.remove_metadata_unchecked(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY);
4245 }
4246
4247 pub fn has_runtime_checkpoint_provenance(&self) -> bool {
4251 self.metadata
4252 .get(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY)
4253 .and_then(serde_json::Value::as_bool)
4254 .unwrap_or(false)
4255 }
4256
4257 pub fn transcript_revision_body(
4259 &self,
4260 revision: &str,
4261 ) -> Result<Option<TranscriptRevisionBody>, serde_json::Error> {
4262 Ok(self.transcript_history_state()?.and_then(|state| {
4263 state
4264 .revisions
4265 .into_iter()
4266 .find(|body| body.revision == revision)
4267 }))
4268 }
4269
4270 pub fn transcript_revision_messages(
4272 &self,
4273 revision: &str,
4274 ) -> Result<Option<Vec<Message>>, serde_json::Error> {
4275 Ok(self
4276 .transcript_revision_body(revision)?
4277 .map(|body| body.messages))
4278 }
4279
4280 pub fn apply_transcript_history_state(
4282 &mut self,
4283 state: TranscriptHistoryState,
4284 ) -> Result<(), TranscriptEditError> {
4285 validate_transcript_history_state(&state)?;
4286 let head_body = state
4287 .revisions
4288 .iter()
4289 .find(|body| body.revision == state.head)
4290 .ok_or_else(|| {
4291 TranscriptEditError::HistoryStateMalformed(format!(
4292 "missing transcript head body {}",
4293 state.head
4294 ))
4295 })?
4296 .clone();
4297 let realtime_state =
4298 self.reconciled_realtime_transcript_metadata_after_rewrite(&head_body.messages)?;
4299 let value = serde_json::to_value(&state)
4300 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4301 self.set_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, value);
4302 if let Some(value) = realtime_state {
4303 self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
4304 }
4305 let mut updated_at = head_body.created_at;
4306 for commit in &state.commits {
4307 if commit.committed_at > updated_at {
4308 updated_at = commit.committed_at;
4309 }
4310 }
4311 self.messages = Arc::new(head_body.messages);
4312 self.updated_at = updated_at;
4313 Ok(())
4314 }
4315
4316 pub fn transcript_revision(&self) -> Result<String, serde_json::Error> {
4319 if let Some(state) = self.transcript_history_state()? {
4320 Ok(state.head)
4321 } else {
4322 transcript_messages_digest(self.messages())
4323 }
4324 }
4325
4326 pub fn transcript_rewrite_generation(&self) -> Result<u64, serde_json::Error> {
4331 Ok(self.transcript_history_state()?.map_or(0, |state| {
4332 u64::try_from(state.commits.len()).unwrap_or(u64::MAX)
4333 }))
4334 }
4335
4336 pub fn commit_transcript_rewrite(
4338 &mut self,
4339 selection: TranscriptRewriteSelection,
4340 replacement: Vec<Message>,
4341 reason: TranscriptRewriteReason,
4342 actor: Option<String>,
4343 expected_parent_revision: Option<String>,
4344 ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
4345 let parent_revision = self
4346 .transcript_revision()
4347 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4348 if let Some(expected) = expected_parent_revision
4349 && expected != parent_revision
4350 {
4351 return Err(TranscriptEditError::RevisionConflict {
4352 expected,
4353 actual: parent_revision,
4354 });
4355 }
4356
4357 let (start, end) = selection.bounds();
4358 let message_count = self.messages.len();
4359 if start > end || end > message_count {
4360 return Err(TranscriptEditError::InvalidRewriteRange {
4361 start,
4362 end,
4363 message_count,
4364 });
4365 }
4366
4367 let replacement_len = replacement.len();
4368 let mut rewritten = Vec::with_capacity(
4369 start
4370 .saturating_add(replacement_len)
4371 .saturating_add(message_count.saturating_sub(end)),
4372 );
4373 rewritten.extend_from_slice(&self.messages[..start]);
4374 rewritten.extend(replacement);
4375 rewritten.extend_from_slice(&self.messages[end..]);
4376 validate_transcript_tool_result_shape(&rewritten)?;
4377
4378 let original_span_digest = transcript_messages_digest(&self.messages[start..end])
4379 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4380 let replacement_digest =
4381 transcript_messages_digest(&rewritten[start..start + replacement_len])
4382 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4383 let revision = transcript_messages_digest(&rewritten)
4384 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4385 if revision == parent_revision {
4386 return Err(TranscriptEditError::NoOpRewrite { revision });
4387 }
4388 let realtime_state =
4389 self.reconciled_realtime_transcript_metadata_after_rewrite(&rewritten)?;
4390
4391 let commit = TranscriptRewriteCommit {
4392 parent_revision,
4393 revision: revision.clone(),
4394 selection,
4395 original_span_digest,
4396 replacement_digest,
4397 messages_before: message_count,
4398 messages_after: rewritten.len(),
4399 reason,
4400 actor,
4401 committed_at: SystemTime::now(),
4402 };
4403
4404 let mut state = self
4405 .transcript_history_state()
4406 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
4407 .unwrap_or_else(|| TranscriptHistoryState {
4408 head: commit.parent_revision.clone(),
4409 commits: Vec::new(),
4410 revisions: Vec::new(),
4411 });
4412 if !state
4413 .revisions
4414 .iter()
4415 .any(|body| body.revision == commit.parent_revision)
4416 {
4417 state.revisions.push(TranscriptRevisionBody {
4418 revision: commit.parent_revision.clone(),
4419 parent_revision: None,
4420 messages: self.messages().to_vec(),
4421 created_at: self.updated_at,
4422 });
4423 }
4424 if !state
4425 .revisions
4426 .iter()
4427 .any(|body| body.revision == commit.revision)
4428 {
4429 state.revisions.push(TranscriptRevisionBody {
4430 revision: commit.revision.clone(),
4431 parent_revision: Some(commit.parent_revision.clone()),
4432 messages: rewritten.clone(),
4433 created_at: commit.committed_at,
4434 });
4435 }
4436 state.head = revision;
4437 state.commits.push(commit.clone());
4438 let value = serde_json::to_value(state)
4439 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4440 self.set_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, value);
4441 if let Some(value) = realtime_state {
4442 self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value);
4443 }
4444
4445 self.messages = Arc::new(rewritten);
4446 self.updated_at = SystemTime::now();
4447 Ok(commit)
4448 }
4449
4450 fn refresh_transcript_head_after_message_mutation(&mut self) {
4451 if !self
4452 .metadata
4453 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
4454 {
4455 return;
4456 }
4457 let Ok(Some(mut state)) = self.transcript_history_state() else {
4458 tracing::warn!(
4459 session_id = %self.id,
4460 "transcript history state is malformed; leaving head unchanged after message mutation"
4461 );
4462 return;
4463 };
4464 let Ok(head) = transcript_messages_digest(self.messages()) else {
4465 tracing::warn!(
4466 session_id = %self.id,
4467 "failed to digest transcript after message mutation; leaving head unchanged"
4468 );
4469 return;
4470 };
4471 let previous_head = state.head.clone();
4472 if !state.revisions.iter().any(|body| body.revision == head) {
4473 state.revisions.push(TranscriptRevisionBody {
4474 revision: head.clone(),
4475 parent_revision: Some(previous_head),
4476 messages: self.messages().to_vec(),
4477 created_at: SystemTime::now(),
4478 });
4479 }
4480 state.head = head;
4481 match serde_json::to_value(state) {
4482 Ok(value) => self.set_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, value),
4483 Err(error) => {
4484 tracing::warn!(
4485 session_id = %self.id,
4486 error = %error,
4487 "failed to serialize transcript history state after message mutation"
4488 );
4489 }
4490 }
4491 }
4492
4493 pub fn set_mob_tool_authority_context(
4501 &mut self,
4502 authority_context: Option<MobToolAuthorityContext>,
4503 ) -> Result<(), serde_json::Error> {
4504 if let Some(authority_context) = authority_context.as_ref()
4505 && !authority_context.is_generated_authority_context()
4506 {
4507 return Err(<serde_json::Error as serde::de::Error>::custom(
4508 "mob authority context was not minted by generated authority",
4509 ));
4510 }
4511 let mut build_state = self.build_state().ok_or_else(|| {
4512 <serde_json::Error as serde::de::Error>::custom(format!(
4513 "session {} is missing session build state",
4514 self.id
4515 ))
4516 })?;
4517 build_state.mob_tool_authority_context = authority_context;
4518 self.set_build_state(build_state)
4519 }
4520
4521 pub fn mob_tool_authority_context(&self) -> Option<MobToolAuthorityContext> {
4526 self.build_state()
4527 .and_then(|state| state.mob_tool_authority_context)
4528 .filter(MobToolAuthorityContext::is_generated_authority_context)
4529 }
4530
4531 pub fn fork_at(&self, index: usize) -> Self {
4536 let now = SystemTime::now();
4537 let truncated = self.messages[..index.min(self.messages.len())].to_vec();
4538 Self {
4539 version: session_version(),
4540 id: SessionId::new(),
4541 messages: Arc::new(truncated),
4542 created_at: now,
4543 updated_at: now,
4544 metadata: self.fork_metadata_projection(),
4545 usage: self.usage.clone(),
4546 }
4547 }
4548
4549 pub fn fork_replacing(
4556 &self,
4557 message_index: usize,
4558 replacement: TranscriptReplacement,
4559 ) -> Result<Self, TranscriptEditError> {
4560 let Some(original) = self.messages.get(message_index) else {
4561 return Err(TranscriptEditError::MessageIndexOutOfBounds {
4562 message_index,
4563 message_count: self.messages.len(),
4564 });
4565 };
4566
4567 let replacement_message = match replacement {
4568 TranscriptReplacement::Message { message } => message,
4569 TranscriptReplacement::UserContentBlock { block_index, block } => {
4570 let Message::User(user) = original else {
4571 return Err(TranscriptEditError::MessageRoleMismatch {
4572 message_index,
4573 expected: "user",
4574 actual: message_role_name(original),
4575 });
4576 };
4577 if block_index >= user.content.len() {
4578 return Err(TranscriptEditError::BlockIndexOutOfBounds {
4579 block_kind: "user content block",
4580 block_index,
4581 block_count: user.content.len(),
4582 });
4583 }
4584 let mut edited = user.clone();
4585 edited.content[block_index] = block;
4586 Message::User(edited)
4587 }
4588 TranscriptReplacement::AssistantBlock { block_index, block } => {
4589 let Message::BlockAssistant(assistant) = original else {
4590 return Err(TranscriptEditError::MessageRoleMismatch {
4591 message_index,
4592 expected: "block_assistant",
4593 actual: message_role_name(original),
4594 });
4595 };
4596 if block_index >= assistant.blocks.len() {
4597 return Err(TranscriptEditError::BlockIndexOutOfBounds {
4598 block_kind: "assistant block",
4599 block_index,
4600 block_count: assistant.blocks.len(),
4601 });
4602 }
4603 let mut edited = assistant.clone();
4604 edited.blocks[block_index] = block;
4605 Message::BlockAssistant(edited)
4606 }
4607 TranscriptReplacement::ToolResultContentBlock {
4608 result_index,
4609 block_index,
4610 block,
4611 } => {
4612 let Message::ToolResults {
4613 results,
4614 created_at,
4615 } = original
4616 else {
4617 return Err(TranscriptEditError::MessageRoleMismatch {
4618 message_index,
4619 expected: "tool_results",
4620 actual: message_role_name(original),
4621 });
4622 };
4623 let Some(result) = results.get(result_index) else {
4624 return Err(TranscriptEditError::BlockIndexOutOfBounds {
4625 block_kind: "tool result",
4626 block_index: result_index,
4627 block_count: results.len(),
4628 });
4629 };
4630 if block_index >= result.content.len() {
4631 return Err(TranscriptEditError::BlockIndexOutOfBounds {
4632 block_kind: "tool result content block",
4633 block_index,
4634 block_count: result.content.len(),
4635 });
4636 }
4637 let mut edited_results = results.clone();
4638 edited_results[result_index].content[block_index] = block;
4639 Message::ToolResults {
4640 results: edited_results,
4641 created_at: *created_at,
4642 }
4643 }
4644 };
4645
4646 let mut forked = self.fork_at(message_index);
4647 forked.push(replacement_message);
4648 Ok(forked)
4649 }
4650
4651 pub fn fork(&self) -> Self {
4656 let now = SystemTime::now();
4657 Self {
4658 version: session_version(),
4659 id: SessionId::new(),
4660 messages: Arc::clone(&self.messages),
4661 created_at: now,
4662 updated_at: now,
4663 metadata: self.fork_metadata_projection(),
4664 usage: self.usage.clone(),
4665 }
4666 }
4667}
4668
4669impl Default for Session {
4670 fn default() -> Self {
4671 Self::new()
4672 }
4673}
4674
4675#[derive(Debug, Clone, Serialize, Deserialize)]
4677#[serde(rename_all = "snake_case")]
4678pub struct SessionMeta {
4679 pub id: SessionId,
4680 pub created_at: SystemTime,
4681 pub updated_at: SystemTime,
4682 pub message_count: usize,
4683 pub total_tokens: u64,
4684 #[serde(default)]
4685 pub metadata: serde_json::Map<String, serde_json::Value>,
4686}
4687
4688#[derive(Debug, Clone, Serialize, Deserialize)]
4690#[serde(rename_all = "snake_case")]
4691pub struct SessionMetadata {
4692 pub schema_version: u32,
4699 pub model: String,
4700 pub max_tokens: u32,
4701 #[serde(default = "crate::config::default_structured_output_retries")]
4702 pub structured_output_retries: u32,
4703 pub provider: Provider,
4704 #[serde(default, skip_serializing_if = "Option::is_none")]
4705 pub self_hosted_server_id: Option<String>,
4706 #[serde(default, skip_serializing_if = "Option::is_none")]
4709 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
4710 pub tooling: SessionTooling,
4711 #[serde(default)]
4712 pub keep_alive: bool,
4713 pub comms_name: Option<String>,
4714 #[serde(default, skip_serializing_if = "Option::is_none")]
4716 pub peer_meta: Option<PeerMeta>,
4717 #[serde(default, skip_serializing_if = "Option::is_none")]
4723 pub realm_id: Option<crate::RealmId>,
4724 #[serde(default, skip_serializing_if = "Option::is_none")]
4726 pub instance_id: Option<String>,
4727 #[serde(default, skip_serializing_if = "Option::is_none")]
4729 pub backend: Option<String>,
4730 #[serde(default, skip_serializing_if = "Option::is_none")]
4732 pub config_generation: Option<u64>,
4733 #[serde(default, skip_serializing_if = "Option::is_none")]
4743 pub auth_binding: Option<crate::AuthBindingRef>,
4744 #[serde(default, skip_serializing_if = "Option::is_none")]
4757 pub mob_member_binding: Option<crate::MobMemberBinding>,
4758}
4759
4760#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
4762#[serde(rename_all = "snake_case")]
4763pub struct SessionLlmIdentity {
4764 pub model: String,
4765 pub provider: Provider,
4766 #[serde(default, skip_serializing_if = "Option::is_none")]
4767 pub self_hosted_server_id: Option<String>,
4768 #[serde(default, skip_serializing_if = "Option::is_none")]
4770 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
4771 #[serde(default, skip_serializing_if = "Option::is_none")]
4784 pub auth_binding: Option<crate::AuthBindingRef>,
4785}
4786
4787pub struct SessionLlmIdentityOverride<'a> {
4795 pub model: Option<&'a str>,
4796 pub provider: Option<Provider>,
4797 pub provider_params:
4798 Option<TurnMetadataOverride<&'a crate::lifecycle::run_primitive::ProviderParamsOverride>>,
4799 pub auth_binding: Option<TurnMetadataOverride<&'a crate::AuthBindingRef>>,
4800}
4801
4802#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
4803pub enum SessionLlmIdentityOverrideError {
4804 #[error("provider override requires model on an existing session")]
4805 ProviderRequiresModel,
4806 #[error("{0}")]
4807 ProviderModelMismatch(String),
4808 #[error("self-hosted provider requires a registered model alias; '{model}' is not configured")]
4809 MissingSelfHostedAlias { model: String },
4810}
4811
4812pub fn resolve_session_llm_identity_override(
4820 current: &SessionLlmIdentity,
4821 registry: &crate::ModelRegistry,
4822 overrides: SessionLlmIdentityOverride<'_>,
4823) -> Result<SessionLlmIdentity, SessionLlmIdentityOverrideError> {
4824 if overrides.provider.is_some() && overrides.model.is_none() {
4825 return Err(SessionLlmIdentityOverrideError::ProviderRequiresModel);
4826 }
4827
4828 let model = overrides
4829 .model
4830 .map(str::to_string)
4831 .unwrap_or_else(|| current.model.clone());
4832 let provider = if let Some(provider) = overrides.provider {
4833 provider
4834 } else if overrides.model.is_some() {
4835 registry
4836 .entry(&model)
4837 .map_or(current.provider, |entry| entry.provider)
4838 } else {
4839 current.provider
4840 };
4841
4842 if (overrides.model.is_some() || overrides.provider.is_some())
4843 && let Some(reason) = registry.provider_override_mismatch_reason(provider, &model)
4844 {
4845 return Err(SessionLlmIdentityOverrideError::ProviderModelMismatch(
4846 reason,
4847 ));
4848 }
4849
4850 let provider_params = match overrides.provider_params {
4851 Some(TurnMetadataOverride::Clear) => None,
4852 Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
4853 None => current.provider_params.clone(),
4854 };
4855 let self_hosted_server_id = if provider == Provider::SelfHosted {
4856 if overrides.model.is_none() {
4857 current.self_hosted_server_id.clone().or_else(|| {
4858 registry
4859 .entry_for_provider(Provider::SelfHosted, &model)
4860 .and_then(|entry| entry.self_hosted.as_ref())
4861 .map(|server| server.server_id.clone())
4862 })
4863 } else {
4864 let entry = registry
4865 .entry_for_provider(Provider::SelfHosted, &model)
4866 .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
4867 model: model.clone(),
4868 })?;
4869 entry
4870 .self_hosted
4871 .as_ref()
4872 .map(|server| server.server_id.clone())
4873 }
4874 } else {
4875 None
4876 };
4877
4878 let auth_binding = match overrides.auth_binding {
4879 Some(TurnMetadataOverride::Clear) => None,
4880 Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
4881 None if provider != current.provider => None,
4884 None => current.auth_binding.clone(),
4885 };
4886
4887 Ok(SessionLlmIdentity {
4888 model,
4889 provider,
4890 self_hosted_server_id,
4891 provider_params,
4892 auth_binding,
4893 })
4894}
4895
4896#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
4903#[serde(rename_all = "snake_case")]
4904pub struct SessionLlmRequestPolicy {
4905 pub model: String,
4906 #[serde(default, skip_serializing_if = "Option::is_none")]
4908 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
4909 #[serde(default, skip_serializing_if = "Option::is_none")]
4911 pub provider_tool_defaults: Option<crate::lifecycle::run_primitive::ProviderTag>,
4912}
4913
4914impl SessionMetadata {
4915 pub fn llm_identity(&self) -> SessionLlmIdentity {
4917 SessionLlmIdentity {
4918 model: self.model.clone(),
4919 provider: self.provider,
4920 self_hosted_server_id: self.self_hosted_server_id.clone(),
4921 provider_params: self.provider_params.clone(),
4922 auth_binding: self.auth_binding.clone(),
4923 }
4924 }
4925
4926 pub fn apply_llm_identity(&mut self, identity: &SessionLlmIdentity) {
4928 self.model = identity.model.clone();
4929 self.provider = identity.provider;
4930 self.self_hosted_server_id = identity.self_hosted_server_id.clone();
4931 self.provider_params = identity.provider_params.clone();
4932 self.auth_binding = identity.auth_binding.clone();
4933 }
4934}
4935
4936pub const SESSION_METADATA_KEY: &str = "session_metadata";
4938
4939#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
4947#[serde(rename_all = "snake_case")]
4948pub enum ToolCategoryOverride {
4949 #[default]
4951 Inherit,
4952 Enable,
4954 Disable,
4956}
4957
4958impl ToolCategoryOverride {
4959 #[must_use]
4965 pub fn resolve(self, runtime_default: bool) -> bool {
4966 match self {
4967 Self::Enable => true,
4968 Self::Disable => false,
4969 Self::Inherit => runtime_default,
4970 }
4971 }
4972
4973 #[must_use]
4979 pub fn to_override(self) -> Option<bool> {
4980 match self {
4981 Self::Enable => Some(true),
4982 Self::Disable => Some(false),
4983 Self::Inherit => None,
4984 }
4985 }
4986
4987 #[must_use]
4995 pub fn from_effective(enabled: bool) -> Self {
4996 if enabled { Self::Enable } else { Self::Disable }
4997 }
4998
4999 #[must_use]
5009 pub fn from_override(value: Option<bool>) -> Self {
5010 match value {
5011 Some(true) => Self::Enable,
5012 Some(false) => Self::Disable,
5013 None => Self::Inherit,
5014 }
5015 }
5016}
5017
5018#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
5025#[serde(rename_all = "snake_case")]
5026pub struct SessionTooling {
5027 #[serde(default)]
5028 pub builtins: ToolCategoryOverride,
5029 #[serde(default)]
5030 pub shell: ToolCategoryOverride,
5031 #[serde(default)]
5032 pub comms: ToolCategoryOverride,
5033 #[serde(default)]
5035 pub mob: ToolCategoryOverride,
5036 #[serde(default)]
5038 pub memory: ToolCategoryOverride,
5039 #[serde(default)]
5041 pub schedule: ToolCategoryOverride,
5042 #[serde(default)]
5044 pub workgraph: ToolCategoryOverride,
5045 #[serde(default)]
5047 pub image_generation: ToolCategoryOverride,
5048 #[serde(default)]
5050 pub web_search: ToolCategoryOverride,
5051 #[serde(default, skip_serializing_if = "Option::is_none")]
5061 pub tool_access_policy: Option<crate::ops::ToolAccessPolicy>,
5062 #[serde(default, skip_serializing_if = "Option::is_none")]
5064 pub active_skills: Option<Vec<crate::skills::SkillKey>>,
5065}
5066
5067impl From<&Session> for SessionMeta {
5068 fn from(session: &Session) -> Self {
5069 Self {
5070 id: session.id.clone(),
5071 created_at: session.created_at,
5072 updated_at: session.updated_at,
5073 message_count: session.messages.len(),
5074 total_tokens: session.total_tokens(),
5075 metadata: session.metadata.clone(),
5076 }
5077 }
5078}
5079
5080pub fn try_session_metadata_from_map(
5089 metadata: &serde_json::Map<String, serde_json::Value>,
5090) -> Result<Option<SessionMetadata>, serde_json::Error> {
5091 let Some(value) = metadata.get(SESSION_METADATA_KEY) else {
5092 return Ok(None);
5093 };
5094 let mut metadata = serde_json::from_value::<SessionMetadata>(value.clone())?;
5095 metadata.schema_version =
5096 session_persistence_version_authority::restore_session_metadata_schema_version(
5097 metadata.schema_version,
5098 )
5099 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
5100 session_durable_config_authority::restore_session_metadata(metadata)
5101 .map(Some)
5102 .map_err(<serde_json::Error as serde::de::Error>::custom)
5103}
5104
5105pub fn try_lifecycle_terminal_from_map(
5112 metadata: &serde_json::Map<String, serde_json::Value>,
5113) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
5114 match metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY) {
5115 Some(value) => serde_json::from_value(value.clone()).map(Some),
5116 None => Ok(None),
5117 }
5118}
5119
5120#[derive(Debug, Clone)]
5128pub struct PersistedSessionMetadataView {
5129 pub session_id: SessionId,
5130 pub session_metadata: Option<SessionMetadata>,
5131 pub lifecycle_terminal: Option<SessionLifecycleTerminal>,
5132}
5133
5134impl PersistedSessionMetadataView {
5135 pub fn try_from_metadata_map(
5141 session_id: SessionId,
5142 metadata: &serde_json::Map<String, serde_json::Value>,
5143 ) -> Result<Self, serde_json::Error> {
5144 Ok(Self {
5145 session_id,
5146 session_metadata: try_session_metadata_from_map(metadata)?,
5147 lifecycle_terminal: try_lifecycle_terminal_from_map(metadata)?,
5148 })
5149 }
5150
5151 pub fn try_from_session(session: &Session) -> Result<Self, serde_json::Error> {
5153 Ok(Self {
5154 session_id: session.id().clone(),
5155 session_metadata: session.try_session_metadata()?,
5156 lifecycle_terminal: session.try_lifecycle_terminal()?,
5157 })
5158 }
5159
5160 pub fn mob_member_binding(&self) -> Option<&crate::MobMemberBinding> {
5163 self.session_metadata.as_ref()?.mob_member_binding.as_ref()
5164 }
5165}
5166
5167#[cfg(test)]
5168#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
5169mod tests {
5170 use super::*;
5171 use crate::realtime_transcript::RealtimeTranscriptRole;
5172 use crate::types::{
5173 AssistantBlock, BlockAssistantMessage, ContentBlock, StopReason, SystemMessage, Usage,
5174 UserMessage,
5175 };
5176 use std::sync::Arc;
5177
5178 fn block_assistant_text(message: &BlockAssistantMessage) -> String {
5179 message
5180 .blocks
5181 .iter()
5182 .filter_map(|block| match block {
5183 AssistantBlock::Text { text, .. } => Some(text.as_str()),
5184 _ => None,
5185 })
5186 .collect()
5187 }
5188
5189 fn append_staged_user_image(
5194 session: &mut Session,
5195 event: &RealtimeTranscriptEvent,
5196 ) -> RealtimeTranscriptApplyOutcome {
5197 let RealtimeTranscriptEvent::UserContentFinal {
5198 idempotency_key,
5199 item_id,
5200 previous_item_id,
5201 content_index,
5202 content,
5203 } = event
5204 else {
5205 panic!("test helper requires user content final")
5206 };
5207 let [ContentBlock::Image { media_type, data }] = content.as_slice() else {
5208 panic!("test helper requires exactly one image")
5209 };
5210 let media_type = crate::image_generation::MediaType::canonical_str(media_type);
5211 let blob_id = match data {
5212 crate::types::ImageData::Inline { data } => {
5213 crate::blob::content_blob_id(&media_type, data)
5214 }
5215 crate::types::ImageData::Blob { blob_id } => blob_id.clone(),
5216 };
5217 let pending = crate::PendingRealtimeUserContentBlob {
5218 idempotency_key: idempotency_key.clone(),
5219 item_id: item_id.clone(),
5220 previous_item_id: previous_item_id.clone(),
5221 content_index: *content_index,
5222 blob_id,
5223 media_type,
5224 };
5225 assert_eq!(
5226 session
5227 .stage_pending_realtime_user_content_blob(pending.clone())
5228 .expect("test pending anchor should stage"),
5229 crate::generated::session_document::RealtimeUserContentBlobStageDisposition::StageNew
5230 );
5231 session.append_realtime_transcript_event(pending.canonical_event())
5232 }
5233
5234 #[test]
5235 fn transcript_digest_is_content_addressed() {
5236 let base_time = crate::types::message_timestamp_now();
5237 let stamped = vec![
5238 Message::User(UserMessage::text("turn one".to_string())),
5239 Message::BlockAssistant(BlockAssistantMessage {
5240 blocks: vec![AssistantBlock::Text {
5241 text: "answer one".to_string(),
5242 meta: None,
5243 }],
5244 stop_reason: StopReason::EndTurn,
5245 identity: crate::types::TranscriptMessageIdentity {
5246 interaction_id: None,
5247 run_id: Some(crate::lifecycle::RunId::new()),
5248 },
5249 created_at: base_time,
5250 }),
5251 ];
5252 let mut restamped = stamped.clone();
5253 for message in &mut restamped {
5254 match message {
5255 Message::User(user) => {
5256 user.created_at = base_time + chrono::Duration::hours(2);
5257 }
5258 Message::BlockAssistant(assistant) => {
5259 assistant.identity = crate::types::TranscriptMessageIdentity {
5260 interaction_id: None,
5261 run_id: Some(crate::lifecycle::RunId::new()),
5262 };
5263 assistant.created_at = base_time + chrono::Duration::hours(2);
5264 }
5265 _ => {}
5266 }
5267 }
5268 assert_eq!(
5269 transcript_messages_digest(&stamped).expect("digest"),
5270 transcript_messages_digest(&restamped).expect("digest"),
5271 "bookkeeping variance must not fork the transcript revision"
5272 );
5273
5274 let mut content_changed = stamped.clone();
5275 if let Message::User(user) = &mut content_changed[0] {
5276 user.content = vec![ContentBlock::Text {
5277 text: "a different turn".to_string(),
5278 }];
5279 }
5280 assert_ne!(
5281 transcript_messages_digest(&stamped).expect("digest"),
5282 transcript_messages_digest(&content_changed).expect("digest"),
5283 "content changes must fork the transcript revision"
5284 );
5285 }
5286
5287 fn legacy_rewrite_fixture() -> (TranscriptRewriteCommit, Vec<Message>, Vec<Message>) {
5288 let parent_messages = vec![
5289 Message::User(UserMessage::text("before rewrite".to_string())),
5290 Message::User(UserMessage::text("retained tail".to_string())),
5291 ];
5292 let revision_messages = vec![
5293 Message::User(UserMessage::text("after rewrite".to_string())),
5294 Message::User(UserMessage::text("retained tail".to_string())),
5295 ];
5296 let parent_revision =
5299 legacy_transcript_messages_digest(&parent_messages).expect("legacy parent digest");
5300 let revision =
5301 legacy_transcript_messages_digest(&revision_messages).expect("legacy revision digest");
5302 let commit = TranscriptRewriteCommit {
5303 parent_revision,
5304 revision,
5305 selection: TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
5306 original_span_digest: legacy_transcript_messages_digest(&parent_messages[0..1])
5307 .expect("legacy span digest"),
5308 replacement_digest: legacy_transcript_messages_digest(&revision_messages[0..1])
5309 .expect("legacy replacement digest"),
5310 messages_before: 2,
5311 messages_after: 2,
5312 reason: TranscriptRewriteReason::new("compaction"),
5313 actor: Some("legacy-test".to_string()),
5314 committed_at: SystemTime::now(),
5315 };
5316 (commit, parent_messages, revision_messages)
5317 }
5318
5319 #[test]
5320 fn legacy_transcript_history_state_heals_to_content_addressed_on_parse() {
5321 let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
5322 let state = TranscriptHistoryState {
5323 head: commit.revision.clone(),
5324 commits: vec![commit.clone()],
5325 revisions: vec![
5326 TranscriptRevisionBody {
5327 revision: commit.parent_revision.clone(),
5328 parent_revision: None,
5329 messages: parent_messages.clone(),
5330 created_at: SystemTime::now(),
5331 },
5332 TranscriptRevisionBody {
5333 revision: commit.revision.clone(),
5334 parent_revision: Some(commit.parent_revision),
5335 messages: revision_messages.clone(),
5336 created_at: SystemTime::now(),
5337 },
5338 ],
5339 };
5340 let value = serde_json::to_value(&state).expect("serialize legacy state");
5341 let healed: TranscriptHistoryState =
5342 serde_json::from_value(value).expect("parse legacy state");
5343
5344 let content_parent =
5345 transcript_messages_digest(&parent_messages).expect("content parent digest");
5346 let content_revision =
5347 transcript_messages_digest(&revision_messages).expect("content revision digest");
5348 assert_eq!(healed.head, content_revision, "head must re-derive");
5349 assert_eq!(healed.commits[0].parent_revision, content_parent);
5350 assert_eq!(healed.commits[0].revision, content_revision);
5351 assert_eq!(healed.revisions[0].revision, content_parent);
5352 assert_eq!(healed.revisions[1].revision, content_revision);
5353 assert_eq!(
5354 healed.revisions[1].parent_revision.as_deref(),
5355 Some(content_parent.as_str())
5356 );
5357 validate_transcript_history_state(&healed).expect("healed graph must validate");
5358
5359 let mut session = Session::new();
5362 session
5363 .apply_transcript_history_state(healed)
5364 .expect("apply healed graph");
5365 session
5366 .commit_transcript_rewrite(
5367 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
5368 vec![Message::User(UserMessage::text(
5369 "rewritten again".to_string(),
5370 ))],
5371 TranscriptRewriteReason::new("unit-test"),
5372 None,
5373 None,
5374 )
5375 .expect("extend healed graph with a new rewrite");
5376 session
5377 .validate_transcript_history_state()
5378 .expect("extended graph must validate");
5379 }
5380
5381 #[test]
5382 fn legacy_transcript_rewrite_record_heals_on_parse() {
5383 let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
5384 let record_value = serde_json::json!({
5385 "commit": commit,
5386 "parent_body": TranscriptRevisionBody {
5387 revision: commit.parent_revision.clone(),
5388 parent_revision: None,
5389 messages: parent_messages,
5390 created_at: SystemTime::now(),
5391 },
5392 "revision_body": TranscriptRevisionBody {
5393 revision: commit.revision.clone(),
5394 parent_revision: Some(commit.parent_revision),
5395 messages: revision_messages.clone(),
5396 created_at: SystemTime::now(),
5397 },
5398 });
5399 let healed: TranscriptRewriteRecord =
5400 serde_json::from_value(record_value).expect("parse legacy record");
5401 assert_eq!(
5402 healed.commit.revision,
5403 transcript_messages_digest(&revision_messages).expect("content digest")
5404 );
5405 TranscriptRewriteRecord::new(healed.commit, healed.parent_body, healed.revision_body)
5407 .expect("healed record must validate");
5408 }
5409
5410 #[test]
5411 fn corrupt_transcript_history_strings_stay_untouched_and_fail_validation() {
5412 let (commit, parent_messages, _revision_messages) = legacy_rewrite_fixture();
5413 let bogus = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
5414 let state = TranscriptHistoryState {
5415 head: bogus.to_string(),
5416 commits: Vec::new(),
5417 revisions: vec![TranscriptRevisionBody {
5418 revision: bogus.to_string(),
5419 parent_revision: None,
5420 messages: parent_messages,
5421 created_at: SystemTime::now(),
5422 }],
5423 };
5424 let _ = commit;
5425 let value = serde_json::to_value(&state).expect("serialize corrupt state");
5426 let parsed: TranscriptHistoryState =
5427 serde_json::from_value(value).expect("corrupt strings still parse");
5428 assert_eq!(
5429 parsed.head, bogus,
5430 "unverifiable strings must not be rewritten"
5431 );
5432 assert!(
5433 validate_transcript_history_state(&parsed).is_err(),
5434 "corrupt graph must keep failing validation"
5435 );
5436 }
5437
5438 #[test]
5442 fn replace_synthetic_notices_leaves_only_replacements_of_kind() {
5443 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
5444
5445 let mut session = Session::new();
5446 session.push(Message::User(UserMessage::text("hello".to_string())));
5447 session.push(Message::SystemNotice(SystemNoticeMessage::new(
5448 SystemNoticeKind::McpPending,
5449 "stale one",
5450 )));
5451 session.push(Message::SystemNotice(SystemNoticeMessage::new(
5452 SystemNoticeKind::McpPending,
5453 "stale two",
5454 )));
5455 session.push(Message::SystemNotice(SystemNoticeMessage::new(
5457 SystemNoticeKind::BackgroundJob,
5458 "other-kind",
5459 )));
5460
5461 session
5462 .replace_synthetic_notices(
5463 SystemNoticeKind::McpPending,
5464 vec![Message::SystemNotice(SystemNoticeMessage::new(
5465 SystemNoticeKind::McpPending,
5466 "fresh",
5467 ))],
5468 )
5469 .expect("notice refresh succeeds");
5470
5471 let mcp_pending: Vec<&SystemNoticeMessage> = session
5472 .messages()
5473 .iter()
5474 .filter_map(|message| match message {
5475 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending => {
5476 Some(notice)
5477 }
5478 _ => None,
5479 })
5480 .collect();
5481 assert_eq!(mcp_pending.len(), 1, "exactly one notice of the kind");
5482 assert_eq!(mcp_pending[0].body.as_deref(), Some("fresh"));
5483 assert!(
5484 session.messages().iter().any(|message| matches!(
5485 message,
5486 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::BackgroundJob
5487 )),
5488 "other-kind notices are untouched"
5489 );
5490
5491 session
5493 .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
5494 .expect("pure strip succeeds");
5495 assert!(
5496 !session.messages().iter().any(|message| matches!(
5497 message,
5498 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending
5499 )),
5500 "empty replacement clears the kind"
5501 );
5502 }
5503
5504 #[test]
5508 fn replace_synthetic_notices_rejects_mismatched_kind_without_mutation() {
5509 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
5510
5511 let mut session = Session::new();
5512 session.push(Message::SystemNotice(SystemNoticeMessage::new(
5513 SystemNoticeKind::McpPending,
5514 "stale",
5515 )));
5516 let before = session.messages().to_vec();
5517
5518 let err = session
5519 .replace_synthetic_notices(
5520 SystemNoticeKind::McpPending,
5521 vec![Message::User(UserMessage::text("not a notice".to_string()))],
5522 )
5523 .expect_err("mismatched replacement must fail typed");
5524 assert!(
5525 matches!(err, TranscriptEditError::InvalidTranscriptShape(_)),
5526 "expected InvalidTranscriptShape, got {err:?}"
5527 );
5528 assert_eq!(
5529 session.messages(),
5530 before.as_slice(),
5531 "fault must leave the transcript unchanged (no partial strip)"
5532 );
5533 }
5534
5535 #[test]
5536 fn transcript_rewrite_preserves_full_assistant_block_trace() {
5537 let mut session = Session::new();
5538 session.push(Message::User(UserMessage::text(
5539 "run the trace".to_string(),
5540 )));
5541 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
5542 vec![AssistantBlock::Text {
5543 text: "original assistant trace".to_string(),
5544 meta: None,
5545 }],
5546 StopReason::EndTurn,
5547 )));
5548
5549 let parent_revision = session.transcript_revision().expect("parent revision");
5550 let replacement = vec![
5551 Message::BlockAssistant(BlockAssistantMessage::new(
5552 vec![
5553 AssistantBlock::Text {
5554 text: "compacted assistant trace".to_string(),
5555 meta: None,
5556 },
5557 AssistantBlock::ToolUse {
5558 id: "toolu_trace".to_string(),
5559 name: "trace_probe".to_string(),
5560 args: serde_json::value::RawValue::from_string(
5561 r#"{"path":"N-3"}"#.to_string(),
5562 )
5563 .expect("valid tool args"),
5564 meta: None,
5565 },
5566 ],
5567 StopReason::ToolUse,
5568 )),
5569 Message::tool_results(vec![ToolResult::new(
5570 "toolu_trace".to_string(),
5571 "trace complete".to_string(),
5572 false,
5573 )]),
5574 ];
5575
5576 let commit = session
5577 .commit_transcript_rewrite(
5578 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5579 replacement,
5580 TranscriptRewriteReason::new("compaction"),
5581 Some("unit-test".to_string()),
5582 Some(parent_revision.clone()),
5583 )
5584 .expect("rewrite should commit");
5585
5586 assert_eq!(commit.parent_revision, parent_revision);
5587 let current = session
5588 .transcript_revision_messages(&commit.revision)
5589 .expect("history state should decode")
5590 .expect("current revision should be retained");
5591 let Message::BlockAssistant(assistant) = ¤t[1] else {
5592 panic!("replacement should remain a block assistant message");
5593 };
5594 assert!(assistant.blocks.iter().any(|block| matches!(
5595 block,
5596 AssistantBlock::ToolUse { name, args, .. }
5597 if name == "trace_probe" && args.get().contains("\"N-3\"")
5598 )));
5599
5600 let parent = session
5601 .transcript_revision_messages(&parent_revision)
5602 .expect("history state should decode")
5603 .expect("parent revision should remain retained");
5604 assert!(matches!(
5605 &parent[1],
5606 Message::BlockAssistant(assistant)
5607 if block_assistant_text(assistant).contains("original assistant trace")
5608 ));
5609 }
5610
5611 #[test]
5612 fn transcript_rewrite_rejects_trailing_block_assistant_tool_call() {
5613 let mut session = Session::new();
5614 session.push(Message::User(UserMessage::text("question".to_string())));
5615 session.push(Message::BlockAssistant(BlockAssistantMessage {
5616 blocks: vec![AssistantBlock::Text {
5617 text: "plain answer".to_string(),
5618 meta: None,
5619 }],
5620 stop_reason: StopReason::EndTurn,
5621 identity: crate::types::TranscriptMessageIdentity::default(),
5622 created_at: crate::types::message_timestamp_now(),
5623 }));
5624 let parent_revision = session.transcript_revision().expect("parent revision");
5625
5626 let err = session
5627 .commit_transcript_rewrite(
5628 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5629 vec![Message::BlockAssistant(BlockAssistantMessage::new(
5630 vec![AssistantBlock::ToolUse {
5631 id: "toolu_1".to_string(),
5632 name: "lookup".to_string(),
5633 args: serde_json::value::RawValue::from_string("{}".to_string())
5634 .expect("valid args"),
5635 meta: None,
5636 }],
5637 StopReason::ToolUse,
5638 ))],
5639 TranscriptRewriteReason::new("compaction"),
5640 Some("unit-test".to_string()),
5641 Some(parent_revision),
5642 )
5643 .expect_err("rewrite should reject trailing unresolved block-assistant tool call");
5644 assert!(matches!(
5645 err,
5646 TranscriptEditError::InvalidTranscriptShape(_)
5647 ));
5648 }
5649
5650 #[test]
5651 fn transcript_rewrite_rejects_no_op_self_edge() {
5652 let mut session = Session::new();
5653 session.push(Message::User(UserMessage::text(
5654 "keep this exact transcript".to_string(),
5655 )));
5656 session.push(Message::BlockAssistant(BlockAssistantMessage {
5657 blocks: vec![AssistantBlock::Text {
5658 text: "unchanged".to_string(),
5659 meta: None,
5660 }],
5661 stop_reason: StopReason::EndTurn,
5662 identity: crate::types::TranscriptMessageIdentity::default(),
5663 created_at: crate::types::message_timestamp_now(),
5664 }));
5665
5666 let parent_revision = session.transcript_revision().expect("parent revision");
5667 let err = session
5668 .commit_transcript_rewrite(
5669 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5670 vec![session.messages()[1].clone()],
5671 TranscriptRewriteReason::new("retry"),
5672 Some("unit-test".to_string()),
5673 Some(parent_revision.clone()),
5674 )
5675 .expect_err("same-content rewrite should not emit a self-edge commit");
5676
5677 assert!(matches!(
5678 err,
5679 TranscriptEditError::NoOpRewrite { revision } if revision == parent_revision
5680 ));
5681 assert!(
5682 session
5683 .transcript_history_state()
5684 .expect("history state should decode")
5685 .is_none()
5686 );
5687 }
5688
5689 #[test]
5690 fn transcript_rewrite_run_boundary_guard_accepts_rewrite_then_append() {
5691 let mut original = Session::new();
5692 original.push(Message::User(UserMessage::text("question".to_string())));
5693 original.push(Message::BlockAssistant(BlockAssistantMessage {
5694 blocks: vec![AssistantBlock::Text {
5695 text: "verbose answer".to_string(),
5696 meta: None,
5697 }],
5698 stop_reason: StopReason::EndTurn,
5699 identity: crate::types::TranscriptMessageIdentity::default(),
5700 created_at: crate::types::message_timestamp_now(),
5701 }));
5702
5703 let parent_revision = original.transcript_revision().expect("parent revision");
5704 let mut incoming = original.clone();
5705 incoming
5706 .commit_transcript_rewrite(
5707 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5708 vec![Message::BlockAssistant(BlockAssistantMessage {
5709 blocks: vec![AssistantBlock::Text {
5710 text: "compact answer".to_string(),
5711 meta: None,
5712 }],
5713 stop_reason: StopReason::EndTurn,
5714 identity: crate::types::TranscriptMessageIdentity::default(),
5715 created_at: crate::types::message_timestamp_now(),
5716 })],
5717 TranscriptRewriteReason::new("compaction"),
5718 Some("unit-test".to_string()),
5719 Some(parent_revision),
5720 )
5721 .expect("rewrite should commit");
5722 incoming.push(Message::User(UserMessage::text("follow-up".to_string())));
5723 incoming.push(Message::BlockAssistant(BlockAssistantMessage {
5724 blocks: vec![AssistantBlock::Text {
5725 text: "follow-up answer".to_string(),
5726 meta: None,
5727 }],
5728 stop_reason: StopReason::EndTurn,
5729 identity: crate::types::TranscriptMessageIdentity::default(),
5730 created_at: crate::types::message_timestamp_now(),
5731 }));
5732
5733 crate::session_store::run_boundary_snapshot_save_guard(&incoming, Some(&original))
5734 .expect("rewrite plus appended turn should be a valid run-boundary commit");
5735 }
5736
5737 #[test]
5738 fn transcript_rewrite_rejects_orphaned_tool_results() {
5739 let mut session = Session::new();
5740 session.push(Message::User(UserMessage::text("use a tool".to_string())));
5741 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
5742 vec![AssistantBlock::ToolUse {
5743 id: "toolu_1".to_string(),
5744 name: "lookup".to_string(),
5745 args: serde_json::value::RawValue::from_string("{}".to_string())
5746 .expect("valid args"),
5747 meta: None,
5748 }],
5749 StopReason::ToolUse,
5750 )));
5751 session.push(Message::tool_results(vec![ToolResult::new(
5752 "toolu_1".to_string(),
5753 "done".to_string(),
5754 false,
5755 )]));
5756 let parent_revision = session.transcript_revision().expect("parent revision");
5757
5758 let err = session
5759 .commit_transcript_rewrite(
5760 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5761 vec![Message::BlockAssistant(BlockAssistantMessage {
5762 blocks: vec![AssistantBlock::Text {
5763 text: "no tool after all".to_string(),
5764 meta: None,
5765 }],
5766 stop_reason: StopReason::EndTurn,
5767 identity: crate::types::TranscriptMessageIdentity::default(),
5768 created_at: crate::types::message_timestamp_now(),
5769 })],
5770 TranscriptRewriteReason::new("compaction"),
5771 Some("unit-test".to_string()),
5772 Some(parent_revision),
5773 )
5774 .expect_err("rewrite should reject stranded tool results");
5775 assert!(matches!(
5776 err,
5777 TranscriptEditError::InvalidTranscriptShape(_)
5778 ));
5779 }
5780
5781 #[test]
5782 fn transcript_rewrite_rejects_trailing_assistant_tool_call() {
5783 let mut session = Session::new();
5784 session.push(Message::User(UserMessage::text("question".to_string())));
5785 session.push(Message::BlockAssistant(BlockAssistantMessage {
5786 blocks: vec![AssistantBlock::Text {
5787 text: "plain answer".to_string(),
5788 meta: None,
5789 }],
5790 stop_reason: StopReason::EndTurn,
5791 identity: crate::types::TranscriptMessageIdentity::default(),
5792 created_at: crate::types::message_timestamp_now(),
5793 }));
5794 let parent_revision = session.transcript_revision().expect("parent revision");
5795
5796 let err = session
5797 .commit_transcript_rewrite(
5798 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5799 vec![Message::BlockAssistant(BlockAssistantMessage {
5800 blocks: vec![AssistantBlock::ToolUse {
5801 id: "toolu_1".to_string(),
5802 name: "lookup".to_string(),
5803 args: serde_json::value::RawValue::from_string("{}".to_string())
5804 .expect("valid args"),
5805 meta: None,
5806 }],
5807 stop_reason: StopReason::ToolUse,
5808 identity: crate::types::TranscriptMessageIdentity::default(),
5809 created_at: crate::types::message_timestamp_now(),
5810 })],
5811 TranscriptRewriteReason::new("compaction"),
5812 Some("unit-test".to_string()),
5813 Some(parent_revision),
5814 )
5815 .expect_err("rewrite should reject trailing unresolved tool call");
5816 assert!(matches!(
5817 err,
5818 TranscriptEditError::InvalidTranscriptShape(_)
5819 ));
5820 }
5821
5822 #[test]
5823 fn transcript_rewrite_rejects_duplicate_tool_results() {
5824 let mut session = Session::new();
5825 session.push(Message::User(UserMessage::text("use a tool".to_string())));
5826 session.push(Message::BlockAssistant(BlockAssistantMessage {
5827 blocks: vec![AssistantBlock::Text {
5828 text: "plain answer".to_string(),
5829 meta: None,
5830 }],
5831 stop_reason: StopReason::EndTurn,
5832 identity: crate::types::TranscriptMessageIdentity::default(),
5833 created_at: crate::types::message_timestamp_now(),
5834 }));
5835 let parent_revision = session.transcript_revision().expect("parent revision");
5836
5837 let err = session
5838 .commit_transcript_rewrite(
5839 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5840 vec![
5841 Message::BlockAssistant(BlockAssistantMessage::new(
5842 vec![AssistantBlock::ToolUse {
5843 id: "toolu_1".to_string(),
5844 name: "lookup".to_string(),
5845 args: serde_json::value::RawValue::from_string("{}".to_string())
5846 .expect("valid args"),
5847 meta: None,
5848 }],
5849 StopReason::ToolUse,
5850 )),
5851 Message::tool_results(vec![
5852 ToolResult::new("toolu_1".to_string(), "one".to_string(), false),
5853 ToolResult::new("toolu_1".to_string(), "two".to_string(), false),
5854 ]),
5855 ],
5856 TranscriptRewriteReason::new("compaction"),
5857 Some("unit-test".to_string()),
5858 Some(parent_revision),
5859 )
5860 .expect_err("rewrite should reject duplicate tool results");
5861 assert!(matches!(
5862 err,
5863 TranscriptEditError::InvalidTranscriptShape(_)
5864 ));
5865 }
5866
5867 #[test]
5868 fn transcript_rewrite_record_rejects_prefix_or_suffix_tampering() {
5869 let mut session = Session::new();
5870 session.push(Message::System(SystemMessage::new("keep prefix")));
5871 session.push(Message::BlockAssistant(BlockAssistantMessage {
5872 blocks: vec![AssistantBlock::Text {
5873 text: "verbose answer".to_string(),
5874 meta: None,
5875 }],
5876 stop_reason: StopReason::EndTurn,
5877 identity: crate::types::TranscriptMessageIdentity::default(),
5878 created_at: crate::types::message_timestamp_now(),
5879 }));
5880 session.push(Message::User(UserMessage::text("keep suffix".to_string())));
5881
5882 let parent_revision = session.transcript_revision().expect("parent revision");
5883 let commit = session
5884 .commit_transcript_rewrite(
5885 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5886 vec![Message::BlockAssistant(BlockAssistantMessage {
5887 blocks: vec![AssistantBlock::Text {
5888 text: "compact answer".to_string(),
5889 meta: None,
5890 }],
5891 stop_reason: StopReason::EndTurn,
5892 identity: crate::types::TranscriptMessageIdentity::default(),
5893 created_at: crate::types::message_timestamp_now(),
5894 })],
5895 TranscriptRewriteReason::new("compaction"),
5896 Some("unit-test".to_string()),
5897 Some(parent_revision),
5898 )
5899 .expect("rewrite should commit");
5900 let state = session
5901 .transcript_history_state()
5902 .expect("history state should decode")
5903 .expect("history state should exist");
5904 let parent_body = state
5905 .revisions
5906 .iter()
5907 .find(|body| body.revision == commit.parent_revision)
5908 .expect("parent body retained")
5909 .clone();
5910 let revision_body = state
5911 .revisions
5912 .iter()
5913 .find(|body| body.revision == commit.revision)
5914 .expect("revision body retained")
5915 .clone();
5916
5917 let mut forged_body = revision_body;
5918 forged_body.messages[0] = Message::System(SystemMessage::new("tampered prefix"));
5919 forged_body.revision =
5920 transcript_messages_digest(&forged_body.messages).expect("forged digest");
5921 let mut forged_commit = commit;
5922 forged_commit.revision = forged_body.revision.clone();
5923 let err = TranscriptRewriteRecord::new(forged_commit, parent_body, forged_body)
5924 .expect_err("record validation must reject changes outside selected span");
5925 assert!(
5926 err.to_string().contains("before the selected span"),
5927 "unexpected error: {err}"
5928 );
5929 }
5930
5931 #[test]
5932 fn transcript_rewrite_replay_allows_normal_turn_revisions_between_rewrites() {
5933 let mut session = Session::new();
5934 session.push(Message::User(UserMessage::text("first".to_string())));
5935 session.push(Message::BlockAssistant(BlockAssistantMessage {
5936 blocks: vec![AssistantBlock::Text {
5937 text: "verbose first answer".to_string(),
5938 meta: None,
5939 }],
5940 stop_reason: StopReason::EndTurn,
5941 identity: crate::types::TranscriptMessageIdentity::default(),
5942 created_at: crate::types::message_timestamp_now(),
5943 }));
5944
5945 let first_parent = session.transcript_revision().expect("first parent");
5946 let first_commit = session
5947 .commit_transcript_rewrite(
5948 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5949 vec![Message::BlockAssistant(BlockAssistantMessage {
5950 blocks: vec![AssistantBlock::Text {
5951 text: "compact first answer".to_string(),
5952 meta: None,
5953 }],
5954 stop_reason: StopReason::EndTurn,
5955 identity: crate::types::TranscriptMessageIdentity::default(),
5956 created_at: crate::types::message_timestamp_now(),
5957 })],
5958 TranscriptRewriteReason::new("compaction"),
5959 Some("unit-test".to_string()),
5960 Some(first_parent),
5961 )
5962 .expect("first rewrite");
5963
5964 session.push(Message::User(UserMessage::text("normal turn".to_string())));
5965 session.push(Message::BlockAssistant(BlockAssistantMessage {
5966 blocks: vec![AssistantBlock::Text {
5967 text: "verbose second answer".to_string(),
5968 meta: None,
5969 }],
5970 stop_reason: StopReason::EndTurn,
5971 identity: crate::types::TranscriptMessageIdentity::default(),
5972 created_at: crate::types::message_timestamp_now(),
5973 }));
5974 let bridge_parent = session
5975 .transcript_revision()
5976 .expect("normal turn should advance transcript head");
5977 assert_ne!(bridge_parent, first_commit.revision);
5978 validate_transcript_history_state(
5979 &session
5980 .transcript_history_state()
5981 .expect("history state should decode")
5982 .expect("history state should exist"),
5983 )
5984 .expect("normal turn head may legitimately differ from last rewrite commit");
5985
5986 let second_commit = session
5987 .commit_transcript_rewrite(
5988 TranscriptRewriteSelection::MessageRange { start: 3, end: 4 },
5989 vec![Message::BlockAssistant(BlockAssistantMessage {
5990 blocks: vec![AssistantBlock::Text {
5991 text: "compact second answer".to_string(),
5992 meta: None,
5993 }],
5994 stop_reason: StopReason::EndTurn,
5995 identity: crate::types::TranscriptMessageIdentity::default(),
5996 created_at: crate::types::message_timestamp_now(),
5997 })],
5998 TranscriptRewriteReason::new("compaction"),
5999 Some("unit-test".to_string()),
6000 Some(bridge_parent.clone()),
6001 )
6002 .expect("second rewrite");
6003
6004 let state = session
6005 .transcript_history_state()
6006 .expect("history state should decode")
6007 .expect("history state should exist");
6008 let records = state.commits.iter().map(|commit| {
6009 let parent_body = state
6010 .revisions
6011 .iter()
6012 .find(|body| body.revision == commit.parent_revision)
6013 .expect("parent body retained")
6014 .clone();
6015 let revision_body = state
6016 .revisions
6017 .iter()
6018 .find(|body| body.revision == commit.revision)
6019 .expect("revision body retained")
6020 .clone();
6021 TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
6022 .expect("record should validate")
6023 });
6024
6025 let replayed = TranscriptHistoryState::from_rewrite_records(records)
6026 .expect("rewrite replay should accept normal-turn bridge revisions")
6027 .expect("rewrite records should exist");
6028 assert_eq!(replayed.head, second_commit.revision);
6029 assert!(
6030 replayed
6031 .revisions
6032 .iter()
6033 .any(|body| body.revision == bridge_parent)
6034 );
6035 }
6036
6037 #[test]
6038 fn transcript_rewrite_replay_rejects_branched_rewrite_records() {
6039 let mut base = Session::new();
6040 base.push(Message::User(UserMessage::text("question".to_string())));
6041 base.push(Message::BlockAssistant(BlockAssistantMessage {
6042 blocks: vec![AssistantBlock::Text {
6043 text: "verbose answer".to_string(),
6044 meta: None,
6045 }],
6046 stop_reason: StopReason::EndTurn,
6047 identity: crate::types::TranscriptMessageIdentity::default(),
6048 created_at: crate::types::message_timestamp_now(),
6049 }));
6050 let parent = base.transcript_revision().expect("parent revision");
6051
6052 let mut first = base.clone();
6053 let first_commit = first
6054 .commit_transcript_rewrite(
6055 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6056 vec![Message::BlockAssistant(BlockAssistantMessage {
6057 blocks: vec![AssistantBlock::Text {
6058 text: "first compact answer".to_string(),
6059 meta: None,
6060 }],
6061 stop_reason: StopReason::EndTurn,
6062 identity: crate::types::TranscriptMessageIdentity::default(),
6063 created_at: crate::types::message_timestamp_now(),
6064 })],
6065 TranscriptRewriteReason::new("compaction"),
6066 Some("unit-test".to_string()),
6067 Some(parent.clone()),
6068 )
6069 .expect("first rewrite");
6070 let first_state = first
6071 .transcript_history_state()
6072 .expect("first state decodes")
6073 .expect("first state exists");
6074
6075 let mut second = base;
6076 let second_commit = second
6077 .commit_transcript_rewrite(
6078 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6079 vec![Message::BlockAssistant(BlockAssistantMessage {
6080 blocks: vec![AssistantBlock::Text {
6081 text: "second compact answer".to_string(),
6082 meta: None,
6083 }],
6084 stop_reason: StopReason::EndTurn,
6085 identity: crate::types::TranscriptMessageIdentity::default(),
6086 created_at: crate::types::message_timestamp_now(),
6087 })],
6088 TranscriptRewriteReason::new("compaction"),
6089 Some("unit-test".to_string()),
6090 Some(parent),
6091 )
6092 .expect("second rewrite");
6093 let second_state = second
6094 .transcript_history_state()
6095 .expect("second state decodes")
6096 .expect("second state exists");
6097
6098 let record = |state: &TranscriptHistoryState, commit: &TranscriptRewriteCommit| {
6099 let parent_body = state
6100 .revisions
6101 .iter()
6102 .find(|body| body.revision == commit.parent_revision)
6103 .expect("parent body retained")
6104 .clone();
6105 let revision_body = state
6106 .revisions
6107 .iter()
6108 .find(|body| body.revision == commit.revision)
6109 .expect("revision body retained")
6110 .clone();
6111 TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
6112 .expect("record should validate")
6113 };
6114
6115 let err = TranscriptHistoryState::from_rewrite_records(vec![
6116 record(&first_state, &first_commit),
6117 record(&second_state, &second_commit),
6118 ])
6119 .expect_err("branched rewrite records must not replay as a linear source history");
6120 assert!(
6121 err.to_string().contains("does not extend transcript head"),
6122 "unexpected error: {err}"
6123 );
6124 }
6125
6126 #[test]
6127 fn internal_message_rewrites_refresh_transcript_history_head() {
6128 let mut session = Session::new();
6129 session.push(Message::User(UserMessage::text("question".to_string())));
6130 session.push(Message::BlockAssistant(BlockAssistantMessage {
6131 blocks: vec![AssistantBlock::Text {
6132 text: "verbose answer".to_string(),
6133 meta: None,
6134 }],
6135 stop_reason: StopReason::EndTurn,
6136 identity: crate::types::TranscriptMessageIdentity::default(),
6137 created_at: crate::types::message_timestamp_now(),
6138 }));
6139
6140 let parent = session.transcript_revision().expect("parent revision");
6141 session
6142 .commit_transcript_rewrite(
6143 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6144 vec![Message::BlockAssistant(BlockAssistantMessage {
6145 blocks: vec![AssistantBlock::Text {
6146 text: "compact answer".to_string(),
6147 meta: None,
6148 }],
6149 stop_reason: StopReason::EndTurn,
6150 identity: crate::types::TranscriptMessageIdentity::default(),
6151 created_at: crate::types::message_timestamp_now(),
6152 })],
6153 TranscriptRewriteReason::new("compaction"),
6154 Some("unit-test".to_string()),
6155 Some(parent),
6156 )
6157 .expect("rewrite should commit");
6158
6159 session.push(Message::User(UserMessage::text(
6160 "notice-bearing turn".to_string(),
6161 )));
6162 session
6163 .retain_messages_internal(
6164 |message| {
6165 !matches!(
6166 message,
6167 Message::User(user)
6168 if user.content.iter().any(|block| matches!(
6169 block,
6170 ContentBlock::Text { text } if text.contains("notice-bearing")
6171 ))
6172 )
6173 },
6174 TranscriptRewriteReason::new("synthetic_notice_cleanup"),
6175 )
6176 .expect("retain should commit internal rewrite");
6177 let retained_digest =
6178 transcript_messages_digest(session.messages()).expect("retained digest");
6179 assert_eq!(
6180 session.transcript_revision().expect("retained head"),
6181 retained_digest
6182 );
6183
6184 session
6185 .replace_messages_internal(
6186 vec![
6187 Message::User(UserMessage::text("compacted question".to_string())),
6188 Message::BlockAssistant(BlockAssistantMessage {
6189 blocks: vec![AssistantBlock::Text {
6190 text: "compacted answer".to_string(),
6191 meta: None,
6192 }],
6193 stop_reason: StopReason::EndTurn,
6194 identity: crate::types::TranscriptMessageIdentity::default(),
6195 created_at: crate::types::message_timestamp_now(),
6196 }),
6197 ],
6198 TranscriptRewriteReason::new("compaction"),
6199 )
6200 .expect("replace should commit internal rewrite");
6201 let replaced_digest =
6202 transcript_messages_digest(session.messages()).expect("replaced digest");
6203 assert_eq!(
6204 session.transcript_revision().expect("replaced head"),
6205 replaced_digest
6206 );
6207 let state = session
6208 .transcript_history_state()
6209 .expect("history state should decode")
6210 .expect("history state should exist");
6211 assert!(
6212 state
6213 .revisions
6214 .iter()
6215 .any(|body| body.revision == replaced_digest)
6216 );
6217 validate_transcript_history_state(&state).expect("history state remains valid");
6218 }
6219
6220 #[test]
6221 fn set_system_prompt_refreshes_transcript_history_head_after_rewrite() {
6222 let mut session = Session::new();
6223 session.push(Message::User(UserMessage::text("question".to_string())));
6224 session.push(Message::BlockAssistant(BlockAssistantMessage {
6225 blocks: vec![AssistantBlock::Text {
6226 text: "verbose answer".to_string(),
6227 meta: None,
6228 }],
6229 stop_reason: StopReason::EndTurn,
6230 identity: crate::types::TranscriptMessageIdentity::default(),
6231 created_at: crate::types::message_timestamp_now(),
6232 }));
6233
6234 let parent = session.transcript_revision().expect("parent revision");
6235 let rewrite = session
6236 .commit_transcript_rewrite(
6237 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6238 vec![Message::BlockAssistant(BlockAssistantMessage {
6239 blocks: vec![AssistantBlock::Text {
6240 text: "compact answer".to_string(),
6241 meta: None,
6242 }],
6243 stop_reason: StopReason::EndTurn,
6244 identity: crate::types::TranscriptMessageIdentity::default(),
6245 created_at: crate::types::message_timestamp_now(),
6246 })],
6247 TranscriptRewriteReason::new("compaction"),
6248 Some("unit-test".to_string()),
6249 Some(parent),
6250 )
6251 .expect("rewrite should commit");
6252
6253 session.set_system_prompt("durable system prompt".to_string());
6254
6255 let head = session
6256 .transcript_revision()
6257 .expect("system prompt should refresh transcript head");
6258 assert_ne!(head, rewrite.revision);
6259 assert_eq!(
6260 head,
6261 transcript_messages_digest(session.messages()).expect("current digest")
6262 );
6263 let head_messages = session
6264 .transcript_revision_messages(&head)
6265 .expect("history state should decode")
6266 .expect("refreshed head body should be retained");
6267 assert_eq!(
6268 serde_json::to_value(&head_messages).expect("head serializes"),
6269 serde_json::to_value(session.messages()).expect("session serializes")
6270 );
6271 validate_transcript_history_state(
6272 &session
6273 .transcript_history_state()
6274 .expect("history state should decode")
6275 .expect("history state should exist"),
6276 )
6277 .expect("history state remains valid after system prompt update");
6278 }
6279
6280 #[test]
6281 fn apply_transcript_history_state_uses_latest_commit_time_for_restored_head() {
6282 let mut session = Session::new();
6283 session.push(Message::User(UserMessage::text("question".to_string())));
6284 session.push(Message::BlockAssistant(BlockAssistantMessage {
6285 blocks: vec![AssistantBlock::Text {
6286 text: "verbose answer".to_string(),
6287 meta: None,
6288 }],
6289 stop_reason: StopReason::EndTurn,
6290 identity: crate::types::TranscriptMessageIdentity::default(),
6291 created_at: crate::types::message_timestamp_now(),
6292 }));
6293 let original_messages = session.messages().to_vec();
6294 let parent = session.transcript_revision().expect("parent revision");
6295 let compact = session
6296 .commit_transcript_rewrite(
6297 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6298 vec![Message::BlockAssistant(BlockAssistantMessage {
6299 blocks: vec![AssistantBlock::Text {
6300 text: "compact answer".to_string(),
6301 meta: None,
6302 }],
6303 stop_reason: StopReason::EndTurn,
6304 identity: crate::types::TranscriptMessageIdentity::default(),
6305 created_at: crate::types::message_timestamp_now(),
6306 })],
6307 TranscriptRewriteReason::new("compaction"),
6308 Some("unit-test".to_string()),
6309 Some(parent.clone()),
6310 )
6311 .expect("rewrite should commit");
6312
6313 std::thread::sleep(std::time::Duration::from_millis(2));
6314 let restore = session
6315 .commit_transcript_rewrite(
6316 TranscriptRewriteSelection::MessageRange {
6317 start: 0,
6318 end: session.messages().len(),
6319 },
6320 original_messages.clone(),
6321 TranscriptRewriteReason::new("restore"),
6322 Some("unit-test".to_string()),
6323 Some(compact.revision),
6324 )
6325 .expect("restore should commit");
6326 assert_eq!(restore.revision, parent);
6327
6328 let state = session
6329 .transcript_history_state()
6330 .expect("history state should decode")
6331 .expect("history state should exist");
6332 let restored_body_created_at = state
6333 .revisions
6334 .iter()
6335 .find(|body| body.revision == restore.revision)
6336 .expect("restored body should be retained")
6337 .created_at;
6338 assert!(
6339 restored_body_created_at < restore.committed_at,
6340 "test requires restore commit to be newer than retained body"
6341 );
6342
6343 let mut replayed = Session::new();
6344 replayed
6345 .apply_transcript_history_state(state)
6346 .expect("replay should materialize restored head");
6347 assert_eq!(
6348 serde_json::to_value(replayed.messages()).expect("replayed serializes"),
6349 serde_json::to_value(&original_messages).expect("original serializes")
6350 );
6351 assert_eq!(replayed.updated_at(), restore.committed_at);
6352 }
6353
6354 #[test]
6355 fn test_session_new() {
6356 let session = Session::new();
6357 assert_eq!(session.version(), SESSION_VERSION);
6358 assert!(session.messages().is_empty());
6359 assert!(session.created_at() <= session.updated_at());
6360 }
6361
6362 #[test]
6363 fn llm_identity_model_override_switches_to_catalog_provider() {
6364 let registry = crate::ModelRegistry::from_config(
6365 &crate::Config::default(),
6366 *crate::model_profile::test_catalog::TEST_CATALOG,
6367 )
6368 .unwrap();
6369 let current = SessionLlmIdentity {
6370 model: "test-anthropic-default".to_string(),
6371 provider: Provider::Anthropic,
6372 self_hosted_server_id: None,
6373 provider_params: None,
6374 auth_binding: Some(crate::AuthBindingRef {
6375 realm: crate::RealmId::parse("tenant_a").unwrap(),
6376 binding: crate::BindingId::parse("anthropic_default").unwrap(),
6377 profile: None,
6378 origin: crate::BindingOrigin::Configured,
6379 }),
6380 };
6381
6382 let resolved = resolve_session_llm_identity_override(
6383 ¤t,
6384 ®istry,
6385 SessionLlmIdentityOverride {
6386 model: Some("test-openai-default"),
6387 provider: None,
6388 provider_params: None,
6389 auth_binding: None,
6390 },
6391 )
6392 .unwrap();
6393
6394 assert_eq!(resolved.model, "test-openai-default");
6395 assert_eq!(resolved.provider, Provider::OpenAI);
6396 assert!(
6397 resolved.auth_binding.is_none(),
6398 "provider switches must not inherit a binding from the previous provider"
6399 );
6400 }
6401
6402 #[test]
6403 fn llm_identity_model_override_keeps_uncatalogued_model_on_current_provider() {
6404 let registry = crate::ModelRegistry::from_config(
6405 &crate::Config::default(),
6406 *crate::model_profile::test_catalog::TEST_CATALOG,
6407 )
6408 .unwrap();
6409 let current = SessionLlmIdentity {
6410 model: "custom-model".to_string(),
6411 provider: Provider::Anthropic,
6412 self_hosted_server_id: None,
6413 provider_params: None,
6414 auth_binding: None,
6415 };
6416
6417 let resolved = resolve_session_llm_identity_override(
6418 ¤t,
6419 ®istry,
6420 SessionLlmIdentityOverride {
6421 model: Some("uncatalogued-custom-model"),
6422 provider: None,
6423 provider_params: None,
6424 auth_binding: None,
6425 },
6426 )
6427 .unwrap();
6428
6429 assert_eq!(resolved.model, "uncatalogued-custom-model");
6430 assert_eq!(resolved.provider, Provider::Anthropic);
6431 }
6432
6433 #[test]
6434 fn realtime_transcript_append_is_idempotent_by_provider_item_and_delta_id() {
6435 let mut session = Session::new();
6436
6437 let user = RealtimeTranscriptEvent::UserTranscriptFinal {
6438 item_id: "item_user".to_string(),
6439 previous_item_id: None,
6440 content_index: 0,
6441 text: "hello".to_string(),
6442 };
6443 assert!(
6444 !session
6445 .append_realtime_transcript_event(user.clone())
6446 .is_inert()
6447 );
6448 assert!(session.append_realtime_transcript_event(user).is_inert());
6449
6450 let delta = RealtimeTranscriptEvent::AssistantTextDelta {
6451 response_id: "resp_assistant".to_string(),
6452 delta_id: "evt_delta_1".to_string(),
6453 item_id: "item_assistant".to_string(),
6454 previous_item_id: Some("item_user".to_string()),
6455 content_index: 0,
6456 delta: "hi".to_string(),
6457 };
6458 assert!(
6459 session
6460 .append_realtime_transcript_event(delta.clone())
6461 .is_inert()
6462 );
6463 assert!(session.append_realtime_transcript_event(delta).is_inert());
6464
6465 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
6466 response_id: "resp_assistant".to_string(),
6467 stop_reason: StopReason::EndTurn,
6468 usage: Usage::default(),
6469 };
6470 assert!(
6471 !session
6472 .append_realtime_transcript_event(terminal.clone())
6473 .is_inert()
6474 );
6475 assert!(
6476 session
6477 .append_realtime_transcript_event(terminal)
6478 .is_inert()
6479 );
6480
6481 assert_eq!(session.messages().len(), 2);
6482 assert!(matches!(
6483 &session.messages()[0],
6484 Message::User(user) if user.text_content() == "hello"
6485 ));
6486 assert!(matches!(
6487 &session.messages()[1],
6488 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "hi"
6489 ));
6490 }
6491
6492 #[test]
6493 fn realtime_user_image_materializes_once_and_unblocks_causal_assistant() {
6494 let mut session = Session::new();
6495 let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
6496 let image = RealtimeTranscriptEvent::UserContentFinal {
6497 idempotency_key: "image-request-1".to_string(),
6498 item_id: "item_image".to_string(),
6499 previous_item_id: None,
6500 content_index: 0,
6501 content: vec![ContentBlock::Image {
6502 media_type: "image/png".to_string(),
6503 data: crate::types::ImageData::Inline {
6504 data: image_data.clone(),
6505 },
6506 }],
6507 };
6508
6509 assert!(
6510 !append_staged_user_image(&mut session, &image).is_inert(),
6511 "first image final must materialize canonical user content"
6512 );
6513 let replay = session
6514 .preflight_realtime_user_content_event(&image)
6515 .expect("exact retry should preflight as committed");
6516 assert!(matches!(
6517 replay,
6518 crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(_)
6519 ));
6520
6521 let staged_state = session
6522 .metadata
6523 .get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY)
6524 .expect("realtime state must be persisted");
6525 assert!(
6526 !staged_state.to_string().contains(&image_data),
6527 "materialized image bytes must not remain duplicated in transcript metadata"
6528 );
6529
6530 assert!(
6531 session
6532 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6533 response_id: "resp_image".to_string(),
6534 delta_id: "delta_image".to_string(),
6535 item_id: "item_assistant".to_string(),
6536 previous_item_id: Some("item_image".to_string()),
6537 content_index: 0,
6538 delta: "I see red.".to_string(),
6539 })
6540 .is_inert()
6541 );
6542 assert!(
6543 !session
6544 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
6545 response_id: "resp_image".to_string(),
6546 stop_reason: StopReason::EndTurn,
6547 usage: Usage::default(),
6548 },)
6549 .is_inert(),
6550 "materialized image predecessor must unblock the assistant response"
6551 );
6552
6553 assert_eq!(session.messages().len(), 2);
6554 assert!(matches!(
6555 &session.messages()[0],
6556 Message::User(user)
6557 if matches!(
6558 user.content.as_slice(),
6559 [ContentBlock::Image {
6560 media_type,
6561 data: crate::types::ImageData::Blob { blob_id },
6562 }] if media_type == "image/png"
6563 && blob_id == &crate::blob::content_blob_id("image/png", &image_data)
6564 )
6565 ));
6566 assert!(matches!(
6567 &session.messages()[1],
6568 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "I see red."
6569 ));
6570 }
6571
6572 #[test]
6573 fn realtime_user_image_identity_is_durable_canonical_and_conflict_safe() {
6574 let mut session = Session::new();
6575 let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
6576 let initial = RealtimeTranscriptEvent::UserContentFinal {
6577 idempotency_key: "stable-image-key".to_string(),
6578 item_id: "canonical-image-item".to_string(),
6579 previous_item_id: None,
6580 content_index: 0,
6581 content: vec![ContentBlock::Image {
6582 media_type: " image/PNG; charset=binary ".to_string(),
6583 data: crate::types::ImageData::Inline { data: data.clone() },
6584 }],
6585 };
6586 let committed = append_staged_user_image(&mut session, &initial);
6587 let Some(crate::RealtimeUserContentApplyOutcome::Committed(identity)) =
6588 committed.user_content
6589 else {
6590 panic!("first image must commit its durable identity");
6591 };
6592 assert_eq!(identity.item_id, "canonical-image-item");
6593 assert_eq!(identity.media_type, "image/png");
6594
6595 let encoded = serde_json::to_string(&session).expect("session should serialize");
6596 let restored: Session =
6597 serde_json::from_str(&encoded).expect("committed identity should restore");
6598
6599 let replay_event = RealtimeTranscriptEvent::UserContentFinal {
6600 idempotency_key: "stable-image-key".to_string(),
6601 item_id: "ignored-retry-item".to_string(),
6602 previous_item_id: None,
6603 content_index: 0,
6604 content: vec![ContentBlock::Image {
6605 media_type: "image/png".to_string(),
6606 data: crate::types::ImageData::Inline { data: data.clone() },
6607 }],
6608 };
6609 let replay = restored
6610 .preflight_realtime_user_content_event(&replay_event)
6611 .expect("exact retry should preflight");
6612 assert!(matches!(
6613 replay,
6614 crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(
6615 crate::RealtimeUserContentIdentity { ref item_id, .. }
6616 ) if item_id == "canonical-image-item"
6617 ));
6618
6619 let conflict = restored
6620 .preflight_realtime_user_content_event(&RealtimeTranscriptEvent::UserContentFinal {
6621 idempotency_key: "stable-image-key".to_string(),
6622 item_id: "conflicting-item".to_string(),
6623 previous_item_id: None,
6624 content_index: 0,
6625 content: vec![ContentBlock::Image {
6626 media_type: "image/png".to_string(),
6627 data: crate::types::ImageData::Inline {
6628 data: "different-payload".to_string(),
6629 },
6630 }],
6631 })
6632 .expect("conflicting retry should preflight");
6633 assert!(matches!(
6634 conflict,
6635 crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. }
6636 ));
6637
6638 let item_collision = restored
6639 .preflight_realtime_user_content_event(&RealtimeTranscriptEvent::UserContentFinal {
6640 idempotency_key: "another-key".to_string(),
6641 item_id: "canonical-image-item".to_string(),
6642 previous_item_id: None,
6643 content_index: 0,
6644 content: vec![ContentBlock::Image {
6645 media_type: "image/png".to_string(),
6646 data: crate::types::ImageData::Inline { data },
6647 }],
6648 })
6649 .expect("item collision should preflight");
6650 assert!(matches!(
6651 item_collision,
6652 crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. }
6653 ));
6654 assert_eq!(restored.messages().len(), 1);
6655 serde_json::to_string(&restored).expect("rejections must not corrupt durable state");
6656 }
6657
6658 #[test]
6659 fn realtime_user_image_reducer_never_receipts_without_pending_blob_proof() {
6660 for data in [
6661 crate::types::ImageData::Inline {
6662 data: "iVBORw0KGgo=".to_string(),
6663 },
6664 crate::types::ImageData::Blob {
6665 blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgo="),
6666 },
6667 ] {
6668 let mut session = Session::new();
6669 let outcome = session.append_realtime_transcript_event(
6670 RealtimeTranscriptEvent::UserContentFinal {
6671 idempotency_key: "unstaged-image-key".to_string(),
6672 item_id: "unstaged-image-item".to_string(),
6673 previous_item_id: None,
6674 content_index: 0,
6675 content: vec![ContentBlock::Image {
6676 media_type: "image/png".to_string(),
6677 data,
6678 }],
6679 },
6680 );
6681 assert!(matches!(
6682 outcome.user_content,
6683 Some(crate::RealtimeUserContentApplyOutcome::RejectedInvalidIdentity { .. })
6684 ));
6685 assert!(session.messages().is_empty());
6686 assert!(session.realtime_user_content_identities().is_empty());
6687 }
6688 }
6689
6690 #[test]
6691 fn realtime_user_image_pending_slot_is_generated_bounded_and_recovery_typed() {
6692 use crate::generated::session_document::{
6693 RealtimeUserContentBlobRecoveryDisposition, RealtimeUserContentBlobStageDisposition,
6694 };
6695 let mut session = Session::new();
6696 let pending = crate::PendingRealtimeUserContentBlob {
6697 idempotency_key: "pending-key-a".to_string(),
6698 item_id: "pending-item-a".to_string(),
6699 previous_item_id: None,
6700 content_index: 0,
6701 blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgo="),
6702 media_type: "image/png".to_string(),
6703 };
6704 let different = crate::PendingRealtimeUserContentBlob {
6705 idempotency_key: "pending-key-b".to_string(),
6706 item_id: "pending-item-b".to_string(),
6707 previous_item_id: None,
6708 content_index: 0,
6709 blob_id: crate::blob::content_blob_id("image/png", "iVBORw0KGgoB"),
6710 media_type: "image/png".to_string(),
6711 };
6712 assert_eq!(
6713 session
6714 .stage_pending_realtime_user_content_blob(pending.clone())
6715 .expect("empty slot stages"),
6716 RealtimeUserContentBlobStageDisposition::StageNew
6717 );
6718 assert_eq!(
6719 session
6720 .stage_pending_realtime_user_content_blob(pending.clone())
6721 .expect("exact stage retry is idempotent"),
6722 RealtimeUserContentBlobStageDisposition::ReuseExact
6723 );
6724 assert_eq!(
6725 session
6726 .stage_pending_realtime_user_content_blob(different.clone())
6727 .expect("occupied decision is typed"),
6728 RealtimeUserContentBlobStageDisposition::RejectOccupied
6729 );
6730 assert_eq!(
6731 session.pending_realtime_user_content_blob(),
6732 Some(pending.clone())
6733 );
6734 assert_eq!(
6735 session
6736 .resolve_pending_realtime_user_content_blob_recovery(Some(&pending), false)
6737 .expect("exact recovery decision"),
6738 RealtimeUserContentBlobRecoveryDisposition::RetryExact
6739 );
6740 assert_eq!(
6741 session
6742 .resolve_pending_realtime_user_content_blob_recovery(Some(&different), true)
6743 .expect("verified older recovery decision"),
6744 RealtimeUserContentBlobRecoveryDisposition::CommitVerifiedBeforeCurrent
6745 );
6746 assert_eq!(
6747 session
6748 .resolve_pending_realtime_user_content_blob_recovery(Some(&different), false)
6749 .expect("invalid older recovery decision"),
6750 RealtimeUserContentBlobRecoveryDisposition::ClearInvalidBeforeCurrent
6751 );
6752 session
6753 .clear_invalid_pending_realtime_user_content_blob(Some(&different))
6754 .expect("generated clear-invalid disposition authorizes clear");
6755 assert!(session.pending_realtime_user_content_blob().is_none());
6756 }
6757
6758 #[test]
6759 fn transcript_rewrite_tombstones_removed_image_key_and_accepts_new_key() {
6760 let mut session = Session::new();
6761 let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
6762 let original = RealtimeTranscriptEvent::UserContentFinal {
6763 idempotency_key: "removed-image-key".to_string(),
6764 item_id: "removed-image-item".to_string(),
6765 previous_item_id: None,
6766 content_index: 0,
6767 content: vec![ContentBlock::Image {
6768 media_type: "image/png".to_string(),
6769 data: crate::types::ImageData::Inline { data: data.clone() },
6770 }],
6771 };
6772 assert!(matches!(
6773 append_staged_user_image(&mut session, &original).user_content,
6774 Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
6775 ));
6776
6777 let parent = session.transcript_revision().expect("parent revision");
6778 session
6779 .commit_transcript_rewrite(
6780 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
6781 vec![Message::User(UserMessage::text("image removed"))],
6782 TranscriptRewriteReason::new("remove-image"),
6783 None,
6784 Some(parent),
6785 )
6786 .expect("rewrite should tombstone removed image identity");
6787
6788 assert!(session.realtime_user_content_identities().is_empty());
6789 assert_eq!(
6790 session.realtime_user_content_tombstones(),
6791 vec![crate::RealtimeUserContentTombstone {
6792 idempotency_key: "removed-image-key".to_string(),
6793 }]
6794 );
6795 assert!(matches!(
6796 session.preflight_realtime_user_content_event(&original),
6797 Some(crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. })
6798 ));
6799 assert!(matches!(
6800 session
6801 .append_realtime_transcript_event(original)
6802 .user_content,
6803 Some(crate::RealtimeUserContentApplyOutcome::RejectedConflict { .. })
6804 ));
6805 assert_eq!(
6806 session.messages().len(),
6807 1,
6808 "stale retry emits no receipt content"
6809 );
6810
6811 let new_image = RealtimeTranscriptEvent::UserContentFinal {
6812 idempotency_key: "new-image-key".to_string(),
6813 item_id: "new-image-item".to_string(),
6814 previous_item_id: None,
6815 content_index: 0,
6816 content: vec![ContentBlock::Image {
6817 media_type: "image/png".to_string(),
6818 data: crate::types::ImageData::Inline { data },
6819 }],
6820 };
6821 assert!(matches!(
6822 append_staged_user_image(&mut session, &new_image).user_content,
6823 Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
6824 ));
6825 assert_eq!(session.messages().len(), 2);
6826
6827 let restored: Session = serde_json::from_str(
6828 &serde_json::to_string(&session).expect("serialize rewritten session"),
6829 )
6830 .expect("cold restore rewritten session");
6831 assert_eq!(restored.realtime_user_content_identities().len(), 1);
6832 assert_eq!(restored.realtime_user_content_tombstones().len(), 1);
6833 }
6834
6835 #[test]
6836 fn transcript_rewrite_retains_only_canonical_image_occurrence_for_exact_replay() {
6837 let mut session = Session::new();
6838 let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB".to_string();
6839 let original = RealtimeTranscriptEvent::UserContentFinal {
6840 idempotency_key: "retained-image-key".to_string(),
6841 item_id: "retained-image-item".to_string(),
6842 previous_item_id: None,
6843 content_index: 0,
6844 content: vec![ContentBlock::Image {
6845 media_type: "image/png".to_string(),
6846 data: crate::types::ImageData::Inline { data },
6847 }],
6848 };
6849 assert!(matches!(
6850 append_staged_user_image(&mut session, &original).user_content,
6851 Some(crate::RealtimeUserContentApplyOutcome::Committed(_))
6852 ));
6853 let retained_message = session.messages()[0].clone();
6854 let parent = session.transcript_revision().expect("parent revision");
6855 session
6856 .commit_transcript_rewrite(
6857 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
6858 vec![
6859 retained_message,
6860 Message::User(UserMessage::text("new canonical neighbor")),
6861 ],
6862 TranscriptRewriteReason::new("retain-image"),
6863 None,
6864 Some(parent),
6865 )
6866 .expect("rewrite retaining exact inline image should reconcile");
6867
6868 assert!(session.realtime_user_content_tombstones().is_empty());
6869 let replay = session
6870 .preflight_realtime_user_content_event(&original)
6871 .expect("retained image should preflight as exact replay");
6872 assert!(matches!(
6873 replay,
6874 crate::RealtimeUserContentApplyOutcome::AlreadyCommitted(_)
6875 ));
6876 assert_eq!(session.messages().len(), 2);
6877 }
6878
6879 #[test]
6880 fn transcript_rewrite_rejects_atomically_while_image_blob_anchor_is_pending() {
6881 let mut session = Session::new();
6882 session.push(Message::User(UserMessage::text("before rewrite")));
6883 let pending = crate::PendingRealtimeUserContentBlob {
6884 idempotency_key: "pending-rewrite-key".to_string(),
6885 item_id: "pending-rewrite-item".to_string(),
6886 previous_item_id: None,
6887 content_index: 0,
6888 blob_id: crate::blob::content_blob_id("image/png", "pending-bytes"),
6889 media_type: "image/png".to_string(),
6890 };
6891 session
6892 .stage_pending_realtime_user_content_blob(pending.clone())
6893 .expect("stage durable pending anchor");
6894 let parent = session.transcript_revision().expect("parent revision");
6895 let error = session
6896 .commit_transcript_rewrite(
6897 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
6898 vec![Message::User(UserMessage::text("after rewrite"))],
6899 TranscriptRewriteReason::new("blocked-pending-image"),
6900 None,
6901 Some(parent),
6902 )
6903 .expect_err("rewrite must not cross an unresolved image anchor");
6904 assert!(
6905 error
6906 .to_string()
6907 .contains("history_rewrite_pending_user_content_blob")
6908 );
6909 assert!(matches!(
6910 &session.messages()[0],
6911 Message::User(user) if user.text_content() == "before rewrite"
6912 ));
6913 assert_eq!(session.pending_realtime_user_content_blob(), Some(pending));
6914 }
6915
6916 #[test]
6917 fn realtime_user_image_rejects_noncanonical_blob_and_multiblock_shape() {
6918 let mut session = Session::new();
6919 for (key, content) in [
6920 (
6921 "invalid-blob",
6922 vec![ContentBlock::Image {
6923 media_type: "image/png".to_string(),
6924 data: crate::types::ImageData::Blob {
6925 blob_id: crate::BlobId::new("sha256:not-a-digest"),
6926 },
6927 }],
6928 ),
6929 (
6930 "multi-block",
6931 vec![
6932 ContentBlock::Image {
6933 media_type: "image/png".to_string(),
6934 data: crate::types::ImageData::Inline {
6935 data: "payload".to_string(),
6936 },
6937 },
6938 ContentBlock::Text {
6939 text: "smuggled".to_string(),
6940 },
6941 ],
6942 ),
6943 ] {
6944 let outcome = session.append_realtime_transcript_event(
6945 RealtimeTranscriptEvent::UserContentFinal {
6946 idempotency_key: key.to_string(),
6947 item_id: format!("item-{key}"),
6948 previous_item_id: None,
6949 content_index: 0,
6950 content,
6951 },
6952 );
6953 assert!(matches!(
6954 outcome.user_content,
6955 Some(crate::RealtimeUserContentApplyOutcome::RejectedInvalidIdentity { .. })
6956 ));
6957 }
6958 assert!(session.messages().is_empty());
6959 let encoded = serde_json::to_string(&session).expect("session should serialize");
6960 serde_json::from_str::<Session>(&encoded).expect("rejections must leave restorable state");
6961 }
6962
6963 #[test]
6964 fn realtime_restore_rejects_malformed_causal_graphs_and_accepts_waiting_dag() {
6965 fn restore(
6966 items: serde_json::Value,
6967 first_seen_order: Vec<&str>,
6968 ) -> Result<
6969 crate::realtime_transcript_revision::SessionRealtimeTranscriptState,
6970 crate::realtime_transcript_revision::RealtimeTranscriptShellError,
6971 > {
6972 let state = serde_json::from_value(serde_json::json!({
6973 "items": items,
6974 "first_seen_order": first_seen_order,
6975 }))
6976 .expect("test state shape should deserialize");
6977 crate::realtime_transcript_revision::restore_realtime_transcript_state(state)
6978 }
6979
6980 assert!(
6981 restore(
6982 serde_json::json!({
6983 "child": { "role": "user", "previous_item_id": "missing" }
6984 }),
6985 vec!["child"],
6986 )
6987 .is_ok(),
6988 "an unmaterialized out-of-order item must survive cold restore until its predecessor arrives"
6989 );
6990 assert!(
6991 restore(
6992 serde_json::json!({
6993 "child": {
6994 "role": "user",
6995 "previous_item_id": "missing",
6996 "ready": true,
6997 "materialized": true
6998 }
6999 }),
7000 vec!["child"],
7001 )
7002 .is_err(),
7003 "a materialized item cannot reference a missing predecessor"
7004 );
7005 assert!(
7006 restore(
7007 serde_json::json!({
7008 "self": { "role": "user", "previous_item_id": "self" }
7009 }),
7010 vec!["self"],
7011 )
7012 .is_err(),
7013 "self edge must fail cold restore"
7014 );
7015 assert!(
7016 restore(
7017 serde_json::json!({
7018 "a": { "role": "user", "previous_item_id": "b" },
7019 "b": { "role": "user", "previous_item_id": "a" }
7020 }),
7021 vec!["a", "b"],
7022 )
7023 .is_err(),
7024 "cycle must fail cold restore"
7025 );
7026 assert!(
7027 restore(
7028 serde_json::json!({
7029 "root": { "role": "user" },
7030 "materialized_child": {
7031 "role": "user",
7032 "previous_item_id": "root",
7033 "ready": true,
7034 "materialized": true
7035 }
7036 }),
7037 vec!["root", "materialized_child"],
7038 )
7039 .is_err(),
7040 "materialized child cannot have unmaterialized ancestry"
7041 );
7042 assert!(
7043 restore(
7044 serde_json::json!({
7045 "root": { "role": "user" },
7046 "waiting_child": { "role": "user", "previous_item_id": "root" }
7047 }),
7048 vec!["waiting_child", "root"],
7049 )
7050 .is_ok(),
7051 "valid acyclic waiting graph should restore even when first-seen order is child-first"
7052 );
7053 }
7054
7055 #[test]
7056 fn realtime_restore_handles_long_waiting_chain_with_bounded_graph_walk() {
7057 const ITEM_COUNT: usize = 4_096;
7058 let mut items = serde_json::Map::new();
7059 let mut order = Vec::with_capacity(ITEM_COUNT);
7060 for index in 0..ITEM_COUNT {
7061 let item_id = format!("item-{index:04}");
7062 let value = if index == 0 {
7063 serde_json::json!({ "role": "user" })
7064 } else {
7065 serde_json::json!({
7066 "role": "user",
7067 "previous_item_id": format!("item-{:04}", index - 1),
7068 })
7069 };
7070 order.push(item_id.clone());
7071 items.insert(item_id, value);
7072 }
7073 let state = serde_json::from_value(serde_json::json!({
7074 "items": items,
7075 "first_seen_order": order,
7076 }))
7077 .expect("long-chain fixture should deserialize");
7078 crate::realtime_transcript_revision::restore_realtime_transcript_state(state)
7079 .expect("long valid waiting DAG should restore in one bounded graph walk");
7080 }
7081
7082 #[test]
7087 fn realtime_transcript_final_text_overrides_partial_delta_and_promotes_to_spoken_lane() {
7088 let mut session = Session::new();
7089
7090 assert!(
7093 session
7094 .append_realtime_transcript_event(
7095 RealtimeTranscriptEvent::AssistantTranscriptDelta {
7096 response_id: "resp_a".to_string(),
7097 delta_id: "evt_1".to_string(),
7098 item_id: "item_a".to_string(),
7099 previous_item_id: None,
7100 content_index: 0,
7101 delta: "incom".to_string(),
7102 }
7103 )
7104 .is_inert()
7105 );
7106
7107 assert!(
7109 session
7110 .append_realtime_transcript_event(
7111 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
7112 response_id: "resp_a".to_string(),
7113 item_id: "item_a".to_string(),
7114 content_index: 0,
7115 text: "complete answer".to_string(),
7116 }
7117 )
7118 .is_inert()
7119 );
7120
7121 let outcome = session.append_realtime_transcript_event(
7123 RealtimeTranscriptEvent::AssistantTurnCompleted {
7124 response_id: "resp_a".to_string(),
7125 stop_reason: StopReason::EndTurn,
7126 usage: Usage::default(),
7127 },
7128 );
7129 assert!(!outcome.is_inert());
7130
7131 assert_eq!(session.messages().len(), 1);
7134 match &session.messages()[0] {
7135 Message::BlockAssistant(assistant) => {
7136 let mut found_transcript = false;
7137 for block in &assistant.blocks {
7138 if let AssistantBlock::Transcript { text, .. } = block {
7139 assert_eq!(text, "complete answer");
7140 found_transcript = true;
7141 }
7142 }
7143 assert!(
7144 found_transcript,
7145 "AssistantTranscriptFinalText must promote to the Spoken lane and \
7146 materialize as AssistantBlock::Transcript"
7147 );
7148 }
7149 other => unreachable!("expected BlockAssistant, got {other:?}"),
7150 }
7151 }
7152
7153 #[test]
7156 fn realtime_transcript_final_text_creates_item_when_no_delta_staged() {
7157 let mut session = Session::new();
7158
7159 assert!(
7160 session
7161 .append_realtime_transcript_event(
7162 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
7163 response_id: "resp_a".to_string(),
7164 item_id: "item_a".to_string(),
7165 content_index: 0,
7166 text: "spoken-final-only".to_string(),
7167 }
7168 )
7169 .is_inert()
7170 );
7171
7172 let outcome = session.append_realtime_transcript_event(
7173 RealtimeTranscriptEvent::AssistantTurnCompleted {
7174 response_id: "resp_a".to_string(),
7175 stop_reason: StopReason::EndTurn,
7176 usage: Usage::default(),
7177 },
7178 );
7179 assert!(!outcome.is_inert());
7180
7181 assert_eq!(session.messages().len(), 1);
7182 match &session.messages()[0] {
7183 Message::BlockAssistant(assistant) => {
7184 let has_transcript = assistant.blocks.iter().any(|b| {
7185 matches!(b, AssistantBlock::Transcript { text, .. } if text == "spoken-final-only")
7186 });
7187 assert!(
7188 has_transcript,
7189 "final-only provider path must materialize as Transcript on the Spoken lane"
7190 );
7191 }
7192 other => unreachable!("expected BlockAssistant, got {other:?}"),
7193 }
7194 }
7195
7196 #[test]
7197 fn realtime_transcript_append_orders_causally_equivalent_out_of_order_items() {
7198 let mut session = Session::new();
7199
7200 assert!(
7201 session
7202 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
7203 response_id: "resp_assistant".to_string(),
7204 delta_id: "evt_delta_1".to_string(),
7205 item_id: "item_assistant".to_string(),
7206 previous_item_id: Some("item_user".to_string()),
7207 content_index: 0,
7208 delta: "answer".to_string(),
7209 })
7210 .is_inert()
7211 );
7212 assert!(
7213 session
7214 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
7215 response_id: "resp_assistant".to_string(),
7216 stop_reason: StopReason::EndTurn,
7217 usage: Usage::default(),
7218 })
7219 .is_inert()
7220 );
7221
7222 let outcome = session.append_realtime_transcript_event(
7223 RealtimeTranscriptEvent::UserTranscriptFinal {
7224 item_id: "item_user".to_string(),
7225 previous_item_id: None,
7226 content_index: 0,
7227 text: "question".to_string(),
7228 },
7229 );
7230
7231 assert_eq!(outcome.materialized_messages.len(), 2);
7232 assert_eq!(session.messages().len(), 2);
7233 assert!(matches!(
7234 &session.messages()[0],
7235 Message::User(user) if user.text_content() == "question"
7236 ));
7237 assert!(matches!(
7238 &session.messages()[1],
7239 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer"
7240 ));
7241 }
7242
7243 #[test]
7244 fn realtime_transcript_replay_of_seen_provider_items_is_inert() {
7245 let mut session = Session::new();
7246 let events = vec![
7247 RealtimeTranscriptEvent::UserTranscriptFinal {
7248 item_id: "item_user".to_string(),
7249 previous_item_id: None,
7250 content_index: 0,
7251 text: "hello".to_string(),
7252 },
7253 RealtimeTranscriptEvent::AssistantTextDelta {
7254 response_id: "resp_assistant".to_string(),
7255 delta_id: "evt_delta_1".to_string(),
7256 item_id: "item_assistant".to_string(),
7257 previous_item_id: Some("item_user".to_string()),
7258 content_index: 0,
7259 delta: "world".to_string(),
7260 },
7261 RealtimeTranscriptEvent::AssistantTurnCompleted {
7262 response_id: "resp_assistant".to_string(),
7263 stop_reason: StopReason::EndTurn,
7264 usage: Usage::default(),
7265 },
7266 ];
7267
7268 for event in events.iter().cloned() {
7269 let _ = session.append_realtime_transcript_event(event);
7270 }
7271 let first_messages = serde_json::to_value(session.messages()).unwrap();
7272
7273 for event in events {
7274 assert!(session.append_realtime_transcript_event(event).is_inert());
7275 }
7276
7277 assert_eq!(
7278 serde_json::to_value(session.messages()).unwrap(),
7279 first_messages
7280 );
7281 }
7282
7283 #[test]
7284 fn realtime_transcript_user_final_replay_cannot_erase_existing_segment() {
7285 let mut session = Session::new();
7286
7287 let user = RealtimeTranscriptEvent::UserTranscriptFinal {
7288 item_id: "item_user".to_string(),
7289 previous_item_id: None,
7290 content_index: 0,
7291 text: "remember amber lantern".to_string(),
7292 };
7293 assert!(
7294 !session
7295 .append_realtime_transcript_event(user.clone())
7296 .is_inert()
7297 );
7298 let first_messages = serde_json::to_value(session.messages()).unwrap();
7299
7300 assert!(
7301 session
7302 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
7303 item_id: "item_user".to_string(),
7304 previous_item_id: None,
7305 content_index: 0,
7306 text: String::new(),
7307 })
7308 .is_inert()
7309 );
7310 assert!(session.append_realtime_transcript_event(user).is_inert());
7311 assert_eq!(
7312 serde_json::to_value(session.messages()).unwrap(),
7313 first_messages
7314 );
7315 }
7316
7317 #[test]
7318 fn realtime_transcript_empty_user_final_can_be_filled_by_later_nonempty_replay() {
7319 let mut session = Session::new();
7320
7321 assert!(
7322 session
7323 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
7324 item_id: "item_user".to_string(),
7325 previous_item_id: None,
7326 content_index: 0,
7327 text: String::new(),
7328 })
7329 .is_inert()
7330 );
7331 assert!(session.messages().is_empty());
7332
7333 let outcome = session.append_realtime_transcript_event(
7334 RealtimeTranscriptEvent::UserTranscriptFinal {
7335 item_id: "item_user".to_string(),
7336 previous_item_id: None,
7337 content_index: 0,
7338 text: "remember amber lantern".to_string(),
7339 },
7340 );
7341 assert_eq!(outcome.materialized_messages.len(), 1);
7342 assert_eq!(session.messages().len(), 1);
7343 assert!(matches!(
7344 &session.messages()[0],
7345 Message::User(user) if user.text_content() == "remember amber lantern"
7346 ));
7347 }
7348
7349 #[test]
7350 fn realtime_transcript_skipped_provider_items_preserve_causal_order_without_content() {
7351 let mut session = Session::new();
7352
7353 let assistant_delta = RealtimeTranscriptEvent::AssistantTextDelta {
7354 response_id: "resp_assistant".to_string(),
7355 delta_id: "evt_delta_1".to_string(),
7356 item_id: "item_assistant".to_string(),
7357 previous_item_id: Some("item_tool".to_string()),
7358 content_index: 0,
7359 delta: "done".to_string(),
7360 };
7361 assert!(
7362 session
7363 .append_realtime_transcript_event(assistant_delta.clone())
7364 .is_inert()
7365 );
7366 let assistant_complete = RealtimeTranscriptEvent::AssistantTurnCompleted {
7367 response_id: "resp_assistant".to_string(),
7368 stop_reason: StopReason::EndTurn,
7369 usage: Usage::default(),
7370 };
7371 assert!(
7372 session
7373 .append_realtime_transcript_event(assistant_complete.clone())
7374 .is_inert()
7375 );
7376
7377 let skipped = RealtimeTranscriptEvent::ItemSkipped {
7378 item_id: "item_tool".to_string(),
7379 previous_item_id: Some("item_user".to_string()),
7380 };
7381 assert!(
7382 session
7383 .append_realtime_transcript_event(skipped.clone())
7384 .is_inert(),
7385 "a skipped provider item must not append transcript content"
7386 );
7387 assert!(session.messages().is_empty());
7388
7389 let outcome = session.append_realtime_transcript_event(
7390 RealtimeTranscriptEvent::UserTranscriptFinal {
7391 item_id: "item_user".to_string(),
7392 previous_item_id: None,
7393 content_index: 0,
7394 text: "please use the tool".to_string(),
7395 },
7396 );
7397 assert_eq!(outcome.materialized_messages.len(), 2);
7398 assert_eq!(session.messages().len(), 2);
7399 assert!(matches!(
7400 &session.messages()[0],
7401 Message::User(user) if user.text_content() == "please use the tool"
7402 ));
7403 assert!(matches!(
7404 &session.messages()[1],
7405 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "done"
7406 ));
7407
7408 let first_messages = serde_json::to_value(session.messages()).unwrap();
7409 assert!(session.append_realtime_transcript_event(skipped).is_inert());
7410 assert!(
7411 session
7412 .append_realtime_transcript_event(assistant_delta)
7413 .is_inert()
7414 );
7415 assert!(
7416 session
7417 .append_realtime_transcript_event(assistant_complete)
7418 .is_inert()
7419 );
7420 assert_eq!(
7421 serde_json::to_value(session.messages()).unwrap(),
7422 first_messages
7423 );
7424 }
7425
7426 #[test]
7427 fn realtime_transcript_interrupted_assistant_item_unblocks_later_provider_items() {
7428 let mut session = Session::new();
7435
7436 let _ = session.append_realtime_transcript_event(
7437 RealtimeTranscriptEvent::UserTranscriptFinal {
7438 item_id: "item_repeat".to_string(),
7439 previous_item_id: None,
7440 content_index: 0,
7441 text: "repeat until stop".to_string(),
7442 },
7443 );
7444 assert!(
7445 session
7446 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
7447 response_id: "resp_loop".to_string(),
7448 delta_id: "evt_loop_1".to_string(),
7449 item_id: "item_loop".to_string(),
7450 previous_item_id: Some("item_repeat".to_string()),
7451 content_index: 0,
7452 delta: "Looping now".to_string(),
7453 })
7454 .is_inert()
7455 );
7456 assert!(
7457 session
7458 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
7459 item_id: "item_stop".to_string(),
7460 previous_item_id: Some("item_loop".to_string()),
7461 content_index: 0,
7462 text: "Stop.".to_string(),
7463 })
7464 .is_inert(),
7465 "the stop turn waits until the interrupted assistant provider item is resolved"
7466 );
7467
7468 let outcome = session.append_realtime_transcript_event(
7469 RealtimeTranscriptEvent::AssistantTurnInterrupted {
7470 response_id: "resp_loop".to_string(),
7471 },
7472 );
7473
7474 assert_eq!(outcome.materialized_messages.len(), 2);
7477 assert_eq!(session.messages().len(), 3);
7479 assert!(matches!(
7480 &session.messages()[0],
7481 Message::User(user) if user.text_content() == "repeat until stop"
7482 ));
7483 match &session.messages()[1] {
7484 Message::BlockAssistant(assistant) => {
7485 let text = block_assistant_text(assistant);
7486 assert_eq!(text, "Looping now");
7487 }
7488 other => unreachable!(
7489 "Display lane assistant item must be retained on Interrupted, got {other:?}"
7490 ),
7491 }
7492 assert!(matches!(
7493 &session.messages()[2],
7494 Message::User(user) if user.text_content() == "Stop."
7495 ));
7496 }
7497
7498 #[test]
7499 fn realtime_transcript_late_interrupted_assistant_delta_stays_noncanonical() {
7500 let mut session = Session::new();
7501
7502 let _ = session.append_realtime_transcript_event(
7503 RealtimeTranscriptEvent::UserTranscriptFinal {
7504 item_id: "item_repeat".to_string(),
7505 previous_item_id: None,
7506 content_index: 0,
7507 text: "repeat until stop".to_string(),
7508 },
7509 );
7510 assert!(
7511 session
7512 .append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
7513 item_id: "item_loop".to_string(),
7514 previous_item_id: Some("item_repeat".to_string()),
7515 role: RealtimeTranscriptRole::Assistant,
7516 response_id: None,
7517 })
7518 .is_inert(),
7519 "provider can observe an assistant item before the adapter learns its response id"
7520 );
7521 assert!(
7522 session
7523 .append_realtime_transcript_event(
7524 RealtimeTranscriptEvent::AssistantTurnInterrupted {
7525 response_id: "resp_loop".to_string(),
7526 }
7527 )
7528 .is_inert(),
7529 "an interruption can arrive before delayed transcript deltas for the response"
7530 );
7531 assert!(
7532 session
7533 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
7534 item_id: "item_stop".to_string(),
7535 previous_item_id: Some("item_loop".to_string()),
7536 content_index: 0,
7537 text: "Stop.".to_string(),
7538 })
7539 .is_inert(),
7540 "the stop turn waits for the provider's interrupted assistant item anchor"
7541 );
7542
7543 let late_delta_outcome =
7544 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
7545 response_id: "resp_loop".to_string(),
7546 delta_id: "evt_loop_late".to_string(),
7547 item_id: "item_loop".to_string(),
7548 previous_item_id: Some("item_repeat".to_string()),
7549 content_index: 0,
7550 delta: "Looping now".to_string(),
7551 });
7552 assert_eq!(late_delta_outcome.materialized_messages.len(), 1);
7553 assert!(matches!(
7554 &session.messages()[1],
7555 Message::User(user) if user.text_content() == "Stop."
7556 ));
7557 assert!(
7558 session
7559 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
7560 response_id: "resp_loop".to_string(),
7561 stop_reason: StopReason::EndTurn,
7562 usage: Usage::default(),
7563 })
7564 .is_inert(),
7565 "late completion for an interrupted response must not resurrect its deltas"
7566 );
7567 assert!(
7568 session
7569 .messages()
7570 .iter()
7571 .filter_map(|message| match message {
7572 Message::BlockAssistant(assistant) => Some(block_assistant_text(assistant)),
7573 _ => None,
7574 })
7575 .all(|text| !text.contains("Looping now")),
7576 "late interrupted assistant text must remain non-canonical"
7577 );
7578 }
7579
7580 #[test]
7581 fn realtime_transcript_completion_only_finalizes_matching_response() {
7582 let mut session = Session::new();
7583
7584 let _ = session.append_realtime_transcript_event(
7585 RealtimeTranscriptEvent::UserTranscriptFinal {
7586 item_id: "item_user".to_string(),
7587 previous_item_id: None,
7588 content_index: 0,
7589 text: "question".to_string(),
7590 },
7591 );
7592 assert!(
7593 session
7594 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
7595 response_id: "resp_a".to_string(),
7596 delta_id: "evt_a".to_string(),
7597 item_id: "item_a".to_string(),
7598 previous_item_id: Some("item_user".to_string()),
7599 content_index: 0,
7600 delta: "answer a".to_string(),
7601 })
7602 .is_inert()
7603 );
7604
7605 assert!(
7606 session
7607 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
7608 response_id: "resp_b".to_string(),
7609 stop_reason: StopReason::EndTurn,
7610 usage: Usage::default(),
7611 })
7612 .is_inert(),
7613 "a completion for another response must not finalize buffered assistant text"
7614 );
7615 assert_eq!(session.messages().len(), 1);
7616
7617 let outcome = session.append_realtime_transcript_event(
7618 RealtimeTranscriptEvent::AssistantTurnCompleted {
7619 response_id: "resp_a".to_string(),
7620 stop_reason: StopReason::EndTurn,
7621 usage: Usage::default(),
7622 },
7623 );
7624 assert_eq!(outcome.materialized_messages.len(), 1);
7625 assert_eq!(session.messages().len(), 2);
7626 assert!(matches!(
7627 &session.messages()[1],
7628 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer a"
7629 ));
7630 }
7631
7632 #[test]
7633 fn realtime_transcript_completion_before_later_delta_is_response_scoped() {
7634 let mut session = Session::new();
7635
7636 let _ = session.append_realtime_transcript_event(
7637 RealtimeTranscriptEvent::UserTranscriptFinal {
7638 item_id: "item_user".to_string(),
7639 previous_item_id: None,
7640 content_index: 0,
7641 text: "question".to_string(),
7642 },
7643 );
7644 assert!(
7645 session
7646 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
7647 response_id: "resp_a".to_string(),
7648 stop_reason: StopReason::EndTurn,
7649 usage: Usage::default(),
7650 })
7651 .is_inert()
7652 );
7653 assert!(
7654 session
7655 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
7656 response_id: "resp_b".to_string(),
7657 delta_id: "evt_b".to_string(),
7658 item_id: "item_b".to_string(),
7659 previous_item_id: Some("item_user".to_string()),
7660 content_index: 0,
7661 delta: "wrong response".to_string(),
7662 })
7663 .is_inert(),
7664 "a later delta for another response must not be finalized by resp_a's pending completion"
7665 );
7666
7667 let outcome =
7668 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
7669 response_id: "resp_a".to_string(),
7670 delta_id: "evt_a".to_string(),
7671 item_id: "item_a".to_string(),
7672 previous_item_id: Some("item_user".to_string()),
7673 content_index: 0,
7674 delta: "right response".to_string(),
7675 });
7676
7677 assert_eq!(outcome.materialized_messages.len(), 1);
7678 assert_eq!(session.messages().len(), 2);
7679 assert!(matches!(
7680 &session.messages()[1],
7681 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "right response"
7682 ));
7683 }
7684
7685 #[test]
7686 fn realtime_transcript_late_duplicate_completion_cannot_finalize_unrelated_response() {
7687 let mut session = Session::new();
7688
7689 let _ = session.append_realtime_transcript_event(
7690 RealtimeTranscriptEvent::UserTranscriptFinal {
7691 item_id: "item_user".to_string(),
7692 previous_item_id: None,
7693 content_index: 0,
7694 text: "question".to_string(),
7695 },
7696 );
7697 let _ =
7698 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
7699 response_id: "resp_a".to_string(),
7700 delta_id: "evt_a".to_string(),
7701 item_id: "item_a".to_string(),
7702 previous_item_id: Some("item_user".to_string()),
7703 content_index: 0,
7704 delta: "first".to_string(),
7705 });
7706 let _ = session.append_realtime_transcript_event(
7707 RealtimeTranscriptEvent::AssistantTurnCompleted {
7708 response_id: "resp_a".to_string(),
7709 stop_reason: StopReason::EndTurn,
7710 usage: Usage::default(),
7711 },
7712 );
7713 assert_eq!(session.messages().len(), 2);
7714
7715 assert!(
7716 session
7717 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
7718 response_id: "resp_b".to_string(),
7719 delta_id: "evt_b".to_string(),
7720 item_id: "item_b".to_string(),
7721 previous_item_id: Some("item_a".to_string()),
7722 content_index: 0,
7723 delta: "second".to_string(),
7724 })
7725 .is_inert()
7726 );
7727 assert!(
7728 session
7729 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
7730 response_id: "resp_a".to_string(),
7731 stop_reason: StopReason::EndTurn,
7732 usage: Usage::default(),
7733 })
7734 .is_inert(),
7735 "a duplicate late terminal for resp_a must not finalize resp_b"
7736 );
7737 assert_eq!(session.messages().len(), 2);
7738
7739 let outcome = session.append_realtime_transcript_event(
7740 RealtimeTranscriptEvent::AssistantTurnCompleted {
7741 response_id: "resp_b".to_string(),
7742 stop_reason: StopReason::EndTurn,
7743 usage: Usage::default(),
7744 },
7745 );
7746 assert_eq!(outcome.materialized_messages.len(), 1);
7747 assert_eq!(session.messages().len(), 3);
7748 }
7749
7750 #[test]
7751 fn realtime_transcript_interruption_discards_only_matching_response() {
7752 let mut session = Session::new();
7758
7759 let _ = session.append_realtime_transcript_event(
7760 RealtimeTranscriptEvent::UserTranscriptFinal {
7761 item_id: "item_user".to_string(),
7762 previous_item_id: None,
7763 content_index: 0,
7764 text: "question".to_string(),
7765 },
7766 );
7767 let _ =
7768 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
7769 response_id: "resp_a".to_string(),
7770 delta_id: "evt_a".to_string(),
7771 item_id: "item_a".to_string(),
7772 previous_item_id: Some("item_user".to_string()),
7773 content_index: 0,
7774 delta: "interrupted display".to_string(),
7775 });
7776 let _ =
7777 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
7778 response_id: "resp_b".to_string(),
7779 delta_id: "evt_b".to_string(),
7780 item_id: "item_b".to_string(),
7781 previous_item_id: Some("item_user".to_string()),
7782 content_index: 0,
7783 delta: "keep me".to_string(),
7784 });
7785
7786 let interrupt_outcome = session.append_realtime_transcript_event(
7789 RealtimeTranscriptEvent::AssistantTurnInterrupted {
7790 response_id: "resp_a".to_string(),
7791 },
7792 );
7793 assert_eq!(
7794 interrupt_outcome.materialized_messages.len(),
7795 1,
7796 "resp_a's Display item commits on Interrupted"
7797 );
7798
7799 let outcome = session.append_realtime_transcript_event(
7800 RealtimeTranscriptEvent::AssistantTurnCompleted {
7801 response_id: "resp_b".to_string(),
7802 stop_reason: StopReason::EndTurn,
7803 usage: Usage::default(),
7804 },
7805 );
7806 assert_eq!(
7807 outcome.materialized_messages.len(),
7808 1,
7809 "resp_b commits on its TurnCompleted, untouched by resp_a's Interrupted"
7810 );
7811
7812 assert_eq!(session.messages().len(), 3);
7814 assert!(matches!(
7815 &session.messages()[1],
7816 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "interrupted display"
7817 ));
7818 assert!(matches!(
7819 &session.messages()[2],
7820 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "keep me"
7821 ));
7822 }
7823
7824 #[test]
7827 fn test_fork_shares_arc_no_clone() {
7828 let mut session = Session::new();
7829 for i in 0..100 {
7830 session.push(Message::User(UserMessage::text(format!("Message {i}"))));
7831 }
7832
7833 let forked = session.fork();
7835
7836 assert!(Arc::ptr_eq(&session.messages, &forked.messages));
7838 assert_eq!(forked.messages().len(), 100);
7839 }
7840
7841 #[test]
7842 fn test_fork_at_shares_arc_prefix() {
7843 let mut session = Session::new();
7844 for i in 0..100 {
7845 session.push(Message::User(UserMessage::text(format!("Message {i}"))));
7846 }
7847
7848 let forked = session.fork_at(50);
7850 assert_eq!(forked.messages().len(), 50);
7851
7852 assert_eq!(session.messages().len(), 100);
7854 }
7855
7856 #[test]
7857 fn test_fork_at_resets_transcript_history_state_for_branch_identity() {
7858 let mut session = Session::new();
7859 session.push(Message::User(UserMessage::text(
7860 "summarize this".to_string(),
7861 )));
7862 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
7863 vec![AssistantBlock::Text {
7864 text: "long assistant trace".to_string(),
7865 meta: None,
7866 }],
7867 StopReason::EndTurn,
7868 )));
7869 let parent_revision = session.transcript_revision().expect("parent revision");
7870 session
7871 .commit_transcript_rewrite(
7872 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
7873 vec![Message::BlockAssistant(BlockAssistantMessage::new(
7874 vec![AssistantBlock::Text {
7875 text: "compact trace".to_string(),
7876 meta: None,
7877 }],
7878 StopReason::EndTurn,
7879 ))],
7880 TranscriptRewriteReason::new("compaction"),
7881 Some("test".to_string()),
7882 Some(parent_revision),
7883 )
7884 .expect("rewrite should commit");
7885
7886 let source_head = session.transcript_revision().expect("source head");
7887 let mut forked = session.fork_at(1);
7888 assert_ne!(forked.id(), session.id());
7889 assert!(
7890 !forked
7891 .metadata()
7892 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
7893 );
7894 assert_eq!(
7895 forked.transcript_revision().expect("fork head"),
7896 transcript_messages_digest(forked.messages()).expect("fork digest")
7897 );
7898 assert!(
7899 forked
7900 .transcript_revision_messages(&source_head)
7901 .expect("fork history lookup")
7902 .is_none()
7903 );
7904
7905 let fork_parent = forked.transcript_revision().expect("fork parent");
7906 let commit = forked
7907 .commit_transcript_rewrite(
7908 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7909 vec![Message::User(UserMessage::text(
7910 "branch prompt".to_string(),
7911 ))],
7912 TranscriptRewriteReason::new("branch_edit"),
7913 Some("test".to_string()),
7914 Some(fork_parent.clone()),
7915 )
7916 .expect("fork rewrite should use fork-local parent");
7917 assert_eq!(commit.parent_revision, fork_parent);
7918 }
7919
7920 #[test]
7921 fn test_push_cow_behavior() {
7922 let mut session = Session::new();
7923 session.push(Message::User(UserMessage::text("First".to_string())));
7924
7925 let forked = session.fork();
7927 assert!(Arc::ptr_eq(&session.messages, &forked.messages));
7928
7929 session.push(Message::User(UserMessage::text("Second".to_string())));
7931
7932 assert!(!Arc::ptr_eq(&session.messages, &forked.messages));
7934 assert_eq!(session.messages().len(), 2);
7935 assert_eq!(forked.messages().len(), 1);
7936 }
7937
7938 #[test]
7941 fn test_push_batch_single_timestamp() {
7942 let mut session = Session::new();
7943 let initial_updated = session.updated_at();
7944
7945 session.push_batch(vec![
7947 Message::User(UserMessage::text("First".to_string())),
7948 Message::User(UserMessage::text("Second".to_string())),
7949 Message::User(UserMessage::text("Third".to_string())),
7950 ]);
7951
7952 assert_eq!(session.messages().len(), 3);
7953 assert!(session.updated_at() >= initial_updated);
7955 }
7956
7957 #[test]
7958 fn test_touch_updates_timestamp() {
7959 let mut session = Session::new();
7960 let initial = session.updated_at();
7961
7962 std::thread::sleep(std::time::Duration::from_millis(10));
7963
7964 session.touch();
7966
7967 assert!(session.updated_at() > initial);
7968 }
7969
7970 #[test]
7971 fn test_session_push() {
7972 let mut session = Session::new();
7973 let initial_updated = session.updated_at();
7974
7975 std::thread::sleep(std::time::Duration::from_millis(10));
7977
7978 session.push(Message::User(UserMessage::text("Hello".to_string())));
7979
7980 assert_eq!(session.messages().len(), 1);
7981 assert!(session.updated_at() > initial_updated);
7982 }
7983
7984 #[test]
7985 fn test_session_fork() {
7986 let mut session = Session::new();
7987 session.push(Message::System(SystemMessage::new("System prompt")));
7988 session.push(Message::User(UserMessage::text("Hello".to_string())));
7989 session.push(Message::BlockAssistant(BlockAssistantMessage {
7990 blocks: vec![AssistantBlock::Text {
7991 text: "Hi!".to_string(),
7992 meta: None,
7993 }],
7994 stop_reason: StopReason::EndTurn,
7995 identity: crate::types::TranscriptMessageIdentity::default(),
7996 created_at: crate::types::message_timestamp_now(),
7997 }));
7998
7999 let forked = session.fork_at(2);
8001 assert_eq!(forked.messages().len(), 2);
8002 assert_ne!(forked.id(), session.id());
8003
8004 let full_fork = session.fork();
8006 assert_eq!(full_fork.messages().len(), 3);
8007 }
8008
8009 #[test]
8010 fn test_session_forks_drop_generated_authority_metadata() {
8011 let mut session = Session::new();
8012 session.push(Message::User(UserMessage::text("original")));
8013 session.set_metadata("ordinary", serde_json::json!("keep"));
8014 session
8015 .set_build_state(SessionBuildState::default())
8016 .expect("build state should serialize");
8017 session
8018 .set_system_context_state(SessionSystemContextState::default())
8019 .expect("system-context state should serialize");
8020 session
8021 .set_deferred_turn_state(SessionDeferredTurnState::default())
8022 .expect("deferred-turn state should serialize");
8023 session
8024 .set_tool_visibility_state(
8025 AuthorizedSessionToolVisibilityState::from_generated_authority(
8026 SessionToolVisibilityState::default(),
8027 ),
8028 )
8029 .expect("visibility state should serialize");
8030 let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
8031 item_id: "rt-item".to_string(),
8032 previous_item_id: None,
8033 role: RealtimeTranscriptRole::User,
8034 response_id: None,
8035 });
8036 assert!(
8037 session
8038 .metadata()
8039 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
8040 "test setup should install realtime transcript authority state"
8041 );
8042
8043 let forked_at = session.fork_at(1);
8044 let full_fork = session.fork();
8045 let replaced = session
8046 .fork_replacing(
8047 0,
8048 TranscriptReplacement::Message {
8049 message: Message::User(UserMessage::text("replacement")),
8050 },
8051 )
8052 .expect("replacement fork should succeed");
8053
8054 for forked in [&forked_at, &full_fork, &replaced] {
8055 assert_eq!(forked.metadata().get("ordinary").unwrap(), "keep");
8056 assert!(
8057 !forked.metadata().contains_key(SESSION_BUILD_STATE_KEY),
8058 "forked sessions must not raw-copy durable build-state authority"
8059 );
8060 assert!(
8061 !forked
8062 .metadata()
8063 .contains_key(SESSION_SYSTEM_CONTEXT_STATE_KEY),
8064 "forked sessions must not raw-copy system-context authority state"
8065 );
8066 assert!(
8067 !forked
8068 .metadata()
8069 .contains_key(SESSION_DEFERRED_TURN_STATE_KEY),
8070 "forked sessions must not raw-copy deferred-turn authority state"
8071 );
8072 assert!(
8073 !forked
8074 .metadata()
8075 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY),
8076 "forked sessions must not raw-copy tool-visibility authority state"
8077 );
8078 assert!(
8079 !forked
8080 .metadata()
8081 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
8082 "forked sessions must not raw-copy realtime transcript authority state"
8083 );
8084 }
8085 }
8086
8087 #[test]
8088 fn test_session_metadata() {
8089 let mut session = Session::new();
8090 session.set_metadata("key", serde_json::json!("value"));
8091
8092 assert_eq!(session.metadata().get("key").unwrap(), "value");
8093 }
8094
8095 #[test]
8096 fn session_metadata_realm_id_is_back_read_compatible_string() {
8097 let metadata = SessionMetadata {
8100 schema_version: SESSION_METADATA_SCHEMA_VERSION,
8101 model: "test-model".to_string(),
8102 max_tokens: 1024,
8103 structured_output_retries: 2,
8104 provider: Provider::Other,
8105 self_hosted_server_id: None,
8106 provider_params: None,
8107 tooling: SessionTooling::default(),
8108 keep_alive: false,
8109 comms_name: None,
8110 peer_meta: None,
8111 realm_id: Some(crate::RealmId::parse("env_default").unwrap()),
8112 instance_id: None,
8113 backend: None,
8114 config_generation: None,
8115 auth_binding: None,
8116 mob_member_binding: None,
8117 };
8118 let value = serde_json::to_value(&metadata).unwrap();
8119 assert_eq!(
8120 value.get("realm_id"),
8121 Some(&serde_json::json!("env_default")),
8122 "typed realm_id must serialize as a bare slug string"
8123 );
8124
8125 let legacy = serde_json::json!({
8128 "schema_version": SESSION_METADATA_SCHEMA_VERSION,
8129 "model": "test-model",
8130 "max_tokens": 1024,
8131 "structured_output_retries": 2,
8132 "provider": "other",
8133 "tooling": SessionTooling::default(),
8134 "keep_alive": false,
8135 "comms_name": null,
8136 "realm_id": "legacy_realm",
8137 });
8138 let restored: SessionMetadata = serde_json::from_value(legacy).unwrap();
8139 assert_eq!(
8140 restored.realm_id.as_ref().map(crate::RealmId::as_str),
8141 Some("legacy_realm")
8142 );
8143 }
8144
8145 #[test]
8150 fn session_tooling_tool_access_policy_round_trip_and_absent_default() {
8151 let legacy = serde_json::json!({});
8153 let restored: SessionTooling = serde_json::from_value(legacy).unwrap();
8154 assert_eq!(restored.tool_access_policy, None);
8155
8156 let value = serde_json::to_value(SessionTooling::default()).unwrap();
8158 assert!(
8159 value.get("tool_access_policy").is_none(),
8160 "None policy must not serialize"
8161 );
8162
8163 let tooling = SessionTooling {
8165 tool_access_policy: Some(crate::ops::ToolAccessPolicy::AllowList(
8166 ["read_file", "send_message"].into_iter().collect(),
8167 )),
8168 ..SessionTooling::default()
8169 };
8170 let value = serde_json::to_value(&tooling).unwrap();
8171 let restored: SessionTooling = serde_json::from_value(value).unwrap();
8172 assert_eq!(restored.tool_access_policy, tooling.tool_access_policy);
8173 }
8174
8175 #[test]
8176 fn lifecycle_terminal_typed_round_trip() {
8177 let mut session = Session::new();
8178 assert_eq!(session.lifecycle_terminal(), None);
8179
8180 session
8181 .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
8182 .expect("typed terminal write should serialize");
8183 assert_eq!(
8184 session.lifecycle_terminal(),
8185 Some(SessionLifecycleTerminal::Archived)
8186 );
8187 assert!(
8188 session
8189 .lifecycle_terminal()
8190 .is_some_and(SessionLifecycleTerminal::is_archived)
8191 );
8192 assert_eq!(
8194 session
8195 .metadata()
8196 .get(SESSION_LIFECYCLE_TERMINAL_KEY)
8197 .unwrap(),
8198 &serde_json::json!("archived")
8199 );
8200 }
8201
8202 #[test]
8203 fn lifecycle_terminal_key_rejects_raw_mutation() {
8204 let mut session = Session::new();
8205 assert!(
8206 session
8207 .try_set_metadata(
8208 SESSION_LIFECYCLE_TERMINAL_KEY,
8209 serde_json::json!("archived")
8210 )
8211 .is_err(),
8212 "the typed lifecycle-terminal key is reserved for session authority"
8213 );
8214 }
8215
8216 #[test]
8217 fn test_session_metadata_backfill_preserves_timestamp() {
8218 let mut session = Session::new();
8219 let initial_updated = session.updated_at();
8220
8221 std::thread::sleep(std::time::Duration::from_millis(10));
8222
8223 assert!(session.backfill_metadata_if_absent("key", serde_json::json!("value")));
8224 assert_eq!(session.metadata().get("key").unwrap(), "value");
8225 assert_eq!(session.updated_at(), initial_updated);
8226 assert!(!session.backfill_metadata_if_absent("key", serde_json::json!("other")));
8227 assert_eq!(session.metadata().get("key").unwrap(), "value");
8228 assert_eq!(session.updated_at(), initial_updated);
8229 }
8230
8231 #[test]
8232 fn test_reserved_generated_authority_metadata_rejects_raw_mutation() {
8233 let mut session = Session::new();
8234
8235 assert!(
8236 session
8237 .try_set_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY, serde_json::json!({}))
8238 .is_err()
8239 );
8240 assert!(
8241 session
8242 .try_set_metadata(SESSION_METADATA_KEY, serde_json::json!({}))
8243 .is_err()
8244 );
8245 assert!(
8246 session
8247 .try_set_metadata(SESSION_BUILD_STATE_KEY, serde_json::json!({}))
8248 .is_err()
8249 );
8250 session
8251 .set_session_metadata(SessionMetadata {
8252 schema_version: SESSION_METADATA_SCHEMA_VERSION,
8253 model: "test-model".to_string(),
8254 max_tokens: 1024,
8255 structured_output_retries: 2,
8256 provider: Provider::Other,
8257 self_hosted_server_id: None,
8258 provider_params: None,
8259 tooling: SessionTooling::default(),
8260 keep_alive: false,
8261 comms_name: None,
8262 peer_meta: None,
8263 realm_id: None,
8264 instance_id: None,
8265 backend: None,
8266 config_generation: None,
8267 auth_binding: None,
8268 mob_member_binding: None,
8269 })
8270 .expect("typed metadata setter should route through generated authority");
8271 session
8272 .set_build_state(SessionBuildState::default())
8273 .expect("typed build-state setter should route through generated authority");
8274 session.remove_metadata(SESSION_METADATA_KEY);
8275 session.remove_metadata(SESSION_BUILD_STATE_KEY);
8276 assert!(
8277 session.metadata().contains_key(SESSION_METADATA_KEY),
8278 "raw removal must not delete generated-authority session metadata"
8279 );
8280 assert!(
8281 session.metadata().contains_key(SESSION_BUILD_STATE_KEY),
8282 "raw removal must not delete generated-authority build state"
8283 );
8284 session.set_metadata(SESSION_DEFERRED_TURN_STATE_KEY, serde_json::json!({}));
8285 assert!(
8286 !session
8287 .metadata()
8288 .contains_key(SESSION_DEFERRED_TURN_STATE_KEY)
8289 );
8290 assert!(
8291 !session.backfill_metadata_if_absent(
8292 SESSION_SYSTEM_CONTEXT_STATE_KEY,
8293 serde_json::json!({})
8294 )
8295 );
8296
8297 let state = SessionSystemContextState::default();
8298 session
8299 .set_system_context_state(state.clone())
8300 .expect("typed setter should route through generated authority");
8301 session.remove_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY);
8302 assert_eq!(
8303 session
8304 .try_system_context_state()
8305 .expect("typed state should restore"),
8306 Some(state)
8307 );
8308
8309 session.metadata.insert(
8310 SESSION_SYSTEM_CONTEXT_STATE_KEY.to_string(),
8311 serde_json::json!("not-a-state"),
8312 );
8313 assert!(
8314 session.try_system_context_state().is_err(),
8315 "malformed generated authority state must not decode as absent/default"
8316 );
8317
8318 session.metadata.insert(
8319 SESSION_METADATA_KEY.to_string(),
8320 serde_json::json!("not-metadata"),
8321 );
8322 assert!(
8323 session.try_session_metadata().is_err(),
8324 "malformed session metadata must not decode as absent/default"
8325 );
8326
8327 session.metadata.insert(
8328 SESSION_BUILD_STATE_KEY.to_string(),
8329 serde_json::json!("not-build-state"),
8330 );
8331 assert!(
8332 session.try_build_state().is_err(),
8333 "malformed build state must not decode as absent/default"
8334 );
8335
8336 assert!(
8337 session
8338 .try_set_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY, serde_json::json!({}))
8339 .is_err()
8340 );
8341 session
8342 .set_tool_visibility_state(
8343 AuthorizedSessionToolVisibilityState::from_generated_authority(
8344 SessionToolVisibilityState::default(),
8345 ),
8346 )
8347 .expect("typed visibility setter should route through typed authority handoff");
8348 session.remove_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY);
8349 assert!(
8350 session
8351 .metadata()
8352 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
8353 );
8354 session.clear_tool_visibility_state();
8355 assert!(
8356 !session
8357 .metadata()
8358 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
8359 );
8360 assert!(
8361 session
8362 .try_set_metadata(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, serde_json::json!({}))
8363 .is_err()
8364 );
8365 let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
8366 item_id: "rt-item".to_string(),
8367 previous_item_id: None,
8368 role: RealtimeTranscriptRole::User,
8369 response_id: None,
8370 });
8371 assert!(
8372 session
8373 .metadata()
8374 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
8375 "typed realtime transcript append should retain authority to persist its state"
8376 );
8377 session.metadata.insert(
8378 SESSION_REALTIME_TRANSCRIPT_STATE_KEY.to_string(),
8379 serde_json::json!("not-a-state"),
8380 );
8381 assert!(
8382 session.try_realtime_transcript_state().is_err(),
8383 "malformed realtime generated authority state must not decode as absent/default"
8384 );
8385 }
8386
8387 #[test]
8388 fn test_session_mob_tool_authority_context_persists_projection_without_authority_seal() {
8389 let mut session = Session::new();
8390 session
8391 .set_build_state(SessionBuildState::default())
8392 .expect("session build state should serialize");
8393 let authority = MobToolAuthorityContext::generated_for_test(
8394 crate::service::OpaquePrincipalToken::new("opaque-principal"),
8395 false,
8396 false,
8397 false,
8398 std::collections::BTreeSet::from(["mob-a".to_string()]),
8399 std::collections::BTreeMap::new(),
8400 None,
8401 Some("audit-1".to_string()),
8402 );
8403
8404 session
8405 .set_mob_tool_authority_context(Some(authority))
8406 .expect("authority should serialize");
8407 assert!(session.mob_tool_authority_context().is_none());
8408 let stored = session
8409 .build_state()
8410 .and_then(|state| state.mob_tool_authority_context)
8411 .expect("stored projection should deserialize");
8412 assert!(!stored.is_generated_authority_context());
8413 assert!(!stored.can_manage_mob("mob-a"));
8414
8415 session
8416 .set_mob_tool_authority_context(None)
8417 .expect("authority should clear");
8418 assert!(session.mob_tool_authority_context().is_none());
8419 }
8420
8421 #[test]
8422 fn test_session_build_state_rejects_forged_mob_authority_projection() {
8423 let mut session = Session::new();
8424 let authority = MobToolAuthorityContext::generated_for_test(
8425 crate::service::OpaquePrincipalToken::new("opaque-principal"),
8426 false,
8427 false,
8428 false,
8429 std::collections::BTreeSet::from(["mob-a".to_string()]),
8430 std::collections::BTreeMap::new(),
8431 None,
8432 Some("audit-1".to_string()),
8433 );
8434 let forged_projection: MobToolAuthorityContext =
8435 serde_json::from_value(serde_json::to_value(authority).expect("serialize authority"))
8436 .expect("deserialize projection");
8437 assert!(!forged_projection.is_generated_authority_context());
8438
8439 let err = session
8440 .set_build_state(SessionBuildState {
8441 mob_tool_authority_context: Some(forged_projection),
8442 ..Default::default()
8443 })
8444 .expect_err("forged build state must be rejected by generated authority");
8445 assert!(
8449 err.to_string()
8450 .contains("generated session document authority rejected"),
8451 "unexpected error: {err}"
8452 );
8453 }
8454
8455 #[test]
8456 fn test_session_tool_visibility_state_roundtrip() {
8457 let mut session = Session::new();
8458 let state = SessionToolVisibilityState {
8459 inherited_base_filter: ToolFilter::Allow(["visible".to_string()].into_iter().collect()),
8460 active_filter: ToolFilter::Allow(
8461 ["visible".to_string(), "missing".to_string()]
8462 .into_iter()
8463 .collect(),
8464 ),
8465 staged_filter: ToolFilter::Allow(
8466 ["visible".to_string(), "missing".to_string()]
8467 .into_iter()
8468 .collect(),
8469 ),
8470 active_revision: 1,
8471 staged_revision: 2,
8472 ..Default::default()
8473 };
8474
8475 session
8476 .set_tool_visibility_state(
8477 AuthorizedSessionToolVisibilityState::from_generated_authority(state.clone()),
8478 )
8479 .expect("tool visibility state should serialize");
8480 assert_eq!(session.tool_visibility_state().unwrap(), Some(state));
8481 }
8482
8483 #[test]
8484 fn test_session_tool_visibility_state_malformed_returns_error() {
8485 let mut session = Session::new();
8486 session.metadata.insert(
8487 SESSION_TOOL_VISIBILITY_STATE_KEY.to_string(),
8488 serde_json::json!({
8489 "active_filter": {
8490 "unexpected_filter_kind": ["secret"]
8491 }
8492 }),
8493 );
8494
8495 assert!(
8496 session.tool_visibility_state().is_err(),
8497 "malformed canonical visibility metadata must not decode as absent/default"
8498 );
8499 }
8500
8501 #[test]
8502 fn test_session_serialization() {
8503 let mut session = Session::new();
8504 session.push(Message::User(UserMessage::text("Test".to_string())));
8505
8506 let json = serde_json::to_string(&session).unwrap();
8507 let parsed: Session = serde_json::from_str(&json).unwrap();
8508
8509 assert_eq!(parsed.id(), session.id());
8510 assert_eq!(parsed.messages().len(), 1);
8511 assert_eq!(parsed.version(), SESSION_VERSION);
8512 }
8513
8514 #[test]
8515 fn test_session_meta_from_session() {
8516 let mut session = Session::new();
8517 session.push(Message::User(UserMessage::text("Hello".to_string())));
8518 session.push(Message::BlockAssistant(BlockAssistantMessage {
8519 blocks: vec![AssistantBlock::Text {
8520 text: "Hi!".to_string(),
8521 meta: None,
8522 }],
8523 stop_reason: StopReason::EndTurn,
8524 identity: crate::types::TranscriptMessageIdentity::default(),
8525 created_at: crate::types::message_timestamp_now(),
8526 }));
8527 session.record_usage(Usage {
8528 input_tokens: 10,
8529 output_tokens: 5,
8530 cache_creation_tokens: None,
8531 cache_read_tokens: None,
8532 });
8533
8534 let meta = SessionMeta::from(&session);
8535 assert_eq!(meta.id, *session.id());
8536 assert_eq!(meta.message_count, 2);
8537 assert_eq!(meta.total_tokens, 15);
8538 }
8539
8540 #[test]
8541 fn system_context_state_preserves_applied_runtime_context() {
8542 let accepted_at = SystemTime::UNIX_EPOCH;
8543 let mut state = SessionSystemContextState::default();
8544 state
8545 .stage_append(
8546 &AppendSystemContextRequest {
8547 content: crate::lifecycle::run_primitive::CoreRenderable::text(
8548 "Authoritative peer token is birch seventeen.".to_string(),
8549 ),
8550 source: Some(
8551 "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1"
8552 .to_string(),
8553 ),
8554 idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
8555 source_kind: SystemContextSource::Normal,
8556 peer_response_terminal: None,
8557 },
8558 accepted_at,
8559 )
8560 .expect("append should stage");
8561
8562 state.mark_pending_applied();
8563
8564 assert!(state.pending.is_empty());
8565 assert_eq!(state.applied.len(), 1);
8566 assert_eq!(
8567 state.applied[0].content.render_text(),
8568 "Authoritative peer token is birch seventeen."
8569 );
8570 assert_eq!(
8571 state.applied[0].source.as_deref(),
8572 Some("peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1")
8573 );
8574
8575 let round_tripped: SessionSystemContextState =
8576 serde_json::from_value(serde_json::to_value(&state).expect("serialize state"))
8577 .expect("deserialize state");
8578 assert_eq!(round_tripped.applied, state.applied);
8579 }
8580
8581 #[test]
8582 fn active_turn_system_context_is_discarded_when_not_applied() {
8583 let mut state = SessionSystemContextState::default();
8584 state
8585 .stage_active_turn_append(
8586 &AppendSystemContextRequest {
8587 content: crate::lifecycle::run_primitive::CoreRenderable::text(
8588 "only for the active run".to_string(),
8589 ),
8590 source: Some("runtime:steer:input-1".to_string()),
8591 idempotency_key: Some("runtime:steer:input-1".to_string()),
8592 source_kind: SystemContextSource::RuntimeSteer,
8593 peer_response_terminal: None,
8594 },
8595 SystemTime::UNIX_EPOCH,
8596 )
8597 .expect("active context should stage");
8598
8599 let discarded = state.discard_unapplied_active_turn_pending();
8600
8601 assert_eq!(discarded.len(), 1);
8602 assert!(state.pending.is_empty());
8603 assert!(state.applied.is_empty());
8604 assert!(state.active_turn_pending_keys.is_empty());
8605 assert!(
8606 state.seen.is_empty(),
8607 "discarded active-turn context should not block later idempotency keys"
8608 );
8609 }
8610
8611 #[test]
8612 fn active_turn_system_context_can_roll_back_targeted_keys() {
8613 let mut state = SessionSystemContextState::default();
8614 for key in ["runtime:steer:input-1", "runtime:steer:input-2"] {
8615 state
8616 .stage_active_turn_append(
8617 &AppendSystemContextRequest {
8618 content: crate::lifecycle::run_primitive::CoreRenderable::text(format!(
8619 "context for {key}"
8620 )),
8621 source: Some(key.to_string()),
8622 idempotency_key: Some(key.to_string()),
8623 source_kind: SystemContextSource::RuntimeSteer,
8624 peer_response_terminal: None,
8625 },
8626 SystemTime::UNIX_EPOCH,
8627 )
8628 .expect("active context should stage");
8629 }
8630
8631 let discarded =
8632 state.discard_active_turn_pending_by_keys(&["runtime:steer:input-1".to_string()]);
8633
8634 assert_eq!(discarded.len(), 1);
8635 assert_eq!(
8636 discarded[0].idempotency_key.as_deref(),
8637 Some("runtime:steer:input-1")
8638 );
8639 assert_eq!(state.pending.len(), 1);
8640 assert_eq!(
8641 state.pending[0].idempotency_key.as_deref(),
8642 Some("runtime:steer:input-2")
8643 );
8644 assert!(!state.seen.contains_key("runtime:steer:input-1"));
8645 assert!(state.seen.contains_key("runtime:steer:input-2"));
8646 assert!(
8647 !state
8648 .active_turn_pending_keys
8649 .contains("runtime:steer:input-1")
8650 );
8651 assert!(
8652 state
8653 .active_turn_pending_keys
8654 .contains("runtime:steer:input-2")
8655 );
8656 }
8657
8658 #[test]
8659 fn active_turn_system_context_is_transient_when_boundary_consumes_it() {
8660 let mut state = SessionSystemContextState::default();
8661 state
8662 .stage_active_turn_append(
8663 &AppendSystemContextRequest {
8664 content: crate::lifecycle::run_primitive::CoreRenderable::text(
8665 "visible to this run".to_string(),
8666 ),
8667 source: Some("runtime:steer:input-2".to_string()),
8668 idempotency_key: Some("runtime:steer:input-2".to_string()),
8669 source_kind: SystemContextSource::RuntimeSteer,
8670 peer_response_terminal: None,
8671 },
8672 SystemTime::UNIX_EPOCH,
8673 )
8674 .expect("active context should stage");
8675
8676 state.mark_pending_applied();
8677 let discarded = state.discard_unapplied_active_turn_pending();
8678
8679 assert!(discarded.is_empty());
8680 assert!(state.pending.is_empty());
8681 assert!(state.applied.is_empty());
8682 assert!(state.active_turn_pending_keys.is_empty());
8683 assert_eq!(
8684 state.seen.get("runtime:steer:input-2"),
8685 None,
8686 "consumed active-turn steer context must not become durable state"
8687 );
8688 }
8689
8690 #[test]
8691 fn discard_transient_runtime_steer_context_removes_steer_via_typed_marker() {
8692 let mut session = Session::new();
8693 session.set_system_prompt(format!(
8697 "base{}{}{}{}",
8698 SYSTEM_CONTEXT_SEPARATOR,
8699 render_system_context_block(&PendingSystemContextAppend {
8700 content: crate::lifecycle::run_primitive::CoreRenderable::text(
8701 "old steer".to_string()
8702 ),
8703 source: Some("steer-source-old".to_string()),
8704 idempotency_key: Some("steer-key-old".to_string()),
8705 source_kind: SystemContextSource::RuntimeSteer,
8706 peer_response_terminal: None,
8707 accepted_at: SystemTime::UNIX_EPOCH,
8708 }),
8709 SYSTEM_CONTEXT_SEPARATOR,
8710 render_system_context_block(&PendingSystemContextAppend {
8711 content: crate::lifecycle::run_primitive::CoreRenderable::text(
8712 "durable peer fact".to_string()
8713 ),
8714 source: Some("peer_response_terminal:analyst:req".to_string()),
8715 idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
8716 source_kind: SystemContextSource::Normal,
8717 peer_response_terminal: None,
8718 accepted_at: SystemTime::UNIX_EPOCH,
8719 })
8720 ));
8721 session
8722 .set_system_context_state(SessionSystemContextState {
8723 pending: vec![PendingSystemContextAppend {
8724 content: crate::lifecycle::run_primitive::CoreRenderable::text(
8725 "pending steer".to_string(),
8726 ),
8727 source: Some("steer-source-pending".to_string()),
8728 idempotency_key: Some("steer-key-pending".to_string()),
8729 source_kind: SystemContextSource::RuntimeSteer,
8730 peer_response_terminal: None,
8731 accepted_at: SystemTime::UNIX_EPOCH,
8732 }],
8733 applied: vec![
8734 PendingSystemContextAppend {
8735 content: crate::lifecycle::run_primitive::CoreRenderable::text(
8736 "old steer".to_string(),
8737 ),
8738 source: Some("steer-source-old".to_string()),
8739 idempotency_key: Some("steer-key-old".to_string()),
8740 source_kind: SystemContextSource::RuntimeSteer,
8741 peer_response_terminal: None,
8742 accepted_at: SystemTime::UNIX_EPOCH,
8743 },
8744 PendingSystemContextAppend {
8745 content: crate::lifecycle::run_primitive::CoreRenderable::text(
8746 "durable peer fact".to_string(),
8747 ),
8748 source: Some("peer_response_terminal:analyst:req".to_string()),
8749 idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
8750 source_kind: SystemContextSource::Normal,
8751 peer_response_terminal: None,
8752 accepted_at: SystemTime::UNIX_EPOCH,
8753 },
8754 ],
8755 seen: BTreeMap::from([(
8756 "steer-key-old".to_string(),
8757 SeenSystemContextKey {
8758 content: crate::lifecycle::run_primitive::CoreRenderable::text(
8759 "old steer".to_string(),
8760 ),
8761 source: Some("steer-source-old".to_string()),
8762 source_kind: SystemContextSource::RuntimeSteer,
8763 state: SeenSystemContextState::Applied,
8764 },
8765 )]),
8766 active_turn_pending_keys: BTreeSet::from(["steer-key-pending".to_string()]),
8767 })
8768 .expect("system context state should serialize");
8769
8770 let removed = session.discard_transient_runtime_steer_context();
8771
8772 assert!(removed >= 4);
8773 let system_prompt = match session.messages().first() {
8774 Some(Message::System(system)) => system.content.as_str(),
8775 other => panic!("expected system prompt, got {other:?}"),
8776 };
8777 assert!(!system_prompt.contains("old steer"));
8778 assert!(system_prompt.contains("durable peer fact"));
8779 let state = session.system_context_state().unwrap_or_default();
8780 assert!(state.pending.is_empty());
8781 assert_eq!(state.applied.len(), 1);
8782 assert_eq!(state.applied[0].content.render_text(), "durable peer fact");
8783 assert!(state.seen.is_empty());
8784 assert!(state.active_turn_pending_keys.is_empty());
8785 }
8786
8787 #[test]
8788 fn append_system_context_blocks_records_typed_applied_context() {
8789 let append = PendingSystemContextAppend {
8790 content: crate::lifecycle::run_primitive::CoreRenderable::text(
8791 "Authoritative peer token is birch seventeen.".to_string(),
8792 ),
8793 source: Some(
8794 "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string(),
8795 ),
8796 idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
8797 source_kind: SystemContextSource::Normal,
8798 peer_response_terminal: None,
8799 accepted_at: SystemTime::UNIX_EPOCH,
8800 };
8801 let mut session = Session::new();
8802
8803 session.append_system_context_blocks(std::slice::from_ref(&append));
8804
8805 let state = session
8806 .system_context_state()
8807 .expect("append should persist typed context state");
8808 assert_eq!(state.applied, vec![append]);
8809 }
8810
8811 fn roster_append() -> PendingSystemContextAppend {
8812 PendingSystemContextAppend {
8813 content: crate::lifecycle::run_primitive::CoreRenderable::text(
8814 "peer roster: lead-1, w-1".to_string(),
8815 ),
8816 source: Some("comms:roster".to_string()),
8817 idempotency_key: Some("comms:roster:v1".to_string()),
8818 source_kind: SystemContextSource::Normal,
8819 peer_response_terminal: None,
8820 accepted_at: SystemTime::UNIX_EPOCH,
8821 }
8822 }
8823
8824 fn resumed_session_with_context_appended_prompt(base: &str) -> Session {
8825 let mut session = Session::new();
8826 session.set_system_prompt(base.to_string());
8827 session.push(Message::User(UserMessage::text("hello".to_string())));
8828 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
8829 session
8830 }
8831
8832 #[test]
8833 fn reconcile_resumed_system_prompt_preserves_identical_base() {
8834 let mut session = Session::new();
8835 session.set_system_prompt("base prompt".to_string());
8836 session.push(Message::User(UserMessage::text("hello".to_string())));
8837 let digest_before = transcript_messages_digest(session.messages()).unwrap();
8838
8839 let outcome = session
8840 .reconcile_resumed_system_prompt("base prompt".to_string(), None)
8841 .expect("reconcile");
8842
8843 assert_eq!(
8844 outcome,
8845 ResumedSystemPromptReconciliation::PreservedContinuation
8846 );
8847 assert_eq!(
8848 transcript_messages_digest(session.messages()).unwrap(),
8849 digest_before,
8850 "identical base must leave the transcript revision unchanged"
8851 );
8852 }
8853
8854 #[test]
8855 fn reconcile_resumed_system_prompt_preserves_context_appended_base() {
8856 let mut session = resumed_session_with_context_appended_prompt("base prompt");
8857 let digest_before = transcript_messages_digest(session.messages()).unwrap();
8858
8859 let outcome = session
8860 .reconcile_resumed_system_prompt("base prompt".to_string(), None)
8861 .expect("reconcile");
8862
8863 assert_eq!(
8864 outcome,
8865 ResumedSystemPromptReconciliation::PreservedContinuation
8866 );
8867 assert_eq!(
8868 transcript_messages_digest(session.messages()).unwrap(),
8869 digest_before,
8870 "a base extended only by runtime context appends must stay untouched"
8871 );
8872 let system = match session.messages().first() {
8873 Some(Message::System(system)) => system.clone(),
8874 other => panic!("expected system message, got {other:?}"),
8875 };
8876 assert!(system.content.contains("peer roster: lead-1, w-1"));
8877 assert!(
8878 system.mutation_kind.is_runtime_context_append(),
8879 "the persisted mutation provenance must survive reconciliation"
8880 );
8881 }
8882
8883 #[test]
8884 fn reconcile_resumed_system_prompt_rewrites_changed_base_preserving_tail() {
8885 let mut session = resumed_session_with_context_appended_prompt("base prompt");
8886
8887 let outcome = session
8888 .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
8889 .expect("reconcile");
8890
8891 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
8892 let system_content = match session.messages().first() {
8893 Some(Message::System(system)) => system.content.clone(),
8894 other => panic!("expected system message, got {other:?}"),
8895 };
8896 assert!(
8897 system_content.starts_with("new base prompt"),
8898 "the changed base must be applied: {system_content}"
8899 );
8900 assert!(
8901 system_content.contains("peer roster: lead-1, w-1"),
8902 "the runtime-applied context tail must survive the base change: {system_content}"
8903 );
8904 let state = session
8905 .transcript_history_state()
8906 .expect("history state deserializes")
8907 .expect("rewrite must record transcript history");
8908 assert_eq!(state.commits.len(), 1);
8909 assert_eq!(
8910 state.commits[0].reason.kind,
8911 RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON
8912 );
8913 assert_eq!(
8914 state.head,
8915 transcript_messages_digest(session.messages()).unwrap(),
8916 "the committed head must match the rewritten transcript"
8917 );
8918 }
8919
8920 #[test]
8921 fn reconcile_resumed_system_prompt_inserts_prompt_on_promptless_transcript() {
8922 let mut session = Session::new();
8923 session.push(Message::User(UserMessage::text("hello".to_string())));
8924
8925 let outcome = session
8926 .reconcile_resumed_system_prompt("late prompt".to_string(), None)
8927 .expect("reconcile");
8928
8929 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
8930 assert!(matches!(
8931 session.messages().first(),
8932 Some(Message::System(system)) if system.content == "late prompt"
8933 ));
8934 let state = session
8935 .transcript_history_state()
8936 .expect("history state deserializes")
8937 .expect("insert must record transcript history");
8938 assert_eq!(state.commits.len(), 1);
8939 assert_eq!(
8940 state.commits[0].reason.kind,
8941 RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON
8942 );
8943 }
8944
8945 fn leading_system_content(session: &Session) -> String {
8946 match session.messages().first() {
8947 Some(Message::System(system)) => system.content.clone(),
8948 other => panic!("expected leading system message, got {other:?}"),
8949 }
8950 }
8951
8952 #[test]
8953 fn reconcile_resumed_system_prompt_preserves_full_context_prompt_from_empty_base() {
8954 let mut session = Session::new();
8959 session.push(Message::User(UserMessage::text("hello".to_string())));
8960 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
8961 let all_context_content = leading_system_content(&session);
8962 assert!(all_context_content.contains("peer roster: lead-1, w-1"));
8963
8964 let outcome = session
8965 .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
8966 .expect("reconcile");
8967
8968 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
8969 assert_eq!(
8970 leading_system_content(&session),
8971 format!("new base prompt{SYSTEM_CONTEXT_SEPARATOR}{all_context_content}"),
8972 "the all-context prompt must survive as the runtime tail of the new base"
8973 );
8974 }
8975
8976 #[test]
8977 fn reconcile_resumed_system_prompt_preserves_context_only_prompt_on_empty_base_resume() {
8978 let mut session = Session::new();
8981 session.push(Message::User(UserMessage::text("hello".to_string())));
8982 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
8983 let digest_before = transcript_messages_digest(session.messages()).unwrap();
8984
8985 let outcome = session
8986 .reconcile_resumed_system_prompt(String::new(), None)
8987 .expect("reconcile");
8988
8989 assert_eq!(
8990 outcome,
8991 ResumedSystemPromptReconciliation::PreservedContinuation
8992 );
8993 assert_eq!(
8994 transcript_messages_digest(session.messages()).unwrap(),
8995 digest_before
8996 );
8997 }
8998
8999 #[test]
9000 fn reconcile_resumed_system_prompt_applies_shortened_base_with_recorded_prior() {
9001 let full_base = format!("part one{SYSTEM_CONTEXT_SEPARATOR}part two");
9005 let mut session = Session::new();
9006 session.set_system_prompt(full_base.clone());
9007 session.push(Message::User(UserMessage::text("hello".to_string())));
9008 session
9009 .set_build_state(SessionBuildState {
9010 assembled_system_prompt: Some(full_base),
9011 ..Default::default()
9012 })
9013 .expect("build state");
9014
9015 let outcome = session
9016 .reconcile_resumed_system_prompt("part one".to_string(), None)
9017 .expect("reconcile");
9018
9019 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
9020 assert_eq!(leading_system_content(&session), "part one");
9021 }
9022
9023 #[test]
9024 fn reconcile_resumed_system_prompt_applies_shortened_base_without_context_provenance() {
9025 let full_base = format!("part one{SYSTEM_CONTEXT_SEPARATOR}part two");
9030 let mut session = Session::new();
9031 session.set_system_prompt(full_base);
9032 session.push(Message::User(UserMessage::text("hello".to_string())));
9033
9034 let outcome = session
9035 .reconcile_resumed_system_prompt("part one".to_string(), None)
9036 .expect("reconcile");
9037
9038 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
9039 assert_eq!(leading_system_content(&session), "part one");
9040 }
9041
9042 #[test]
9043 fn reconcile_resumed_system_prompt_preserves_appended_prompt_without_applied_records() {
9044 let mut session = resumed_session_with_context_appended_prompt("base prompt");
9049 session
9050 .set_system_context_state(SessionSystemContextState::default())
9051 .expect("sweep applied records");
9052 let digest_before = transcript_messages_digest(session.messages()).unwrap();
9053
9054 let outcome = session
9055 .reconcile_resumed_system_prompt("base prompt".to_string(), None)
9056 .expect("reconcile");
9057
9058 assert_eq!(
9059 outcome,
9060 ResumedSystemPromptReconciliation::PreservedContinuation
9061 );
9062 assert_eq!(
9063 transcript_messages_digest(session.messages()).unwrap(),
9064 digest_before
9065 );
9066 }
9067
9068 #[test]
9069 fn reconcile_resumed_system_prompt_clears_orphaned_applied_records_on_tail_drop() {
9070 let mut session = resumed_session_with_context_appended_prompt("base prompt");
9071 session.set_system_prompt(format!(
9076 "mutated base{SYSTEM_CONTEXT_SEPARATOR}stale-looking tail"
9077 ));
9078
9079 let outcome = session
9080 .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
9081 .expect("reconcile");
9082
9083 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
9084 assert_eq!(leading_system_content(&session), "new base prompt");
9085 let state = session.system_context_state().unwrap_or_default();
9086 assert!(
9087 state.applied.is_empty(),
9088 "orphaned applied records must be cleared so the context stays restorable"
9089 );
9090 assert!(
9091 state.seen.is_empty(),
9092 "orphaned idempotency keys must be cleared so keyed re-sends re-apply"
9093 );
9094
9095 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
9098 assert!(
9099 leading_system_content(&session).contains("peer roster: lead-1, w-1"),
9100 "re-sent keyed context must re-apply after the drop"
9101 );
9102 }
9103
9104 #[test]
9105 fn append_system_context_blocks_renders_pre_marked_pending_context() {
9106 let accepted_at = SystemTime::UNIX_EPOCH;
9107 let mut state = SessionSystemContextState::default();
9108 state
9109 .stage_append(
9110 &AppendSystemContextRequest {
9111 content: crate::lifecycle::run_primitive::CoreRenderable::text(
9112 "Apply this staged context at the request boundary.".to_string(),
9113 ),
9114 source: Some("rpc/session_inject_context".to_string()),
9115 idempotency_key: Some("ctx-boundary".to_string()),
9116 source_kind: SystemContextSource::Normal,
9117 peer_response_terminal: None,
9118 },
9119 accepted_at,
9120 )
9121 .expect("append should stage");
9122 let pending = state.pending.clone();
9123 state.mark_pending_applied();
9124 let mut session = Session::new();
9125 session
9126 .set_system_context_state(state)
9127 .expect("state should serialize");
9128
9129 session.append_system_context_blocks(&pending);
9130
9131 let system_prompt = session
9132 .messages()
9133 .first()
9134 .and_then(|message| match message {
9135 Message::System(system) => Some(system.content.as_str()),
9136 _ => None,
9137 })
9138 .unwrap_or_default();
9139 assert!(system_prompt.contains("Apply this staged context at the request boundary."));
9140 let state = session
9141 .system_context_state()
9142 .expect("append should persist typed context state");
9143 assert_eq!(state.applied.len(), 1);
9144 assert_eq!(
9145 state.seen["ctx-boundary"].state,
9146 SeenSystemContextState::Applied
9147 );
9148 }
9149
9150 #[test]
9151 fn append_system_context_blocks_renders_pre_marked_context_without_idempotency_key() {
9152 let accepted_at = SystemTime::UNIX_EPOCH;
9153 let mut state = SessionSystemContextState::default();
9154 state
9155 .stage_append(
9156 &AppendSystemContextRequest {
9157 content: crate::lifecycle::run_primitive::CoreRenderable::text(
9158 "Apply this unkeyed staged context at the request boundary.".to_string(),
9159 ),
9160 source: Some("rpc/session_inject_context".to_string()),
9161 idempotency_key: None,
9162 source_kind: SystemContextSource::Normal,
9163 peer_response_terminal: None,
9164 },
9165 accepted_at,
9166 )
9167 .expect("append should stage");
9168 let pending = state.pending.clone();
9169 state.mark_pending_applied();
9170 let mut session = Session::new();
9171 session
9172 .set_system_context_state(state)
9173 .expect("state should serialize");
9174
9175 session.append_system_context_blocks(&pending);
9176
9177 let system_prompt = session
9178 .messages()
9179 .first()
9180 .and_then(|message| match message {
9181 Message::System(system) => Some(system.content.as_str()),
9182 _ => None,
9183 })
9184 .unwrap_or_default();
9185 assert!(
9186 system_prompt.contains("Apply this unkeyed staged context at the request boundary.")
9187 );
9188 }
9189
9190 #[test]
9194 fn staged_system_context_carries_typed_renderable_to_render_seam() {
9195 use crate::lifecycle::run_primitive::CoreRenderable;
9196
9197 let accepted_at = SystemTime::UNIX_EPOCH;
9198 let mut state = SessionSystemContextState::default();
9199 let renderable = CoreRenderable::Json {
9200 value: serde_json::json!({"alert": "disk-full", "severity": 2}),
9201 };
9202 state
9203 .stage_append(
9204 &AppendSystemContextRequest {
9205 content: renderable.clone(),
9206 source: Some("ops/monitor".to_string()),
9207 idempotency_key: Some("alert-1".to_string()),
9208 source_kind: SystemContextSource::Normal,
9209 peer_response_terminal: None,
9210 },
9211 accepted_at,
9212 )
9213 .expect("typed renderable append should stage");
9214
9215 assert_eq!(state.pending.len(), 1);
9218 assert_eq!(state.pending[0].content, renderable);
9219
9220 let rendered = render_system_context_block(&state.pending[0]);
9223 assert!(rendered.starts_with(SYSTEM_CONTEXT_RENDER_LABEL));
9224 assert!(
9225 rendered.contains(renderable.render_text().trim()),
9226 "render seam must lower via CoreRenderable::render_text: {rendered}"
9227 );
9228 }
9229
9230 #[test]
9231 fn append_system_context_blocks_skips_duplicate_idempotency_key() {
9232 let first = PendingSystemContextAppend {
9233 content: crate::lifecycle::run_primitive::CoreRenderable::text(
9234 "Authoritative peer token is birch seventeen.".to_string(),
9235 ),
9236 source: Some("peer_response_terminal:analyst:req-1".to_string()),
9237 idempotency_key: Some("req-1".to_string()),
9238 source_kind: SystemContextSource::Normal,
9239 peer_response_terminal: None,
9240 accepted_at: SystemTime::UNIX_EPOCH,
9241 };
9242 let duplicate = PendingSystemContextAppend {
9243 accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
9244 ..first.clone()
9245 };
9246 let mut session = Session::new();
9247
9248 session.append_system_context_blocks(std::slice::from_ref(&first));
9249 session.append_system_context_blocks(std::slice::from_ref(&duplicate));
9250
9251 let state = session
9252 .system_context_state()
9253 .expect("append should persist typed context state");
9254 assert_eq!(state.applied, vec![first]);
9255 let system_prompt = session
9256 .messages()
9257 .first()
9258 .and_then(|message| match message {
9259 Message::System(system) => Some(system.content.as_str()),
9260 _ => None,
9261 })
9262 .unwrap_or_default();
9263 assert_eq!(
9264 system_prompt
9265 .matches("Authoritative peer token is birch seventeen.")
9266 .count(),
9267 1
9268 );
9269 }
9270
9271 #[test]
9272 fn append_system_context_blocks_skips_conflicting_duplicate_idempotency_key() {
9273 let first = PendingSystemContextAppend {
9274 content: crate::lifecycle::run_primitive::CoreRenderable::text(
9275 "Authoritative peer token is birch seventeen.".to_string(),
9276 ),
9277 source: Some("peer_response_terminal:analyst:req-1".to_string()),
9278 idempotency_key: Some("req-1".to_string()),
9279 source_kind: SystemContextSource::Normal,
9280 peer_response_terminal: None,
9281 accepted_at: SystemTime::UNIX_EPOCH,
9282 };
9283 let conflicting = PendingSystemContextAppend {
9284 content: crate::lifecycle::run_primitive::CoreRenderable::text(
9285 "Conflicting peer token should not reach the prompt.".to_string(),
9286 ),
9287 accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
9288 ..first.clone()
9289 };
9290 let mut session = Session::new();
9291
9292 session.append_system_context_blocks(std::slice::from_ref(&first));
9293 session.append_system_context_blocks(std::slice::from_ref(&conflicting));
9294
9295 let state = session
9296 .system_context_state()
9297 .expect("append should persist typed context state");
9298 assert_eq!(state.applied, vec![first]);
9299 let system_prompt = session
9300 .messages()
9301 .first()
9302 .and_then(|message| match message {
9303 Message::System(system) => Some(system.content.as_str()),
9304 _ => None,
9305 })
9306 .unwrap_or_default();
9307 assert!(system_prompt.contains("Authoritative peer token is birch seventeen."));
9308 assert!(!system_prompt.contains("Conflicting peer token should not reach the prompt."));
9309 }
9310
9311 #[test]
9323 fn realtime_transcript_assistant_transcript_delta_materializes_transcript_block() {
9324 let mut session = Session::new();
9325
9326 let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
9327 response_id: "resp_spoken".to_string(),
9328 delta_id: "evt_delta_spoken_1".to_string(),
9329 item_id: "item_spoken".to_string(),
9330 previous_item_id: None,
9331 content_index: 0,
9332 delta: "I said hi".to_string(),
9333 };
9334 assert!(
9335 session.append_realtime_transcript_event(delta).is_inert(),
9336 "delta alone is inert until turn-completed flushes"
9337 );
9338
9339 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
9340 response_id: "resp_spoken".to_string(),
9341 stop_reason: StopReason::EndTurn,
9342 usage: Usage::default(),
9343 };
9344 let outcome = session.append_realtime_transcript_event(terminal);
9345 assert_eq!(outcome.materialized_messages.len(), 1);
9346
9347 let messages = session.messages();
9349 assert_eq!(messages.len(), 1);
9350 match &messages[0] {
9351 Message::BlockAssistant(assistant) => {
9352 assert_eq!(assistant.blocks.len(), 1);
9353 match &assistant.blocks[0] {
9354 AssistantBlock::Transcript { text, source, .. } => {
9355 assert_eq!(text, "I said hi");
9356 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
9357 }
9358 other => unreachable!(
9359 "AssistantTranscriptDelta must materialize as AssistantBlock::Transcript, got {other:?}"
9360 ),
9361 }
9362 }
9363 other => unreachable!("expected BlockAssistant message, got {other:?}"),
9364 }
9365 }
9366
9367 #[test]
9368 fn round4_cc4_in_flight_response_ids_lists_distinct_unmaterialized_responses() {
9369 let mut session = Session::new();
9375
9376 for (i, response_id) in [
9380 ("resp_a", "resp_a"),
9381 ("resp_a_extra", "resp_a"),
9382 ("resp_b", "resp_b"),
9383 ("resp_c", "resp_c"),
9384 ]
9385 .iter()
9386 .enumerate()
9387 {
9388 let event = RealtimeTranscriptEvent::AssistantTranscriptDelta {
9389 response_id: response_id.1.to_string(),
9390 delta_id: format!("delta_{i}"),
9391 item_id: response_id.0.to_string(),
9392 previous_item_id: None,
9393 content_index: 0,
9394 delta: "x".to_string(),
9395 };
9396 let _ = session.append_realtime_transcript_event(event);
9397 }
9398
9399 let _ = session.append_realtime_transcript_event(
9401 RealtimeTranscriptEvent::AssistantTurnInterrupted {
9402 response_id: "resp_c".to_string(),
9403 },
9404 );
9405
9406 let _ = session.append_realtime_transcript_event(
9409 RealtimeTranscriptEvent::UserTranscriptFinal {
9410 item_id: "u_item".to_string(),
9411 previous_item_id: None,
9412 content_index: 0,
9413 text: "hi".to_string(),
9414 },
9415 );
9416
9417 let in_flight = session.in_flight_realtime_assistant_response_ids();
9418 assert!(in_flight.contains(&"resp_a".to_string()), "{in_flight:?}");
9419 assert!(in_flight.contains(&"resp_b".to_string()), "{in_flight:?}");
9420 assert!(
9421 !in_flight.contains(&"resp_c".to_string()),
9422 "discarded response must not appear in in_flight: {in_flight:?}"
9423 );
9424 assert_eq!(
9426 in_flight.iter().filter(|r| *r == "resp_a").count(),
9427 1,
9428 "distinct response_ids only: {in_flight:?}"
9429 );
9430 }
9431
9432 #[test]
9433 fn round4_cc2_assistant_turn_completed_after_transcript_deltas_materializes_transcript() {
9434 let mut session = Session::new();
9441
9442 let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
9443 response_id: "resp_cc2".to_string(),
9444 delta_id: "delta_cc2_1".to_string(),
9445 item_id: "item_cc2".to_string(),
9446 previous_item_id: None,
9447 content_index: 0,
9448 delta: "hello world".to_string(),
9449 };
9450 assert!(session.append_realtime_transcript_event(delta).is_inert());
9451
9452 assert_eq!(
9454 session.in_flight_realtime_assistant_response_ids(),
9455 vec!["resp_cc2".to_string()]
9456 );
9457
9458 let outcome = session.append_realtime_transcript_event(
9459 RealtimeTranscriptEvent::AssistantTurnCompleted {
9460 response_id: "resp_cc2".to_string(),
9461 stop_reason: StopReason::EndTurn,
9462 usage: Usage::default(),
9463 },
9464 );
9465 assert_eq!(outcome.materialized_messages.len(), 1);
9466
9467 assert!(
9469 session
9470 .in_flight_realtime_assistant_response_ids()
9471 .is_empty(),
9472 "materialized items must not appear in in_flight_realtime_assistant_response_ids"
9473 );
9474
9475 let messages = session.messages();
9476 let assistant = messages.iter().find_map(|m| match m {
9477 Message::BlockAssistant(a) => Some(a),
9478 _ => None,
9479 });
9480 let assistant = assistant.expect("assistant block message expected");
9481 assert_eq!(assistant.blocks.len(), 1);
9482 assert!(matches!(
9483 &assistant.blocks[0],
9484 AssistantBlock::Transcript {
9485 source: crate::types::TranscriptSource::Spoken,
9486 ..
9487 }
9488 ));
9489 }
9490
9491 #[test]
9492 fn realtime_transcript_assistant_text_delta_still_materializes_text_block() {
9493 let mut session = Session::new();
9497
9498 let delta = RealtimeTranscriptEvent::AssistantTextDelta {
9499 response_id: "resp_display".to_string(),
9500 delta_id: "evt_delta_display_1".to_string(),
9501 item_id: "item_display".to_string(),
9502 previous_item_id: None,
9503 content_index: 0,
9504 delta: "I wrote".to_string(),
9505 };
9506 let _ = session.append_realtime_transcript_event(delta);
9507
9508 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
9509 response_id: "resp_display".to_string(),
9510 stop_reason: StopReason::EndTurn,
9511 usage: Usage::default(),
9512 };
9513 let outcome = session.append_realtime_transcript_event(terminal);
9514 assert_eq!(outcome.materialized_messages.len(), 1);
9515
9516 let messages = session.messages();
9517 match &messages[0] {
9518 Message::BlockAssistant(assistant) => match &assistant.blocks[0] {
9519 AssistantBlock::Text { text, .. } => assert_eq!(text, "I wrote"),
9520 other => unreachable!(
9521 "AssistantTextDelta must keep materializing AssistantBlock::Text, got {other:?}"
9522 ),
9523 },
9524 other => unreachable!("expected BlockAssistant message, got {other:?}"),
9525 }
9526 }
9527
9528 #[test]
9529 fn round4_cc7_mixed_response_persists_text_and_transcript_in_order() {
9530 let mut session = Session::new();
9548
9549 let display_a = RealtimeTranscriptEvent::AssistantTextDelta {
9551 response_id: "resp_mixed_1".to_string(),
9552 delta_id: "delta_disp_1".to_string(),
9553 item_id: "item_display".to_string(),
9554 previous_item_id: None,
9555 content_index: 0,
9556 delta: "Here's the report:".to_string(),
9557 };
9558 assert!(
9559 session
9560 .append_realtime_transcript_event(display_a)
9561 .is_inert()
9562 );
9563
9564 let display_b = RealtimeTranscriptEvent::AssistantTextDelta {
9565 response_id: "resp_mixed_1".to_string(),
9566 delta_id: "delta_disp_2".to_string(),
9567 item_id: "item_display".to_string(),
9568 previous_item_id: None,
9569 content_index: 0,
9570 delta: " (still writing)".to_string(),
9571 };
9572 assert!(
9573 session
9574 .append_realtime_transcript_event(display_b)
9575 .is_inert()
9576 );
9577
9578 let spoken_a = RealtimeTranscriptEvent::AssistantTranscriptDelta {
9583 response_id: "resp_mixed_1".to_string(),
9584 delta_id: "delta_spoken_1".to_string(),
9585 item_id: "item_spoken".to_string(),
9586 previous_item_id: Some("item_display".to_string()),
9587 content_index: 0,
9588 delta: "I'm reading the report aloud:".to_string(),
9589 };
9590 assert!(
9591 session
9592 .append_realtime_transcript_event(spoken_a)
9593 .is_inert()
9594 );
9595
9596 let spoken_b = RealtimeTranscriptEvent::AssistantTranscriptDelta {
9597 response_id: "resp_mixed_1".to_string(),
9598 delta_id: "delta_spoken_2".to_string(),
9599 item_id: "item_spoken".to_string(),
9600 previous_item_id: Some("item_display".to_string()),
9601 content_index: 0,
9602 delta: " sentence two.".to_string(),
9603 };
9604 assert!(
9605 session
9606 .append_realtime_transcript_event(spoken_b)
9607 .is_inert()
9608 );
9609
9610 let outcome = session.append_realtime_transcript_event(
9613 RealtimeTranscriptEvent::AssistantTurnCompleted {
9614 response_id: "resp_mixed_1".to_string(),
9615 stop_reason: StopReason::EndTurn,
9616 usage: Usage {
9617 input_tokens: 11,
9618 output_tokens: 22,
9619 cache_creation_tokens: None,
9620 cache_read_tokens: None,
9621 },
9622 },
9623 );
9624 assert_eq!(outcome.materialized_messages.len(), 2);
9626
9627 let messages = session.messages();
9630 let assistants: Vec<&BlockAssistantMessage> = messages
9631 .iter()
9632 .filter_map(|m| match m {
9633 Message::BlockAssistant(a) => Some(a),
9634 _ => None,
9635 })
9636 .collect();
9637 assert_eq!(
9638 assistants.len(),
9639 1,
9640 "mixed display+spoken response under one response_id must produce exactly ONE BlockAssistant message, got: {assistants:?}"
9641 );
9642 let assistant = assistants[0];
9643 assert_eq!(
9644 assistant.blocks.len(),
9645 2,
9646 "mixed response message must carry both blocks: {:?}",
9647 assistant.blocks
9648 );
9649
9650 match &assistant.blocks[0] {
9652 AssistantBlock::Text { text, .. } => {
9653 assert_eq!(text, "Here's the report: (still writing)");
9654 }
9655 other => unreachable!(
9656 "first block must be AssistantBlock::Text (display lane), got {other:?}"
9657 ),
9658 }
9659 match &assistant.blocks[1] {
9661 AssistantBlock::Transcript { text, source, .. } => {
9662 assert_eq!(text, "I'm reading the report aloud: sentence two.");
9663 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
9664 }
9665 other => unreachable!(
9666 "second block must be AssistantBlock::Transcript {{ source: Spoken }}, got {other:?}"
9667 ),
9668 }
9669
9670 assert_eq!(session.usage.input_tokens, 11);
9672 assert_eq!(session.usage.output_tokens, 22);
9673 }
9674
9675 #[test]
9676 fn round5_r55_mixed_response_barge_in_preserves_display_drops_spoken() {
9677 let mut session = Session::new();
9693
9694 let display = RealtimeTranscriptEvent::AssistantTextDelta {
9695 response_id: "resp_mixed_2".to_string(),
9696 delta_id: "delta_disp_1".to_string(),
9697 item_id: "item_display_2".to_string(),
9698 previous_item_id: None,
9699 content_index: 0,
9700 delta: "Working on the report...".to_string(),
9701 };
9702 let _ = session.append_realtime_transcript_event(display);
9703
9704 let spoken = RealtimeTranscriptEvent::AssistantTranscriptDelta {
9705 response_id: "resp_mixed_2".to_string(),
9706 delta_id: "delta_spoken_1".to_string(),
9707 item_id: "item_spoken_2".to_string(),
9708 previous_item_id: Some("item_display_2".to_string()),
9709 content_index: 0,
9710 delta: "I'm reading the report".to_string(),
9711 };
9712 let _ = session.append_realtime_transcript_event(spoken);
9713
9714 let outcome = session.append_realtime_transcript_event(
9718 RealtimeTranscriptEvent::AssistantTurnInterrupted {
9719 response_id: "resp_mixed_2".to_string(),
9720 },
9721 );
9722 assert_eq!(
9723 outcome.materialized_messages.len(),
9724 1,
9725 "Display lane item must materialize on Interrupted: {outcome:?}"
9726 );
9727
9728 let late_completion = session.append_realtime_transcript_event(
9732 RealtimeTranscriptEvent::AssistantTurnCompleted {
9733 response_id: "resp_mixed_2".to_string(),
9734 stop_reason: StopReason::Cancelled,
9735 usage: Usage::default(),
9736 },
9737 );
9738 assert_eq!(
9739 late_completion.materialized_messages.len(),
9740 0,
9741 "post-barge-in TurnCompleted must not resurrect anything"
9742 );
9743
9744 let messages = session.messages();
9747 let assistants: Vec<&BlockAssistantMessage> = messages
9748 .iter()
9749 .filter_map(|m| match m {
9750 Message::BlockAssistant(a) => Some(a),
9751 _ => None,
9752 })
9753 .collect();
9754 assert_eq!(
9755 assistants.len(),
9756 1,
9757 "barge-in must commit exactly one BlockAssistant containing the Display lane: {assistants:?}"
9758 );
9759 let assistant = assistants[0];
9760 assert_eq!(assistant.blocks.len(), 1, "blocks: {:?}", assistant.blocks);
9761 match &assistant.blocks[0] {
9762 AssistantBlock::Text { text, .. } => {
9763 assert_eq!(text, "Working on the report...");
9764 }
9765 other => {
9766 unreachable!("Display lane must materialize as AssistantBlock::Text, got {other:?}")
9767 }
9768 }
9769 assert!(
9771 !assistant
9772 .blocks
9773 .iter()
9774 .any(|b| matches!(b, AssistantBlock::Transcript { .. })),
9775 "Spoken lane must be dropped on barge-in"
9776 );
9777
9778 assert!(
9781 !session
9782 .in_flight_realtime_assistant_response_ids()
9783 .contains(&"resp_mixed_2".to_string()),
9784 "barged-in response must not appear in in_flight_realtime_assistant_response_ids"
9785 );
9786 }
9787
9788 #[test]
9789 fn round5_r55_barge_in_preserves_display_lane_drops_spoken() {
9790 let mut session = Session::new();
9794
9795 let _ =
9796 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
9797 response_id: "resp_a".to_string(),
9798 delta_id: "delta_d_1".to_string(),
9799 item_id: "item_display".to_string(),
9800 previous_item_id: None,
9801 content_index: 0,
9802 delta: "display-text".to_string(),
9803 });
9804 let _ = session.append_realtime_transcript_event(
9805 RealtimeTranscriptEvent::AssistantTranscriptDelta {
9806 response_id: "resp_a".to_string(),
9807 delta_id: "delta_s_1".to_string(),
9808 item_id: "item_spoken".to_string(),
9809 previous_item_id: None,
9810 content_index: 0,
9811 delta: "spoken-transcript".to_string(),
9812 },
9813 );
9814
9815 let outcome = session.append_realtime_transcript_event(
9816 RealtimeTranscriptEvent::AssistantTurnInterrupted {
9817 response_id: "resp_a".to_string(),
9818 },
9819 );
9820 assert_eq!(outcome.materialized_messages.len(), 1);
9822
9823 let messages = session.messages();
9824 let assistants: Vec<&BlockAssistantMessage> = messages
9825 .iter()
9826 .filter_map(|m| match m {
9827 Message::BlockAssistant(a) => Some(a),
9828 _ => None,
9829 })
9830 .collect();
9831 assert_eq!(assistants.len(), 1);
9832 assert_eq!(assistants[0].blocks.len(), 1);
9834 match &assistants[0].blocks[0] {
9835 AssistantBlock::Text { text, .. } => assert_eq!(text, "display-text"),
9836 other => unreachable!("expected Text, got {other:?}"),
9837 }
9838 }
9839
9840 #[test]
9841 fn round5_r55_barge_in_finalizes_retained_display_into_committed_block() {
9842 let mut session = Session::new();
9847
9848 let _ =
9849 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
9850 response_id: "resp_a".to_string(),
9851 delta_id: "delta_d_1".to_string(),
9852 item_id: "item_display".to_string(),
9853 previous_item_id: None,
9854 content_index: 0,
9855 delta: "committed-display-text".to_string(),
9856 });
9857
9858 assert!(session.messages().is_empty());
9860
9861 let outcome = session.append_realtime_transcript_event(
9862 RealtimeTranscriptEvent::AssistantTurnInterrupted {
9863 response_id: "resp_a".to_string(),
9864 },
9865 );
9866 assert_eq!(
9867 outcome.materialized_messages.len(),
9868 1,
9869 "Interrupted must finalize retained Display lane immediately"
9870 );
9871
9872 let messages = session.messages();
9874 assert_eq!(messages.len(), 1);
9875 match &messages[0] {
9876 Message::BlockAssistant(assistant) => {
9877 assert_eq!(assistant.blocks.len(), 1);
9878 match &assistant.blocks[0] {
9879 AssistantBlock::Text { text, .. } => {
9880 assert_eq!(text, "committed-display-text");
9881 }
9882 other => unreachable!("expected Text, got {other:?}"),
9883 }
9884 }
9885 other => unreachable!("expected BlockAssistant, got {other:?}"),
9886 }
9887 }
9888
9889 #[test]
9890 fn round5_r56_truncation_promotes_default_lane_item_to_spoken() {
9891 let mut session = Session::new();
9898
9899 let _ = session.append_realtime_transcript_event(
9900 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
9901 response_id: "resp_a".to_string(),
9902 item_id: "item_a".to_string(),
9903 content_index: 0,
9904 text: "what was actually heard".to_string(),
9905 },
9906 );
9907
9908 let outcome = session.append_realtime_transcript_event(
9909 RealtimeTranscriptEvent::AssistantTurnCompleted {
9910 response_id: "resp_a".to_string(),
9911 stop_reason: StopReason::EndTurn,
9912 usage: Usage::default(),
9913 },
9914 );
9915 assert_eq!(outcome.materialized_messages.len(), 1);
9916
9917 assert_eq!(session.messages().len(), 1);
9918 match &session.messages()[0] {
9919 Message::BlockAssistant(assistant) => {
9920 assert_eq!(assistant.blocks.len(), 1);
9921 match &assistant.blocks[0] {
9922 AssistantBlock::Transcript { text, source, .. } => {
9923 assert_eq!(text, "what was actually heard");
9924 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
9925 }
9926 other => unreachable!(
9927 "truncation-only path must materialize as AssistantBlock::Transcript, got {other:?}"
9928 ),
9929 }
9930 }
9931 other => unreachable!("expected BlockAssistant, got {other:?}"),
9932 }
9933 }
9934
9935 #[test]
9936 fn round5_r56_truncation_after_display_delta_is_no_op_keeping_display_content() {
9937 let mut session = Session::new();
9945
9946 let _ =
9947 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
9948 response_id: "resp_a".to_string(),
9949 delta_id: "delta_d_1".to_string(),
9950 item_id: "item_a".to_string(),
9951 previous_item_id: None,
9952 content_index: 0,
9953 delta: "display-text-from-delta".to_string(),
9954 });
9955
9956 let _ = session.append_realtime_transcript_event(
9957 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
9958 response_id: "resp_a".to_string(),
9959 item_id: "item_a".to_string(),
9960 content_index: 0,
9961 text: "spoken-truncation-text".to_string(),
9962 },
9963 );
9964
9965 let _ = session.append_realtime_transcript_event(
9966 RealtimeTranscriptEvent::AssistantTurnCompleted {
9967 response_id: "resp_a".to_string(),
9968 stop_reason: StopReason::EndTurn,
9969 usage: Usage::default(),
9970 },
9971 );
9972
9973 assert_eq!(session.messages().len(), 1);
9976 match &session.messages()[0] {
9977 Message::BlockAssistant(assistant) => {
9978 assert_eq!(assistant.blocks.len(), 1);
9979 match &assistant.blocks[0] {
9980 AssistantBlock::Text { text, .. } => {
9981 assert_eq!(text, "display-text-from-delta");
9982 }
9983 other => unreachable!(
9984 "Display content must survive misrouted truncation, got {other:?}"
9985 ),
9986 }
9987 }
9988 other => unreachable!("expected BlockAssistant, got {other:?}"),
9989 }
9990 }
9991
9992 #[test]
10000 fn round5_r56_sibling_display_delta_skipped_on_spoken_item() {
10001 let mut session = Session::new();
10002
10003 let _ = session.append_realtime_transcript_event(
10005 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
10006 response_id: "resp_a".to_string(),
10007 item_id: "item_a".to_string(),
10008 content_index: 0,
10009 text: "what was actually heard".to_string(),
10010 },
10011 );
10012
10013 let _ =
10016 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10017 response_id: "resp_a".to_string(),
10018 delta_id: "delta_d_1".to_string(),
10019 item_id: "item_a".to_string(),
10020 previous_item_id: None,
10021 content_index: 0,
10022 delta: "should-not-appear".to_string(),
10023 });
10024
10025 let _ = session.append_realtime_transcript_event(
10026 RealtimeTranscriptEvent::AssistantTurnCompleted {
10027 response_id: "resp_a".to_string(),
10028 stop_reason: StopReason::EndTurn,
10029 usage: Usage::default(),
10030 },
10031 );
10032
10033 assert_eq!(session.messages().len(), 1);
10036 match &session.messages()[0] {
10037 Message::BlockAssistant(assistant) => {
10038 assert_eq!(assistant.blocks.len(), 1);
10039 match &assistant.blocks[0] {
10040 AssistantBlock::Transcript { text, source, .. } => {
10041 assert_eq!(text, "what was actually heard");
10042 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
10043 }
10044 other => unreachable!(
10045 "Spoken-locked item must materialize as Transcript, got {other:?}"
10046 ),
10047 }
10048 }
10049 other => unreachable!("expected BlockAssistant, got {other:?}"),
10050 }
10051 }
10052
10053 #[test]
10060 fn round5_r56_sibling_spoken_delta_skipped_on_display_item() {
10061 let mut session = Session::new();
10062
10063 let _ =
10065 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
10066 response_id: "resp_a".to_string(),
10067 delta_id: "delta_d_1".to_string(),
10068 item_id: "item_a".to_string(),
10069 previous_item_id: None,
10070 content_index: 0,
10071 delta: "display-locked-text".to_string(),
10072 });
10073
10074 let _ = session.append_realtime_transcript_event(
10077 RealtimeTranscriptEvent::AssistantTranscriptDelta {
10078 response_id: "resp_a".to_string(),
10079 delta_id: "delta_s_1".to_string(),
10080 item_id: "item_a".to_string(),
10081 previous_item_id: None,
10082 content_index: 0,
10083 delta: "should-not-appear".to_string(),
10084 },
10085 );
10086
10087 let _ = session.append_realtime_transcript_event(
10088 RealtimeTranscriptEvent::AssistantTurnCompleted {
10089 response_id: "resp_a".to_string(),
10090 stop_reason: StopReason::EndTurn,
10091 usage: Usage::default(),
10092 },
10093 );
10094
10095 assert_eq!(session.messages().len(), 1);
10097 match &session.messages()[0] {
10098 Message::BlockAssistant(assistant) => {
10099 assert_eq!(assistant.blocks.len(), 1);
10100 match &assistant.blocks[0] {
10101 AssistantBlock::Text { text, .. } => {
10102 assert_eq!(text, "display-locked-text");
10103 }
10104 other => {
10105 unreachable!("Display-locked item must materialize as Text, got {other:?}")
10106 }
10107 }
10108 }
10109 other => unreachable!("expected BlockAssistant, got {other:?}"),
10110 }
10111 }
10112
10113 #[test]
10121 fn round5_r57_late_final_text_after_turn_completed_warns_and_skips() {
10122 let mut session = Session::new();
10123
10124 let _ = session.append_realtime_transcript_event(
10126 RealtimeTranscriptEvent::AssistantTranscriptDelta {
10127 response_id: "resp_a".to_string(),
10128 delta_id: "delta_s_1".to_string(),
10129 item_id: "item_a".to_string(),
10130 previous_item_id: None,
10131 content_index: 0,
10132 delta: "delta-accumulated".to_string(),
10133 },
10134 );
10135
10136 let commit_outcome = session.append_realtime_transcript_event(
10138 RealtimeTranscriptEvent::AssistantTurnCompleted {
10139 response_id: "resp_a".to_string(),
10140 stop_reason: StopReason::EndTurn,
10141 usage: Usage::default(),
10142 },
10143 );
10144 assert_eq!(commit_outcome.materialized_messages.len(), 1);
10145
10146 let late_outcome = session.append_realtime_transcript_event(
10150 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
10151 response_id: "resp_a".to_string(),
10152 item_id: "item_a".to_string(),
10153 content_index: 0,
10154 text: "authoritative-final-that-must-not-land".to_string(),
10155 },
10156 );
10157 assert!(
10158 late_outcome.is_inert(),
10159 "late FinalText after materialization must produce inert outcome"
10160 );
10161
10162 assert_eq!(session.messages().len(), 1);
10165 match &session.messages()[0] {
10166 Message::BlockAssistant(assistant) => {
10167 assert_eq!(assistant.blocks.len(), 1);
10168 match &assistant.blocks[0] {
10169 AssistantBlock::Transcript { text, .. } => {
10170 assert_eq!(
10171 text, "delta-accumulated",
10172 "canonical message must preserve delta-accumulated text; \
10173 append-only history forbids late FinalText repair"
10174 );
10175 }
10176 other => unreachable!("expected Transcript, got {other:?}"),
10177 }
10178 }
10179 other => unreachable!("expected BlockAssistant, got {other:?}"),
10180 }
10181 }
10182
10183 fn metadata_seam_session_metadata() -> SessionMetadata {
10184 SessionMetadata {
10185 schema_version: SESSION_METADATA_SCHEMA_VERSION,
10186 model: "test-model".to_string(),
10187 max_tokens: 1024,
10188 structured_output_retries: 2,
10189 provider: Provider::Anthropic,
10190 self_hosted_server_id: None,
10191 provider_params: None,
10192 tooling: SessionTooling::default(),
10193 keep_alive: false,
10194 comms_name: Some("team/reviewer/alice".to_string()),
10195 peer_meta: None,
10196 realm_id: None,
10197 instance_id: None,
10198 backend: None,
10199 config_generation: None,
10200 auth_binding: None,
10201 mob_member_binding: Some(crate::MobMemberBinding {
10202 mob_id: "team".to_string(),
10203 role: "reviewer".to_string(),
10204 member: "alice".to_string(),
10205 }),
10206 }
10207 }
10208
10209 #[test]
10214 fn session_metadata_document_lockstep_with_full_envelope() {
10215 let mut session = Session::new();
10216 session.push(Message::User(UserMessage::text("hello".to_string())));
10217 session
10218 .set_session_metadata(metadata_seam_session_metadata())
10219 .expect("session metadata should persist");
10220 session
10221 .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
10222 .expect("lifecycle terminal should persist");
10223
10224 let bytes = serde_json::to_vec(&session).expect("session should serialize");
10225 let document = session_metadata_document_from_slice(&bytes)
10226 .expect("partial decode must accept the canonical envelope");
10227
10228 assert_eq!(document.session_id(), session.id());
10229 assert_eq!(
10230 document.session_metadata_value(),
10231 session.metadata().get(SESSION_METADATA_KEY),
10232 "partial decode must project the identical raw session-metadata value"
10233 );
10234 assert_eq!(
10235 document.lifecycle_terminal_value(),
10236 session.metadata().get(SESSION_LIFECYCLE_TERMINAL_KEY),
10237 "partial decode must project the identical raw lifecycle-terminal value"
10238 );
10239
10240 let view = document
10241 .try_into_view()
10242 .expect("typed view must decode from the partial document");
10243 let full_view =
10244 PersistedSessionMetadataView::try_from_session(&session).expect("full-session view");
10245 assert_eq!(view.session_id, full_view.session_id);
10246 assert_eq!(
10247 view.session_metadata.as_ref().map(|m| m.model.clone()),
10248 full_view.session_metadata.as_ref().map(|m| m.model.clone())
10249 );
10250 assert_eq!(
10251 view.mob_member_binding(),
10252 full_view.mob_member_binding(),
10253 "typed binding must be identical across the two decode paths"
10254 );
10255 assert_eq!(
10256 view.lifecycle_terminal,
10257 Some(SessionLifecycleTerminal::Archived)
10258 );
10259 assert_eq!(
10260 full_view.lifecycle_terminal,
10261 Some(SessionLifecycleTerminal::Archived)
10262 );
10263 }
10264
10265 #[test]
10268 fn session_metadata_document_fails_closed_on_envelope_version() {
10269 let session = Session::new();
10270 let mut value = serde_json::to_value(&session).expect("session should serialize");
10271 value["version"] = serde_json::json!(SESSION_VERSION + 999);
10272 let bytes = serde_json::to_vec(&value).expect("mangled envelope should serialize");
10273
10274 session_metadata_document_from_slice(&bytes)
10275 .expect_err("an unsupported envelope version must fail the partial decode closed");
10276 }
10277
10278 #[test]
10281 fn persisted_session_metadata_view_fails_closed_on_corrupt_values() {
10282 let session_id = SessionId::new();
10283
10284 let mut corrupt_metadata = serde_json::Map::new();
10285 corrupt_metadata.insert(SESSION_METADATA_KEY.to_string(), serde_json::json!(42));
10286 PersistedSessionMetadataView::try_from_metadata_map(session_id.clone(), &corrupt_metadata)
10287 .expect_err("corrupt session_metadata must fail the view decode closed");
10288
10289 let mut corrupt_terminal = serde_json::Map::new();
10290 corrupt_terminal.insert(
10291 SESSION_LIFECYCLE_TERMINAL_KEY.to_string(),
10292 serde_json::json!("definitely-not-a-terminal"),
10293 );
10294 PersistedSessionMetadataView::try_from_metadata_map(session_id, &corrupt_terminal)
10295 .expect_err("corrupt lifecycle terminal must fail the view decode closed");
10296 }
10297
10298 #[test]
10300 fn persisted_session_metadata_view_reads_absent_facts_as_none() {
10301 let view = PersistedSessionMetadataView::try_from_metadata_map(
10302 SessionId::new(),
10303 &serde_json::Map::new(),
10304 )
10305 .expect("empty metadata map must decode");
10306 assert!(view.session_metadata.is_none());
10307 assert!(view.lifecycle_terminal.is_none());
10308 assert!(view.mob_member_binding().is_none());
10309 }
10310}