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, SESSION_REALTIME_TRANSCRIPT_STATE_KEY,
18};
19use crate::realtime_transcript_revision::{self, SessionRealtimeTranscriptState};
20use crate::service::{AppendSystemContextRequest, MobToolAuthorityContext};
21use crate::session_durable_config_authority;
22use crate::time_compat::SystemTime;
23use crate::tool_scope::ToolFilter;
24use crate::types::{
25 AssistantBlock, BlockAssistantMessage, ContentBlock, ContentInput, Message, SessionId,
26 StopReason, ToolDef, ToolName, ToolProvenance, ToolResult, Usage, UserMessage,
27};
28use serde::{Deserialize, Deserializer, Serialize, Serializer};
29use sha2::{Digest, Sha256};
30use std::collections::{BTreeMap, BTreeSet, HashMap};
31use std::sync::Arc;
32
33pub use crate::generated::session_persistence_version_authority::SESSION_VERSION;
40
41pub use crate::generated::session_persistence_version_authority::SESSION_METADATA_SCHEMA_VERSION;
46
47pub fn session_version() -> u32 {
49 session_persistence_version_authority::session_envelope_version()
50}
51
52pub fn session_metadata_schema_version() -> u32 {
54 session_persistence_version_authority::session_metadata_schema_version()
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(tag = "type", rename_all = "snake_case")]
63pub enum TranscriptReplacement {
64 Message { message: Message },
66 UserContentBlock {
68 block_index: usize,
69 block: ContentBlock,
70 },
71 AssistantBlock {
73 block_index: usize,
74 block: AssistantBlock,
75 },
76 ToolResultContentBlock {
78 result_index: usize,
79 block_index: usize,
80 block: ContentBlock,
81 },
82}
83
84pub const SESSION_TRANSCRIPT_HISTORY_STATE_KEY: &str = "session_transcript_history_state_v1";
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
90#[serde(tag = "type", rename_all = "snake_case")]
91pub enum TranscriptRewriteSelection {
92 MessageRange { start: usize, end: usize },
94}
95
96impl TranscriptRewriteSelection {
97 fn bounds(&self) -> (usize, usize) {
98 match self {
99 Self::MessageRange { start, end } => (*start, *end),
100 }
101 }
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
110#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
111#[serde(rename_all = "snake_case")]
112pub struct TranscriptRewriteReason {
113 pub kind: String,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub note: Option<String>,
116}
117
118impl TranscriptRewriteReason {
119 pub fn new(kind: impl Into<String>) -> Self {
120 Self {
121 kind: kind.into(),
122 note: None,
123 }
124 }
125}
126
127pub const RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON: &str = "resume-system-prompt-refresh";
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum ResumedSystemPromptReconciliation {
134 PreservedContinuation,
139 RewrittenBase,
143 NoChange,
146}
147
148impl std::fmt::Display for TranscriptRewriteReason {
149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152 match &self.note {
153 Some(note) => write!(f, "{}: {note}", self.kind),
154 None => f.write_str(&self.kind),
155 }
156 }
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
161#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
162#[serde(rename_all = "snake_case")]
163pub struct TranscriptRewriteCommit {
164 pub parent_revision: String,
165 pub revision: String,
166 pub selection: TranscriptRewriteSelection,
167 pub original_span_digest: String,
168 pub replacement_digest: String,
169 pub messages_before: usize,
170 pub messages_after: usize,
171 pub reason: TranscriptRewriteReason,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub actor: Option<String>,
174 #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
175 pub committed_at: SystemTime,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
180#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
181#[serde(rename_all = "snake_case")]
182pub struct TranscriptRevisionBody {
183 pub revision: String,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub parent_revision: Option<String>,
186 #[cfg_attr(feature = "schema", schemars(with = "Vec<serde_json::Value>"))]
187 pub messages: Vec<Message>,
188 #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
189 pub created_at: SystemTime,
190}
191
192#[cfg(feature = "schema")]
193#[allow(dead_code)]
194#[derive(schemars::JsonSchema)]
195#[schemars(rename = "SystemTime")]
196struct SchemaSystemTime {
197 secs_since_epoch: u64,
198 nanos_since_epoch: u32,
199}
200
201#[derive(Debug, Clone, Serialize)]
203#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
204#[serde(rename_all = "snake_case")]
205pub struct TranscriptRewriteRecord {
206 pub commit: TranscriptRewriteCommit,
207 pub parent_body: TranscriptRevisionBody,
208 pub revision_body: TranscriptRevisionBody,
209}
210
211impl<'de> Deserialize<'de> for TranscriptRewriteRecord {
212 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
213 where
214 D: Deserializer<'de>,
215 {
216 #[derive(Deserialize)]
217 #[serde(rename_all = "snake_case")]
218 struct Wire {
219 commit: TranscriptRewriteCommit,
220 parent_body: TranscriptRevisionBody,
221 revision_body: TranscriptRevisionBody,
222 }
223 let wire = Wire::deserialize(deserializer)?;
224 let mut revisions = vec![wire.parent_body, wire.revision_body];
225 let mut commits = vec![wire.commit];
226 heal_legacy_revision_strings(&mut revisions, &mut commits, None)
227 .map_err(serde::de::Error::custom)?;
228 let mut revisions = revisions.into_iter();
229 let parent_body = revisions
230 .next()
231 .ok_or_else(|| serde::de::Error::custom("rewrite record lost its parent body"))?;
232 let revision_body = revisions
233 .next()
234 .ok_or_else(|| serde::de::Error::custom("rewrite record lost its revision body"))?;
235 let commit = commits
236 .into_iter()
237 .next()
238 .ok_or_else(|| serde::de::Error::custom("rewrite record lost its commit"))?;
239 Ok(Self {
240 commit,
241 parent_body,
242 revision_body,
243 })
244 }
245}
246
247impl TranscriptRewriteRecord {
248 pub fn new(
249 commit: TranscriptRewriteCommit,
250 parent_body: TranscriptRevisionBody,
251 revision_body: TranscriptRevisionBody,
252 ) -> Result<Self, TranscriptEditError> {
253 validate_transcript_rewrite_record(&commit, &parent_body, &revision_body)?;
254 Ok(Self {
255 commit,
256 parent_body,
257 revision_body,
258 })
259 }
260}
261
262#[derive(Debug, Clone, Serialize)]
264#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
265#[serde(rename_all = "snake_case")]
266pub struct TranscriptHistoryState {
267 pub head: String,
268 #[serde(default, skip_serializing_if = "Vec::is_empty")]
269 pub commits: Vec<TranscriptRewriteCommit>,
270 #[serde(default, skip_serializing_if = "Vec::is_empty")]
271 pub revisions: Vec<TranscriptRevisionBody>,
272}
273
274impl<'de> Deserialize<'de> for TranscriptHistoryState {
275 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
276 where
277 D: Deserializer<'de>,
278 {
279 #[derive(Deserialize)]
280 #[serde(rename_all = "snake_case")]
281 struct Wire {
282 head: String,
283 #[serde(default)]
284 commits: Vec<TranscriptRewriteCommit>,
285 #[serde(default)]
286 revisions: Vec<TranscriptRevisionBody>,
287 }
288 let wire = Wire::deserialize(deserializer)?;
289 let mut state = TranscriptHistoryState {
290 head: wire.head,
291 commits: wire.commits,
292 revisions: wire.revisions,
293 };
294 let head_is_current = match state
297 .revisions
298 .iter()
299 .find(|body| body.revision == state.head)
300 {
301 Some(head_body) => {
302 transcript_messages_digest(&head_body.messages).map_err(serde::de::Error::custom)?
303 == state.head
304 }
305 None => true,
306 };
307 if !head_is_current {
308 let TranscriptHistoryState {
309 head,
310 commits,
311 revisions,
312 } = &mut state;
313 heal_legacy_revision_strings(revisions, commits, Some(head))
314 .map_err(serde::de::Error::custom)?;
315 }
316 Ok(state)
317 }
318}
319
320fn heal_legacy_revision_strings(
329 revisions: &mut [TranscriptRevisionBody],
330 commits: &mut [TranscriptRewriteCommit],
331 head: Option<&mut String>,
332) -> Result<(), serde_json::Error> {
333 let mut remap: BTreeMap<String, String> = BTreeMap::new();
334 for body in revisions.iter() {
335 let content = transcript_messages_digest(&body.messages)?;
336 if body.revision == content {
337 continue;
338 }
339 if body.revision == legacy_transcript_messages_digest(&body.messages)? {
340 remap.insert(body.revision.clone(), content);
341 }
342 }
343 if remap.is_empty() {
344 return Ok(());
345 }
346 for body in revisions.iter_mut() {
347 if let Some(current) = remap.get(&body.revision) {
348 body.revision = current.clone();
349 }
350 if let Some(parent) = body.parent_revision.as_ref()
351 && let Some(current) = remap.get(parent)
352 {
353 body.parent_revision = Some(current.clone());
354 }
355 }
356 for commit in commits.iter_mut() {
357 if let Some(current) = remap.get(&commit.parent_revision) {
358 commit.parent_revision = current.clone();
359 }
360 if let Some(current) = remap.get(&commit.revision) {
361 commit.revision = current.clone();
362 }
363 heal_legacy_commit_span_digests(commit, revisions)?;
364 }
365 if let Some(head) = head
366 && let Some(current) = remap.get(head.as_str())
367 {
368 *head = current.clone();
369 }
370 Ok(())
371}
372
373fn heal_legacy_commit_span_digests(
379 commit: &mut TranscriptRewriteCommit,
380 revisions: &[TranscriptRevisionBody],
381) -> Result<(), serde_json::Error> {
382 let Some(parent_body) = revisions
383 .iter()
384 .find(|body| body.revision == commit.parent_revision)
385 else {
386 return Ok(());
387 };
388 let Some(revision_body) = revisions
389 .iter()
390 .find(|body| body.revision == commit.revision)
391 else {
392 return Ok(());
393 };
394 let (start, end) = commit.selection.bounds();
395 if start > end || end > parent_body.messages.len() {
396 return Ok(());
397 }
398 let removed_len = end - start;
399 let Some(retained_len) = commit.messages_before.checked_sub(removed_len) else {
400 return Ok(());
401 };
402 let Some(replacement_len) = commit.messages_after.checked_sub(retained_len) else {
403 return Ok(());
404 };
405 let Some(replacement_end) = start.checked_add(replacement_len) else {
406 return Ok(());
407 };
408 if replacement_end > revision_body.messages.len() {
409 return Ok(());
410 }
411 let original_span = &parent_body.messages[start..end];
412 if commit.original_span_digest == legacy_transcript_messages_digest(original_span)? {
413 commit.original_span_digest = transcript_messages_digest(original_span)?;
414 }
415 let replacement_span = &revision_body.messages[start..replacement_end];
416 if commit.replacement_digest == legacy_transcript_messages_digest(replacement_span)? {
417 commit.replacement_digest = transcript_messages_digest(replacement_span)?;
418 }
419 Ok(())
420}
421
422impl TranscriptHistoryState {
423 pub fn from_rewrite_records<I>(records: I) -> Result<Option<Self>, TranscriptEditError>
425 where
426 I: IntoIterator<Item = TranscriptRewriteRecord>,
427 {
428 let mut state: Option<Self> = None;
429 for record in records {
430 validate_transcript_rewrite_record(
431 &record.commit,
432 &record.parent_body,
433 &record.revision_body,
434 )?;
435 let state = state.get_or_insert_with(|| Self {
436 head: record.commit.parent_revision.clone(),
437 commits: Vec::new(),
438 revisions: Vec::new(),
439 });
440 if record.commit.parent_revision != state.head {
441 if revision_body_extends_head(&record.parent_body, &state.revisions, &state.head)? {
442 state.head = record.commit.parent_revision.clone();
443 } else {
444 return Err(TranscriptEditError::HistoryStateMalformed(format!(
445 "rewrite record parent {} does not extend transcript head {}",
446 record.commit.parent_revision, state.head
447 )));
448 }
449 }
450 if !state
451 .revisions
452 .iter()
453 .any(|body| body.revision == record.parent_body.revision)
454 {
455 state.revisions.push(record.parent_body);
456 }
457 if !state
458 .revisions
459 .iter()
460 .any(|body| body.revision == record.revision_body.revision)
461 {
462 state.revisions.push(record.revision_body);
463 }
464 state.head = record.commit.revision.clone();
465 state.commits.push(record.commit);
466 }
467 Ok(state)
468 }
469}
470
471#[derive(Debug, Clone, thiserror::Error)]
473pub enum TranscriptEditError {
474 #[error("message index {message_index} out of bounds for {message_count} messages")]
475 MessageIndexOutOfBounds {
476 message_index: usize,
477 message_count: usize,
478 },
479 #[error("{block_kind} index {block_index} out of bounds for {block_count} blocks")]
480 BlockIndexOutOfBounds {
481 block_kind: &'static str,
482 block_index: usize,
483 block_count: usize,
484 },
485 #[error("replacement expected {expected} at message index {message_index}, found {actual}")]
486 MessageRoleMismatch {
487 message_index: usize,
488 expected: &'static str,
489 actual: &'static str,
490 },
491 #[error("invalid transcript rewrite range {start}..{end} for {message_count} messages")]
492 InvalidRewriteRange {
493 start: usize,
494 end: usize,
495 message_count: usize,
496 },
497 #[error("transcript rewrite does not change transcript revision {revision}")]
498 NoOpRewrite { revision: String },
499 #[error("transcript rewrite parent revision mismatch: expected {expected}, actual {actual}")]
500 RevisionConflict { expected: String, actual: String },
501 #[error("transcript history state is malformed: {0}")]
502 HistoryStateMalformed(String),
503 #[error("invalid transcript shape after rewrite: {0}")]
504 InvalidTranscriptShape(String),
505}
506
507fn message_role_name(message: &Message) -> &'static str {
508 match message {
509 Message::System(_) => "system",
510 Message::SystemNotice(_) => "system_notice",
511 Message::User(_) => "user",
512 Message::BlockAssistant(_) => "block_assistant",
513 Message::ToolResults { .. } => "tool_results",
514 }
515}
516
517fn assistant_tool_use_ids(message: &Message) -> Vec<&str> {
518 match message {
519 Message::BlockAssistant(assistant) => assistant
520 .blocks
521 .iter()
522 .filter_map(|block| match block {
523 AssistantBlock::ToolUse { id, .. } => Some(id.as_str()),
524 _ => None,
525 })
526 .collect(),
527 _ => Vec::new(),
528 }
529}
530
531fn validate_transcript_tool_result_shape(messages: &[Message]) -> Result<(), TranscriptEditError> {
532 for (index, message) in messages.iter().enumerate() {
533 if let Message::ToolResults { results, .. } = message {
534 let Some(previous) = index
535 .checked_sub(1)
536 .and_then(|previous| messages.get(previous))
537 else {
538 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
539 "tool_results at message {index} has no preceding assistant tool-use message"
540 )));
541 };
542 let expected = assistant_tool_use_ids(previous);
543 if expected.is_empty() {
544 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
545 "tool_results at message {index} follows {}, not an assistant tool-use message",
546 message_role_name(previous)
547 )));
548 }
549 let actual = results
550 .iter()
551 .map(|result| result.tool_use_id.as_str())
552 .collect::<Vec<_>>();
553 let actual_set = actual.iter().copied().collect::<BTreeSet<_>>();
554 let expected_set = expected.iter().copied().collect::<BTreeSet<_>>();
555 if actual.len() != actual_set.len() {
556 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
557 "tool_results at message {index} contains duplicate tool ids"
558 )));
559 }
560 if expected.len() != expected_set.len() {
561 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
562 "assistant tool-use message before tool_results at message {index} contains duplicate tool ids"
563 )));
564 }
565 if actual_set != expected_set {
566 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
567 "tool_results at message {index} resolve tool ids {actual_set:?}, expected {expected_set:?}"
568 )));
569 }
570 }
571
572 let tool_use_ids = assistant_tool_use_ids(message);
573 if tool_use_ids.is_empty() {
574 continue;
575 }
576 let Some(next) = messages.get(index + 1) else {
577 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
578 "assistant tool-use message {index} has no following tool_results"
579 )));
580 };
581 if !matches!(next, Message::ToolResults { .. }) {
582 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
583 "assistant tool-use message {index} is followed by {}, not tool_results",
584 message_role_name(next)
585 )));
586 }
587 }
588 Ok(())
589}
590
591fn canonicalize_digest_image_blocks(blocks: &mut [crate::types::ContentBlock]) {
592 for block in blocks.iter_mut() {
593 if let crate::types::ContentBlock::Image {
594 media_type,
595 data: crate::types::ImageData::Inline { data },
596 } = block
597 {
598 let blob_id = crate::blob::content_blob_id(media_type, data);
601 *block = crate::types::ContentBlock::Image {
602 media_type: media_type.clone(),
603 data: crate::types::ImageData::Blob { blob_id },
604 };
605 }
606 }
607}
608
609fn canonicalize_message_images_for_digest(messages: &[Message]) -> Vec<Message> {
618 let mut canonical = messages.to_vec();
619 for message in &mut canonical {
620 match message {
621 Message::User(user) => canonicalize_digest_image_blocks(&mut user.content),
622 Message::ToolResults { results, .. } => {
623 for result in results.iter_mut() {
624 canonicalize_digest_image_blocks(&mut result.content);
625 }
626 }
627 Message::SystemNotice(notice) => {
628 for block in &mut notice.blocks {
629 match block {
630 crate::types::SystemNoticeBlock::Comms { content, .. }
631 | crate::types::SystemNoticeBlock::ExternalEvent { content, .. } => {
632 canonicalize_digest_image_blocks(content);
633 }
634 _ => {}
635 }
636 }
637 }
638 _ => {}
639 }
640 }
641 canonical
642}
643
644fn digest_timestamp_sentinel() -> crate::types::MessageTimestamp {
648 chrono::DateTime::<chrono::Utc>::UNIX_EPOCH
649}
650
651fn canonicalize_messages_for_digest(messages: &[Message]) -> Vec<Message> {
669 let mut canonical = canonicalize_message_images_for_digest(messages);
670 for message in &mut canonical {
671 match message {
672 Message::System(system) => {
673 system.created_at = digest_timestamp_sentinel();
674 }
675 Message::SystemNotice(notice) => {
676 notice.created_at = digest_timestamp_sentinel();
677 }
678 Message::User(user) => {
679 user.identity = crate::types::TranscriptMessageIdentity::default();
680 user.created_at = digest_timestamp_sentinel();
681 }
682 Message::BlockAssistant(assistant) => {
683 assistant.identity = crate::types::TranscriptMessageIdentity::default();
684 assistant.created_at = digest_timestamp_sentinel();
685 }
686 Message::ToolResults { created_at, .. } => {
687 *created_at = digest_timestamp_sentinel();
688 }
689 }
690 }
691 canonical
692}
693
694pub fn transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
695 sha256_json_digest(&canonicalize_messages_for_digest(messages))
696}
697
698fn legacy_transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
707 sha256_json_digest(&canonicalize_message_images_for_digest(messages))
708}
709
710fn validate_transcript_rewrite_record(
711 commit: &TranscriptRewriteCommit,
712 parent_body: &TranscriptRevisionBody,
713 revision_body: &TranscriptRevisionBody,
714) -> Result<(), TranscriptEditError> {
715 if parent_body.revision != commit.parent_revision {
716 return Err(TranscriptEditError::HistoryStateMalformed(format!(
717 "parent body revision {} does not match commit parent {}",
718 parent_body.revision, commit.parent_revision
719 )));
720 }
721 if revision_body.revision != commit.revision {
722 return Err(TranscriptEditError::HistoryStateMalformed(format!(
723 "revision body {} does not match commit revision {}",
724 revision_body.revision, commit.revision
725 )));
726 }
727 if commit.parent_revision == commit.revision {
728 return Err(TranscriptEditError::NoOpRewrite {
729 revision: commit.revision.clone(),
730 });
731 }
732 let parent_digest = transcript_messages_digest(&parent_body.messages)
733 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
734 if parent_digest != commit.parent_revision {
735 return Err(TranscriptEditError::HistoryStateMalformed(format!(
736 "parent body digest {parent_digest} does not match commit parent {}",
737 commit.parent_revision
738 )));
739 }
740 let revision_digest = transcript_messages_digest(&revision_body.messages)
741 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
742 if revision_digest != commit.revision {
743 return Err(TranscriptEditError::HistoryStateMalformed(format!(
744 "revision body digest {revision_digest} does not match commit revision {}",
745 commit.revision
746 )));
747 }
748 let (start, end) = commit.selection.bounds();
749 if start > end || end > parent_body.messages.len() {
750 return Err(TranscriptEditError::InvalidRewriteRange {
751 start,
752 end,
753 message_count: parent_body.messages.len(),
754 });
755 }
756 if commit.messages_before != parent_body.messages.len()
757 || commit.messages_after != revision_body.messages.len()
758 {
759 return Err(TranscriptEditError::HistoryStateMalformed(format!(
760 "commit message counts {} -> {} do not match revision bodies {} -> {}",
761 commit.messages_before,
762 commit.messages_after,
763 parent_body.messages.len(),
764 revision_body.messages.len()
765 )));
766 }
767 let original_span_digest = transcript_messages_digest(&parent_body.messages[start..end])
768 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
769 if original_span_digest != commit.original_span_digest {
770 return Err(TranscriptEditError::HistoryStateMalformed(format!(
771 "original span digest {original_span_digest} does not match commit digest {}",
772 commit.original_span_digest
773 )));
774 }
775 let removed_len = end - start;
776 let retained_len = commit
777 .messages_before
778 .checked_sub(removed_len)
779 .ok_or_else(|| {
780 TranscriptEditError::HistoryStateMalformed(
781 "commit removed more messages than it recorded before rewrite".to_string(),
782 )
783 })?;
784 let replacement_len = commit
785 .messages_after
786 .checked_sub(retained_len)
787 .ok_or_else(|| {
788 TranscriptEditError::HistoryStateMalformed(
789 "commit message counts cannot describe a replacement span".to_string(),
790 )
791 })?;
792 let replacement_end = start.checked_add(replacement_len).ok_or_else(|| {
793 TranscriptEditError::HistoryStateMalformed("replacement span end overflowed".to_string())
794 })?;
795 if replacement_end > revision_body.messages.len() {
796 return Err(TranscriptEditError::InvalidRewriteRange {
797 start,
798 end: replacement_end,
799 message_count: revision_body.messages.len(),
800 });
801 }
802 let parent_prefix_digest = transcript_messages_digest(&parent_body.messages[..start])
803 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
804 let revision_prefix_digest = transcript_messages_digest(&revision_body.messages[..start])
805 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
806 if parent_prefix_digest != revision_prefix_digest {
807 return Err(TranscriptEditError::HistoryStateMalformed(
808 "rewrite revision changed messages before the selected span".to_string(),
809 ));
810 }
811 let parent_suffix_digest = transcript_messages_digest(&parent_body.messages[end..])
812 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
813 let revision_suffix_digest =
814 transcript_messages_digest(&revision_body.messages[replacement_end..])
815 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
816 if parent_suffix_digest != revision_suffix_digest {
817 return Err(TranscriptEditError::HistoryStateMalformed(
818 "rewrite revision changed messages after the selected span".to_string(),
819 ));
820 }
821 let replacement_digest =
822 transcript_messages_digest(&revision_body.messages[start..replacement_end])
823 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
824 if replacement_digest != commit.replacement_digest {
825 return Err(TranscriptEditError::HistoryStateMalformed(format!(
826 "replacement span digest {replacement_digest} does not match commit digest {}",
827 commit.replacement_digest
828 )));
829 }
830 Ok(())
831}
832
833fn validate_transcript_history_state(
834 state: &TranscriptHistoryState,
835) -> Result<(), TranscriptEditError> {
836 if state
837 .revisions
838 .iter()
839 .all(|body| body.revision != state.head)
840 {
841 return Err(TranscriptEditError::HistoryStateMalformed(format!(
842 "missing transcript head body {}",
843 state.head
844 )));
845 }
846 for body in &state.revisions {
847 let digest = transcript_messages_digest(&body.messages)
848 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
849 if digest != body.revision {
850 return Err(TranscriptEditError::HistoryStateMalformed(format!(
851 "transcript revision body {} has digest {digest}",
852 body.revision
853 )));
854 }
855 }
856 for commit in &state.commits {
857 let parent_body = state
858 .revisions
859 .iter()
860 .find(|body| body.revision == commit.parent_revision)
861 .ok_or_else(|| {
862 TranscriptEditError::HistoryStateMalformed(format!(
863 "missing parent transcript body {}",
864 commit.parent_revision
865 ))
866 })?;
867 let revision_body = state
868 .revisions
869 .iter()
870 .find(|body| body.revision == commit.revision)
871 .ok_or_else(|| {
872 TranscriptEditError::HistoryStateMalformed(format!(
873 "missing transcript revision body {}",
874 commit.revision
875 ))
876 })?;
877 validate_transcript_rewrite_record(commit, parent_body, revision_body)?;
878 }
879 let Some(first_commit) = state.commits.first() else {
880 return Ok(());
881 };
882 let mut expected_head = first_commit.parent_revision.clone();
883 for commit in &state.commits {
884 let parent_body = state
885 .revisions
886 .iter()
887 .find(|body| body.revision == commit.parent_revision)
888 .ok_or_else(|| {
889 TranscriptEditError::HistoryStateMalformed(format!(
890 "missing parent transcript body {}",
891 commit.parent_revision
892 ))
893 })?;
894 if commit.parent_revision != expected_head
895 && !revision_body_extends_head(parent_body, &state.revisions, &expected_head)?
896 {
897 return Err(TranscriptEditError::HistoryStateMalformed(format!(
898 "rewrite commit parent {} does not extend transcript head {}",
899 commit.parent_revision, expected_head
900 )));
901 }
902 expected_head = commit.revision.clone();
903 }
904 let mut cursor = state.head.clone();
905 while cursor != expected_head {
906 let Some(head_body) = state.revisions.iter().find(|body| body.revision == cursor) else {
907 break;
908 };
909 match head_body.parent_revision.as_deref() {
910 Some(parent) => cursor = parent.to_string(),
911 None => break,
912 }
913 }
914 if cursor != expected_head {
915 return Err(TranscriptEditError::HistoryStateMalformed(format!(
916 "transcript head {} does not extend the rewrite chain",
917 state.head
918 )));
919 }
920 Ok(())
921}
922
923fn revision_body_extends_head(
924 candidate: &TranscriptRevisionBody,
925 revisions: &[TranscriptRevisionBody],
926 head: &str,
927) -> Result<bool, TranscriptEditError> {
928 if candidate.parent_revision.as_deref() == Some(head) {
929 return Ok(true);
930 }
931 let Some(head_body) = revisions.iter().find(|body| body.revision == head) else {
932 return Ok(false);
933 };
934 if candidate.messages.len() < head_body.messages.len() {
935 return Ok(false);
936 }
937 let prefix_digest = transcript_messages_digest(&candidate.messages[..head_body.messages.len()])
938 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
939 Ok(prefix_digest == head)
940}
941
942fn sha256_json_digest<T: Serialize + ?Sized>(value: &T) -> Result<String, serde_json::Error> {
943 let bytes = serde_json::to_vec(value)?;
944 let digest = Sha256::digest(bytes);
945 let mut out = String::with_capacity(digest.len() * 2);
946 const HEX: &[u8; 16] = b"0123456789abcdef";
947 for byte in digest {
948 out.push(HEX[(byte >> 4) as usize] as char);
949 out.push(HEX[(byte & 0x0f) as usize] as char);
950 }
951 Ok(format!("sha256:{out}"))
952}
953
954#[derive(Debug, Clone)]
958pub struct Session {
959 version: u32,
962 id: SessionId,
964 pub(crate) messages: Arc<Vec<Message>>,
966 created_at: SystemTime,
968 updated_at: SystemTime,
970 metadata: serde_json::Map<String, serde_json::Value>,
972 usage: Usage,
974}
975
976#[derive(Serialize, Deserialize)]
978#[serde(rename_all = "snake_case")]
979struct SessionSerde {
980 version: u32,
981 id: SessionId,
982 messages: Vec<Message>,
983 created_at: SystemTime,
984 updated_at: SystemTime,
985 #[serde(default)]
986 metadata: serde_json::Map<String, serde_json::Value>,
987 #[serde(default)]
988 usage: Usage,
989}
990
991impl Serialize for Session {
992 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
993 where
994 S: Serializer,
995 {
996 let serde_repr = SessionSerde {
997 version: self.version,
998 id: self.id.clone(),
999 messages: (*self.messages).clone(),
1000 created_at: self.created_at,
1001 updated_at: self.updated_at,
1002 metadata: self.metadata.clone(),
1003 usage: self.usage.clone(),
1004 };
1005 serde_repr.serialize(serializer)
1006 }
1007}
1008
1009impl<'de> Deserialize<'de> for Session {
1010 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1011 where
1012 D: Deserializer<'de>,
1013 {
1014 let serde_repr = SessionSerde::deserialize(deserializer)?;
1015 let version = session_persistence_version_authority::restore_session_envelope_version(
1016 serde_repr.version,
1017 )
1018 .map_err(<D::Error as serde::de::Error>::custom)?;
1019 Ok(Session {
1020 version,
1021 id: serde_repr.id,
1022 messages: Arc::new(serde_repr.messages),
1023 created_at: serde_repr.created_at,
1024 updated_at: serde_repr.updated_at,
1025 metadata: serde_repr.metadata,
1026 usage: serde_repr.usage,
1027 })
1028 }
1029}
1030
1031pub const SESSION_SYSTEM_CONTEXT_STATE_KEY: &str = "session_system_context_state";
1033
1034pub const SESSION_DEFERRED_TURN_STATE_KEY: &str = "session_deferred_turn_state";
1036
1037pub const SESSION_BUILD_STATE_KEY: &str = "session_build_state";
1039
1040pub const SESSION_TOOL_VISIBILITY_STATE_KEY: &str = "session_tool_visibility_state_v1";
1042
1043pub const SESSION_LIFECYCLE_TERMINAL_KEY: &str = "session_lifecycle_terminal";
1045
1046pub const SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY: &str =
1054 "session_runtime_checkpoint_provenance_v1";
1055
1056pub const VIEW_IMAGE_TOOL_NAME: &str = "view_image";
1058
1059pub const SYSTEM_CONTEXT_SEPARATOR: &str = "\n\n---\n\n";
1061
1062#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1063#[error("metadata key `{key}` is reserved for session authority")]
1064pub struct ReservedSessionMetadataKey {
1065 key: String,
1066}
1067
1068impl ReservedSessionMetadataKey {
1069 fn new(key: &str) -> Self {
1070 Self {
1071 key: key.to_string(),
1072 }
1073 }
1074}
1075
1076fn is_session_authority_metadata_key(key: &str) -> bool {
1077 crate::surface_metadata::ReservedMetadataKey::is_session_authority(key)
1080}
1081
1082#[allow(clippy::panic)]
1083fn fail_closed_generated_restore(authority: &'static str, err: serde_json::Error) -> ! {
1084 tracing::error!(
1085 authority,
1086 error = %err,
1087 "generated authority rejected durable restore"
1088 );
1089 panic!("generated {authority} authority rejected durable restore: {err}");
1090}
1091
1092#[derive(Clone)]
1098pub struct SystemContextStateHandle {
1099 inner: Arc<std::sync::Mutex<SessionSystemContextState>>,
1100}
1101
1102impl std::fmt::Debug for SystemContextStateHandle {
1103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1104 f.debug_struct("SystemContextStateHandle")
1105 .field("inner", &"<Arc<Mutex<SessionSystemContextState>>>")
1106 .finish()
1107 }
1108}
1109
1110impl SystemContextStateHandle {
1111 pub fn new(state: SessionSystemContextState) -> Result<Self, serde_json::Error> {
1112 let state = system_context_authority::restore_system_context_state(state)
1113 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1114 Ok(Self {
1115 inner: Arc::new(std::sync::Mutex::new(state)),
1116 })
1117 }
1118
1119 pub fn from_shared_authority_state(
1120 inner: Arc<std::sync::Mutex<SessionSystemContextState>>,
1121 ) -> Self {
1122 Self { inner }
1123 }
1124
1125 pub fn snapshot(&self) -> SessionSystemContextState {
1126 match self.inner.lock() {
1127 Ok(guard) => guard.clone(),
1128 Err(poisoned) => {
1129 tracing::warn!("system-context state lock poisoned while reading snapshot");
1130 poisoned.into_inner().clone()
1131 }
1132 }
1133 }
1134
1135 pub fn replace_from_generated_restore(
1136 &self,
1137 state: SessionSystemContextState,
1138 ) -> Result<(), serde_json::Error> {
1139 let state = system_context_authority::restore_system_context_state(state)
1140 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1141 match self.inner.lock() {
1142 Ok(mut guard) => {
1143 *guard = state;
1144 }
1145 Err(poisoned) => {
1146 tracing::warn!("system-context state lock poisoned while restoring state");
1147 *poisoned.into_inner() = state;
1148 }
1149 }
1150 Ok(())
1151 }
1152
1153 pub fn replace_from_generated_restore_if_changed(
1154 &self,
1155 state: SessionSystemContextState,
1156 ) -> Result<bool, serde_json::Error> {
1157 let state = system_context_authority::restore_system_context_state(state)
1158 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1159 let mut guard = match self.inner.lock() {
1160 Ok(guard) => guard,
1161 Err(poisoned) => {
1162 tracing::warn!(
1163 "system-context state lock poisoned while replacing generated-restored state"
1164 );
1165 poisoned.into_inner()
1166 }
1167 };
1168 if *guard == state {
1169 return Ok(false);
1170 }
1171 *guard = state;
1172 Ok(true)
1173 }
1174
1175 pub fn replace_from_generated_restore_if_current(
1176 &self,
1177 current: &SessionSystemContextState,
1178 replacement: SessionSystemContextState,
1179 ) -> Result<bool, serde_json::Error> {
1180 let replacement = system_context_authority::restore_system_context_state(replacement)
1181 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1182 let mut guard = match self.inner.lock() {
1183 Ok(guard) => guard,
1184 Err(poisoned) => {
1185 tracing::warn!(
1186 "system-context state lock poisoned while conditionally replacing generated-restored state"
1187 );
1188 poisoned.into_inner()
1189 }
1190 };
1191 if *guard != *current {
1192 return Ok(false);
1193 }
1194 *guard = replacement;
1195 Ok(true)
1196 }
1197
1198 pub fn stage_append_with_snapshot(
1199 &self,
1200 req: &AppendSystemContextRequest,
1201 accepted_at: SystemTime,
1202 ) -> Result<
1203 (
1204 crate::service::AppendSystemContextStatus,
1205 SessionSystemContextState,
1206 SessionSystemContextState,
1207 ),
1208 SystemContextStageError,
1209 > {
1210 let mut guard = match self.inner.lock() {
1211 Ok(guard) => guard,
1212 Err(poisoned) => {
1213 tracing::warn!("system-context state lock poisoned while staging append");
1214 poisoned.into_inner()
1215 }
1216 };
1217 let snapshot = guard.clone();
1218 let status = guard.stage_append(req, accepted_at)?;
1219 let staged = guard.clone();
1220 Ok((status, snapshot, staged))
1221 }
1222
1223 pub fn stage_active_turn_appends_with_snapshot(
1224 &self,
1225 appends: Vec<(AppendSystemContextRequest, SystemTime)>,
1226 ) -> Result<(SessionSystemContextState, SessionSystemContextState), SystemContextStageError>
1227 {
1228 let mut guard = match self.inner.lock() {
1229 Ok(guard) => guard,
1230 Err(poisoned) => {
1231 tracing::warn!(
1232 "system-context state lock poisoned while staging active-turn appends"
1233 );
1234 poisoned.into_inner()
1235 }
1236 };
1237 let snapshot = guard.clone();
1238 let mut candidate = snapshot.clone();
1239 for (req, accepted_at) in appends {
1240 candidate.stage_active_turn_append(&req, accepted_at)?;
1241 }
1242 *guard = candidate.clone();
1243 let staged = candidate;
1244 Ok((snapshot, staged))
1245 }
1246
1247 pub fn discard_unapplied_active_turn_pending(&self) -> usize {
1248 let discarded = match self.inner.lock() {
1249 Ok(mut guard) => guard.discard_unapplied_active_turn_pending(),
1250 Err(poisoned) => {
1251 tracing::warn!(
1252 "system-context state lock poisoned while discarding active-turn context"
1253 );
1254 poisoned
1255 .into_inner()
1256 .discard_unapplied_active_turn_pending()
1257 }
1258 };
1259 discarded.len()
1260 }
1261
1262 pub fn discard_active_turn_pending_by_keys(
1263 &self,
1264 idempotency_keys: &[String],
1265 ) -> Vec<PendingSystemContextAppend> {
1266 match self.inner.lock() {
1267 Ok(mut guard) => guard.discard_active_turn_pending_by_keys(idempotency_keys),
1268 Err(poisoned) => {
1269 tracing::warn!(
1270 "system-context state lock poisoned while discarding active-turn pending appends"
1271 );
1272 poisoned
1273 .into_inner()
1274 .discard_active_turn_pending_by_keys(idempotency_keys)
1275 }
1276 }
1277 }
1278
1279 pub fn stage_active_turn_append(
1280 &self,
1281 req: &AppendSystemContextRequest,
1282 accepted_at: SystemTime,
1283 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
1284 match self.inner.lock() {
1285 Ok(mut guard) => guard.stage_active_turn_append(req, accepted_at),
1286 Err(poisoned) => {
1287 tracing::warn!(
1288 "system-context state lock poisoned while staging active-turn context"
1289 );
1290 poisoned
1291 .into_inner()
1292 .stage_active_turn_append(req, accepted_at)
1293 }
1294 }
1295 }
1296}
1297
1298#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
1302#[serde(rename_all = "snake_case")]
1303pub struct SessionSystemContextState {
1304 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1305 pub(crate) pending: Vec<PendingSystemContextAppend>,
1306 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1307 pub(crate) applied: Vec<PendingSystemContextAppend>,
1308 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1309 pub(crate) seen: std::collections::BTreeMap<String, SeenSystemContextKey>,
1310 #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
1311 pub(crate) active_turn_pending_keys: std::collections::BTreeSet<String>,
1312}
1313
1314#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1324#[serde(rename_all = "snake_case")]
1325pub enum SystemContextSource {
1326 #[default]
1328 Normal,
1329 RuntimeSteer,
1332}
1333
1334impl From<SystemContextSource> for session_document::SystemContextSource {
1335 fn from(value: SystemContextSource) -> Self {
1336 match value {
1337 SystemContextSource::Normal => Self::Normal,
1338 SystemContextSource::RuntimeSteer => Self::RuntimeSteer,
1339 }
1340 }
1341}
1342
1343impl SystemContextSource {
1344 #[must_use]
1347 pub fn is_normal(&self) -> bool {
1348 matches!(self, Self::Normal)
1349 }
1350
1351 #[must_use]
1353 pub fn is_runtime_steer(&self) -> bool {
1354 matches!(self, Self::RuntimeSteer)
1355 }
1356}
1357
1358#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1362#[serde(rename_all = "snake_case")]
1363pub struct PendingSystemContextAppend {
1364 pub content: crate::lifecycle::run_primitive::CoreRenderable,
1372 #[serde(default, skip_serializing_if = "Option::is_none")]
1373 pub source: Option<String>,
1374 #[serde(default, skip_serializing_if = "Option::is_none")]
1375 pub idempotency_key: Option<String>,
1376 #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
1378 pub source_kind: SystemContextSource,
1379 #[serde(default, skip_serializing_if = "Option::is_none")]
1387 pub peer_response_terminal: Option<crate::handles::PeerResponseTerminalFact>,
1388 pub accepted_at: SystemTime,
1389}
1390
1391#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1406#[serde(rename_all = "snake_case")]
1407pub enum SessionLifecycleTerminal {
1408 Active,
1410 Archived,
1412}
1413
1414impl SessionLifecycleTerminal {
1415 #[must_use]
1417 pub fn is_archived(self) -> bool {
1418 matches!(self, Self::Archived)
1419 }
1420}
1421
1422impl From<SessionLifecycleTerminal> for session_document::SessionDocumentLifecycle {
1423 fn from(value: SessionLifecycleTerminal) -> Self {
1424 match value {
1425 SessionLifecycleTerminal::Active => Self::Active,
1426 SessionLifecycleTerminal::Archived => Self::Archived,
1427 }
1428 }
1429}
1430
1431impl From<session_document::SessionDocumentLifecycle> for SessionLifecycleTerminal {
1432 fn from(value: session_document::SessionDocumentLifecycle) -> Self {
1433 match value {
1434 session_document::SessionDocumentLifecycle::Active => Self::Active,
1435 session_document::SessionDocumentLifecycle::Archived => Self::Archived,
1436 }
1437 }
1438}
1439
1440#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
1442#[serde(rename_all = "snake_case")]
1443pub struct SessionDeferredTurnState {
1444 #[serde(default, skip_serializing_if = "DeferredFirstTurnPhase::is_inactive")]
1445 pub(crate) first_turn_phase: DeferredFirstTurnPhase,
1446 #[serde(default, skip_serializing_if = "Option::is_none")]
1447 pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
1448 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1449 pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
1450}
1451
1452#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
1454#[serde(rename_all = "snake_case")]
1455pub enum DeferredFirstTurnPhase {
1456 #[default]
1458 Inactive,
1459 Pending,
1461 Consumed,
1463}
1464
1465impl DeferredFirstTurnPhase {
1466 pub fn is_inactive(&self) -> bool {
1467 matches!(self, Self::Inactive)
1468 }
1469}
1470
1471impl From<DeferredFirstTurnPhase> for session_document::SessionFirstTurnPhase {
1472 fn from(value: DeferredFirstTurnPhase) -> Self {
1473 match value {
1474 DeferredFirstTurnPhase::Inactive => Self::Inactive,
1475 DeferredFirstTurnPhase::Pending => Self::Pending,
1476 DeferredFirstTurnPhase::Consumed => Self::Consumed,
1477 }
1478 }
1479}
1480
1481impl From<session_document::SessionFirstTurnPhase> for DeferredFirstTurnPhase {
1482 fn from(value: session_document::SessionFirstTurnPhase) -> Self {
1483 match value {
1484 session_document::SessionFirstTurnPhase::Inactive => Self::Inactive,
1485 session_document::SessionFirstTurnPhase::Pending => Self::Pending,
1486 session_document::SessionFirstTurnPhase::Consumed => Self::Consumed,
1487 }
1488 }
1489}
1490
1491fn is_default_hook_run_overrides(value: &crate::HookRunOverrides) -> bool {
1492 value == &crate::HookRunOverrides::default()
1493}
1494
1495fn is_default_call_timeout_override(value: &crate::CallTimeoutOverride) -> bool {
1496 value == &crate::CallTimeoutOverride::default()
1497}
1498
1499fn is_tool_filter_all(value: &ToolFilter) -> bool {
1500 matches!(value, ToolFilter::All)
1501}
1502
1503fn is_zero(value: &u64) -> bool {
1504 *value == 0
1505}
1506
1507pub fn capability_base_filter_for_image_tool_results(image_tool_results: bool) -> ToolFilter {
1509 if image_tool_results {
1510 ToolFilter::All
1511 } else {
1512 ToolFilter::Deny([VIEW_IMAGE_TOOL_NAME.to_string()].into_iter().collect())
1513 }
1514}
1515
1516#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1523#[serde(rename_all = "snake_case")]
1524pub struct ToolVisibilityWitness {
1525 #[serde(default, skip_serializing_if = "Option::is_none")]
1526 pub last_seen_provenance: Option<ToolProvenance>,
1527}
1528
1529impl ToolVisibilityWitness {
1530 pub fn has_identity_witness(&self) -> bool {
1531 self.last_seen_provenance.is_some()
1532 }
1533}
1534
1535#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1541#[serde(rename_all = "snake_case")]
1542pub struct DeferredToolLoadAuthority {
1543 pub name: ToolName,
1544 pub witness: ToolVisibilityWitness,
1545}
1546
1547impl DeferredToolLoadAuthority {
1548 pub fn new(name: impl Into<ToolName>, witness: ToolVisibilityWitness) -> Self {
1549 Self {
1550 name: name.into(),
1551 witness,
1552 }
1553 }
1554
1555 pub fn into_parts(self) -> (ToolName, ToolVisibilityWitness) {
1556 (self.name, self.witness)
1557 }
1558}
1559
1560#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1563#[serde(rename_all = "snake_case")]
1564pub struct WitnessedToolFilter {
1565 pub filter: ToolFilter,
1566 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1567 pub witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1568}
1569
1570impl WitnessedToolFilter {
1571 pub fn new(filter: ToolFilter, witnesses: BTreeMap<ToolName, ToolVisibilityWitness>) -> Self {
1572 Self { filter, witnesses }
1573 }
1574
1575 pub fn into_parts(self) -> (ToolFilter, BTreeMap<ToolName, ToolVisibilityWitness>) {
1576 (self.filter, self.witnesses)
1577 }
1578}
1579
1580#[derive(Debug, Clone, PartialEq, Eq)]
1587pub struct InheritedToolVisibilityAuthority {
1588 filter: ToolFilter,
1589 witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1590}
1591
1592impl InheritedToolVisibilityAuthority {
1593 pub(crate) fn from_generated_composition_authority(
1594 filter: ToolFilter,
1595 witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1596 ) -> Self {
1597 Self { filter, witnesses }
1598 }
1599
1600 pub fn filter(&self) -> &ToolFilter {
1601 &self.filter
1602 }
1603
1604 pub fn witnesses(&self) -> &BTreeMap<ToolName, ToolVisibilityWitness> {
1605 &self.witnesses
1606 }
1607
1608 pub(crate) fn into_initial_visibility_state(self) -> SessionToolVisibilityState {
1609 SessionToolVisibilityState {
1610 inherited_base_filter: self.filter,
1611 filter_witnesses: self.witnesses,
1612 ..Default::default()
1613 }
1614 }
1615}
1616
1617#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1619#[serde(rename_all = "snake_case")]
1620pub struct SessionToolVisibilityState {
1621 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
1622 pub capability_base_filter: ToolFilter,
1623 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
1624 pub inherited_base_filter: ToolFilter,
1625 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
1626 pub active_filter: ToolFilter,
1627 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
1628 pub staged_filter: ToolFilter,
1629 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1630 pub active_requested_deferred_names: BTreeSet<ToolName>,
1631 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1632 pub staged_requested_deferred_names: BTreeSet<ToolName>,
1633 #[serde(default, skip_serializing_if = "is_zero")]
1634 pub active_revision: u64,
1635 #[serde(default, skip_serializing_if = "is_zero")]
1636 pub staged_revision: u64,
1637 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1638 pub requested_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1639 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1640 pub filter_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1641}
1642
1643#[derive(Debug, Clone, PartialEq, Eq)]
1649pub struct AuthorizedSessionToolVisibilityState {
1650 state: SessionToolVisibilityState,
1651}
1652
1653impl AuthorizedSessionToolVisibilityState {
1654 pub(crate) fn from_generated_authority(state: SessionToolVisibilityState) -> Self {
1655 Self { state }
1656 }
1657
1658 pub fn as_state(&self) -> &SessionToolVisibilityState {
1659 &self.state
1660 }
1661
1662 pub fn into_state(self) -> SessionToolVisibilityState {
1663 self.state
1664 }
1665}
1666
1667#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1670#[serde(rename_all = "snake_case")]
1671pub struct SessionBuildState {
1672 #[serde(
1673 default,
1674 skip_serializing_if = "crate::config::SystemPromptOverride::is_inherit"
1675 )]
1676 pub system_prompt: crate::config::SystemPromptOverride,
1677 #[serde(default, skip_serializing_if = "Option::is_none")]
1678 pub output_schema: Option<crate::OutputSchema>,
1679 #[serde(default, skip_serializing_if = "is_default_hook_run_overrides")]
1680 pub hooks_override: crate::HookRunOverrides,
1681 #[serde(default, skip_serializing_if = "Option::is_none")]
1682 pub budget_limits: Option<crate::BudgetLimits>,
1683 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1684 pub recoverable_tool_defs: Vec<ToolDef>,
1685 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1686 pub silent_comms_intents: Vec<String>,
1687 #[serde(default, skip_serializing_if = "Option::is_none")]
1688 pub max_inline_peer_notifications: Option<i32>,
1689 #[serde(default, skip_serializing_if = "Option::is_none")]
1690 pub app_context: Option<serde_json::Value>,
1691 #[serde(default, skip_serializing_if = "Option::is_none")]
1692 pub additional_instructions: Option<Vec<String>>,
1693 #[serde(default, skip_serializing_if = "Option::is_none")]
1694 pub shell_env: Option<HashMap<String, String>>,
1695 #[serde(default, skip_serializing_if = "Option::is_none")]
1701 pub mob_tool_authority_context: Option<MobToolAuthorityContext>,
1702 #[serde(default, skip_serializing_if = "is_default_call_timeout_override")]
1703 pub call_timeout_override: crate::CallTimeoutOverride,
1704 #[serde(default, skip_serializing_if = "Option::is_none")]
1711 pub assembled_system_prompt: Option<String>,
1712}
1713
1714#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1716#[serde(rename_all = "snake_case")]
1717pub struct PendingDeferredPrompt {
1718 pub prompt: ContentInput,
1719 pub accepted_at: SystemTime,
1720}
1721
1722#[derive(Debug, Clone, Serialize, Deserialize)]
1724#[serde(rename_all = "snake_case")]
1725pub struct PendingToolResultsMessage {
1726 pub results: Vec<ToolResult>,
1727 pub accepted_at: SystemTime,
1728}
1729
1730impl PartialEq for PendingToolResultsMessage {
1731 fn eq(&self, other: &Self) -> bool {
1732 self.accepted_at == other.accepted_at
1733 && serde_json::to_value(&self.results).ok() == serde_json::to_value(&other.results).ok()
1734 }
1735}
1736
1737#[derive(Debug, Clone, Default, PartialEq)]
1739pub struct ConsumedDeferredTurnInputs {
1740 pub(crate) restore_first_turn_pending: bool,
1741 pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
1742 pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
1743}
1744
1745impl ConsumedDeferredTurnInputs {
1746 pub fn is_empty(&self) -> bool {
1747 !self.restore_first_turn_pending
1748 && self.pending_initial_prompt.is_none()
1749 && self.pending_tool_results.is_empty()
1750 }
1751
1752 pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
1753 self.pending_initial_prompt.as_ref()
1754 }
1755
1756 pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
1757 &self.pending_tool_results
1758 }
1759}
1760
1761#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1763#[serde(rename_all = "snake_case")]
1764pub struct SeenSystemContextKey {
1765 pub content: crate::lifecycle::run_primitive::CoreRenderable,
1767 #[serde(default, skip_serializing_if = "Option::is_none")]
1768 pub source: Option<String>,
1769 #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
1772 pub source_kind: SystemContextSource,
1773 pub state: SeenSystemContextState,
1774}
1775
1776#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1778#[serde(rename_all = "snake_case")]
1779pub enum SeenSystemContextState {
1780 Pending,
1781 Applied,
1782}
1783
1784impl SessionSystemContextState {
1785 pub fn pending(&self) -> &[PendingSystemContextAppend] {
1786 &self.pending
1787 }
1788
1789 pub fn applied(&self) -> &[PendingSystemContextAppend] {
1790 &self.applied
1791 }
1792
1793 pub fn seen(&self) -> &BTreeMap<String, SeenSystemContextKey> {
1794 &self.seen
1795 }
1796
1797 pub fn active_turn_pending_keys(&self) -> &BTreeSet<String> {
1798 &self.active_turn_pending_keys
1799 }
1800
1801 pub fn pending_len(&self) -> usize {
1802 self.pending.len()
1803 }
1804
1805 pub fn applied_len(&self) -> usize {
1806 self.applied.len()
1807 }
1808
1809 pub fn active_turn_pending_len(&self) -> usize {
1810 self.active_turn_pending_keys.len()
1811 }
1812
1813 pub fn realtime_projection_appends(&self) -> Vec<PendingSystemContextAppend> {
1814 self.applied
1815 .iter()
1816 .chain(self.pending.iter())
1817 .cloned()
1818 .collect()
1819 }
1820
1821 pub fn stage_append(
1823 &mut self,
1824 req: &AppendSystemContextRequest,
1825 accepted_at: SystemTime,
1826 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
1827 system_context_authority::stage_append(self, req, accepted_at, false)
1828 }
1829
1830 fn stage_append_with_generated_authority(
1831 &mut self,
1832 req: &AppendSystemContextRequest,
1833 accepted_at: SystemTime,
1834 active_turn_scoped: bool,
1835 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
1836 system_context_authority::stage_append(self, req, accepted_at, active_turn_scoped)
1837 }
1838
1839 pub fn stage_active_turn_append(
1846 &mut self,
1847 req: &AppendSystemContextRequest,
1848 accepted_at: SystemTime,
1849 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
1850 self.stage_append_with_generated_authority(req, accepted_at, true)
1851 }
1852
1853 pub fn mark_pending_applied(&mut self) {
1855 system_context_authority::mark_pending_applied(self);
1856 }
1857
1858 pub fn discard_unapplied_active_turn_pending(&mut self) -> Vec<PendingSystemContextAppend> {
1861 system_context_authority::discard_unapplied_active_turn_pending(self)
1862 }
1863
1864 pub fn discard_active_turn_pending_by_keys(
1871 &mut self,
1872 idempotency_keys: &[String],
1873 ) -> Vec<PendingSystemContextAppend> {
1874 system_context_authority::discard_active_turn_pending_by_keys(self, idempotency_keys)
1875 }
1876
1877 pub fn restore_from_snapshot(self) -> Result<Self, SystemContextStageError> {
1881 system_context_authority::restore_system_context_state(self)
1882 }
1883
1884 pub fn record_applied_blocks(
1888 &mut self,
1889 appends: &[PendingSystemContextAppend],
1890 current_system_prompt: &str,
1891 ) -> Vec<PendingSystemContextAppend> {
1892 system_context_authority::record_applied_system_context_blocks(
1893 self,
1894 appends,
1895 current_system_prompt,
1896 )
1897 }
1898}
1899
1900const SESSION_DOCUMENT_FIRST_TURN_KEY: &str = "first_turn";
1905
1906fn usize_to_u64(value: usize) -> u64 {
1907 u64::try_from(value).unwrap_or(u64::MAX)
1908}
1909
1910fn validate_deferred_turn_snapshot(
1918 state: SessionDeferredTurnState,
1919) -> Result<SessionDeferredTurnState, session_document::SessionDocumentError> {
1920 let mut authority = session_document::SessionDocumentMachineAuthority::new();
1921 let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
1922 authority.recover_session_first_turn_phase(
1926 key,
1927 state.first_turn_phase.into(),
1928 state.pending_initial_prompt.is_some(),
1929 usize_to_u64(state.pending_tool_results.len()),
1930 )?;
1931 Ok(state)
1932}
1933
1934impl SessionDeferredTurnState {
1935 pub fn first_turn_phase(&self) -> DeferredFirstTurnPhase {
1936 self.first_turn_phase
1937 }
1938
1939 pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
1940 self.pending_initial_prompt.as_ref()
1941 }
1942
1943 pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
1944 &self.pending_tool_results
1945 }
1946
1947 pub fn pending_tool_results_len(&self) -> usize {
1948 self.pending_tool_results.len()
1949 }
1950
1951 pub(crate) fn pending_initial_prompt_mut_for_blob_rewrite(
1952 &mut self,
1953 ) -> Option<&mut PendingDeferredPrompt> {
1954 self.pending_initial_prompt.as_mut()
1955 }
1956
1957 pub(crate) fn pending_tool_results_mut_for_blob_rewrite(
1958 &mut self,
1959 ) -> &mut [PendingToolResultsMessage] {
1960 &mut self.pending_tool_results
1961 }
1962
1963 fn document_authority(
1973 &self,
1974 ) -> (
1975 session_document::SessionDocumentMachineAuthority,
1976 session_document::SessionDocumentKey,
1977 ) {
1978 let mut authority = session_document::SessionDocumentMachineAuthority::new();
1979 let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
1980 if let Err(err) = authority.recover_session_first_turn_phase(
1981 key.clone(),
1982 self.first_turn_phase.into(),
1983 self.pending_initial_prompt.is_some(),
1984 usize_to_u64(self.pending_tool_results.len()),
1985 ) {
1986 tracing::warn!(
1987 error = %err,
1988 "generated session document authority rejected first-turn recovery"
1989 );
1990 }
1991 (authority, key)
1992 }
1993
1994 fn mirror_first_turn_phase(
1997 &mut self,
1998 effects: &[session_document::SessionDocumentEffect],
1999 ) -> Option<bool> {
2000 for effect in effects {
2001 if let session_document::SessionDocumentEffect::SessionFirstTurnPhaseResolved {
2002 phase,
2003 was_pending,
2004 } = effect
2005 {
2006 self.first_turn_phase = (*phase).into();
2007 return Some(*was_pending);
2008 }
2009 }
2010 None
2011 }
2012
2013 pub fn mark_initial_turn_pending(&mut self) {
2015 let (mut authority, key) = self.document_authority();
2016 match authority.mark_session_initial_turn_pending(key) {
2017 Ok(effects) => {
2018 self.mirror_first_turn_phase(&effects);
2019 }
2020 Err(err) => tracing::warn!(
2021 error = %err,
2022 "generated session document authority rejected pending mark"
2023 ),
2024 }
2025 }
2026
2027 pub fn mark_initial_turn_started(&mut self) -> bool {
2031 let (mut authority, key) = self.document_authority();
2032 match authority.start_session_initial_turn(key) {
2033 Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
2034 Err(err) => {
2035 tracing::warn!(
2036 error = %err,
2037 "generated session document authority rejected first-turn start"
2038 );
2039 false
2040 }
2041 }
2042 }
2043
2044 pub fn restore_initial_turn_pending(&mut self) {
2046 let (mut authority, key) = self.document_authority();
2051 match authority.restore_session_consumed_inputs(
2052 key.clone(),
2053 true,
2054 self.pending_initial_prompt.is_some(),
2055 usize_to_u64(self.pending_tool_results.len()),
2056 ) {
2057 Ok(_) => {
2058 if let Some(phase) = authority.session_first_turn_phase_for(&key) {
2061 self.first_turn_phase = phase.into();
2062 }
2063 }
2064 Err(err) => tracing::warn!(
2065 error = %err,
2066 "generated session document authority rejected pending restore"
2067 ),
2068 }
2069 }
2070
2071 pub fn allows_initial_turn_overrides(&self) -> bool {
2073 let (mut authority, key) = self.document_authority();
2074 match authority.resolve_session_first_turn_overrides_allowed(key) {
2075 Ok(effects) => effects
2076 .iter()
2077 .find_map(|effect| {
2078 match effect {
2079 session_document::SessionDocumentEffect::SessionFirstTurnOverridesResolved {
2080 allowed,
2081 } => Some(*allowed),
2082 _ => None,
2083 }
2084 })
2085 .unwrap_or(false),
2086 Err(err) => {
2087 tracing::warn!(
2088 error = %err,
2089 "generated session document authority rejected override resolution"
2090 );
2091 false
2092 }
2093 }
2094 }
2095
2096 pub fn stage_initial_prompt(&mut self, prompt: ContentInput, accepted_at: SystemTime) {
2098 let prompt_has_content = prompt.has_images() || !prompt.text_content().trim().is_empty();
2099 let (mut authority, key) = self.document_authority();
2100 match authority.stage_session_initial_prompt(key, prompt_has_content) {
2101 Ok(effects) => {
2102 let decision = effects.iter().find_map(|effect| {
2103 match effect {
2104 session_document::SessionDocumentEffect::SessionInitialPromptStageResolved {
2105 decision,
2106 } => Some(*decision),
2107 _ => None,
2108 }
2109 });
2110 match decision {
2111 Some(session_document::SessionInitialPromptStageDecision::Store) => {
2112 self.pending_initial_prompt = Some(PendingDeferredPrompt {
2113 prompt,
2114 accepted_at,
2115 });
2116 }
2117 Some(session_document::SessionInitialPromptStageDecision::Clear) => {
2118 self.pending_initial_prompt = None;
2119 }
2120 None => tracing::warn!(
2121 "generated session document authority returned no prompt-stage decision"
2122 ),
2123 }
2124 }
2125 Err(err) => tracing::warn!(
2126 error = %err,
2127 "generated session document authority rejected initial prompt stage"
2128 ),
2129 }
2130 }
2131
2132 pub fn stage_tool_results(
2134 &mut self,
2135 results: Vec<ToolResult>,
2136 accepted_at: SystemTime,
2137 ) -> usize {
2138 let (mut authority, key) = self.document_authority();
2139 let accepted = match authority.stage_session_tool_results(key, usize_to_u64(results.len()))
2140 {
2141 Ok(effects) => effects.iter().find_map(|effect| match effect {
2142 session_document::SessionDocumentEffect::SessionToolResultsStageResolved {
2143 accepted_count,
2144 } => Some(*accepted_count),
2145 _ => None,
2146 }),
2147 Err(err) => {
2148 tracing::warn!(
2149 error = %err,
2150 "generated session document authority rejected tool-results stage"
2151 );
2152 return 0;
2153 }
2154 };
2155 let Some(accepted) = accepted else {
2156 tracing::warn!(
2157 "generated session document authority returned no tool-results decision"
2158 );
2159 return 0;
2160 };
2161 if accepted == 0 {
2162 return 0;
2163 }
2164 let accepted = usize::try_from(accepted).unwrap_or(usize::MAX);
2165 self.pending_tool_results.push(PendingToolResultsMessage {
2166 results,
2167 accepted_at,
2168 });
2169 accepted
2170 }
2171
2172 pub fn has_pending_tool_results(&self) -> bool {
2174 !self.pending_tool_results.is_empty()
2175 }
2176
2177 pub fn consume_for_started_turn(&mut self) -> ConsumedDeferredTurnInputs {
2179 let (mut authority, key) = self.document_authority();
2180 let was_pending = match authority.consume_session_deferred_inputs(key) {
2181 Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
2182 Err(err) => {
2183 tracing::warn!(
2184 error = %err,
2185 "generated session document authority rejected started-turn consumption"
2186 );
2187 return ConsumedDeferredTurnInputs::default();
2188 }
2189 };
2190 ConsumedDeferredTurnInputs {
2191 restore_first_turn_pending: was_pending,
2192 pending_initial_prompt: self.pending_initial_prompt.take(),
2193 pending_tool_results: std::mem::take(&mut self.pending_tool_results),
2194 }
2195 }
2196
2197 pub fn restore_consumed_turn_inputs(&mut self, consumed: ConsumedDeferredTurnInputs) {
2199 if consumed.is_empty() {
2200 return;
2201 }
2202 let (mut authority, key) = self.document_authority();
2203 let effects = match authority.restore_session_consumed_inputs(
2204 key,
2205 consumed.restore_first_turn_pending,
2206 consumed.pending_initial_prompt.is_some(),
2207 usize_to_u64(consumed.pending_tool_results.len()),
2208 ) {
2209 Ok(effects) => effects,
2210 Err(err) => {
2211 tracing::warn!(
2212 error = %err,
2213 "generated session document authority rejected consumed input restore"
2214 );
2215 return;
2216 }
2217 };
2218 let Some((restore_first_turn_pending, restore_initial_prompt, restore_tool_results)) =
2219 effects.iter().find_map(|effect| match effect {
2220 session_document::SessionDocumentEffect::SessionConsumedInputsRestoreResolved {
2221 restore_first_turn_pending,
2222 restore_initial_prompt,
2223 restore_tool_results,
2224 } => Some((
2225 *restore_first_turn_pending,
2226 *restore_initial_prompt,
2227 *restore_tool_results,
2228 )),
2229 _ => None,
2230 })
2231 else {
2232 tracing::warn!(
2233 "generated session document authority returned no consumed-input restore decision"
2234 );
2235 return;
2236 };
2237 if restore_first_turn_pending {
2238 self.restore_initial_turn_pending();
2239 }
2240 if restore_initial_prompt && self.pending_initial_prompt.is_none() {
2241 self.pending_initial_prompt = consumed.pending_initial_prompt;
2242 }
2243 if restore_tool_results {
2244 let mut restored = consumed.pending_tool_results;
2245 restored.extend(std::mem::take(&mut self.pending_tool_results));
2246 self.pending_tool_results = restored;
2247 }
2248 }
2249}
2250
2251#[derive(Debug, Clone, PartialEq, Eq)]
2253pub enum SystemContextStageError {
2254 InvalidRequest(String),
2255 Conflict {
2256 key: String,
2257 existing_text: String,
2258 existing_source: Option<String>,
2259 },
2260}
2261
2262impl std::fmt::Display for SystemContextStageError {
2263 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2264 match self {
2265 Self::InvalidRequest(message) => {
2266 write!(f, "invalid system-context append request: {message}")
2267 }
2268 Self::Conflict { key, .. } => {
2269 write!(
2270 f,
2271 "system-context append conflict for idempotency key `{key}`"
2272 )
2273 }
2274 }
2275 }
2276}
2277
2278impl std::error::Error for SystemContextStageError {}
2279
2280fn render_system_context_block(append: &PendingSystemContextAppend) -> String {
2288 let mut rendered = String::from(SYSTEM_CONTEXT_RENDER_LABEL);
2289 if let Some(source) = &append.source {
2290 rendered.push_str("\nsource: ");
2291 rendered.push_str(source);
2292 }
2293 rendered.push_str("\n\n");
2294 rendered.push_str(append.content.render_text().trim());
2297 rendered
2298}
2299
2300const SYSTEM_CONTEXT_RENDER_LABEL: &str = "[Runtime System Context]";
2305
2306fn render_system_context_blocks_joined(appends: &[PendingSystemContextAppend]) -> String {
2312 appends
2313 .iter()
2314 .map(render_system_context_block)
2315 .collect::<Vec<_>>()
2316 .join(SYSTEM_CONTEXT_SEPARATOR)
2317}
2318
2319fn compose_system_prompt_with_context_tail(base: &str, tail: &str) -> String {
2324 if tail.is_empty() {
2325 return base.to_string();
2326 }
2327 if base.is_empty() {
2328 return tail
2329 .strip_prefix(SYSTEM_CONTEXT_SEPARATOR)
2330 .unwrap_or(tail)
2331 .to_string();
2332 }
2333 format!("{base}{tail}")
2334}
2335
2336fn persisted_prompt_is_admitted_context_append_continuation(
2347 assembled_base: &str,
2348 persisted_content: &str,
2349 persisted_mutation_kind: crate::types::SystemPromptMutationKind,
2350) -> bool {
2351 let content_identical = persisted_content == assembled_base;
2352 let content_extends = persisted_content.starts_with(assembled_base);
2353 let appended_starts_with_separator = content_extends
2354 && persisted_content[assembled_base.len()..].starts_with(SYSTEM_CONTEXT_SEPARATOR);
2355 let mut authority = session_document::SessionDocumentMachineAuthority::new();
2356 match authority.resolve_system_context_persist_append_admission(
2357 true,
2358 content_identical,
2359 content_extends,
2360 appended_starts_with_separator,
2361 persisted_mutation_kind.is_runtime_context_append(),
2362 ) {
2363 Ok(effects) => effects.into_iter().any(|effect| {
2364 matches!(
2365 effect,
2366 session_document::SessionDocumentEffect::SystemContextPersistAppendAdmissionResolved {
2367 admission: session_document::SystemContextPersistAppendAdmission::Admit,
2368 }
2369 )
2370 }),
2371 Err(error) => {
2372 tracing::warn!(
2373 error = %error,
2374 "session document authority refused resume prompt continuation admission; \
2375 falling back to audited rewrite"
2376 );
2377 false
2378 }
2379 }
2380}
2381
2382mod system_context_authority {
2393 use super::{
2394 AppendSystemContextRequest, BTreeSet, PendingSystemContextAppend, SeenSystemContextKey,
2395 SeenSystemContextState, SessionSystemContextState, SystemContextSource,
2396 SystemContextStageError, SystemTime, render_system_context_block, session_document,
2397 usize_to_u64,
2398 };
2399 use crate::service::AppendSystemContextStatus;
2400
2401 fn document_authority() -> session_document::SessionDocumentMachineAuthority {
2402 session_document::SessionDocumentMachineAuthority::new()
2403 }
2404
2405 fn resolve_append_decision(
2407 trimmed_text_byte_count: u64,
2408 idempotency_key_present: bool,
2409 existing_key_matches: bool,
2410 existing_key_conflicts: bool,
2411 active_turn_scoped: bool,
2412 ) -> Result<session_document::SystemContextAppendDecision, SystemContextStageError> {
2413 let mut authority = document_authority();
2414 let effects = authority
2415 .resolve_system_context_append(
2416 trimmed_text_byte_count,
2417 idempotency_key_present,
2418 existing_key_matches,
2419 existing_key_conflicts,
2420 active_turn_scoped,
2421 )
2422 .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
2423 effects
2424 .into_iter()
2425 .find_map(|effect| match effect {
2426 session_document::SessionDocumentEffect::SystemContextAppendResolved {
2427 decision,
2428 ..
2429 } => Some(decision),
2430 _ => None,
2431 })
2432 .ok_or_else(|| {
2433 SystemContextStageError::InvalidRequest(
2434 "generated session document authority returned no append decision".to_string(),
2435 )
2436 })
2437 }
2438
2439 fn pending_apply_item(source_kind: SystemContextSource) -> Option<(bool, bool, bool)> {
2442 let mut authority = document_authority();
2443 match authority.resolve_system_context_pending_apply_item(source_kind.into()) {
2444 Ok(effects) => effects.into_iter().find_map(|effect| {
2445 match effect {
2446 session_document::SessionDocumentEffect::SystemContextPendingApplyItemResolved {
2447 promote_to_applied,
2448 mark_seen_applied,
2449 remove_seen,
2450 } => Some((promote_to_applied, mark_seen_applied, remove_seen)),
2451 _ => None,
2452 }
2453 }),
2454 Err(err) => {
2455 tracing::warn!(
2456 error = %err,
2457 "generated session document authority rejected system-context apply item"
2458 );
2459 None
2460 }
2461 }
2462 }
2463
2464 fn steer_cleanup_discards(source_kind: SystemContextSource) -> bool {
2467 let mut authority = document_authority();
2468 match authority.resolve_system_context_steer_cleanup_item(source_kind.into()) {
2469 Ok(effects) => effects
2470 .into_iter()
2471 .find_map(|effect| {
2472 match effect {
2473 session_document::SessionDocumentEffect::SystemContextSteerCleanupItemResolved {
2474 discard,
2475 } => Some(discard),
2476 _ => None,
2477 }
2478 })
2479 .unwrap_or(false),
2480 Err(err) => {
2481 tracing::warn!(
2482 error = %err,
2483 "generated session document authority rejected system-context steer cleanup item"
2484 );
2485 false
2486 }
2487 }
2488 }
2489
2490 pub(super) fn restore_system_context_state(
2491 state: SessionSystemContextState,
2492 ) -> Result<SessionSystemContextState, SystemContextStageError> {
2493 let active_keys_have_known_pending_or_seen =
2494 state.active_turn_pending_keys.iter().all(|key| {
2495 state.seen.contains_key(key)
2496 || state
2497 .pending
2498 .iter()
2499 .any(|append| append.idempotency_key.as_ref() == Some(key))
2500 });
2501 let seen_keys_match_known_appends = state.seen.iter().all(|(key, seen)| {
2502 state
2503 .pending
2504 .iter()
2505 .chain(state.applied.iter())
2506 .any(|append| {
2507 append.idempotency_key.as_ref() == Some(key)
2508 && seen.content == append.content
2509 && seen.source.as_deref() == append.source.as_deref()
2510 })
2511 });
2512 let mut authority = document_authority();
2513 authority
2514 .restore_system_context_snapshot(
2515 active_keys_have_known_pending_or_seen,
2516 seen_keys_match_known_appends,
2517 )
2518 .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
2519 Ok(state)
2520 }
2521
2522 pub(super) fn stage_append(
2523 state: &mut SessionSystemContextState,
2524 req: &AppendSystemContextRequest,
2525 accepted_at: SystemTime,
2526 active_turn_scoped: bool,
2527 ) -> Result<AppendSystemContextStatus, SystemContextStageError> {
2528 let rendered_text = req.content.render_text();
2532 let rendered_len = rendered_text.trim().len();
2533 let existing = req
2534 .idempotency_key
2535 .as_ref()
2536 .and_then(|key| state.seen.get(key));
2537 let existing_key_matches = existing.is_some_and(|existing| {
2538 existing.content == req.content && existing.source.as_deref() == req.source.as_deref()
2539 });
2540 let existing_key_conflicts = existing.is_some() && !existing_key_matches;
2541 let decision = resolve_append_decision(
2542 usize_to_u64(rendered_len),
2543 req.idempotency_key.is_some(),
2544 existing_key_matches,
2545 existing_key_conflicts,
2546 active_turn_scoped,
2547 )?;
2548
2549 match decision {
2550 session_document::SystemContextAppendDecision::RejectEmpty => {
2551 return Err(SystemContextStageError::InvalidRequest(
2552 "system context text must not be empty".to_string(),
2553 ));
2554 }
2555 session_document::SystemContextAppendDecision::RejectConflict => {
2556 let Some(key) = req.idempotency_key.as_ref() else {
2557 return Err(SystemContextStageError::InvalidRequest(
2558 "generated system-context authority rejected append without a key"
2559 .to_string(),
2560 ));
2561 };
2562 let Some(existing) = existing else {
2563 return Err(SystemContextStageError::InvalidRequest(
2564 "generated system-context authority rejected append without a conflict"
2565 .to_string(),
2566 ));
2567 };
2568 return Err(SystemContextStageError::Conflict {
2569 key: key.clone(),
2570 existing_text: existing.content.render_text(),
2571 existing_source: existing.source.clone(),
2572 });
2573 }
2574 session_document::SystemContextAppendDecision::Duplicate => {
2575 return Ok(AppendSystemContextStatus::Duplicate);
2576 }
2577 session_document::SystemContextAppendDecision::Staged => {}
2578 }
2579
2580 let append = PendingSystemContextAppend {
2581 content: req.content.clone(),
2582 source: req.source.clone(),
2583 idempotency_key: req.idempotency_key.clone(),
2584 source_kind: req.source_kind,
2585 peer_response_terminal: req.peer_response_terminal.clone(),
2589 accepted_at,
2590 };
2591 if let Some(key) = req.idempotency_key.as_ref() {
2592 state.seen.insert(
2593 key.clone(),
2594 SeenSystemContextKey {
2595 content: append.content.clone(),
2596 source: append.source.clone(),
2597 source_kind: append.source_kind,
2598 state: SeenSystemContextState::Pending,
2599 },
2600 );
2601 }
2602 if active_turn_scoped && let Some(key) = req.idempotency_key.as_ref() {
2603 state.active_turn_pending_keys.insert(key.clone());
2604 }
2605 state.pending.push(append);
2606 Ok(AppendSystemContextStatus::Staged)
2607 }
2608
2609 pub(super) fn mark_pending_applied(state: &mut SessionSystemContextState) {
2610 let pending = std::mem::take(&mut state.pending);
2613 let mut seen_to_remove = Vec::new();
2614 for append in &pending {
2615 let Some((promote_to_applied, mark_seen_applied, remove_seen)) =
2616 pending_apply_item(append.source_kind)
2617 else {
2618 continue;
2619 };
2620 if promote_to_applied && !state.applied.contains(append) {
2621 state.applied.push(append.clone());
2622 }
2623 if let Some(key) = append.idempotency_key.as_ref() {
2624 if remove_seen {
2625 seen_to_remove.push(key.clone());
2626 } else if mark_seen_applied && let Some(seen) = state.seen.get_mut(key) {
2627 seen.state = SeenSystemContextState::Applied;
2628 }
2629 }
2630 }
2631 for key in seen_to_remove {
2632 state.seen.remove(&key);
2633 }
2634 state.active_turn_pending_keys.clear();
2635 }
2636
2637 pub(super) fn discard_unapplied_active_turn_pending(
2638 state: &mut SessionSystemContextState,
2639 ) -> Vec<PendingSystemContextAppend> {
2640 if state.active_turn_pending_keys.is_empty() {
2641 return Vec::new();
2642 }
2643 let active_keys = std::mem::take(&mut state.active_turn_pending_keys);
2644 let mut discarded = Vec::new();
2645 state.pending.retain(|append| {
2646 let should_discard = append
2647 .idempotency_key
2648 .as_ref()
2649 .is_some_and(|key| active_keys.contains(key));
2650 if should_discard {
2651 discarded.push(append.clone());
2652 }
2653 !should_discard
2654 });
2655
2656 for append in &discarded {
2657 if let Some(key) = append.idempotency_key.as_ref()
2658 && state
2659 .seen
2660 .get(key)
2661 .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
2662 {
2663 state.seen.remove(key);
2664 }
2665 }
2666
2667 discarded
2668 }
2669
2670 pub(super) fn discard_active_turn_pending_by_keys(
2671 state: &mut SessionSystemContextState,
2672 idempotency_keys: &[String],
2673 ) -> Vec<PendingSystemContextAppend> {
2674 if idempotency_keys.is_empty() || state.active_turn_pending_keys.is_empty() {
2675 return Vec::new();
2676 }
2677 let requested_keys: BTreeSet<&str> = idempotency_keys.iter().map(String::as_str).collect();
2678 let mut discarded = Vec::new();
2679 let mut discarded_keys = Vec::new();
2680 state.pending.retain(|append| {
2681 let should_discard = append.idempotency_key.as_ref().is_some_and(|key| {
2682 requested_keys.contains(key.as_str())
2683 && state.active_turn_pending_keys.contains(key)
2684 });
2685 if should_discard {
2686 if let Some(key) = append.idempotency_key.as_ref() {
2687 discarded_keys.push(key.clone());
2688 }
2689 discarded.push(append.clone());
2690 }
2691 !should_discard
2692 });
2693
2694 for key in discarded_keys {
2695 state.active_turn_pending_keys.remove(&key);
2696 if state
2697 .seen
2698 .get(&key)
2699 .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
2700 {
2701 state.seen.remove(&key);
2702 }
2703 }
2704
2705 discarded
2706 }
2707
2708 pub(super) fn discard_transient_runtime_steer_state(
2709 state: &mut SessionSystemContextState,
2710 ) -> usize {
2711 let mut removed = 0usize;
2712
2713 let before_pending = state.pending.len();
2714 state
2715 .pending
2716 .retain(|append| !steer_cleanup_discards(append.source_kind));
2717 removed += before_pending.saturating_sub(state.pending.len());
2718
2719 let before_applied = state.applied.len();
2720 state
2721 .applied
2722 .retain(|append| !steer_cleanup_discards(append.source_kind));
2723 removed += before_applied.saturating_sub(state.applied.len());
2724
2725 let before_seen = state.seen.len();
2726 state
2727 .seen
2728 .retain(|_key, seen| !steer_cleanup_discards(seen.source_kind));
2729 removed += before_seen.saturating_sub(state.seen.len());
2730
2731 let before_active = state.active_turn_pending_keys.len();
2735 let steer_keys: BTreeSet<String> = state
2736 .seen
2737 .iter()
2738 .filter(|(_key, seen)| steer_cleanup_discards(seen.source_kind))
2739 .map(|(key, _seen)| key.clone())
2740 .collect();
2741 state
2745 .active_turn_pending_keys
2746 .retain(|key| state.seen.contains_key(key) && !steer_keys.contains(key));
2747 removed += before_active.saturating_sub(state.active_turn_pending_keys.len());
2748
2749 removed
2750 }
2751
2752 pub(super) fn remove_runtime_steer_blocks_for_rendered(
2753 system_prompt: &str,
2754 runtime_steer_appends: &[PendingSystemContextAppend],
2755 ) -> (String, usize) {
2756 if runtime_steer_appends.is_empty() {
2757 return (system_prompt.to_string(), 0);
2758 }
2759 let steer_blocks: BTreeSet<String> = runtime_steer_appends
2763 .iter()
2764 .map(render_system_context_block)
2765 .collect();
2766 let parts = system_prompt
2767 .split(super::SYSTEM_CONTEXT_SEPARATOR)
2768 .map(str::to_string)
2769 .collect::<Vec<_>>();
2770 let original_len = parts.len();
2771 let retained = parts
2772 .into_iter()
2773 .filter(|part| !steer_blocks.contains(part))
2774 .collect::<Vec<_>>();
2775 let removed = original_len.saturating_sub(retained.len());
2776 (retained.join(super::SYSTEM_CONTEXT_SEPARATOR), removed)
2777 }
2778
2779 pub(super) fn record_applied_system_context_blocks(
2780 state: &mut SessionSystemContextState,
2781 appends: &[PendingSystemContextAppend],
2782 current_system_prompt: &str,
2783 ) -> Vec<PendingSystemContextAppend> {
2784 let mut new_appends: Vec<PendingSystemContextAppend> = Vec::new();
2785 for append in appends {
2786 if append.content.render_text().trim().is_empty() {
2787 continue;
2788 }
2789 let rendered = render_system_context_block(append);
2790 if let Some(key) = append.idempotency_key.as_ref() {
2791 if let Some(existing) = state.seen.get(key)
2792 && !seen_system_context_matches(existing, append)
2793 {
2794 tracing::warn!(
2795 idempotency_key = %key,
2796 "skipping conflicting runtime system-context append"
2797 );
2798 continue;
2799 }
2800 if let Some(existing) = state
2801 .applied
2802 .iter()
2803 .find(|applied| applied.idempotency_key.as_ref() == Some(key))
2804 && !pending_system_context_matches(existing, append)
2805 {
2806 tracing::warn!(
2807 idempotency_key = %key,
2808 "skipping conflicting runtime system-context append"
2809 );
2810 continue;
2811 }
2812 if let Some(existing) = new_appends
2813 .iter()
2814 .find(|pending| pending.idempotency_key.as_ref() == Some(key))
2815 {
2816 if !pending_system_context_matches(existing, append) {
2817 tracing::warn!(
2818 idempotency_key = %key,
2819 "skipping conflicting runtime system-context append"
2820 );
2821 }
2822 continue;
2823 }
2824 if current_system_prompt.contains(&rendered) {
2825 record_applied_append(state, append);
2826 continue;
2827 }
2828 } else if new_appends.contains(append) || current_system_prompt.contains(&rendered) {
2829 continue;
2830 }
2831 record_applied_append(state, append);
2832 new_appends.push(append.clone());
2833 }
2834 new_appends
2835 }
2836
2837 fn record_applied_append(
2838 state: &mut SessionSystemContextState,
2839 append: &PendingSystemContextAppend,
2840 ) {
2841 if let Some(key) = append.idempotency_key.as_ref() {
2842 state.seen.insert(
2843 key.clone(),
2844 SeenSystemContextKey {
2845 content: append.content.clone(),
2846 source: append.source.clone(),
2847 source_kind: append.source_kind,
2848 state: SeenSystemContextState::Applied,
2849 },
2850 );
2851 if state
2852 .applied
2853 .iter()
2854 .any(|applied| applied.idempotency_key.as_ref() == Some(key))
2855 {
2856 return;
2857 }
2858 } else if state.applied.contains(append) {
2859 return;
2860 }
2861 state.applied.push(append.clone());
2862 }
2863
2864 fn seen_system_context_matches(
2865 seen: &SeenSystemContextKey,
2866 append: &PendingSystemContextAppend,
2867 ) -> bool {
2868 seen.content == append.content && seen.source.as_deref() == append.source.as_deref()
2869 }
2870
2871 fn pending_system_context_matches(
2872 existing: &PendingSystemContextAppend,
2873 append: &PendingSystemContextAppend,
2874 ) -> bool {
2875 existing.content == append.content && existing.source.as_deref() == append.source.as_deref()
2876 }
2877}
2878
2879impl Session {
2880 pub fn new() -> Self {
2882 let now = SystemTime::now();
2883 Self {
2884 version: session_version(),
2885 id: SessionId::new(),
2886 messages: Arc::new(Vec::new()),
2887 created_at: now,
2888 updated_at: now,
2889 metadata: serde_json::Map::new(),
2890 usage: Usage::default(),
2891 }
2892 }
2893
2894 pub fn with_id(id: SessionId) -> Self {
2896 let mut session = Self::new();
2897 session.id = id;
2898 session
2899 }
2900
2901 pub fn id(&self) -> &SessionId {
2903 &self.id
2904 }
2905
2906 pub fn version(&self) -> u32 {
2908 self.version
2909 }
2910
2911 pub fn messages(&self) -> &[Message] {
2913 &self.messages
2914 }
2915
2916 pub(crate) fn replace_messages_internal(
2922 &mut self,
2923 messages: Vec<Message>,
2924 reason: TranscriptRewriteReason,
2925 ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError> {
2926 if transcript_messages_digest(self.messages()).ok()
2927 == transcript_messages_digest(&messages).ok()
2928 {
2929 return Ok(None);
2930 }
2931 let commit = self.commit_transcript_rewrite(
2932 TranscriptRewriteSelection::MessageRange {
2933 start: 0,
2934 end: self.messages.len(),
2935 },
2936 messages,
2937 reason,
2938 Some("meerkat-core".to_string()),
2939 None,
2940 )?;
2941 Ok(Some(commit))
2942 }
2943
2944 pub(crate) fn retain_messages_internal<F>(
2946 &mut self,
2947 mut retain: F,
2948 reason: TranscriptRewriteReason,
2949 ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError>
2950 where
2951 F: FnMut(&Message) -> bool,
2952 {
2953 let retained = self
2954 .messages
2955 .iter()
2956 .filter(|message| retain(message))
2957 .cloned()
2958 .collect::<Vec<_>>();
2959 if retained.len() == self.messages.len()
2960 && transcript_messages_digest(self.messages()).ok()
2961 == transcript_messages_digest(&retained).ok()
2962 {
2963 return Ok(None);
2964 }
2965 self.replace_messages_internal(retained, reason)
2966 }
2967
2968 pub fn replace_synthetic_notices(
2978 &mut self,
2979 kind: crate::types::SystemNoticeKind,
2980 replacements: Vec<Message>,
2981 ) -> Result<(), TranscriptEditError> {
2982 for (index, message) in replacements.iter().enumerate() {
2983 let matches_kind =
2984 matches!(message, Message::SystemNotice(notice) if notice.kind == kind);
2985 if !matches_kind {
2986 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
2987 "replacement {index} for synthetic notice kind {kind:?} is not a system notice of that kind"
2988 )));
2989 }
2990 }
2991 self.retain_messages_internal(
2992 |message| !matches!(message, Message::SystemNotice(notice) if notice.kind == kind),
2993 TranscriptRewriteReason::new("synthetic_notice_cleanup"),
2994 )?;
2995 for message in replacements {
2996 self.push(message);
2997 }
2998 Ok(())
2999 }
3000
3001 pub fn created_at(&self) -> SystemTime {
3003 self.created_at
3004 }
3005
3006 pub fn updated_at(&self) -> SystemTime {
3008 self.updated_at
3009 }
3010
3011 pub fn push(&mut self, message: Message) {
3015 Arc::make_mut(&mut self.messages).push(message);
3016 self.updated_at = SystemTime::now();
3017 self.refresh_transcript_head_after_message_mutation();
3018 }
3019
3020 pub fn push_batch(&mut self, messages: Vec<Message>) {
3024 if messages.is_empty() {
3025 return;
3026 }
3027 let inner = Arc::make_mut(&mut self.messages);
3028 inner.extend(messages);
3029 self.updated_at = SystemTime::now();
3030 self.refresh_transcript_head_after_message_mutation();
3031 }
3032
3033 pub async fn externalize_media(
3044 &mut self,
3045 blob_store: &dyn crate::BlobStore,
3046 start: usize,
3047 ) -> Result<(), crate::blob::BlobStoreError> {
3048 let previous_digest = if self
3049 .metadata
3050 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
3051 {
3052 transcript_messages_digest(self.messages()).ok()
3053 } else {
3054 None
3055 };
3056 let messages = Arc::make_mut(&mut self.messages);
3057 crate::image_content::externalize_messages_from(blob_store, messages, start).await?;
3058 if let Some(previous_digest) = previous_digest
3059 && transcript_messages_digest(self.messages()).ok().as_ref() != Some(&previous_digest)
3060 {
3061 self.refresh_transcript_head_after_message_mutation();
3062 }
3063 Ok(())
3064 }
3065
3066 pub fn touch(&mut self) {
3070 self.updated_at = SystemTime::now();
3071 }
3072
3073 pub fn last_n(&self, n: usize) -> &[Message] {
3075 let start = self.messages.len().saturating_sub(n);
3076 &self.messages[start..]
3077 }
3078
3079 pub fn total_tokens(&self) -> u64 {
3081 self.usage.total_tokens()
3082 }
3083
3084 pub fn total_usage(&self) -> Usage {
3086 self.usage.clone()
3087 }
3088
3089 pub fn record_usage(&mut self, turn_usage: Usage) {
3091 self.usage.add(&turn_usage);
3092 self.updated_at = SystemTime::now();
3093 }
3094
3095 pub fn append_external_user_content(&mut self, content: ContentInput) {
3097 self.push(Message::User(UserMessage::with_blocks(
3098 content.into_blocks(),
3099 )));
3100 }
3101
3102 pub fn append_external_assistant_blocks(
3104 &mut self,
3105 blocks: Vec<AssistantBlock>,
3106 stop_reason: StopReason,
3107 usage: Usage,
3108 ) {
3109 if !blocks.is_empty() {
3110 self.push(Message::BlockAssistant(BlockAssistantMessage::new(
3111 blocks,
3112 stop_reason,
3113 )));
3114 }
3115 if usage != Usage::default() {
3116 self.record_usage(usage);
3117 }
3118 }
3119
3120 pub fn append_realtime_transcript_event(
3128 &mut self,
3129 event: RealtimeTranscriptEvent,
3130 ) -> RealtimeTranscriptApplyOutcome {
3131 let mut state = self.realtime_transcript_state();
3132 let commit =
3133 realtime_transcript_revision::apply_realtime_transcript_event(&mut state, event)
3134 .unwrap_or_else(|err| {
3135 fail_closed_generated_restore(
3136 "realtime-transcript",
3137 <serde_json::Error as serde::de::Error>::custom(err),
3138 )
3139 });
3140 self.store_realtime_transcript_state(&state);
3141 self.push_batch(commit.messages);
3142 if commit.usage != Usage::default() {
3143 self.record_usage(commit.usage);
3144 }
3145 commit.outcome
3146 }
3147
3148 #[must_use]
3167 pub fn in_flight_realtime_assistant_response_ids(&self) -> Vec<String> {
3168 let state = self.realtime_transcript_state();
3169 realtime_transcript_revision::in_flight_realtime_assistant_response_ids(&state)
3170 }
3171
3172 fn realtime_transcript_state(&self) -> SessionRealtimeTranscriptState {
3173 match self.try_realtime_transcript_state() {
3174 Ok(Some(state)) => state,
3175 Ok(None) => SessionRealtimeTranscriptState::default(),
3176 Err(err) => fail_closed_generated_restore("realtime-transcript", err),
3177 }
3178 }
3179
3180 fn try_realtime_transcript_state(
3181 &self,
3182 ) -> Result<Option<SessionRealtimeTranscriptState>, serde_json::Error> {
3183 self.metadata
3184 .get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY)
3185 .map(|value| {
3186 let state = serde_json::from_value(value.clone())?;
3187 realtime_transcript_revision::restore_realtime_transcript_state(state)
3188 .map_err(<serde_json::Error as serde::de::Error>::custom)
3189 })
3190 .transpose()
3191 }
3192
3193 fn store_realtime_transcript_state(&mut self, state: &SessionRealtimeTranscriptState) {
3194 match serde_json::to_value(state) {
3195 Ok(value) => self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value),
3196 Err(error) => {
3197 tracing::warn!(error = %error, "failed to serialize realtime transcript state");
3198 }
3199 }
3200 }
3201
3202 fn apply_authorized_system_prompt(
3203 &mut self,
3204 prompt: session_durable_config_authority::AuthorizedSystemPrompt,
3205 ) {
3206 use crate::types::SystemMessage;
3207
3208 let mutation_kind = prompt.mutation_kind();
3213 let (prompt, _replacing_existing) = prompt.into_parts();
3214 let message = SystemMessage::with_mutation_kind(prompt, mutation_kind);
3215 let inner = Arc::make_mut(&mut self.messages);
3216 if let Some(Message::System(_)) = inner.first() {
3218 inner[0] = Message::System(message);
3219 } else {
3220 inner.insert(0, Message::System(message));
3221 }
3222 self.updated_at = SystemTime::now();
3223 self.refresh_transcript_head_after_message_mutation();
3224 }
3225
3226 pub fn set_system_prompt_with_source(
3228 &mut self,
3229 prompt: String,
3230 source: session_durable_config_authority::SessionSystemPromptSource,
3231 ) -> Result<(), session_durable_config_authority::SessionDurableConfigAuthorityError> {
3232 let replacing_existing = matches!(self.messages.first(), Some(Message::System(_)));
3233 let prompt = session_durable_config_authority::authorize_system_prompt_mutation(
3234 prompt,
3235 source,
3236 replacing_existing,
3237 )?;
3238 self.apply_authorized_system_prompt(prompt);
3239 Ok(())
3240 }
3241
3242 pub fn set_system_prompt(&mut self, prompt: String) {
3244 if let Err(err) = self.set_system_prompt_with_source(
3245 prompt,
3246 session_durable_config_authority::SessionSystemPromptSource::DirectMutation,
3247 ) {
3248 tracing::warn!(error = %err, "generated session durable-config authority rejected system prompt mutation");
3249 }
3250 }
3251
3252 pub fn discard_transient_runtime_steer_context(&mut self) -> usize {
3258 let mut removed = 0usize;
3259
3260 let mut state = match self.try_system_context_state() {
3261 Ok(state) => state.unwrap_or_default(),
3262 Err(err) => {
3263 tracing::warn!(
3264 error = %err,
3265 "generated system-context authority rejected runtime steer cleanup state"
3266 );
3267 return removed;
3268 }
3269 };
3270
3271 let runtime_steer_appends = state
3276 .pending
3277 .iter()
3278 .chain(state.applied.iter())
3279 .filter(|append| append.source_kind.is_runtime_steer())
3280 .cloned()
3281 .collect::<Vec<_>>();
3282 if let Some(Message::System(system)) = self.messages.first() {
3283 let (retained_prompt, removed_blocks) =
3284 system_context_authority::remove_runtime_steer_blocks_for_rendered(
3285 &system.content,
3286 &runtime_steer_appends,
3287 );
3288 if removed_blocks > 0 {
3289 removed += removed_blocks;
3290 if let Err(err) = self.set_system_prompt_with_source(
3291 retained_prompt,
3292 session_durable_config_authority::SessionSystemPromptSource::RuntimeSteerCleanup,
3293 ) {
3294 tracing::warn!(
3295 error = %err,
3296 "generated session durable-config authority rejected runtime steer prompt cleanup"
3297 );
3298 }
3299 }
3300 }
3301
3302 removed += system_context_authority::discard_transient_runtime_steer_state(&mut state);
3303
3304 if removed > 0
3305 && let Err(err) = self.set_system_context_state(state)
3306 {
3307 tracing::warn!(
3308 error = %err,
3309 "failed to persist runtime steer context cleanup"
3310 );
3311 }
3312
3313 removed
3314 }
3315
3316 pub fn append_system_context_blocks(&mut self, appends: &[PendingSystemContextAppend]) {
3318 if appends.is_empty() {
3319 return;
3320 }
3321
3322 let current_system_prompt = self
3323 .messages
3324 .first()
3325 .and_then(|message| match message {
3326 Message::System(system) => Some(system.content.as_str()),
3327 _ => None,
3328 })
3329 .unwrap_or_default();
3330 let mut state = match self.try_system_context_state() {
3331 Ok(state) => state.unwrap_or_default(),
3332 Err(err) => {
3333 tracing::warn!(
3334 error = %err,
3335 "generated system-context authority rejected applied context state"
3336 );
3337 return;
3338 }
3339 };
3340 let new_appends = system_context_authority::record_applied_system_context_blocks(
3341 &mut state,
3342 appends,
3343 current_system_prompt,
3344 );
3345 if new_appends.is_empty() {
3346 if let Err(err) = self.set_system_context_state(state) {
3347 tracing::warn!(error = %err, "failed to persist applied system-context state");
3348 }
3349 return;
3350 }
3351
3352 let rendered = render_system_context_blocks_joined(&new_appends);
3353
3354 let next = match self.messages.first() {
3355 Some(Message::System(sys)) if !sys.content.is_empty() => {
3356 format!("{}{}{}", sys.content, SYSTEM_CONTEXT_SEPARATOR, rendered)
3357 }
3358 _ => rendered,
3359 };
3360 if let Err(err) = self.set_system_prompt_with_source(
3361 next,
3362 session_durable_config_authority::SessionSystemPromptSource::RuntimeContextAppend,
3363 ) {
3364 tracing::warn!(
3365 error = %err,
3366 "generated session durable-config authority rejected system-context prompt append"
3367 );
3368 return;
3369 }
3370 if let Err(err) = self.set_system_context_state(state) {
3371 tracing::warn!(error = %err, "failed to persist applied system-context state");
3372 }
3373 }
3374
3375 pub fn reconcile_resumed_system_prompt(
3401 &mut self,
3402 assembled_base: String,
3403 actor: Option<String>,
3404 ) -> Result<ResumedSystemPromptReconciliation, TranscriptEditError> {
3405 let persisted = match self.messages.first() {
3406 Some(Message::System(system)) => Some((system.content.clone(), system.mutation_kind)),
3407 _ => None,
3408 };
3409
3410 let Some((persisted_content, persisted_mutation_kind)) = persisted else {
3411 if assembled_base.is_empty() {
3412 return Ok(ResumedSystemPromptReconciliation::NoChange);
3413 }
3414 self.commit_resume_system_prompt_rewrite(assembled_base, false, actor)?;
3418 return Ok(ResumedSystemPromptReconciliation::RewrittenBase);
3419 };
3420
3421 if persisted_content == assembled_base {
3422 return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
3423 }
3424
3425 if let Some(tail) = self.verified_runtime_context_tail(&persisted_content) {
3434 let expected = compose_system_prompt_with_context_tail(&assembled_base, &tail);
3435 if expected == persisted_content {
3436 return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
3437 }
3438 self.commit_resume_system_prompt_rewrite(expected, true, actor)?;
3439 return Ok(ResumedSystemPromptReconciliation::RewrittenBase);
3440 }
3441
3442 if persisted_prompt_is_admitted_context_append_continuation(
3450 &assembled_base,
3451 &persisted_content,
3452 persisted_mutation_kind,
3453 ) {
3454 return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
3455 }
3456
3457 let dropping_applied_context = persisted_mutation_kind.is_runtime_context_append()
3464 || self
3465 .system_context_state()
3466 .is_some_and(|state| !state.applied.is_empty());
3467 self.commit_resume_system_prompt_rewrite(assembled_base, true, actor)?;
3468 if dropping_applied_context {
3469 tracing::warn!(
3470 session_id = %self.id,
3471 "resume base-prompt refresh dropped an unverifiable runtime system-context tail; \
3472 clearing applied-append records so keyed re-sends can restore the context"
3473 );
3474 self.clear_applied_system_context_records();
3475 }
3476 Ok(ResumedSystemPromptReconciliation::RewrittenBase)
3477 }
3478
3479 fn verified_runtime_context_tail(&self, persisted_content: &str) -> Option<String> {
3489 if let Some(prior_base) = self
3490 .build_state()
3491 .and_then(|state| state.assembled_system_prompt)
3492 {
3493 if persisted_content == prior_base {
3494 return Some(String::new());
3495 }
3496 if let Some(appended) = persisted_content.strip_prefix(prior_base.as_str())
3497 && appended.starts_with(SYSTEM_CONTEXT_SEPARATOR)
3498 {
3499 return Some(appended.to_string());
3500 }
3501 }
3504 let rendered_tail = self
3505 .system_context_state()
3506 .map(|state| render_system_context_blocks_joined(&state.applied))
3507 .unwrap_or_default();
3508 if rendered_tail.is_empty() {
3509 return None;
3510 }
3511 if persisted_content == rendered_tail {
3512 return Some(format!("{SYSTEM_CONTEXT_SEPARATOR}{rendered_tail}"));
3516 }
3517 let with_separator = format!("{SYSTEM_CONTEXT_SEPARATOR}{rendered_tail}");
3518 persisted_content
3519 .ends_with(&with_separator)
3520 .then_some(with_separator)
3521 }
3522
3523 fn commit_resume_system_prompt_rewrite(
3526 &mut self,
3527 content: String,
3528 replacing_existing: bool,
3529 actor: Option<String>,
3530 ) -> Result<(), TranscriptEditError> {
3531 let authorized = session_durable_config_authority::authorize_system_prompt_mutation(
3532 content,
3533 session_durable_config_authority::SessionSystemPromptSource::ExplicitBuild,
3534 replacing_existing,
3535 )
3536 .map_err(|err| {
3537 TranscriptEditError::HistoryStateMalformed(format!(
3538 "generated session durable-config authority rejected resume system prompt refresh: {err}"
3539 ))
3540 })?;
3541 let mutation_kind = authorized.mutation_kind();
3542 let (content, _replacing_existing) = authorized.into_parts();
3543 let replacement = Message::System(crate::types::SystemMessage::with_mutation_kind(
3544 content,
3545 mutation_kind,
3546 ));
3547 let end = usize::from(replacing_existing);
3548 self.commit_transcript_rewrite(
3549 TranscriptRewriteSelection::MessageRange { start: 0, end },
3550 vec![replacement],
3551 TranscriptRewriteReason::new(RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON),
3552 actor,
3553 None,
3554 )?;
3555 Ok(())
3556 }
3557
3558 fn clear_applied_system_context_records(&mut self) {
3562 let mut state = match self.try_system_context_state() {
3563 Ok(Some(state)) => state,
3564 Ok(None) => return,
3565 Err(error) => {
3566 tracing::warn!(
3567 session_id = %self.id,
3568 error = %error,
3569 "failed to read system-context state while clearing orphaned applied records"
3570 );
3571 return;
3572 }
3573 };
3574 if state.applied.is_empty() {
3575 return;
3576 }
3577 let dropped_keys: Vec<String> = state
3578 .applied
3579 .iter()
3580 .filter_map(|append| append.idempotency_key.clone())
3581 .collect();
3582 state.applied.clear();
3583 for key in &dropped_keys {
3584 state.seen.remove(key);
3585 }
3586 if let Err(error) = self.set_system_context_state(state) {
3587 tracing::warn!(
3588 session_id = %self.id,
3589 error = %error,
3590 "failed to persist cleared applied system-context records after resume prompt refresh"
3591 );
3592 }
3593 }
3594
3595 pub fn last_assistant_text(&self) -> Option<String> {
3602 self.messages.iter().rev().find_map(|m| match m {
3603 Message::BlockAssistant(a) => {
3604 let mut buf = String::new();
3605 for block in &a.blocks {
3606 match block {
3607 crate::types::AssistantBlock::Text { text, .. }
3608 | crate::types::AssistantBlock::Transcript { text, .. } => {
3609 buf.push_str(text);
3610 }
3611 _ => {}
3612 }
3613 }
3614 if buf.is_empty() { None } else { Some(buf) }
3615 }
3616 _ => None,
3617 })
3618 }
3619
3620 pub fn tool_call_count(&self) -> usize {
3622 self.messages
3623 .iter()
3624 .filter_map(|m| match m {
3625 Message::BlockAssistant(a) => Some(
3626 a.blocks
3627 .iter()
3628 .filter(|b| matches!(b, crate::types::AssistantBlock::ToolUse { .. }))
3629 .count(),
3630 ),
3631 _ => None,
3632 })
3633 .sum()
3634 }
3635
3636 pub fn metadata(&self) -> &serde_json::Map<String, serde_json::Value> {
3638 &self.metadata
3639 }
3640
3641 fn set_metadata_unchecked(&mut self, key: &str, value: serde_json::Value) {
3642 self.metadata.insert(key.to_string(), value);
3643 self.updated_at = SystemTime::now();
3644 }
3645
3646 #[cfg(test)]
3647 pub(crate) fn set_metadata_unchecked_for_test(&mut self, key: &str, value: serde_json::Value) {
3648 self.set_metadata_unchecked(key, value);
3649 }
3650
3651 fn fork_metadata_projection(&self) -> serde_json::Map<String, serde_json::Value> {
3652 let mut metadata = self.metadata.clone();
3653 metadata.retain(|key, _| !is_session_authority_metadata_key(key));
3654 metadata
3655 }
3656
3657 fn remove_metadata_unchecked(&mut self, key: &str) {
3658 self.metadata.remove(key);
3659 self.updated_at = SystemTime::now();
3660 }
3661
3662 pub fn try_set_metadata(
3664 &mut self,
3665 key: &str,
3666 value: serde_json::Value,
3667 ) -> Result<(), ReservedSessionMetadataKey> {
3668 if is_session_authority_metadata_key(key) {
3669 return Err(ReservedSessionMetadataKey::new(key));
3670 }
3671 self.set_metadata_unchecked(key, value);
3672 Ok(())
3673 }
3674
3675 pub fn set_metadata(&mut self, key: &str, value: serde_json::Value) {
3680 if let Err(err) = self.try_set_metadata(key, value) {
3681 tracing::warn!(error = %err, "rejected raw session metadata mutation");
3682 }
3683 }
3684
3685 pub fn backfill_metadata_if_absent(&mut self, key: &str, value: serde_json::Value) -> bool {
3691 if is_session_authority_metadata_key(key) {
3692 tracing::warn!(
3693 metadata_key = key,
3694 "rejected raw session metadata backfill for authority key"
3695 );
3696 return false;
3697 }
3698 if self.metadata.contains_key(key) {
3699 false
3700 } else {
3701 self.metadata.insert(key.to_string(), value);
3702 true
3703 }
3704 }
3705
3706 pub fn remove_metadata(&mut self, key: &str) {
3708 if is_session_authority_metadata_key(key) {
3709 tracing::warn!(
3710 metadata_key = key,
3711 "rejected raw session metadata removal for authority key"
3712 );
3713 return;
3714 }
3715 self.metadata.remove(key);
3716 self.updated_at = SystemTime::now();
3717 }
3718
3719 pub fn set_session_metadata(
3721 &mut self,
3722 metadata: SessionMetadata,
3723 ) -> Result<(), serde_json::Error> {
3724 let metadata =
3725 session_durable_config_authority::authorize_session_metadata_persist(metadata)
3726 .map_err(<serde_json::Error as serde::ser::Error>::custom)?
3727 .into_metadata();
3728 let value = serde_json::to_value(metadata)?;
3729 self.set_metadata_unchecked(SESSION_METADATA_KEY, value);
3730 Ok(())
3731 }
3732
3733 pub fn session_metadata(&self) -> Option<SessionMetadata> {
3738 match self.try_session_metadata() {
3739 Ok(metadata) => metadata,
3740 Err(err) => fail_closed_generated_restore("session-metadata", err),
3741 }
3742 }
3743
3744 pub fn try_session_metadata(&self) -> Result<Option<SessionMetadata>, serde_json::Error> {
3746 let Some(value) = self.metadata.get(SESSION_METADATA_KEY) else {
3747 return Ok(None);
3748 };
3749 let mut metadata = serde_json::from_value::<SessionMetadata>(value.clone())?;
3750 metadata.schema_version =
3751 session_persistence_version_authority::restore_session_metadata_schema_version(
3752 metadata.schema_version,
3753 )
3754 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
3755 session_durable_config_authority::restore_session_metadata(metadata)
3756 .map(Some)
3757 .map_err(<serde_json::Error as serde::de::Error>::custom)
3758 }
3759
3760 pub fn set_system_context_state(
3762 &mut self,
3763 state: SessionSystemContextState,
3764 ) -> Result<(), serde_json::Error> {
3765 let state = system_context_authority::restore_system_context_state(state)
3766 .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
3767 let value = serde_json::to_value(state)?;
3768 self.set_metadata_unchecked(SESSION_SYSTEM_CONTEXT_STATE_KEY, value);
3769 Ok(())
3770 }
3771
3772 pub fn try_system_context_state(
3774 &self,
3775 ) -> Result<Option<SessionSystemContextState>, serde_json::Error> {
3776 self.metadata
3777 .get(SESSION_SYSTEM_CONTEXT_STATE_KEY)
3778 .map(|value| {
3779 let state = serde_json::from_value(value.clone())?;
3780 system_context_authority::restore_system_context_state(state)
3781 .map_err(<serde_json::Error as serde::de::Error>::custom)
3782 })
3783 .transpose()
3784 }
3785
3786 pub fn system_context_state(&self) -> Option<SessionSystemContextState> {
3792 match self.try_system_context_state() {
3793 Ok(state) => state,
3794 Err(err) => fail_closed_generated_restore("system-context", err),
3795 }
3796 }
3797
3798 pub fn set_deferred_turn_state(
3800 &mut self,
3801 state: SessionDeferredTurnState,
3802 ) -> Result<(), serde_json::Error> {
3803 let state = validate_deferred_turn_snapshot(state)
3804 .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
3805 let value = serde_json::to_value(state)?;
3806 self.set_metadata_unchecked(SESSION_DEFERRED_TURN_STATE_KEY, value);
3807 Ok(())
3808 }
3809
3810 pub fn try_deferred_turn_state(
3812 &self,
3813 ) -> Result<Option<SessionDeferredTurnState>, serde_json::Error> {
3814 self.metadata
3815 .get(SESSION_DEFERRED_TURN_STATE_KEY)
3816 .map(|value| {
3817 let state = serde_json::from_value(value.clone())?;
3818 validate_deferred_turn_snapshot(state)
3819 .map_err(<serde_json::Error as serde::de::Error>::custom)
3820 })
3821 .transpose()
3822 }
3823
3824 pub fn deferred_turn_state(&self) -> Option<SessionDeferredTurnState> {
3830 match self.try_deferred_turn_state() {
3831 Ok(state) => state,
3832 Err(err) => fail_closed_generated_restore("deferred-turn", err),
3833 }
3834 }
3835
3836 pub fn set_lifecycle_terminal(
3845 &mut self,
3846 terminal: SessionLifecycleTerminal,
3847 ) -> Result<(), serde_json::Error> {
3848 let value = serde_json::to_value(terminal)?;
3849 self.set_metadata_unchecked(SESSION_LIFECYCLE_TERMINAL_KEY, value);
3850 Ok(())
3851 }
3852
3853 pub fn try_lifecycle_terminal(
3858 &self,
3859 ) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
3860 match self.metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY) {
3861 Some(value) => serde_json::from_value(value.clone()).map(Some),
3862 None => Ok(None),
3863 }
3864 }
3865
3866 pub fn lifecycle_terminal(&self) -> Option<SessionLifecycleTerminal> {
3872 match self.try_lifecycle_terminal() {
3873 Ok(state) => state,
3874 Err(err) => fail_closed_generated_restore("session-lifecycle-terminal", err),
3875 }
3876 }
3877
3878 pub fn set_build_state(&mut self, state: SessionBuildState) -> Result<(), serde_json::Error> {
3880 let state = session_durable_config_authority::authorize_session_build_state_persist(state)
3881 .map_err(<serde_json::Error as serde::ser::Error>::custom)?
3882 .into_state();
3883 let value = serde_json::to_value(state)?;
3884 self.set_metadata_unchecked(SESSION_BUILD_STATE_KEY, value);
3885 Ok(())
3886 }
3887
3888 pub fn build_state(&self) -> Option<SessionBuildState> {
3893 match self.try_build_state() {
3894 Ok(state) => state,
3895 Err(err) => fail_closed_generated_restore("session-build-state", err),
3896 }
3897 }
3898
3899 pub fn try_build_state(&self) -> Result<Option<SessionBuildState>, serde_json::Error> {
3901 let Some(value) = self.metadata.get(SESSION_BUILD_STATE_KEY) else {
3902 return Ok(None);
3903 };
3904 let state = serde_json::from_value::<SessionBuildState>(value.clone())?;
3905 session_durable_config_authority::restore_session_build_state(state)
3906 .map(Some)
3907 .map_err(<serde_json::Error as serde::de::Error>::custom)
3908 }
3909
3910 pub fn set_tool_visibility_state(
3912 &mut self,
3913 state: AuthorizedSessionToolVisibilityState,
3914 ) -> Result<(), serde_json::Error> {
3915 let value = serde_json::to_value(state.into_state())?;
3916 self.set_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY, value);
3917 Ok(())
3918 }
3919
3920 #[cfg(test)]
3925 pub(crate) fn clear_tool_visibility_state(&mut self) {
3926 self.remove_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY);
3927 }
3928
3929 pub fn tool_visibility_state(
3931 &self,
3932 ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
3933 self.try_tool_visibility_state()
3934 }
3935
3936 pub fn try_tool_visibility_state(
3939 &self,
3940 ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
3941 self.metadata
3942 .get(SESSION_TOOL_VISIBILITY_STATE_KEY)
3943 .map(|value| serde_json::from_value(value.clone()))
3944 .transpose()
3945 }
3946
3947 pub fn transcript_history_state(
3949 &self,
3950 ) -> Result<Option<TranscriptHistoryState>, serde_json::Error> {
3951 self.metadata
3952 .get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
3953 .map(|value| serde_json::from_value(value.clone()))
3954 .transpose()
3955 }
3956
3957 pub fn validate_transcript_history_state(&self) -> Result<(), TranscriptEditError> {
3959 let Some(state) = self
3960 .transcript_history_state()
3961 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
3962 else {
3963 return Ok(());
3964 };
3965 validate_transcript_history_state(&state)
3966 }
3967
3968 pub fn clear_transcript_history_state(&mut self) {
3971 self.remove_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY);
3972 }
3973
3974 pub fn set_runtime_checkpoint_provenance(&mut self) {
3980 self.set_metadata_unchecked(
3981 SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY,
3982 serde_json::Value::Bool(true),
3983 );
3984 }
3985
3986 pub fn clear_runtime_checkpoint_provenance(&mut self) {
3990 self.remove_metadata_unchecked(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY);
3991 }
3992
3993 pub fn has_runtime_checkpoint_provenance(&self) -> bool {
3997 self.metadata
3998 .get(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY)
3999 .and_then(serde_json::Value::as_bool)
4000 .unwrap_or(false)
4001 }
4002
4003 pub fn transcript_revision_body(
4005 &self,
4006 revision: &str,
4007 ) -> Result<Option<TranscriptRevisionBody>, serde_json::Error> {
4008 Ok(self.transcript_history_state()?.and_then(|state| {
4009 state
4010 .revisions
4011 .into_iter()
4012 .find(|body| body.revision == revision)
4013 }))
4014 }
4015
4016 pub fn transcript_revision_messages(
4018 &self,
4019 revision: &str,
4020 ) -> Result<Option<Vec<Message>>, serde_json::Error> {
4021 Ok(self
4022 .transcript_revision_body(revision)?
4023 .map(|body| body.messages))
4024 }
4025
4026 pub fn apply_transcript_history_state(
4028 &mut self,
4029 state: TranscriptHistoryState,
4030 ) -> Result<(), TranscriptEditError> {
4031 validate_transcript_history_state(&state)?;
4032 let head_body = state
4033 .revisions
4034 .iter()
4035 .find(|body| body.revision == state.head)
4036 .ok_or_else(|| {
4037 TranscriptEditError::HistoryStateMalformed(format!(
4038 "missing transcript head body {}",
4039 state.head
4040 ))
4041 })?
4042 .clone();
4043 let value = serde_json::to_value(&state)
4044 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4045 self.set_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, value);
4046 let mut updated_at = head_body.created_at;
4047 for commit in &state.commits {
4048 if commit.committed_at > updated_at {
4049 updated_at = commit.committed_at;
4050 }
4051 }
4052 self.messages = Arc::new(head_body.messages);
4053 self.updated_at = updated_at;
4054 Ok(())
4055 }
4056
4057 pub fn transcript_revision(&self) -> Result<String, serde_json::Error> {
4060 if let Some(state) = self.transcript_history_state()? {
4061 Ok(state.head)
4062 } else {
4063 transcript_messages_digest(self.messages())
4064 }
4065 }
4066
4067 pub fn commit_transcript_rewrite(
4069 &mut self,
4070 selection: TranscriptRewriteSelection,
4071 replacement: Vec<Message>,
4072 reason: TranscriptRewriteReason,
4073 actor: Option<String>,
4074 expected_parent_revision: Option<String>,
4075 ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
4076 let parent_revision = self
4077 .transcript_revision()
4078 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4079 if let Some(expected) = expected_parent_revision
4080 && expected != parent_revision
4081 {
4082 return Err(TranscriptEditError::RevisionConflict {
4083 expected,
4084 actual: parent_revision,
4085 });
4086 }
4087
4088 let (start, end) = selection.bounds();
4089 let message_count = self.messages.len();
4090 if start > end || end > message_count {
4091 return Err(TranscriptEditError::InvalidRewriteRange {
4092 start,
4093 end,
4094 message_count,
4095 });
4096 }
4097
4098 let replacement_len = replacement.len();
4099 let mut rewritten = Vec::with_capacity(
4100 start
4101 .saturating_add(replacement_len)
4102 .saturating_add(message_count.saturating_sub(end)),
4103 );
4104 rewritten.extend_from_slice(&self.messages[..start]);
4105 rewritten.extend(replacement);
4106 rewritten.extend_from_slice(&self.messages[end..]);
4107 validate_transcript_tool_result_shape(&rewritten)?;
4108
4109 let original_span_digest = transcript_messages_digest(&self.messages[start..end])
4110 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4111 let replacement_digest =
4112 transcript_messages_digest(&rewritten[start..start + replacement_len])
4113 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4114 let revision = transcript_messages_digest(&rewritten)
4115 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4116 if revision == parent_revision {
4117 return Err(TranscriptEditError::NoOpRewrite { revision });
4118 }
4119
4120 let commit = TranscriptRewriteCommit {
4121 parent_revision,
4122 revision: revision.clone(),
4123 selection,
4124 original_span_digest,
4125 replacement_digest,
4126 messages_before: message_count,
4127 messages_after: rewritten.len(),
4128 reason,
4129 actor,
4130 committed_at: SystemTime::now(),
4131 };
4132
4133 let mut state = self
4134 .transcript_history_state()
4135 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
4136 .unwrap_or_else(|| TranscriptHistoryState {
4137 head: commit.parent_revision.clone(),
4138 commits: Vec::new(),
4139 revisions: Vec::new(),
4140 });
4141 if !state
4142 .revisions
4143 .iter()
4144 .any(|body| body.revision == commit.parent_revision)
4145 {
4146 state.revisions.push(TranscriptRevisionBody {
4147 revision: commit.parent_revision.clone(),
4148 parent_revision: None,
4149 messages: self.messages().to_vec(),
4150 created_at: self.updated_at,
4151 });
4152 }
4153 if !state
4154 .revisions
4155 .iter()
4156 .any(|body| body.revision == commit.revision)
4157 {
4158 state.revisions.push(TranscriptRevisionBody {
4159 revision: commit.revision.clone(),
4160 parent_revision: Some(commit.parent_revision.clone()),
4161 messages: rewritten.clone(),
4162 created_at: commit.committed_at,
4163 });
4164 }
4165 state.head = revision;
4166 state.commits.push(commit.clone());
4167 let value = serde_json::to_value(state)
4168 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4169 self.set_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, value);
4170
4171 self.messages = Arc::new(rewritten);
4172 self.updated_at = SystemTime::now();
4173 Ok(commit)
4174 }
4175
4176 fn refresh_transcript_head_after_message_mutation(&mut self) {
4177 if !self
4178 .metadata
4179 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
4180 {
4181 return;
4182 }
4183 let Ok(Some(mut state)) = self.transcript_history_state() else {
4184 tracing::warn!(
4185 session_id = %self.id,
4186 "transcript history state is malformed; leaving head unchanged after message mutation"
4187 );
4188 return;
4189 };
4190 let Ok(head) = transcript_messages_digest(self.messages()) else {
4191 tracing::warn!(
4192 session_id = %self.id,
4193 "failed to digest transcript after message mutation; leaving head unchanged"
4194 );
4195 return;
4196 };
4197 let previous_head = state.head.clone();
4198 if !state.revisions.iter().any(|body| body.revision == head) {
4199 state.revisions.push(TranscriptRevisionBody {
4200 revision: head.clone(),
4201 parent_revision: Some(previous_head),
4202 messages: self.messages().to_vec(),
4203 created_at: SystemTime::now(),
4204 });
4205 }
4206 state.head = head;
4207 match serde_json::to_value(state) {
4208 Ok(value) => self.set_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, value),
4209 Err(error) => {
4210 tracing::warn!(
4211 session_id = %self.id,
4212 error = %error,
4213 "failed to serialize transcript history state after message mutation"
4214 );
4215 }
4216 }
4217 }
4218
4219 pub fn set_mob_tool_authority_context(
4227 &mut self,
4228 authority_context: Option<MobToolAuthorityContext>,
4229 ) -> Result<(), serde_json::Error> {
4230 if let Some(authority_context) = authority_context.as_ref()
4231 && !authority_context.is_generated_authority_context()
4232 {
4233 return Err(<serde_json::Error as serde::de::Error>::custom(
4234 "mob authority context was not minted by generated authority",
4235 ));
4236 }
4237 let mut build_state = self.build_state().ok_or_else(|| {
4238 <serde_json::Error as serde::de::Error>::custom(format!(
4239 "session {} is missing session build state",
4240 self.id
4241 ))
4242 })?;
4243 build_state.mob_tool_authority_context = authority_context;
4244 self.set_build_state(build_state)
4245 }
4246
4247 pub fn mob_tool_authority_context(&self) -> Option<MobToolAuthorityContext> {
4252 self.build_state()
4253 .and_then(|state| state.mob_tool_authority_context)
4254 .filter(MobToolAuthorityContext::is_generated_authority_context)
4255 }
4256
4257 pub fn fork_at(&self, index: usize) -> Self {
4262 let now = SystemTime::now();
4263 let truncated = self.messages[..index.min(self.messages.len())].to_vec();
4264 Self {
4265 version: session_version(),
4266 id: SessionId::new(),
4267 messages: Arc::new(truncated),
4268 created_at: now,
4269 updated_at: now,
4270 metadata: self.fork_metadata_projection(),
4271 usage: self.usage.clone(),
4272 }
4273 }
4274
4275 pub fn fork_replacing(
4282 &self,
4283 message_index: usize,
4284 replacement: TranscriptReplacement,
4285 ) -> Result<Self, TranscriptEditError> {
4286 let Some(original) = self.messages.get(message_index) else {
4287 return Err(TranscriptEditError::MessageIndexOutOfBounds {
4288 message_index,
4289 message_count: self.messages.len(),
4290 });
4291 };
4292
4293 let replacement_message = match replacement {
4294 TranscriptReplacement::Message { message } => message,
4295 TranscriptReplacement::UserContentBlock { block_index, block } => {
4296 let Message::User(user) = original else {
4297 return Err(TranscriptEditError::MessageRoleMismatch {
4298 message_index,
4299 expected: "user",
4300 actual: message_role_name(original),
4301 });
4302 };
4303 if block_index >= user.content.len() {
4304 return Err(TranscriptEditError::BlockIndexOutOfBounds {
4305 block_kind: "user content block",
4306 block_index,
4307 block_count: user.content.len(),
4308 });
4309 }
4310 let mut edited = user.clone();
4311 edited.content[block_index] = block;
4312 Message::User(edited)
4313 }
4314 TranscriptReplacement::AssistantBlock { block_index, block } => {
4315 let Message::BlockAssistant(assistant) = original else {
4316 return Err(TranscriptEditError::MessageRoleMismatch {
4317 message_index,
4318 expected: "block_assistant",
4319 actual: message_role_name(original),
4320 });
4321 };
4322 if block_index >= assistant.blocks.len() {
4323 return Err(TranscriptEditError::BlockIndexOutOfBounds {
4324 block_kind: "assistant block",
4325 block_index,
4326 block_count: assistant.blocks.len(),
4327 });
4328 }
4329 let mut edited = assistant.clone();
4330 edited.blocks[block_index] = block;
4331 Message::BlockAssistant(edited)
4332 }
4333 TranscriptReplacement::ToolResultContentBlock {
4334 result_index,
4335 block_index,
4336 block,
4337 } => {
4338 let Message::ToolResults {
4339 results,
4340 created_at,
4341 } = original
4342 else {
4343 return Err(TranscriptEditError::MessageRoleMismatch {
4344 message_index,
4345 expected: "tool_results",
4346 actual: message_role_name(original),
4347 });
4348 };
4349 let Some(result) = results.get(result_index) else {
4350 return Err(TranscriptEditError::BlockIndexOutOfBounds {
4351 block_kind: "tool result",
4352 block_index: result_index,
4353 block_count: results.len(),
4354 });
4355 };
4356 if block_index >= result.content.len() {
4357 return Err(TranscriptEditError::BlockIndexOutOfBounds {
4358 block_kind: "tool result content block",
4359 block_index,
4360 block_count: result.content.len(),
4361 });
4362 }
4363 let mut edited_results = results.clone();
4364 edited_results[result_index].content[block_index] = block;
4365 Message::ToolResults {
4366 results: edited_results,
4367 created_at: *created_at,
4368 }
4369 }
4370 };
4371
4372 let mut forked = self.fork_at(message_index);
4373 forked.push(replacement_message);
4374 Ok(forked)
4375 }
4376
4377 pub fn fork(&self) -> Self {
4382 let now = SystemTime::now();
4383 Self {
4384 version: session_version(),
4385 id: SessionId::new(),
4386 messages: Arc::clone(&self.messages),
4387 created_at: now,
4388 updated_at: now,
4389 metadata: self.fork_metadata_projection(),
4390 usage: self.usage.clone(),
4391 }
4392 }
4393}
4394
4395impl Default for Session {
4396 fn default() -> Self {
4397 Self::new()
4398 }
4399}
4400
4401#[derive(Debug, Clone, Serialize, Deserialize)]
4403#[serde(rename_all = "snake_case")]
4404pub struct SessionMeta {
4405 pub id: SessionId,
4406 pub created_at: SystemTime,
4407 pub updated_at: SystemTime,
4408 pub message_count: usize,
4409 pub total_tokens: u64,
4410 #[serde(default)]
4411 pub metadata: serde_json::Map<String, serde_json::Value>,
4412}
4413
4414#[derive(Debug, Clone, Serialize, Deserialize)]
4416#[serde(rename_all = "snake_case")]
4417pub struct SessionMetadata {
4418 pub schema_version: u32,
4425 pub model: String,
4426 pub max_tokens: u32,
4427 #[serde(default = "crate::config::default_structured_output_retries")]
4428 pub structured_output_retries: u32,
4429 pub provider: Provider,
4430 #[serde(default, skip_serializing_if = "Option::is_none")]
4431 pub self_hosted_server_id: Option<String>,
4432 #[serde(default, skip_serializing_if = "Option::is_none")]
4435 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
4436 pub tooling: SessionTooling,
4437 #[serde(default)]
4438 pub keep_alive: bool,
4439 pub comms_name: Option<String>,
4440 #[serde(default, skip_serializing_if = "Option::is_none")]
4442 pub peer_meta: Option<PeerMeta>,
4443 #[serde(default, skip_serializing_if = "Option::is_none")]
4449 pub realm_id: Option<crate::RealmId>,
4450 #[serde(default, skip_serializing_if = "Option::is_none")]
4452 pub instance_id: Option<String>,
4453 #[serde(default, skip_serializing_if = "Option::is_none")]
4455 pub backend: Option<String>,
4456 #[serde(default, skip_serializing_if = "Option::is_none")]
4458 pub config_generation: Option<u64>,
4459 #[serde(default, skip_serializing_if = "Option::is_none")]
4469 pub auth_binding: Option<crate::AuthBindingRef>,
4470 #[serde(default, skip_serializing_if = "Option::is_none")]
4483 pub mob_member_binding: Option<crate::MobMemberBinding>,
4484}
4485
4486#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
4488#[serde(rename_all = "snake_case")]
4489pub struct SessionLlmIdentity {
4490 pub model: String,
4491 pub provider: Provider,
4492 #[serde(default, skip_serializing_if = "Option::is_none")]
4493 pub self_hosted_server_id: Option<String>,
4494 #[serde(default, skip_serializing_if = "Option::is_none")]
4496 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
4497 #[serde(default, skip_serializing_if = "Option::is_none")]
4510 pub auth_binding: Option<crate::AuthBindingRef>,
4511}
4512
4513pub struct SessionLlmIdentityOverride<'a> {
4521 pub model: Option<&'a str>,
4522 pub provider: Option<Provider>,
4523 pub provider_params:
4524 Option<TurnMetadataOverride<&'a crate::lifecycle::run_primitive::ProviderParamsOverride>>,
4525 pub auth_binding: Option<TurnMetadataOverride<&'a crate::AuthBindingRef>>,
4526}
4527
4528#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
4529pub enum SessionLlmIdentityOverrideError {
4530 #[error("provider override requires model on an existing session")]
4531 ProviderRequiresModel,
4532 #[error("{0}")]
4533 ProviderModelMismatch(String),
4534 #[error("self-hosted provider requires a registered model alias; '{model}' is not configured")]
4535 MissingSelfHostedAlias { model: String },
4536}
4537
4538pub fn resolve_session_llm_identity_override(
4546 current: &SessionLlmIdentity,
4547 registry: &crate::ModelRegistry,
4548 overrides: SessionLlmIdentityOverride<'_>,
4549) -> Result<SessionLlmIdentity, SessionLlmIdentityOverrideError> {
4550 if overrides.provider.is_some() && overrides.model.is_none() {
4551 return Err(SessionLlmIdentityOverrideError::ProviderRequiresModel);
4552 }
4553
4554 let model = overrides
4555 .model
4556 .map(str::to_string)
4557 .unwrap_or_else(|| current.model.clone());
4558 let provider = if let Some(provider) = overrides.provider {
4559 provider
4560 } else if overrides.model.is_some() {
4561 registry
4562 .entry(&model)
4563 .map_or(current.provider, |entry| entry.provider)
4564 } else {
4565 current.provider
4566 };
4567
4568 if (overrides.model.is_some() || overrides.provider.is_some())
4569 && let Some(reason) = registry.provider_override_mismatch_reason(provider, &model)
4570 {
4571 return Err(SessionLlmIdentityOverrideError::ProviderModelMismatch(
4572 reason,
4573 ));
4574 }
4575
4576 let provider_params = match overrides.provider_params {
4577 Some(TurnMetadataOverride::Clear) => None,
4578 Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
4579 None => current.provider_params.clone(),
4580 };
4581 let self_hosted_server_id = if provider == Provider::SelfHosted {
4582 if overrides.model.is_none() {
4583 current.self_hosted_server_id.clone().or_else(|| {
4584 registry
4585 .entry_for_provider(Provider::SelfHosted, &model)
4586 .and_then(|entry| entry.self_hosted.as_ref())
4587 .map(|server| server.server_id.clone())
4588 })
4589 } else {
4590 let entry = registry
4591 .entry_for_provider(Provider::SelfHosted, &model)
4592 .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
4593 model: model.clone(),
4594 })?;
4595 entry
4596 .self_hosted
4597 .as_ref()
4598 .map(|server| server.server_id.clone())
4599 }
4600 } else {
4601 None
4602 };
4603
4604 let auth_binding = match overrides.auth_binding {
4605 Some(TurnMetadataOverride::Clear) => None,
4606 Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
4607 None if provider != current.provider => None,
4610 None => current.auth_binding.clone(),
4611 };
4612
4613 Ok(SessionLlmIdentity {
4614 model,
4615 provider,
4616 self_hosted_server_id,
4617 provider_params,
4618 auth_binding,
4619 })
4620}
4621
4622#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
4629#[serde(rename_all = "snake_case")]
4630pub struct SessionLlmRequestPolicy {
4631 pub model: String,
4632 #[serde(default, skip_serializing_if = "Option::is_none")]
4634 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
4635 #[serde(default, skip_serializing_if = "Option::is_none")]
4637 pub provider_tool_defaults: Option<crate::lifecycle::run_primitive::ProviderTag>,
4638}
4639
4640impl SessionMetadata {
4641 pub fn llm_identity(&self) -> SessionLlmIdentity {
4643 SessionLlmIdentity {
4644 model: self.model.clone(),
4645 provider: self.provider,
4646 self_hosted_server_id: self.self_hosted_server_id.clone(),
4647 provider_params: self.provider_params.clone(),
4648 auth_binding: self.auth_binding.clone(),
4649 }
4650 }
4651
4652 pub fn apply_llm_identity(&mut self, identity: &SessionLlmIdentity) {
4654 self.model = identity.model.clone();
4655 self.provider = identity.provider;
4656 self.self_hosted_server_id = identity.self_hosted_server_id.clone();
4657 self.provider_params = identity.provider_params.clone();
4658 self.auth_binding = identity.auth_binding.clone();
4659 }
4660}
4661
4662pub const SESSION_METADATA_KEY: &str = "session_metadata";
4664
4665#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
4673#[serde(rename_all = "snake_case")]
4674pub enum ToolCategoryOverride {
4675 #[default]
4677 Inherit,
4678 Enable,
4680 Disable,
4682}
4683
4684impl ToolCategoryOverride {
4685 #[must_use]
4691 pub fn resolve(self, runtime_default: bool) -> bool {
4692 match self {
4693 Self::Enable => true,
4694 Self::Disable => false,
4695 Self::Inherit => runtime_default,
4696 }
4697 }
4698
4699 #[must_use]
4705 pub fn to_override(self) -> Option<bool> {
4706 match self {
4707 Self::Enable => Some(true),
4708 Self::Disable => Some(false),
4709 Self::Inherit => None,
4710 }
4711 }
4712
4713 #[must_use]
4721 pub fn from_effective(enabled: bool) -> Self {
4722 if enabled { Self::Enable } else { Self::Disable }
4723 }
4724
4725 #[must_use]
4735 pub fn from_override(value: Option<bool>) -> Self {
4736 match value {
4737 Some(true) => Self::Enable,
4738 Some(false) => Self::Disable,
4739 None => Self::Inherit,
4740 }
4741 }
4742}
4743
4744#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
4751#[serde(rename_all = "snake_case")]
4752pub struct SessionTooling {
4753 #[serde(default)]
4754 pub builtins: ToolCategoryOverride,
4755 #[serde(default)]
4756 pub shell: ToolCategoryOverride,
4757 #[serde(default)]
4758 pub comms: ToolCategoryOverride,
4759 #[serde(default)]
4761 pub mob: ToolCategoryOverride,
4762 #[serde(default)]
4764 pub memory: ToolCategoryOverride,
4765 #[serde(default)]
4767 pub schedule: ToolCategoryOverride,
4768 #[serde(default)]
4770 pub workgraph: ToolCategoryOverride,
4771 #[serde(default)]
4773 pub image_generation: ToolCategoryOverride,
4774 #[serde(default)]
4776 pub web_search: ToolCategoryOverride,
4777 #[serde(default, skip_serializing_if = "Option::is_none")]
4787 pub tool_access_policy: Option<crate::ops::ToolAccessPolicy>,
4788 #[serde(default, skip_serializing_if = "Option::is_none")]
4790 pub active_skills: Option<Vec<crate::skills::SkillKey>>,
4791}
4792
4793impl From<&Session> for SessionMeta {
4794 fn from(session: &Session) -> Self {
4795 Self {
4796 id: session.id.clone(),
4797 created_at: session.created_at,
4798 updated_at: session.updated_at,
4799 message_count: session.messages.len(),
4800 total_tokens: session.total_tokens(),
4801 metadata: session.metadata.clone(),
4802 }
4803 }
4804}
4805
4806#[cfg(test)]
4807#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
4808mod tests {
4809 use super::*;
4810 use crate::realtime_transcript::RealtimeTranscriptRole;
4811 use crate::types::{
4812 AssistantBlock, BlockAssistantMessage, ContentBlock, StopReason, SystemMessage, Usage,
4813 UserMessage,
4814 };
4815 use std::sync::Arc;
4816
4817 fn block_assistant_text(message: &BlockAssistantMessage) -> String {
4818 message
4819 .blocks
4820 .iter()
4821 .filter_map(|block| match block {
4822 AssistantBlock::Text { text, .. } => Some(text.as_str()),
4823 _ => None,
4824 })
4825 .collect()
4826 }
4827
4828 #[test]
4829 fn transcript_digest_is_content_addressed() {
4830 let base_time = crate::types::message_timestamp_now();
4831 let stamped = vec![
4832 Message::User(UserMessage::text("turn one".to_string())),
4833 Message::BlockAssistant(BlockAssistantMessage {
4834 blocks: vec![AssistantBlock::Text {
4835 text: "answer one".to_string(),
4836 meta: None,
4837 }],
4838 stop_reason: StopReason::EndTurn,
4839 identity: crate::types::TranscriptMessageIdentity {
4840 interaction_id: None,
4841 run_id: Some(crate::lifecycle::RunId::new()),
4842 },
4843 created_at: base_time,
4844 }),
4845 ];
4846 let mut restamped = stamped.clone();
4847 for message in &mut restamped {
4848 match message {
4849 Message::User(user) => {
4850 user.created_at = base_time + chrono::Duration::hours(2);
4851 }
4852 Message::BlockAssistant(assistant) => {
4853 assistant.identity = crate::types::TranscriptMessageIdentity {
4854 interaction_id: None,
4855 run_id: Some(crate::lifecycle::RunId::new()),
4856 };
4857 assistant.created_at = base_time + chrono::Duration::hours(2);
4858 }
4859 _ => {}
4860 }
4861 }
4862 assert_eq!(
4863 transcript_messages_digest(&stamped).expect("digest"),
4864 transcript_messages_digest(&restamped).expect("digest"),
4865 "bookkeeping variance must not fork the transcript revision"
4866 );
4867
4868 let mut content_changed = stamped.clone();
4869 if let Message::User(user) = &mut content_changed[0] {
4870 user.content = vec![ContentBlock::Text {
4871 text: "a different turn".to_string(),
4872 }];
4873 }
4874 assert_ne!(
4875 transcript_messages_digest(&stamped).expect("digest"),
4876 transcript_messages_digest(&content_changed).expect("digest"),
4877 "content changes must fork the transcript revision"
4878 );
4879 }
4880
4881 fn legacy_rewrite_fixture() -> (TranscriptRewriteCommit, Vec<Message>, Vec<Message>) {
4882 let parent_messages = vec![
4883 Message::User(UserMessage::text("before rewrite".to_string())),
4884 Message::User(UserMessage::text("retained tail".to_string())),
4885 ];
4886 let revision_messages = vec![
4887 Message::User(UserMessage::text("after rewrite".to_string())),
4888 Message::User(UserMessage::text("retained tail".to_string())),
4889 ];
4890 let parent_revision =
4893 legacy_transcript_messages_digest(&parent_messages).expect("legacy parent digest");
4894 let revision =
4895 legacy_transcript_messages_digest(&revision_messages).expect("legacy revision digest");
4896 let commit = TranscriptRewriteCommit {
4897 parent_revision,
4898 revision,
4899 selection: TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
4900 original_span_digest: legacy_transcript_messages_digest(&parent_messages[0..1])
4901 .expect("legacy span digest"),
4902 replacement_digest: legacy_transcript_messages_digest(&revision_messages[0..1])
4903 .expect("legacy replacement digest"),
4904 messages_before: 2,
4905 messages_after: 2,
4906 reason: TranscriptRewriteReason::new("compaction"),
4907 actor: Some("legacy-test".to_string()),
4908 committed_at: SystemTime::now(),
4909 };
4910 (commit, parent_messages, revision_messages)
4911 }
4912
4913 #[test]
4914 fn legacy_transcript_history_state_heals_to_content_addressed_on_parse() {
4915 let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
4916 let state = TranscriptHistoryState {
4917 head: commit.revision.clone(),
4918 commits: vec![commit.clone()],
4919 revisions: vec![
4920 TranscriptRevisionBody {
4921 revision: commit.parent_revision.clone(),
4922 parent_revision: None,
4923 messages: parent_messages.clone(),
4924 created_at: SystemTime::now(),
4925 },
4926 TranscriptRevisionBody {
4927 revision: commit.revision.clone(),
4928 parent_revision: Some(commit.parent_revision),
4929 messages: revision_messages.clone(),
4930 created_at: SystemTime::now(),
4931 },
4932 ],
4933 };
4934 let value = serde_json::to_value(&state).expect("serialize legacy state");
4935 let healed: TranscriptHistoryState =
4936 serde_json::from_value(value).expect("parse legacy state");
4937
4938 let content_parent =
4939 transcript_messages_digest(&parent_messages).expect("content parent digest");
4940 let content_revision =
4941 transcript_messages_digest(&revision_messages).expect("content revision digest");
4942 assert_eq!(healed.head, content_revision, "head must re-derive");
4943 assert_eq!(healed.commits[0].parent_revision, content_parent);
4944 assert_eq!(healed.commits[0].revision, content_revision);
4945 assert_eq!(healed.revisions[0].revision, content_parent);
4946 assert_eq!(healed.revisions[1].revision, content_revision);
4947 assert_eq!(
4948 healed.revisions[1].parent_revision.as_deref(),
4949 Some(content_parent.as_str())
4950 );
4951 validate_transcript_history_state(&healed).expect("healed graph must validate");
4952
4953 let mut session = Session::new();
4956 session
4957 .apply_transcript_history_state(healed)
4958 .expect("apply healed graph");
4959 session
4960 .commit_transcript_rewrite(
4961 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
4962 vec![Message::User(UserMessage::text(
4963 "rewritten again".to_string(),
4964 ))],
4965 TranscriptRewriteReason::new("unit-test"),
4966 None,
4967 None,
4968 )
4969 .expect("extend healed graph with a new rewrite");
4970 session
4971 .validate_transcript_history_state()
4972 .expect("extended graph must validate");
4973 }
4974
4975 #[test]
4976 fn legacy_transcript_rewrite_record_heals_on_parse() {
4977 let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
4978 let record_value = serde_json::json!({
4979 "commit": commit,
4980 "parent_body": TranscriptRevisionBody {
4981 revision: commit.parent_revision.clone(),
4982 parent_revision: None,
4983 messages: parent_messages,
4984 created_at: SystemTime::now(),
4985 },
4986 "revision_body": TranscriptRevisionBody {
4987 revision: commit.revision.clone(),
4988 parent_revision: Some(commit.parent_revision),
4989 messages: revision_messages.clone(),
4990 created_at: SystemTime::now(),
4991 },
4992 });
4993 let healed: TranscriptRewriteRecord =
4994 serde_json::from_value(record_value).expect("parse legacy record");
4995 assert_eq!(
4996 healed.commit.revision,
4997 transcript_messages_digest(&revision_messages).expect("content digest")
4998 );
4999 TranscriptRewriteRecord::new(healed.commit, healed.parent_body, healed.revision_body)
5001 .expect("healed record must validate");
5002 }
5003
5004 #[test]
5005 fn corrupt_transcript_history_strings_stay_untouched_and_fail_validation() {
5006 let (commit, parent_messages, _revision_messages) = legacy_rewrite_fixture();
5007 let bogus = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
5008 let state = TranscriptHistoryState {
5009 head: bogus.to_string(),
5010 commits: Vec::new(),
5011 revisions: vec![TranscriptRevisionBody {
5012 revision: bogus.to_string(),
5013 parent_revision: None,
5014 messages: parent_messages,
5015 created_at: SystemTime::now(),
5016 }],
5017 };
5018 let _ = commit;
5019 let value = serde_json::to_value(&state).expect("serialize corrupt state");
5020 let parsed: TranscriptHistoryState =
5021 serde_json::from_value(value).expect("corrupt strings still parse");
5022 assert_eq!(
5023 parsed.head, bogus,
5024 "unverifiable strings must not be rewritten"
5025 );
5026 assert!(
5027 validate_transcript_history_state(&parsed).is_err(),
5028 "corrupt graph must keep failing validation"
5029 );
5030 }
5031
5032 #[test]
5036 fn replace_synthetic_notices_leaves_only_replacements_of_kind() {
5037 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
5038
5039 let mut session = Session::new();
5040 session.push(Message::User(UserMessage::text("hello".to_string())));
5041 session.push(Message::SystemNotice(SystemNoticeMessage::new(
5042 SystemNoticeKind::McpPending,
5043 "stale one",
5044 )));
5045 session.push(Message::SystemNotice(SystemNoticeMessage::new(
5046 SystemNoticeKind::McpPending,
5047 "stale two",
5048 )));
5049 session.push(Message::SystemNotice(SystemNoticeMessage::new(
5051 SystemNoticeKind::BackgroundJob,
5052 "other-kind",
5053 )));
5054
5055 session
5056 .replace_synthetic_notices(
5057 SystemNoticeKind::McpPending,
5058 vec![Message::SystemNotice(SystemNoticeMessage::new(
5059 SystemNoticeKind::McpPending,
5060 "fresh",
5061 ))],
5062 )
5063 .expect("notice refresh succeeds");
5064
5065 let mcp_pending: Vec<&SystemNoticeMessage> = session
5066 .messages()
5067 .iter()
5068 .filter_map(|message| match message {
5069 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending => {
5070 Some(notice)
5071 }
5072 _ => None,
5073 })
5074 .collect();
5075 assert_eq!(mcp_pending.len(), 1, "exactly one notice of the kind");
5076 assert_eq!(mcp_pending[0].body.as_deref(), Some("fresh"));
5077 assert!(
5078 session.messages().iter().any(|message| matches!(
5079 message,
5080 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::BackgroundJob
5081 )),
5082 "other-kind notices are untouched"
5083 );
5084
5085 session
5087 .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
5088 .expect("pure strip succeeds");
5089 assert!(
5090 !session.messages().iter().any(|message| matches!(
5091 message,
5092 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending
5093 )),
5094 "empty replacement clears the kind"
5095 );
5096 }
5097
5098 #[test]
5102 fn replace_synthetic_notices_rejects_mismatched_kind_without_mutation() {
5103 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
5104
5105 let mut session = Session::new();
5106 session.push(Message::SystemNotice(SystemNoticeMessage::new(
5107 SystemNoticeKind::McpPending,
5108 "stale",
5109 )));
5110 let before = session.messages().to_vec();
5111
5112 let err = session
5113 .replace_synthetic_notices(
5114 SystemNoticeKind::McpPending,
5115 vec![Message::User(UserMessage::text("not a notice".to_string()))],
5116 )
5117 .expect_err("mismatched replacement must fail typed");
5118 assert!(
5119 matches!(err, TranscriptEditError::InvalidTranscriptShape(_)),
5120 "expected InvalidTranscriptShape, got {err:?}"
5121 );
5122 assert_eq!(
5123 session.messages(),
5124 before.as_slice(),
5125 "fault must leave the transcript unchanged (no partial strip)"
5126 );
5127 }
5128
5129 #[test]
5130 fn transcript_rewrite_preserves_full_assistant_block_trace() {
5131 let mut session = Session::new();
5132 session.push(Message::User(UserMessage::text(
5133 "run the trace".to_string(),
5134 )));
5135 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
5136 vec![AssistantBlock::Text {
5137 text: "original assistant trace".to_string(),
5138 meta: None,
5139 }],
5140 StopReason::EndTurn,
5141 )));
5142
5143 let parent_revision = session.transcript_revision().expect("parent revision");
5144 let replacement = vec![
5145 Message::BlockAssistant(BlockAssistantMessage::new(
5146 vec![
5147 AssistantBlock::Text {
5148 text: "compacted assistant trace".to_string(),
5149 meta: None,
5150 },
5151 AssistantBlock::ToolUse {
5152 id: "toolu_trace".to_string(),
5153 name: "trace_probe".to_string(),
5154 args: serde_json::value::RawValue::from_string(
5155 r#"{"path":"N-3"}"#.to_string(),
5156 )
5157 .expect("valid tool args"),
5158 meta: None,
5159 },
5160 ],
5161 StopReason::ToolUse,
5162 )),
5163 Message::tool_results(vec![ToolResult::new(
5164 "toolu_trace".to_string(),
5165 "trace complete".to_string(),
5166 false,
5167 )]),
5168 ];
5169
5170 let commit = session
5171 .commit_transcript_rewrite(
5172 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5173 replacement,
5174 TranscriptRewriteReason::new("compaction"),
5175 Some("unit-test".to_string()),
5176 Some(parent_revision.clone()),
5177 )
5178 .expect("rewrite should commit");
5179
5180 assert_eq!(commit.parent_revision, parent_revision);
5181 let current = session
5182 .transcript_revision_messages(&commit.revision)
5183 .expect("history state should decode")
5184 .expect("current revision should be retained");
5185 let Message::BlockAssistant(assistant) = ¤t[1] else {
5186 panic!("replacement should remain a block assistant message");
5187 };
5188 assert!(assistant.blocks.iter().any(|block| matches!(
5189 block,
5190 AssistantBlock::ToolUse { name, args, .. }
5191 if name == "trace_probe" && args.get().contains("\"N-3\"")
5192 )));
5193
5194 let parent = session
5195 .transcript_revision_messages(&parent_revision)
5196 .expect("history state should decode")
5197 .expect("parent revision should remain retained");
5198 assert!(matches!(
5199 &parent[1],
5200 Message::BlockAssistant(assistant)
5201 if block_assistant_text(assistant).contains("original assistant trace")
5202 ));
5203 }
5204
5205 #[test]
5206 fn transcript_rewrite_rejects_trailing_block_assistant_tool_call() {
5207 let mut session = Session::new();
5208 session.push(Message::User(UserMessage::text("question".to_string())));
5209 session.push(Message::BlockAssistant(BlockAssistantMessage {
5210 blocks: vec![AssistantBlock::Text {
5211 text: "plain answer".to_string(),
5212 meta: None,
5213 }],
5214 stop_reason: StopReason::EndTurn,
5215 identity: crate::types::TranscriptMessageIdentity::default(),
5216 created_at: crate::types::message_timestamp_now(),
5217 }));
5218 let parent_revision = session.transcript_revision().expect("parent revision");
5219
5220 let err = session
5221 .commit_transcript_rewrite(
5222 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5223 vec![Message::BlockAssistant(BlockAssistantMessage::new(
5224 vec![AssistantBlock::ToolUse {
5225 id: "toolu_1".to_string(),
5226 name: "lookup".to_string(),
5227 args: serde_json::value::RawValue::from_string("{}".to_string())
5228 .expect("valid args"),
5229 meta: None,
5230 }],
5231 StopReason::ToolUse,
5232 ))],
5233 TranscriptRewriteReason::new("compaction"),
5234 Some("unit-test".to_string()),
5235 Some(parent_revision),
5236 )
5237 .expect_err("rewrite should reject trailing unresolved block-assistant tool call");
5238 assert!(matches!(
5239 err,
5240 TranscriptEditError::InvalidTranscriptShape(_)
5241 ));
5242 }
5243
5244 #[test]
5245 fn transcript_rewrite_rejects_no_op_self_edge() {
5246 let mut session = Session::new();
5247 session.push(Message::User(UserMessage::text(
5248 "keep this exact transcript".to_string(),
5249 )));
5250 session.push(Message::BlockAssistant(BlockAssistantMessage {
5251 blocks: vec![AssistantBlock::Text {
5252 text: "unchanged".to_string(),
5253 meta: None,
5254 }],
5255 stop_reason: StopReason::EndTurn,
5256 identity: crate::types::TranscriptMessageIdentity::default(),
5257 created_at: crate::types::message_timestamp_now(),
5258 }));
5259
5260 let parent_revision = session.transcript_revision().expect("parent revision");
5261 let err = session
5262 .commit_transcript_rewrite(
5263 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5264 vec![session.messages()[1].clone()],
5265 TranscriptRewriteReason::new("retry"),
5266 Some("unit-test".to_string()),
5267 Some(parent_revision.clone()),
5268 )
5269 .expect_err("same-content rewrite should not emit a self-edge commit");
5270
5271 assert!(matches!(
5272 err,
5273 TranscriptEditError::NoOpRewrite { revision } if revision == parent_revision
5274 ));
5275 assert!(
5276 session
5277 .transcript_history_state()
5278 .expect("history state should decode")
5279 .is_none()
5280 );
5281 }
5282
5283 #[test]
5284 fn transcript_rewrite_run_boundary_guard_accepts_rewrite_then_append() {
5285 let mut original = Session::new();
5286 original.push(Message::User(UserMessage::text("question".to_string())));
5287 original.push(Message::BlockAssistant(BlockAssistantMessage {
5288 blocks: vec![AssistantBlock::Text {
5289 text: "verbose answer".to_string(),
5290 meta: None,
5291 }],
5292 stop_reason: StopReason::EndTurn,
5293 identity: crate::types::TranscriptMessageIdentity::default(),
5294 created_at: crate::types::message_timestamp_now(),
5295 }));
5296
5297 let parent_revision = original.transcript_revision().expect("parent revision");
5298 let mut incoming = original.clone();
5299 incoming
5300 .commit_transcript_rewrite(
5301 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5302 vec![Message::BlockAssistant(BlockAssistantMessage {
5303 blocks: vec![AssistantBlock::Text {
5304 text: "compact answer".to_string(),
5305 meta: None,
5306 }],
5307 stop_reason: StopReason::EndTurn,
5308 identity: crate::types::TranscriptMessageIdentity::default(),
5309 created_at: crate::types::message_timestamp_now(),
5310 })],
5311 TranscriptRewriteReason::new("compaction"),
5312 Some("unit-test".to_string()),
5313 Some(parent_revision),
5314 )
5315 .expect("rewrite should commit");
5316 incoming.push(Message::User(UserMessage::text("follow-up".to_string())));
5317 incoming.push(Message::BlockAssistant(BlockAssistantMessage {
5318 blocks: vec![AssistantBlock::Text {
5319 text: "follow-up answer".to_string(),
5320 meta: None,
5321 }],
5322 stop_reason: StopReason::EndTurn,
5323 identity: crate::types::TranscriptMessageIdentity::default(),
5324 created_at: crate::types::message_timestamp_now(),
5325 }));
5326
5327 crate::session_store::run_boundary_snapshot_save_guard(&incoming, Some(&original))
5328 .expect("rewrite plus appended turn should be a valid run-boundary commit");
5329 }
5330
5331 #[test]
5332 fn transcript_rewrite_rejects_orphaned_tool_results() {
5333 let mut session = Session::new();
5334 session.push(Message::User(UserMessage::text("use a tool".to_string())));
5335 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
5336 vec![AssistantBlock::ToolUse {
5337 id: "toolu_1".to_string(),
5338 name: "lookup".to_string(),
5339 args: serde_json::value::RawValue::from_string("{}".to_string())
5340 .expect("valid args"),
5341 meta: None,
5342 }],
5343 StopReason::ToolUse,
5344 )));
5345 session.push(Message::tool_results(vec![ToolResult::new(
5346 "toolu_1".to_string(),
5347 "done".to_string(),
5348 false,
5349 )]));
5350 let parent_revision = session.transcript_revision().expect("parent revision");
5351
5352 let err = session
5353 .commit_transcript_rewrite(
5354 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5355 vec![Message::BlockAssistant(BlockAssistantMessage {
5356 blocks: vec![AssistantBlock::Text {
5357 text: "no tool after all".to_string(),
5358 meta: None,
5359 }],
5360 stop_reason: StopReason::EndTurn,
5361 identity: crate::types::TranscriptMessageIdentity::default(),
5362 created_at: crate::types::message_timestamp_now(),
5363 })],
5364 TranscriptRewriteReason::new("compaction"),
5365 Some("unit-test".to_string()),
5366 Some(parent_revision),
5367 )
5368 .expect_err("rewrite should reject stranded tool results");
5369 assert!(matches!(
5370 err,
5371 TranscriptEditError::InvalidTranscriptShape(_)
5372 ));
5373 }
5374
5375 #[test]
5376 fn transcript_rewrite_rejects_trailing_assistant_tool_call() {
5377 let mut session = Session::new();
5378 session.push(Message::User(UserMessage::text("question".to_string())));
5379 session.push(Message::BlockAssistant(BlockAssistantMessage {
5380 blocks: vec![AssistantBlock::Text {
5381 text: "plain answer".to_string(),
5382 meta: None,
5383 }],
5384 stop_reason: StopReason::EndTurn,
5385 identity: crate::types::TranscriptMessageIdentity::default(),
5386 created_at: crate::types::message_timestamp_now(),
5387 }));
5388 let parent_revision = session.transcript_revision().expect("parent revision");
5389
5390 let err = session
5391 .commit_transcript_rewrite(
5392 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5393 vec![Message::BlockAssistant(BlockAssistantMessage {
5394 blocks: vec![AssistantBlock::ToolUse {
5395 id: "toolu_1".to_string(),
5396 name: "lookup".to_string(),
5397 args: serde_json::value::RawValue::from_string("{}".to_string())
5398 .expect("valid args"),
5399 meta: None,
5400 }],
5401 stop_reason: StopReason::ToolUse,
5402 identity: crate::types::TranscriptMessageIdentity::default(),
5403 created_at: crate::types::message_timestamp_now(),
5404 })],
5405 TranscriptRewriteReason::new("compaction"),
5406 Some("unit-test".to_string()),
5407 Some(parent_revision),
5408 )
5409 .expect_err("rewrite should reject trailing unresolved tool call");
5410 assert!(matches!(
5411 err,
5412 TranscriptEditError::InvalidTranscriptShape(_)
5413 ));
5414 }
5415
5416 #[test]
5417 fn transcript_rewrite_rejects_duplicate_tool_results() {
5418 let mut session = Session::new();
5419 session.push(Message::User(UserMessage::text("use a tool".to_string())));
5420 session.push(Message::BlockAssistant(BlockAssistantMessage {
5421 blocks: vec![AssistantBlock::Text {
5422 text: "plain answer".to_string(),
5423 meta: None,
5424 }],
5425 stop_reason: StopReason::EndTurn,
5426 identity: crate::types::TranscriptMessageIdentity::default(),
5427 created_at: crate::types::message_timestamp_now(),
5428 }));
5429 let parent_revision = session.transcript_revision().expect("parent revision");
5430
5431 let err = session
5432 .commit_transcript_rewrite(
5433 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5434 vec![
5435 Message::BlockAssistant(BlockAssistantMessage::new(
5436 vec![AssistantBlock::ToolUse {
5437 id: "toolu_1".to_string(),
5438 name: "lookup".to_string(),
5439 args: serde_json::value::RawValue::from_string("{}".to_string())
5440 .expect("valid args"),
5441 meta: None,
5442 }],
5443 StopReason::ToolUse,
5444 )),
5445 Message::tool_results(vec![
5446 ToolResult::new("toolu_1".to_string(), "one".to_string(), false),
5447 ToolResult::new("toolu_1".to_string(), "two".to_string(), false),
5448 ]),
5449 ],
5450 TranscriptRewriteReason::new("compaction"),
5451 Some("unit-test".to_string()),
5452 Some(parent_revision),
5453 )
5454 .expect_err("rewrite should reject duplicate tool results");
5455 assert!(matches!(
5456 err,
5457 TranscriptEditError::InvalidTranscriptShape(_)
5458 ));
5459 }
5460
5461 #[test]
5462 fn transcript_rewrite_record_rejects_prefix_or_suffix_tampering() {
5463 let mut session = Session::new();
5464 session.push(Message::System(SystemMessage::new("keep prefix")));
5465 session.push(Message::BlockAssistant(BlockAssistantMessage {
5466 blocks: vec![AssistantBlock::Text {
5467 text: "verbose answer".to_string(),
5468 meta: None,
5469 }],
5470 stop_reason: StopReason::EndTurn,
5471 identity: crate::types::TranscriptMessageIdentity::default(),
5472 created_at: crate::types::message_timestamp_now(),
5473 }));
5474 session.push(Message::User(UserMessage::text("keep suffix".to_string())));
5475
5476 let parent_revision = session.transcript_revision().expect("parent revision");
5477 let commit = session
5478 .commit_transcript_rewrite(
5479 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5480 vec![Message::BlockAssistant(BlockAssistantMessage {
5481 blocks: vec![AssistantBlock::Text {
5482 text: "compact answer".to_string(),
5483 meta: None,
5484 }],
5485 stop_reason: StopReason::EndTurn,
5486 identity: crate::types::TranscriptMessageIdentity::default(),
5487 created_at: crate::types::message_timestamp_now(),
5488 })],
5489 TranscriptRewriteReason::new("compaction"),
5490 Some("unit-test".to_string()),
5491 Some(parent_revision),
5492 )
5493 .expect("rewrite should commit");
5494 let state = session
5495 .transcript_history_state()
5496 .expect("history state should decode")
5497 .expect("history state should exist");
5498 let parent_body = state
5499 .revisions
5500 .iter()
5501 .find(|body| body.revision == commit.parent_revision)
5502 .expect("parent body retained")
5503 .clone();
5504 let revision_body = state
5505 .revisions
5506 .iter()
5507 .find(|body| body.revision == commit.revision)
5508 .expect("revision body retained")
5509 .clone();
5510
5511 let mut forged_body = revision_body;
5512 forged_body.messages[0] = Message::System(SystemMessage::new("tampered prefix"));
5513 forged_body.revision =
5514 transcript_messages_digest(&forged_body.messages).expect("forged digest");
5515 let mut forged_commit = commit;
5516 forged_commit.revision = forged_body.revision.clone();
5517 let err = TranscriptRewriteRecord::new(forged_commit, parent_body, forged_body)
5518 .expect_err("record validation must reject changes outside selected span");
5519 assert!(
5520 err.to_string().contains("before the selected span"),
5521 "unexpected error: {err}"
5522 );
5523 }
5524
5525 #[test]
5526 fn transcript_rewrite_replay_allows_normal_turn_revisions_between_rewrites() {
5527 let mut session = Session::new();
5528 session.push(Message::User(UserMessage::text("first".to_string())));
5529 session.push(Message::BlockAssistant(BlockAssistantMessage {
5530 blocks: vec![AssistantBlock::Text {
5531 text: "verbose first answer".to_string(),
5532 meta: None,
5533 }],
5534 stop_reason: StopReason::EndTurn,
5535 identity: crate::types::TranscriptMessageIdentity::default(),
5536 created_at: crate::types::message_timestamp_now(),
5537 }));
5538
5539 let first_parent = session.transcript_revision().expect("first parent");
5540 let first_commit = session
5541 .commit_transcript_rewrite(
5542 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5543 vec![Message::BlockAssistant(BlockAssistantMessage {
5544 blocks: vec![AssistantBlock::Text {
5545 text: "compact first answer".to_string(),
5546 meta: None,
5547 }],
5548 stop_reason: StopReason::EndTurn,
5549 identity: crate::types::TranscriptMessageIdentity::default(),
5550 created_at: crate::types::message_timestamp_now(),
5551 })],
5552 TranscriptRewriteReason::new("compaction"),
5553 Some("unit-test".to_string()),
5554 Some(first_parent),
5555 )
5556 .expect("first rewrite");
5557
5558 session.push(Message::User(UserMessage::text("normal turn".to_string())));
5559 session.push(Message::BlockAssistant(BlockAssistantMessage {
5560 blocks: vec![AssistantBlock::Text {
5561 text: "verbose second answer".to_string(),
5562 meta: None,
5563 }],
5564 stop_reason: StopReason::EndTurn,
5565 identity: crate::types::TranscriptMessageIdentity::default(),
5566 created_at: crate::types::message_timestamp_now(),
5567 }));
5568 let bridge_parent = session
5569 .transcript_revision()
5570 .expect("normal turn should advance transcript head");
5571 assert_ne!(bridge_parent, first_commit.revision);
5572 validate_transcript_history_state(
5573 &session
5574 .transcript_history_state()
5575 .expect("history state should decode")
5576 .expect("history state should exist"),
5577 )
5578 .expect("normal turn head may legitimately differ from last rewrite commit");
5579
5580 let second_commit = session
5581 .commit_transcript_rewrite(
5582 TranscriptRewriteSelection::MessageRange { start: 3, end: 4 },
5583 vec![Message::BlockAssistant(BlockAssistantMessage {
5584 blocks: vec![AssistantBlock::Text {
5585 text: "compact second answer".to_string(),
5586 meta: None,
5587 }],
5588 stop_reason: StopReason::EndTurn,
5589 identity: crate::types::TranscriptMessageIdentity::default(),
5590 created_at: crate::types::message_timestamp_now(),
5591 })],
5592 TranscriptRewriteReason::new("compaction"),
5593 Some("unit-test".to_string()),
5594 Some(bridge_parent.clone()),
5595 )
5596 .expect("second rewrite");
5597
5598 let state = session
5599 .transcript_history_state()
5600 .expect("history state should decode")
5601 .expect("history state should exist");
5602 let records = state.commits.iter().map(|commit| {
5603 let parent_body = state
5604 .revisions
5605 .iter()
5606 .find(|body| body.revision == commit.parent_revision)
5607 .expect("parent body retained")
5608 .clone();
5609 let revision_body = state
5610 .revisions
5611 .iter()
5612 .find(|body| body.revision == commit.revision)
5613 .expect("revision body retained")
5614 .clone();
5615 TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
5616 .expect("record should validate")
5617 });
5618
5619 let replayed = TranscriptHistoryState::from_rewrite_records(records)
5620 .expect("rewrite replay should accept normal-turn bridge revisions")
5621 .expect("rewrite records should exist");
5622 assert_eq!(replayed.head, second_commit.revision);
5623 assert!(
5624 replayed
5625 .revisions
5626 .iter()
5627 .any(|body| body.revision == bridge_parent)
5628 );
5629 }
5630
5631 #[test]
5632 fn transcript_rewrite_replay_rejects_branched_rewrite_records() {
5633 let mut base = Session::new();
5634 base.push(Message::User(UserMessage::text("question".to_string())));
5635 base.push(Message::BlockAssistant(BlockAssistantMessage {
5636 blocks: vec![AssistantBlock::Text {
5637 text: "verbose answer".to_string(),
5638 meta: None,
5639 }],
5640 stop_reason: StopReason::EndTurn,
5641 identity: crate::types::TranscriptMessageIdentity::default(),
5642 created_at: crate::types::message_timestamp_now(),
5643 }));
5644 let parent = base.transcript_revision().expect("parent revision");
5645
5646 let mut first = base.clone();
5647 let first_commit = first
5648 .commit_transcript_rewrite(
5649 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5650 vec![Message::BlockAssistant(BlockAssistantMessage {
5651 blocks: vec![AssistantBlock::Text {
5652 text: "first compact answer".to_string(),
5653 meta: None,
5654 }],
5655 stop_reason: StopReason::EndTurn,
5656 identity: crate::types::TranscriptMessageIdentity::default(),
5657 created_at: crate::types::message_timestamp_now(),
5658 })],
5659 TranscriptRewriteReason::new("compaction"),
5660 Some("unit-test".to_string()),
5661 Some(parent.clone()),
5662 )
5663 .expect("first rewrite");
5664 let first_state = first
5665 .transcript_history_state()
5666 .expect("first state decodes")
5667 .expect("first state exists");
5668
5669 let mut second = base;
5670 let second_commit = second
5671 .commit_transcript_rewrite(
5672 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5673 vec![Message::BlockAssistant(BlockAssistantMessage {
5674 blocks: vec![AssistantBlock::Text {
5675 text: "second compact answer".to_string(),
5676 meta: None,
5677 }],
5678 stop_reason: StopReason::EndTurn,
5679 identity: crate::types::TranscriptMessageIdentity::default(),
5680 created_at: crate::types::message_timestamp_now(),
5681 })],
5682 TranscriptRewriteReason::new("compaction"),
5683 Some("unit-test".to_string()),
5684 Some(parent),
5685 )
5686 .expect("second rewrite");
5687 let second_state = second
5688 .transcript_history_state()
5689 .expect("second state decodes")
5690 .expect("second state exists");
5691
5692 let record = |state: &TranscriptHistoryState, commit: &TranscriptRewriteCommit| {
5693 let parent_body = state
5694 .revisions
5695 .iter()
5696 .find(|body| body.revision == commit.parent_revision)
5697 .expect("parent body retained")
5698 .clone();
5699 let revision_body = state
5700 .revisions
5701 .iter()
5702 .find(|body| body.revision == commit.revision)
5703 .expect("revision body retained")
5704 .clone();
5705 TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
5706 .expect("record should validate")
5707 };
5708
5709 let err = TranscriptHistoryState::from_rewrite_records(vec![
5710 record(&first_state, &first_commit),
5711 record(&second_state, &second_commit),
5712 ])
5713 .expect_err("branched rewrite records must not replay as a linear source history");
5714 assert!(
5715 err.to_string().contains("does not extend transcript head"),
5716 "unexpected error: {err}"
5717 );
5718 }
5719
5720 #[test]
5721 fn internal_message_rewrites_refresh_transcript_history_head() {
5722 let mut session = Session::new();
5723 session.push(Message::User(UserMessage::text("question".to_string())));
5724 session.push(Message::BlockAssistant(BlockAssistantMessage {
5725 blocks: vec![AssistantBlock::Text {
5726 text: "verbose answer".to_string(),
5727 meta: None,
5728 }],
5729 stop_reason: StopReason::EndTurn,
5730 identity: crate::types::TranscriptMessageIdentity::default(),
5731 created_at: crate::types::message_timestamp_now(),
5732 }));
5733
5734 let parent = session.transcript_revision().expect("parent revision");
5735 session
5736 .commit_transcript_rewrite(
5737 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5738 vec![Message::BlockAssistant(BlockAssistantMessage {
5739 blocks: vec![AssistantBlock::Text {
5740 text: "compact answer".to_string(),
5741 meta: None,
5742 }],
5743 stop_reason: StopReason::EndTurn,
5744 identity: crate::types::TranscriptMessageIdentity::default(),
5745 created_at: crate::types::message_timestamp_now(),
5746 })],
5747 TranscriptRewriteReason::new("compaction"),
5748 Some("unit-test".to_string()),
5749 Some(parent),
5750 )
5751 .expect("rewrite should commit");
5752
5753 session.push(Message::User(UserMessage::text(
5754 "notice-bearing turn".to_string(),
5755 )));
5756 session
5757 .retain_messages_internal(
5758 |message| {
5759 !matches!(
5760 message,
5761 Message::User(user)
5762 if user.content.iter().any(|block| matches!(
5763 block,
5764 ContentBlock::Text { text } if text.contains("notice-bearing")
5765 ))
5766 )
5767 },
5768 TranscriptRewriteReason::new("synthetic_notice_cleanup"),
5769 )
5770 .expect("retain should commit internal rewrite");
5771 let retained_digest =
5772 transcript_messages_digest(session.messages()).expect("retained digest");
5773 assert_eq!(
5774 session.transcript_revision().expect("retained head"),
5775 retained_digest
5776 );
5777
5778 session
5779 .replace_messages_internal(
5780 vec![
5781 Message::User(UserMessage::text("compacted question".to_string())),
5782 Message::BlockAssistant(BlockAssistantMessage {
5783 blocks: vec![AssistantBlock::Text {
5784 text: "compacted answer".to_string(),
5785 meta: None,
5786 }],
5787 stop_reason: StopReason::EndTurn,
5788 identity: crate::types::TranscriptMessageIdentity::default(),
5789 created_at: crate::types::message_timestamp_now(),
5790 }),
5791 ],
5792 TranscriptRewriteReason::new("compaction"),
5793 )
5794 .expect("replace should commit internal rewrite");
5795 let replaced_digest =
5796 transcript_messages_digest(session.messages()).expect("replaced digest");
5797 assert_eq!(
5798 session.transcript_revision().expect("replaced head"),
5799 replaced_digest
5800 );
5801 let state = session
5802 .transcript_history_state()
5803 .expect("history state should decode")
5804 .expect("history state should exist");
5805 assert!(
5806 state
5807 .revisions
5808 .iter()
5809 .any(|body| body.revision == replaced_digest)
5810 );
5811 validate_transcript_history_state(&state).expect("history state remains valid");
5812 }
5813
5814 #[test]
5815 fn set_system_prompt_refreshes_transcript_history_head_after_rewrite() {
5816 let mut session = Session::new();
5817 session.push(Message::User(UserMessage::text("question".to_string())));
5818 session.push(Message::BlockAssistant(BlockAssistantMessage {
5819 blocks: vec![AssistantBlock::Text {
5820 text: "verbose answer".to_string(),
5821 meta: None,
5822 }],
5823 stop_reason: StopReason::EndTurn,
5824 identity: crate::types::TranscriptMessageIdentity::default(),
5825 created_at: crate::types::message_timestamp_now(),
5826 }));
5827
5828 let parent = session.transcript_revision().expect("parent revision");
5829 let rewrite = session
5830 .commit_transcript_rewrite(
5831 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5832 vec![Message::BlockAssistant(BlockAssistantMessage {
5833 blocks: vec![AssistantBlock::Text {
5834 text: "compact answer".to_string(),
5835 meta: None,
5836 }],
5837 stop_reason: StopReason::EndTurn,
5838 identity: crate::types::TranscriptMessageIdentity::default(),
5839 created_at: crate::types::message_timestamp_now(),
5840 })],
5841 TranscriptRewriteReason::new("compaction"),
5842 Some("unit-test".to_string()),
5843 Some(parent),
5844 )
5845 .expect("rewrite should commit");
5846
5847 session.set_system_prompt("durable system prompt".to_string());
5848
5849 let head = session
5850 .transcript_revision()
5851 .expect("system prompt should refresh transcript head");
5852 assert_ne!(head, rewrite.revision);
5853 assert_eq!(
5854 head,
5855 transcript_messages_digest(session.messages()).expect("current digest")
5856 );
5857 let head_messages = session
5858 .transcript_revision_messages(&head)
5859 .expect("history state should decode")
5860 .expect("refreshed head body should be retained");
5861 assert_eq!(
5862 serde_json::to_value(&head_messages).expect("head serializes"),
5863 serde_json::to_value(session.messages()).expect("session serializes")
5864 );
5865 validate_transcript_history_state(
5866 &session
5867 .transcript_history_state()
5868 .expect("history state should decode")
5869 .expect("history state should exist"),
5870 )
5871 .expect("history state remains valid after system prompt update");
5872 }
5873
5874 #[test]
5875 fn apply_transcript_history_state_uses_latest_commit_time_for_restored_head() {
5876 let mut session = Session::new();
5877 session.push(Message::User(UserMessage::text("question".to_string())));
5878 session.push(Message::BlockAssistant(BlockAssistantMessage {
5879 blocks: vec![AssistantBlock::Text {
5880 text: "verbose answer".to_string(),
5881 meta: None,
5882 }],
5883 stop_reason: StopReason::EndTurn,
5884 identity: crate::types::TranscriptMessageIdentity::default(),
5885 created_at: crate::types::message_timestamp_now(),
5886 }));
5887 let original_messages = session.messages().to_vec();
5888 let parent = session.transcript_revision().expect("parent revision");
5889 let compact = session
5890 .commit_transcript_rewrite(
5891 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5892 vec![Message::BlockAssistant(BlockAssistantMessage {
5893 blocks: vec![AssistantBlock::Text {
5894 text: "compact answer".to_string(),
5895 meta: None,
5896 }],
5897 stop_reason: StopReason::EndTurn,
5898 identity: crate::types::TranscriptMessageIdentity::default(),
5899 created_at: crate::types::message_timestamp_now(),
5900 })],
5901 TranscriptRewriteReason::new("compaction"),
5902 Some("unit-test".to_string()),
5903 Some(parent.clone()),
5904 )
5905 .expect("rewrite should commit");
5906
5907 std::thread::sleep(std::time::Duration::from_millis(2));
5908 let restore = session
5909 .commit_transcript_rewrite(
5910 TranscriptRewriteSelection::MessageRange {
5911 start: 0,
5912 end: session.messages().len(),
5913 },
5914 original_messages.clone(),
5915 TranscriptRewriteReason::new("restore"),
5916 Some("unit-test".to_string()),
5917 Some(compact.revision),
5918 )
5919 .expect("restore should commit");
5920 assert_eq!(restore.revision, parent);
5921
5922 let state = session
5923 .transcript_history_state()
5924 .expect("history state should decode")
5925 .expect("history state should exist");
5926 let restored_body_created_at = state
5927 .revisions
5928 .iter()
5929 .find(|body| body.revision == restore.revision)
5930 .expect("restored body should be retained")
5931 .created_at;
5932 assert!(
5933 restored_body_created_at < restore.committed_at,
5934 "test requires restore commit to be newer than retained body"
5935 );
5936
5937 let mut replayed = Session::new();
5938 replayed
5939 .apply_transcript_history_state(state)
5940 .expect("replay should materialize restored head");
5941 assert_eq!(
5942 serde_json::to_value(replayed.messages()).expect("replayed serializes"),
5943 serde_json::to_value(&original_messages).expect("original serializes")
5944 );
5945 assert_eq!(replayed.updated_at(), restore.committed_at);
5946 }
5947
5948 #[test]
5949 fn test_session_new() {
5950 let session = Session::new();
5951 assert_eq!(session.version(), SESSION_VERSION);
5952 assert!(session.messages().is_empty());
5953 assert!(session.created_at() <= session.updated_at());
5954 }
5955
5956 #[test]
5957 fn llm_identity_model_override_switches_to_catalog_provider() {
5958 let registry = crate::ModelRegistry::from_config(
5959 &crate::Config::default(),
5960 *crate::model_profile::test_catalog::TEST_CATALOG,
5961 )
5962 .unwrap();
5963 let current = SessionLlmIdentity {
5964 model: "test-anthropic-default".to_string(),
5965 provider: Provider::Anthropic,
5966 self_hosted_server_id: None,
5967 provider_params: None,
5968 auth_binding: Some(crate::AuthBindingRef {
5969 realm: crate::RealmId::parse("tenant_a").unwrap(),
5970 binding: crate::BindingId::parse("anthropic_default").unwrap(),
5971 profile: None,
5972 origin: crate::BindingOrigin::Configured,
5973 }),
5974 };
5975
5976 let resolved = resolve_session_llm_identity_override(
5977 ¤t,
5978 ®istry,
5979 SessionLlmIdentityOverride {
5980 model: Some("test-openai-default"),
5981 provider: None,
5982 provider_params: None,
5983 auth_binding: None,
5984 },
5985 )
5986 .unwrap();
5987
5988 assert_eq!(resolved.model, "test-openai-default");
5989 assert_eq!(resolved.provider, Provider::OpenAI);
5990 assert!(
5991 resolved.auth_binding.is_none(),
5992 "provider switches must not inherit a binding from the previous provider"
5993 );
5994 }
5995
5996 #[test]
5997 fn llm_identity_model_override_keeps_uncatalogued_model_on_current_provider() {
5998 let registry = crate::ModelRegistry::from_config(
5999 &crate::Config::default(),
6000 *crate::model_profile::test_catalog::TEST_CATALOG,
6001 )
6002 .unwrap();
6003 let current = SessionLlmIdentity {
6004 model: "custom-model".to_string(),
6005 provider: Provider::Anthropic,
6006 self_hosted_server_id: None,
6007 provider_params: None,
6008 auth_binding: None,
6009 };
6010
6011 let resolved = resolve_session_llm_identity_override(
6012 ¤t,
6013 ®istry,
6014 SessionLlmIdentityOverride {
6015 model: Some("uncatalogued-custom-model"),
6016 provider: None,
6017 provider_params: None,
6018 auth_binding: None,
6019 },
6020 )
6021 .unwrap();
6022
6023 assert_eq!(resolved.model, "uncatalogued-custom-model");
6024 assert_eq!(resolved.provider, Provider::Anthropic);
6025 }
6026
6027 #[test]
6028 fn realtime_transcript_append_is_idempotent_by_provider_item_and_delta_id() {
6029 let mut session = Session::new();
6030
6031 let user = RealtimeTranscriptEvent::UserTranscriptFinal {
6032 item_id: "item_user".to_string(),
6033 previous_item_id: None,
6034 content_index: 0,
6035 text: "hello".to_string(),
6036 };
6037 assert!(
6038 !session
6039 .append_realtime_transcript_event(user.clone())
6040 .is_inert()
6041 );
6042 assert!(session.append_realtime_transcript_event(user).is_inert());
6043
6044 let delta = RealtimeTranscriptEvent::AssistantTextDelta {
6045 response_id: "resp_assistant".to_string(),
6046 delta_id: "evt_delta_1".to_string(),
6047 item_id: "item_assistant".to_string(),
6048 previous_item_id: Some("item_user".to_string()),
6049 content_index: 0,
6050 delta: "hi".to_string(),
6051 };
6052 assert!(
6053 session
6054 .append_realtime_transcript_event(delta.clone())
6055 .is_inert()
6056 );
6057 assert!(session.append_realtime_transcript_event(delta).is_inert());
6058
6059 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
6060 response_id: "resp_assistant".to_string(),
6061 stop_reason: StopReason::EndTurn,
6062 usage: Usage::default(),
6063 };
6064 assert!(
6065 !session
6066 .append_realtime_transcript_event(terminal.clone())
6067 .is_inert()
6068 );
6069 assert!(
6070 session
6071 .append_realtime_transcript_event(terminal)
6072 .is_inert()
6073 );
6074
6075 assert_eq!(session.messages().len(), 2);
6076 assert!(matches!(
6077 &session.messages()[0],
6078 Message::User(user) if user.text_content() == "hello"
6079 ));
6080 assert!(matches!(
6081 &session.messages()[1],
6082 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "hi"
6083 ));
6084 }
6085
6086 #[test]
6091 fn realtime_transcript_final_text_overrides_partial_delta_and_promotes_to_spoken_lane() {
6092 let mut session = Session::new();
6093
6094 assert!(
6097 session
6098 .append_realtime_transcript_event(
6099 RealtimeTranscriptEvent::AssistantTranscriptDelta {
6100 response_id: "resp_a".to_string(),
6101 delta_id: "evt_1".to_string(),
6102 item_id: "item_a".to_string(),
6103 previous_item_id: None,
6104 content_index: 0,
6105 delta: "incom".to_string(),
6106 }
6107 )
6108 .is_inert()
6109 );
6110
6111 assert!(
6113 session
6114 .append_realtime_transcript_event(
6115 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
6116 response_id: "resp_a".to_string(),
6117 item_id: "item_a".to_string(),
6118 content_index: 0,
6119 text: "complete answer".to_string(),
6120 }
6121 )
6122 .is_inert()
6123 );
6124
6125 let outcome = session.append_realtime_transcript_event(
6127 RealtimeTranscriptEvent::AssistantTurnCompleted {
6128 response_id: "resp_a".to_string(),
6129 stop_reason: StopReason::EndTurn,
6130 usage: Usage::default(),
6131 },
6132 );
6133 assert!(!outcome.is_inert());
6134
6135 assert_eq!(session.messages().len(), 1);
6138 match &session.messages()[0] {
6139 Message::BlockAssistant(assistant) => {
6140 let mut found_transcript = false;
6141 for block in &assistant.blocks {
6142 if let AssistantBlock::Transcript { text, .. } = block {
6143 assert_eq!(text, "complete answer");
6144 found_transcript = true;
6145 }
6146 }
6147 assert!(
6148 found_transcript,
6149 "AssistantTranscriptFinalText must promote to the Spoken lane and \
6150 materialize as AssistantBlock::Transcript"
6151 );
6152 }
6153 other => unreachable!("expected BlockAssistant, got {other:?}"),
6154 }
6155 }
6156
6157 #[test]
6160 fn realtime_transcript_final_text_creates_item_when_no_delta_staged() {
6161 let mut session = Session::new();
6162
6163 assert!(
6164 session
6165 .append_realtime_transcript_event(
6166 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
6167 response_id: "resp_a".to_string(),
6168 item_id: "item_a".to_string(),
6169 content_index: 0,
6170 text: "spoken-final-only".to_string(),
6171 }
6172 )
6173 .is_inert()
6174 );
6175
6176 let outcome = session.append_realtime_transcript_event(
6177 RealtimeTranscriptEvent::AssistantTurnCompleted {
6178 response_id: "resp_a".to_string(),
6179 stop_reason: StopReason::EndTurn,
6180 usage: Usage::default(),
6181 },
6182 );
6183 assert!(!outcome.is_inert());
6184
6185 assert_eq!(session.messages().len(), 1);
6186 match &session.messages()[0] {
6187 Message::BlockAssistant(assistant) => {
6188 let has_transcript = assistant.blocks.iter().any(|b| {
6189 matches!(b, AssistantBlock::Transcript { text, .. } if text == "spoken-final-only")
6190 });
6191 assert!(
6192 has_transcript,
6193 "final-only provider path must materialize as Transcript on the Spoken lane"
6194 );
6195 }
6196 other => unreachable!("expected BlockAssistant, got {other:?}"),
6197 }
6198 }
6199
6200 #[test]
6201 fn realtime_transcript_append_orders_causally_equivalent_out_of_order_items() {
6202 let mut session = Session::new();
6203
6204 assert!(
6205 session
6206 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6207 response_id: "resp_assistant".to_string(),
6208 delta_id: "evt_delta_1".to_string(),
6209 item_id: "item_assistant".to_string(),
6210 previous_item_id: Some("item_user".to_string()),
6211 content_index: 0,
6212 delta: "answer".to_string(),
6213 })
6214 .is_inert()
6215 );
6216 assert!(
6217 session
6218 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
6219 response_id: "resp_assistant".to_string(),
6220 stop_reason: StopReason::EndTurn,
6221 usage: Usage::default(),
6222 })
6223 .is_inert()
6224 );
6225
6226 let outcome = session.append_realtime_transcript_event(
6227 RealtimeTranscriptEvent::UserTranscriptFinal {
6228 item_id: "item_user".to_string(),
6229 previous_item_id: None,
6230 content_index: 0,
6231 text: "question".to_string(),
6232 },
6233 );
6234
6235 assert_eq!(outcome.materialized_messages.len(), 2);
6236 assert_eq!(session.messages().len(), 2);
6237 assert!(matches!(
6238 &session.messages()[0],
6239 Message::User(user) if user.text_content() == "question"
6240 ));
6241 assert!(matches!(
6242 &session.messages()[1],
6243 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer"
6244 ));
6245 }
6246
6247 #[test]
6248 fn realtime_transcript_replay_of_seen_provider_items_is_inert() {
6249 let mut session = Session::new();
6250 let events = vec![
6251 RealtimeTranscriptEvent::UserTranscriptFinal {
6252 item_id: "item_user".to_string(),
6253 previous_item_id: None,
6254 content_index: 0,
6255 text: "hello".to_string(),
6256 },
6257 RealtimeTranscriptEvent::AssistantTextDelta {
6258 response_id: "resp_assistant".to_string(),
6259 delta_id: "evt_delta_1".to_string(),
6260 item_id: "item_assistant".to_string(),
6261 previous_item_id: Some("item_user".to_string()),
6262 content_index: 0,
6263 delta: "world".to_string(),
6264 },
6265 RealtimeTranscriptEvent::AssistantTurnCompleted {
6266 response_id: "resp_assistant".to_string(),
6267 stop_reason: StopReason::EndTurn,
6268 usage: Usage::default(),
6269 },
6270 ];
6271
6272 for event in events.iter().cloned() {
6273 let _ = session.append_realtime_transcript_event(event);
6274 }
6275 let first_messages = serde_json::to_value(session.messages()).unwrap();
6276
6277 for event in events {
6278 assert!(session.append_realtime_transcript_event(event).is_inert());
6279 }
6280
6281 assert_eq!(
6282 serde_json::to_value(session.messages()).unwrap(),
6283 first_messages
6284 );
6285 }
6286
6287 #[test]
6288 fn realtime_transcript_user_final_replay_cannot_erase_existing_segment() {
6289 let mut session = Session::new();
6290
6291 let user = RealtimeTranscriptEvent::UserTranscriptFinal {
6292 item_id: "item_user".to_string(),
6293 previous_item_id: None,
6294 content_index: 0,
6295 text: "remember amber lantern".to_string(),
6296 };
6297 assert!(
6298 !session
6299 .append_realtime_transcript_event(user.clone())
6300 .is_inert()
6301 );
6302 let first_messages = serde_json::to_value(session.messages()).unwrap();
6303
6304 assert!(
6305 session
6306 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
6307 item_id: "item_user".to_string(),
6308 previous_item_id: None,
6309 content_index: 0,
6310 text: String::new(),
6311 })
6312 .is_inert()
6313 );
6314 assert!(session.append_realtime_transcript_event(user).is_inert());
6315 assert_eq!(
6316 serde_json::to_value(session.messages()).unwrap(),
6317 first_messages
6318 );
6319 }
6320
6321 #[test]
6322 fn realtime_transcript_empty_user_final_can_be_filled_by_later_nonempty_replay() {
6323 let mut session = Session::new();
6324
6325 assert!(
6326 session
6327 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
6328 item_id: "item_user".to_string(),
6329 previous_item_id: None,
6330 content_index: 0,
6331 text: String::new(),
6332 })
6333 .is_inert()
6334 );
6335 assert!(session.messages().is_empty());
6336
6337 let outcome = session.append_realtime_transcript_event(
6338 RealtimeTranscriptEvent::UserTranscriptFinal {
6339 item_id: "item_user".to_string(),
6340 previous_item_id: None,
6341 content_index: 0,
6342 text: "remember amber lantern".to_string(),
6343 },
6344 );
6345 assert_eq!(outcome.materialized_messages.len(), 1);
6346 assert_eq!(session.messages().len(), 1);
6347 assert!(matches!(
6348 &session.messages()[0],
6349 Message::User(user) if user.text_content() == "remember amber lantern"
6350 ));
6351 }
6352
6353 #[test]
6354 fn realtime_transcript_skipped_provider_items_preserve_causal_order_without_content() {
6355 let mut session = Session::new();
6356
6357 let assistant_delta = RealtimeTranscriptEvent::AssistantTextDelta {
6358 response_id: "resp_assistant".to_string(),
6359 delta_id: "evt_delta_1".to_string(),
6360 item_id: "item_assistant".to_string(),
6361 previous_item_id: Some("item_tool".to_string()),
6362 content_index: 0,
6363 delta: "done".to_string(),
6364 };
6365 assert!(
6366 session
6367 .append_realtime_transcript_event(assistant_delta.clone())
6368 .is_inert()
6369 );
6370 let assistant_complete = RealtimeTranscriptEvent::AssistantTurnCompleted {
6371 response_id: "resp_assistant".to_string(),
6372 stop_reason: StopReason::EndTurn,
6373 usage: Usage::default(),
6374 };
6375 assert!(
6376 session
6377 .append_realtime_transcript_event(assistant_complete.clone())
6378 .is_inert()
6379 );
6380
6381 let skipped = RealtimeTranscriptEvent::ItemSkipped {
6382 item_id: "item_tool".to_string(),
6383 previous_item_id: Some("item_user".to_string()),
6384 };
6385 assert!(
6386 session
6387 .append_realtime_transcript_event(skipped.clone())
6388 .is_inert(),
6389 "a skipped provider item must not append transcript content"
6390 );
6391 assert!(session.messages().is_empty());
6392
6393 let outcome = session.append_realtime_transcript_event(
6394 RealtimeTranscriptEvent::UserTranscriptFinal {
6395 item_id: "item_user".to_string(),
6396 previous_item_id: None,
6397 content_index: 0,
6398 text: "please use the tool".to_string(),
6399 },
6400 );
6401 assert_eq!(outcome.materialized_messages.len(), 2);
6402 assert_eq!(session.messages().len(), 2);
6403 assert!(matches!(
6404 &session.messages()[0],
6405 Message::User(user) if user.text_content() == "please use the tool"
6406 ));
6407 assert!(matches!(
6408 &session.messages()[1],
6409 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "done"
6410 ));
6411
6412 let first_messages = serde_json::to_value(session.messages()).unwrap();
6413 assert!(session.append_realtime_transcript_event(skipped).is_inert());
6414 assert!(
6415 session
6416 .append_realtime_transcript_event(assistant_delta)
6417 .is_inert()
6418 );
6419 assert!(
6420 session
6421 .append_realtime_transcript_event(assistant_complete)
6422 .is_inert()
6423 );
6424 assert_eq!(
6425 serde_json::to_value(session.messages()).unwrap(),
6426 first_messages
6427 );
6428 }
6429
6430 #[test]
6431 fn realtime_transcript_interrupted_assistant_item_unblocks_later_provider_items() {
6432 let mut session = Session::new();
6439
6440 let _ = session.append_realtime_transcript_event(
6441 RealtimeTranscriptEvent::UserTranscriptFinal {
6442 item_id: "item_repeat".to_string(),
6443 previous_item_id: None,
6444 content_index: 0,
6445 text: "repeat until stop".to_string(),
6446 },
6447 );
6448 assert!(
6449 session
6450 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6451 response_id: "resp_loop".to_string(),
6452 delta_id: "evt_loop_1".to_string(),
6453 item_id: "item_loop".to_string(),
6454 previous_item_id: Some("item_repeat".to_string()),
6455 content_index: 0,
6456 delta: "Looping now".to_string(),
6457 })
6458 .is_inert()
6459 );
6460 assert!(
6461 session
6462 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
6463 item_id: "item_stop".to_string(),
6464 previous_item_id: Some("item_loop".to_string()),
6465 content_index: 0,
6466 text: "Stop.".to_string(),
6467 })
6468 .is_inert(),
6469 "the stop turn waits until the interrupted assistant provider item is resolved"
6470 );
6471
6472 let outcome = session.append_realtime_transcript_event(
6473 RealtimeTranscriptEvent::AssistantTurnInterrupted {
6474 response_id: "resp_loop".to_string(),
6475 },
6476 );
6477
6478 assert_eq!(outcome.materialized_messages.len(), 2);
6481 assert_eq!(session.messages().len(), 3);
6483 assert!(matches!(
6484 &session.messages()[0],
6485 Message::User(user) if user.text_content() == "repeat until stop"
6486 ));
6487 match &session.messages()[1] {
6488 Message::BlockAssistant(assistant) => {
6489 let text = block_assistant_text(assistant);
6490 assert_eq!(text, "Looping now");
6491 }
6492 other => unreachable!(
6493 "Display lane assistant item must be retained on Interrupted, got {other:?}"
6494 ),
6495 }
6496 assert!(matches!(
6497 &session.messages()[2],
6498 Message::User(user) if user.text_content() == "Stop."
6499 ));
6500 }
6501
6502 #[test]
6503 fn realtime_transcript_late_interrupted_assistant_delta_stays_noncanonical() {
6504 let mut session = Session::new();
6505
6506 let _ = session.append_realtime_transcript_event(
6507 RealtimeTranscriptEvent::UserTranscriptFinal {
6508 item_id: "item_repeat".to_string(),
6509 previous_item_id: None,
6510 content_index: 0,
6511 text: "repeat until stop".to_string(),
6512 },
6513 );
6514 assert!(
6515 session
6516 .append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
6517 item_id: "item_loop".to_string(),
6518 previous_item_id: Some("item_repeat".to_string()),
6519 role: RealtimeTranscriptRole::Assistant,
6520 response_id: None,
6521 })
6522 .is_inert(),
6523 "provider can observe an assistant item before the adapter learns its response id"
6524 );
6525 assert!(
6526 session
6527 .append_realtime_transcript_event(
6528 RealtimeTranscriptEvent::AssistantTurnInterrupted {
6529 response_id: "resp_loop".to_string(),
6530 }
6531 )
6532 .is_inert(),
6533 "an interruption can arrive before delayed transcript deltas for the response"
6534 );
6535 assert!(
6536 session
6537 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
6538 item_id: "item_stop".to_string(),
6539 previous_item_id: Some("item_loop".to_string()),
6540 content_index: 0,
6541 text: "Stop.".to_string(),
6542 })
6543 .is_inert(),
6544 "the stop turn waits for the provider's interrupted assistant item anchor"
6545 );
6546
6547 let late_delta_outcome =
6548 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6549 response_id: "resp_loop".to_string(),
6550 delta_id: "evt_loop_late".to_string(),
6551 item_id: "item_loop".to_string(),
6552 previous_item_id: Some("item_repeat".to_string()),
6553 content_index: 0,
6554 delta: "Looping now".to_string(),
6555 });
6556 assert_eq!(late_delta_outcome.materialized_messages.len(), 1);
6557 assert!(matches!(
6558 &session.messages()[1],
6559 Message::User(user) if user.text_content() == "Stop."
6560 ));
6561 assert!(
6562 session
6563 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
6564 response_id: "resp_loop".to_string(),
6565 stop_reason: StopReason::EndTurn,
6566 usage: Usage::default(),
6567 })
6568 .is_inert(),
6569 "late completion for an interrupted response must not resurrect its deltas"
6570 );
6571 assert!(
6572 session
6573 .messages()
6574 .iter()
6575 .filter_map(|message| match message {
6576 Message::BlockAssistant(assistant) => Some(block_assistant_text(assistant)),
6577 _ => None,
6578 })
6579 .all(|text| !text.contains("Looping now")),
6580 "late interrupted assistant text must remain non-canonical"
6581 );
6582 }
6583
6584 #[test]
6585 fn realtime_transcript_completion_only_finalizes_matching_response() {
6586 let mut session = Session::new();
6587
6588 let _ = session.append_realtime_transcript_event(
6589 RealtimeTranscriptEvent::UserTranscriptFinal {
6590 item_id: "item_user".to_string(),
6591 previous_item_id: None,
6592 content_index: 0,
6593 text: "question".to_string(),
6594 },
6595 );
6596 assert!(
6597 session
6598 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6599 response_id: "resp_a".to_string(),
6600 delta_id: "evt_a".to_string(),
6601 item_id: "item_a".to_string(),
6602 previous_item_id: Some("item_user".to_string()),
6603 content_index: 0,
6604 delta: "answer a".to_string(),
6605 })
6606 .is_inert()
6607 );
6608
6609 assert!(
6610 session
6611 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
6612 response_id: "resp_b".to_string(),
6613 stop_reason: StopReason::EndTurn,
6614 usage: Usage::default(),
6615 })
6616 .is_inert(),
6617 "a completion for another response must not finalize buffered assistant text"
6618 );
6619 assert_eq!(session.messages().len(), 1);
6620
6621 let outcome = session.append_realtime_transcript_event(
6622 RealtimeTranscriptEvent::AssistantTurnCompleted {
6623 response_id: "resp_a".to_string(),
6624 stop_reason: StopReason::EndTurn,
6625 usage: Usage::default(),
6626 },
6627 );
6628 assert_eq!(outcome.materialized_messages.len(), 1);
6629 assert_eq!(session.messages().len(), 2);
6630 assert!(matches!(
6631 &session.messages()[1],
6632 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer a"
6633 ));
6634 }
6635
6636 #[test]
6637 fn realtime_transcript_completion_before_later_delta_is_response_scoped() {
6638 let mut session = Session::new();
6639
6640 let _ = session.append_realtime_transcript_event(
6641 RealtimeTranscriptEvent::UserTranscriptFinal {
6642 item_id: "item_user".to_string(),
6643 previous_item_id: None,
6644 content_index: 0,
6645 text: "question".to_string(),
6646 },
6647 );
6648 assert!(
6649 session
6650 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
6651 response_id: "resp_a".to_string(),
6652 stop_reason: StopReason::EndTurn,
6653 usage: Usage::default(),
6654 })
6655 .is_inert()
6656 );
6657 assert!(
6658 session
6659 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6660 response_id: "resp_b".to_string(),
6661 delta_id: "evt_b".to_string(),
6662 item_id: "item_b".to_string(),
6663 previous_item_id: Some("item_user".to_string()),
6664 content_index: 0,
6665 delta: "wrong response".to_string(),
6666 })
6667 .is_inert(),
6668 "a later delta for another response must not be finalized by resp_a's pending completion"
6669 );
6670
6671 let outcome =
6672 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6673 response_id: "resp_a".to_string(),
6674 delta_id: "evt_a".to_string(),
6675 item_id: "item_a".to_string(),
6676 previous_item_id: Some("item_user".to_string()),
6677 content_index: 0,
6678 delta: "right response".to_string(),
6679 });
6680
6681 assert_eq!(outcome.materialized_messages.len(), 1);
6682 assert_eq!(session.messages().len(), 2);
6683 assert!(matches!(
6684 &session.messages()[1],
6685 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "right response"
6686 ));
6687 }
6688
6689 #[test]
6690 fn realtime_transcript_late_duplicate_completion_cannot_finalize_unrelated_response() {
6691 let mut session = Session::new();
6692
6693 let _ = session.append_realtime_transcript_event(
6694 RealtimeTranscriptEvent::UserTranscriptFinal {
6695 item_id: "item_user".to_string(),
6696 previous_item_id: None,
6697 content_index: 0,
6698 text: "question".to_string(),
6699 },
6700 );
6701 let _ =
6702 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6703 response_id: "resp_a".to_string(),
6704 delta_id: "evt_a".to_string(),
6705 item_id: "item_a".to_string(),
6706 previous_item_id: Some("item_user".to_string()),
6707 content_index: 0,
6708 delta: "first".to_string(),
6709 });
6710 let _ = session.append_realtime_transcript_event(
6711 RealtimeTranscriptEvent::AssistantTurnCompleted {
6712 response_id: "resp_a".to_string(),
6713 stop_reason: StopReason::EndTurn,
6714 usage: Usage::default(),
6715 },
6716 );
6717 assert_eq!(session.messages().len(), 2);
6718
6719 assert!(
6720 session
6721 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6722 response_id: "resp_b".to_string(),
6723 delta_id: "evt_b".to_string(),
6724 item_id: "item_b".to_string(),
6725 previous_item_id: Some("item_a".to_string()),
6726 content_index: 0,
6727 delta: "second".to_string(),
6728 })
6729 .is_inert()
6730 );
6731 assert!(
6732 session
6733 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
6734 response_id: "resp_a".to_string(),
6735 stop_reason: StopReason::EndTurn,
6736 usage: Usage::default(),
6737 })
6738 .is_inert(),
6739 "a duplicate late terminal for resp_a must not finalize resp_b"
6740 );
6741 assert_eq!(session.messages().len(), 2);
6742
6743 let outcome = session.append_realtime_transcript_event(
6744 RealtimeTranscriptEvent::AssistantTurnCompleted {
6745 response_id: "resp_b".to_string(),
6746 stop_reason: StopReason::EndTurn,
6747 usage: Usage::default(),
6748 },
6749 );
6750 assert_eq!(outcome.materialized_messages.len(), 1);
6751 assert_eq!(session.messages().len(), 3);
6752 }
6753
6754 #[test]
6755 fn realtime_transcript_interruption_discards_only_matching_response() {
6756 let mut session = Session::new();
6762
6763 let _ = session.append_realtime_transcript_event(
6764 RealtimeTranscriptEvent::UserTranscriptFinal {
6765 item_id: "item_user".to_string(),
6766 previous_item_id: None,
6767 content_index: 0,
6768 text: "question".to_string(),
6769 },
6770 );
6771 let _ =
6772 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6773 response_id: "resp_a".to_string(),
6774 delta_id: "evt_a".to_string(),
6775 item_id: "item_a".to_string(),
6776 previous_item_id: Some("item_user".to_string()),
6777 content_index: 0,
6778 delta: "interrupted display".to_string(),
6779 });
6780 let _ =
6781 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6782 response_id: "resp_b".to_string(),
6783 delta_id: "evt_b".to_string(),
6784 item_id: "item_b".to_string(),
6785 previous_item_id: Some("item_user".to_string()),
6786 content_index: 0,
6787 delta: "keep me".to_string(),
6788 });
6789
6790 let interrupt_outcome = session.append_realtime_transcript_event(
6793 RealtimeTranscriptEvent::AssistantTurnInterrupted {
6794 response_id: "resp_a".to_string(),
6795 },
6796 );
6797 assert_eq!(
6798 interrupt_outcome.materialized_messages.len(),
6799 1,
6800 "resp_a's Display item commits on Interrupted"
6801 );
6802
6803 let outcome = session.append_realtime_transcript_event(
6804 RealtimeTranscriptEvent::AssistantTurnCompleted {
6805 response_id: "resp_b".to_string(),
6806 stop_reason: StopReason::EndTurn,
6807 usage: Usage::default(),
6808 },
6809 );
6810 assert_eq!(
6811 outcome.materialized_messages.len(),
6812 1,
6813 "resp_b commits on its TurnCompleted, untouched by resp_a's Interrupted"
6814 );
6815
6816 assert_eq!(session.messages().len(), 3);
6818 assert!(matches!(
6819 &session.messages()[1],
6820 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "interrupted display"
6821 ));
6822 assert!(matches!(
6823 &session.messages()[2],
6824 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "keep me"
6825 ));
6826 }
6827
6828 #[test]
6831 fn test_fork_shares_arc_no_clone() {
6832 let mut session = Session::new();
6833 for i in 0..100 {
6834 session.push(Message::User(UserMessage::text(format!("Message {i}"))));
6835 }
6836
6837 let forked = session.fork();
6839
6840 assert!(Arc::ptr_eq(&session.messages, &forked.messages));
6842 assert_eq!(forked.messages().len(), 100);
6843 }
6844
6845 #[test]
6846 fn test_fork_at_shares_arc_prefix() {
6847 let mut session = Session::new();
6848 for i in 0..100 {
6849 session.push(Message::User(UserMessage::text(format!("Message {i}"))));
6850 }
6851
6852 let forked = session.fork_at(50);
6854 assert_eq!(forked.messages().len(), 50);
6855
6856 assert_eq!(session.messages().len(), 100);
6858 }
6859
6860 #[test]
6861 fn test_fork_at_resets_transcript_history_state_for_branch_identity() {
6862 let mut session = Session::new();
6863 session.push(Message::User(UserMessage::text(
6864 "summarize this".to_string(),
6865 )));
6866 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
6867 vec![AssistantBlock::Text {
6868 text: "long assistant trace".to_string(),
6869 meta: None,
6870 }],
6871 StopReason::EndTurn,
6872 )));
6873 let parent_revision = session.transcript_revision().expect("parent revision");
6874 session
6875 .commit_transcript_rewrite(
6876 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6877 vec![Message::BlockAssistant(BlockAssistantMessage::new(
6878 vec![AssistantBlock::Text {
6879 text: "compact trace".to_string(),
6880 meta: None,
6881 }],
6882 StopReason::EndTurn,
6883 ))],
6884 TranscriptRewriteReason::new("compaction"),
6885 Some("test".to_string()),
6886 Some(parent_revision),
6887 )
6888 .expect("rewrite should commit");
6889
6890 let source_head = session.transcript_revision().expect("source head");
6891 let mut forked = session.fork_at(1);
6892 assert_ne!(forked.id(), session.id());
6893 assert!(
6894 !forked
6895 .metadata()
6896 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
6897 );
6898 assert_eq!(
6899 forked.transcript_revision().expect("fork head"),
6900 transcript_messages_digest(forked.messages()).expect("fork digest")
6901 );
6902 assert!(
6903 forked
6904 .transcript_revision_messages(&source_head)
6905 .expect("fork history lookup")
6906 .is_none()
6907 );
6908
6909 let fork_parent = forked.transcript_revision().expect("fork parent");
6910 let commit = forked
6911 .commit_transcript_rewrite(
6912 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
6913 vec![Message::User(UserMessage::text(
6914 "branch prompt".to_string(),
6915 ))],
6916 TranscriptRewriteReason::new("branch_edit"),
6917 Some("test".to_string()),
6918 Some(fork_parent.clone()),
6919 )
6920 .expect("fork rewrite should use fork-local parent");
6921 assert_eq!(commit.parent_revision, fork_parent);
6922 }
6923
6924 #[test]
6925 fn test_push_cow_behavior() {
6926 let mut session = Session::new();
6927 session.push(Message::User(UserMessage::text("First".to_string())));
6928
6929 let forked = session.fork();
6931 assert!(Arc::ptr_eq(&session.messages, &forked.messages));
6932
6933 session.push(Message::User(UserMessage::text("Second".to_string())));
6935
6936 assert!(!Arc::ptr_eq(&session.messages, &forked.messages));
6938 assert_eq!(session.messages().len(), 2);
6939 assert_eq!(forked.messages().len(), 1);
6940 }
6941
6942 #[test]
6945 fn test_push_batch_single_timestamp() {
6946 let mut session = Session::new();
6947 let initial_updated = session.updated_at();
6948
6949 session.push_batch(vec![
6951 Message::User(UserMessage::text("First".to_string())),
6952 Message::User(UserMessage::text("Second".to_string())),
6953 Message::User(UserMessage::text("Third".to_string())),
6954 ]);
6955
6956 assert_eq!(session.messages().len(), 3);
6957 assert!(session.updated_at() >= initial_updated);
6959 }
6960
6961 #[test]
6962 fn test_touch_updates_timestamp() {
6963 let mut session = Session::new();
6964 let initial = session.updated_at();
6965
6966 std::thread::sleep(std::time::Duration::from_millis(10));
6967
6968 session.touch();
6970
6971 assert!(session.updated_at() > initial);
6972 }
6973
6974 #[test]
6975 fn test_session_push() {
6976 let mut session = Session::new();
6977 let initial_updated = session.updated_at();
6978
6979 std::thread::sleep(std::time::Duration::from_millis(10));
6981
6982 session.push(Message::User(UserMessage::text("Hello".to_string())));
6983
6984 assert_eq!(session.messages().len(), 1);
6985 assert!(session.updated_at() > initial_updated);
6986 }
6987
6988 #[test]
6989 fn test_session_fork() {
6990 let mut session = Session::new();
6991 session.push(Message::System(SystemMessage::new("System prompt")));
6992 session.push(Message::User(UserMessage::text("Hello".to_string())));
6993 session.push(Message::BlockAssistant(BlockAssistantMessage {
6994 blocks: vec![AssistantBlock::Text {
6995 text: "Hi!".to_string(),
6996 meta: None,
6997 }],
6998 stop_reason: StopReason::EndTurn,
6999 identity: crate::types::TranscriptMessageIdentity::default(),
7000 created_at: crate::types::message_timestamp_now(),
7001 }));
7002
7003 let forked = session.fork_at(2);
7005 assert_eq!(forked.messages().len(), 2);
7006 assert_ne!(forked.id(), session.id());
7007
7008 let full_fork = session.fork();
7010 assert_eq!(full_fork.messages().len(), 3);
7011 }
7012
7013 #[test]
7014 fn test_session_forks_drop_generated_authority_metadata() {
7015 let mut session = Session::new();
7016 session.push(Message::User(UserMessage::text("original")));
7017 session.set_metadata("ordinary", serde_json::json!("keep"));
7018 session
7019 .set_build_state(SessionBuildState::default())
7020 .expect("build state should serialize");
7021 session
7022 .set_system_context_state(SessionSystemContextState::default())
7023 .expect("system-context state should serialize");
7024 session
7025 .set_deferred_turn_state(SessionDeferredTurnState::default())
7026 .expect("deferred-turn state should serialize");
7027 session
7028 .set_tool_visibility_state(
7029 AuthorizedSessionToolVisibilityState::from_generated_authority(
7030 SessionToolVisibilityState::default(),
7031 ),
7032 )
7033 .expect("visibility state should serialize");
7034 let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
7035 item_id: "rt-item".to_string(),
7036 previous_item_id: None,
7037 role: RealtimeTranscriptRole::User,
7038 response_id: None,
7039 });
7040 assert!(
7041 session
7042 .metadata()
7043 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
7044 "test setup should install realtime transcript authority state"
7045 );
7046
7047 let forked_at = session.fork_at(1);
7048 let full_fork = session.fork();
7049 let replaced = session
7050 .fork_replacing(
7051 0,
7052 TranscriptReplacement::Message {
7053 message: Message::User(UserMessage::text("replacement")),
7054 },
7055 )
7056 .expect("replacement fork should succeed");
7057
7058 for forked in [&forked_at, &full_fork, &replaced] {
7059 assert_eq!(forked.metadata().get("ordinary").unwrap(), "keep");
7060 assert!(
7061 !forked.metadata().contains_key(SESSION_BUILD_STATE_KEY),
7062 "forked sessions must not raw-copy durable build-state authority"
7063 );
7064 assert!(
7065 !forked
7066 .metadata()
7067 .contains_key(SESSION_SYSTEM_CONTEXT_STATE_KEY),
7068 "forked sessions must not raw-copy system-context authority state"
7069 );
7070 assert!(
7071 !forked
7072 .metadata()
7073 .contains_key(SESSION_DEFERRED_TURN_STATE_KEY),
7074 "forked sessions must not raw-copy deferred-turn authority state"
7075 );
7076 assert!(
7077 !forked
7078 .metadata()
7079 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY),
7080 "forked sessions must not raw-copy tool-visibility authority state"
7081 );
7082 assert!(
7083 !forked
7084 .metadata()
7085 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
7086 "forked sessions must not raw-copy realtime transcript authority state"
7087 );
7088 }
7089 }
7090
7091 #[test]
7092 fn test_session_metadata() {
7093 let mut session = Session::new();
7094 session.set_metadata("key", serde_json::json!("value"));
7095
7096 assert_eq!(session.metadata().get("key").unwrap(), "value");
7097 }
7098
7099 #[test]
7100 fn session_metadata_realm_id_is_back_read_compatible_string() {
7101 let metadata = SessionMetadata {
7104 schema_version: SESSION_METADATA_SCHEMA_VERSION,
7105 model: "test-model".to_string(),
7106 max_tokens: 1024,
7107 structured_output_retries: 2,
7108 provider: Provider::Other,
7109 self_hosted_server_id: None,
7110 provider_params: None,
7111 tooling: SessionTooling::default(),
7112 keep_alive: false,
7113 comms_name: None,
7114 peer_meta: None,
7115 realm_id: Some(crate::RealmId::parse("env_default").unwrap()),
7116 instance_id: None,
7117 backend: None,
7118 config_generation: None,
7119 auth_binding: None,
7120 mob_member_binding: None,
7121 };
7122 let value = serde_json::to_value(&metadata).unwrap();
7123 assert_eq!(
7124 value.get("realm_id"),
7125 Some(&serde_json::json!("env_default")),
7126 "typed realm_id must serialize as a bare slug string"
7127 );
7128
7129 let legacy = serde_json::json!({
7132 "schema_version": SESSION_METADATA_SCHEMA_VERSION,
7133 "model": "test-model",
7134 "max_tokens": 1024,
7135 "structured_output_retries": 2,
7136 "provider": "other",
7137 "tooling": SessionTooling::default(),
7138 "keep_alive": false,
7139 "comms_name": null,
7140 "realm_id": "legacy_realm",
7141 });
7142 let restored: SessionMetadata = serde_json::from_value(legacy).unwrap();
7143 assert_eq!(
7144 restored.realm_id.as_ref().map(crate::RealmId::as_str),
7145 Some("legacy_realm")
7146 );
7147 }
7148
7149 #[test]
7154 fn session_tooling_tool_access_policy_round_trip_and_absent_default() {
7155 let legacy = serde_json::json!({});
7157 let restored: SessionTooling = serde_json::from_value(legacy).unwrap();
7158 assert_eq!(restored.tool_access_policy, None);
7159
7160 let value = serde_json::to_value(SessionTooling::default()).unwrap();
7162 assert!(
7163 value.get("tool_access_policy").is_none(),
7164 "None policy must not serialize"
7165 );
7166
7167 let tooling = SessionTooling {
7169 tool_access_policy: Some(crate::ops::ToolAccessPolicy::AllowList(
7170 ["read_file", "send_message"].into_iter().collect(),
7171 )),
7172 ..SessionTooling::default()
7173 };
7174 let value = serde_json::to_value(&tooling).unwrap();
7175 let restored: SessionTooling = serde_json::from_value(value).unwrap();
7176 assert_eq!(restored.tool_access_policy, tooling.tool_access_policy);
7177 }
7178
7179 #[test]
7180 fn lifecycle_terminal_typed_round_trip() {
7181 let mut session = Session::new();
7182 assert_eq!(session.lifecycle_terminal(), None);
7183
7184 session
7185 .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
7186 .expect("typed terminal write should serialize");
7187 assert_eq!(
7188 session.lifecycle_terminal(),
7189 Some(SessionLifecycleTerminal::Archived)
7190 );
7191 assert!(
7192 session
7193 .lifecycle_terminal()
7194 .is_some_and(SessionLifecycleTerminal::is_archived)
7195 );
7196 assert_eq!(
7198 session
7199 .metadata()
7200 .get(SESSION_LIFECYCLE_TERMINAL_KEY)
7201 .unwrap(),
7202 &serde_json::json!("archived")
7203 );
7204 }
7205
7206 #[test]
7207 fn lifecycle_terminal_key_rejects_raw_mutation() {
7208 let mut session = Session::new();
7209 assert!(
7210 session
7211 .try_set_metadata(
7212 SESSION_LIFECYCLE_TERMINAL_KEY,
7213 serde_json::json!("archived")
7214 )
7215 .is_err(),
7216 "the typed lifecycle-terminal key is reserved for session authority"
7217 );
7218 }
7219
7220 #[test]
7221 fn test_session_metadata_backfill_preserves_timestamp() {
7222 let mut session = Session::new();
7223 let initial_updated = session.updated_at();
7224
7225 std::thread::sleep(std::time::Duration::from_millis(10));
7226
7227 assert!(session.backfill_metadata_if_absent("key", serde_json::json!("value")));
7228 assert_eq!(session.metadata().get("key").unwrap(), "value");
7229 assert_eq!(session.updated_at(), initial_updated);
7230 assert!(!session.backfill_metadata_if_absent("key", serde_json::json!("other")));
7231 assert_eq!(session.metadata().get("key").unwrap(), "value");
7232 assert_eq!(session.updated_at(), initial_updated);
7233 }
7234
7235 #[test]
7236 fn test_reserved_generated_authority_metadata_rejects_raw_mutation() {
7237 let mut session = Session::new();
7238
7239 assert!(
7240 session
7241 .try_set_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY, serde_json::json!({}))
7242 .is_err()
7243 );
7244 assert!(
7245 session
7246 .try_set_metadata(SESSION_METADATA_KEY, serde_json::json!({}))
7247 .is_err()
7248 );
7249 assert!(
7250 session
7251 .try_set_metadata(SESSION_BUILD_STATE_KEY, serde_json::json!({}))
7252 .is_err()
7253 );
7254 session
7255 .set_session_metadata(SessionMetadata {
7256 schema_version: SESSION_METADATA_SCHEMA_VERSION,
7257 model: "test-model".to_string(),
7258 max_tokens: 1024,
7259 structured_output_retries: 2,
7260 provider: Provider::Other,
7261 self_hosted_server_id: None,
7262 provider_params: None,
7263 tooling: SessionTooling::default(),
7264 keep_alive: false,
7265 comms_name: None,
7266 peer_meta: None,
7267 realm_id: None,
7268 instance_id: None,
7269 backend: None,
7270 config_generation: None,
7271 auth_binding: None,
7272 mob_member_binding: None,
7273 })
7274 .expect("typed metadata setter should route through generated authority");
7275 session
7276 .set_build_state(SessionBuildState::default())
7277 .expect("typed build-state setter should route through generated authority");
7278 session.remove_metadata(SESSION_METADATA_KEY);
7279 session.remove_metadata(SESSION_BUILD_STATE_KEY);
7280 assert!(
7281 session.metadata().contains_key(SESSION_METADATA_KEY),
7282 "raw removal must not delete generated-authority session metadata"
7283 );
7284 assert!(
7285 session.metadata().contains_key(SESSION_BUILD_STATE_KEY),
7286 "raw removal must not delete generated-authority build state"
7287 );
7288 session.set_metadata(SESSION_DEFERRED_TURN_STATE_KEY, serde_json::json!({}));
7289 assert!(
7290 !session
7291 .metadata()
7292 .contains_key(SESSION_DEFERRED_TURN_STATE_KEY)
7293 );
7294 assert!(
7295 !session.backfill_metadata_if_absent(
7296 SESSION_SYSTEM_CONTEXT_STATE_KEY,
7297 serde_json::json!({})
7298 )
7299 );
7300
7301 let state = SessionSystemContextState::default();
7302 session
7303 .set_system_context_state(state.clone())
7304 .expect("typed setter should route through generated authority");
7305 session.remove_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY);
7306 assert_eq!(
7307 session
7308 .try_system_context_state()
7309 .expect("typed state should restore"),
7310 Some(state)
7311 );
7312
7313 session.metadata.insert(
7314 SESSION_SYSTEM_CONTEXT_STATE_KEY.to_string(),
7315 serde_json::json!("not-a-state"),
7316 );
7317 assert!(
7318 session.try_system_context_state().is_err(),
7319 "malformed generated authority state must not decode as absent/default"
7320 );
7321
7322 session.metadata.insert(
7323 SESSION_METADATA_KEY.to_string(),
7324 serde_json::json!("not-metadata"),
7325 );
7326 assert!(
7327 session.try_session_metadata().is_err(),
7328 "malformed session metadata must not decode as absent/default"
7329 );
7330
7331 session.metadata.insert(
7332 SESSION_BUILD_STATE_KEY.to_string(),
7333 serde_json::json!("not-build-state"),
7334 );
7335 assert!(
7336 session.try_build_state().is_err(),
7337 "malformed build state must not decode as absent/default"
7338 );
7339
7340 assert!(
7341 session
7342 .try_set_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY, serde_json::json!({}))
7343 .is_err()
7344 );
7345 session
7346 .set_tool_visibility_state(
7347 AuthorizedSessionToolVisibilityState::from_generated_authority(
7348 SessionToolVisibilityState::default(),
7349 ),
7350 )
7351 .expect("typed visibility setter should route through typed authority handoff");
7352 session.remove_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY);
7353 assert!(
7354 session
7355 .metadata()
7356 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
7357 );
7358 session.clear_tool_visibility_state();
7359 assert!(
7360 !session
7361 .metadata()
7362 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
7363 );
7364 assert!(
7365 session
7366 .try_set_metadata(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, serde_json::json!({}))
7367 .is_err()
7368 );
7369 let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
7370 item_id: "rt-item".to_string(),
7371 previous_item_id: None,
7372 role: RealtimeTranscriptRole::User,
7373 response_id: None,
7374 });
7375 assert!(
7376 session
7377 .metadata()
7378 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
7379 "typed realtime transcript append should retain authority to persist its state"
7380 );
7381 session.metadata.insert(
7382 SESSION_REALTIME_TRANSCRIPT_STATE_KEY.to_string(),
7383 serde_json::json!("not-a-state"),
7384 );
7385 assert!(
7386 session.try_realtime_transcript_state().is_err(),
7387 "malformed realtime generated authority state must not decode as absent/default"
7388 );
7389 }
7390
7391 #[test]
7392 fn test_session_mob_tool_authority_context_persists_projection_without_authority_seal() {
7393 let mut session = Session::new();
7394 session
7395 .set_build_state(SessionBuildState::default())
7396 .expect("session build state should serialize");
7397 let authority = MobToolAuthorityContext::generated_for_test(
7398 crate::service::OpaquePrincipalToken::new("opaque-principal"),
7399 false,
7400 false,
7401 false,
7402 std::collections::BTreeSet::from(["mob-a".to_string()]),
7403 std::collections::BTreeMap::new(),
7404 None,
7405 Some("audit-1".to_string()),
7406 );
7407
7408 session
7409 .set_mob_tool_authority_context(Some(authority))
7410 .expect("authority should serialize");
7411 assert!(session.mob_tool_authority_context().is_none());
7412 let stored = session
7413 .build_state()
7414 .and_then(|state| state.mob_tool_authority_context)
7415 .expect("stored projection should deserialize");
7416 assert!(!stored.is_generated_authority_context());
7417 assert!(!stored.can_manage_mob("mob-a"));
7418
7419 session
7420 .set_mob_tool_authority_context(None)
7421 .expect("authority should clear");
7422 assert!(session.mob_tool_authority_context().is_none());
7423 }
7424
7425 #[test]
7426 fn test_session_build_state_rejects_forged_mob_authority_projection() {
7427 let mut session = Session::new();
7428 let authority = MobToolAuthorityContext::generated_for_test(
7429 crate::service::OpaquePrincipalToken::new("opaque-principal"),
7430 false,
7431 false,
7432 false,
7433 std::collections::BTreeSet::from(["mob-a".to_string()]),
7434 std::collections::BTreeMap::new(),
7435 None,
7436 Some("audit-1".to_string()),
7437 );
7438 let forged_projection: MobToolAuthorityContext =
7439 serde_json::from_value(serde_json::to_value(authority).expect("serialize authority"))
7440 .expect("deserialize projection");
7441 assert!(!forged_projection.is_generated_authority_context());
7442
7443 let err = session
7444 .set_build_state(SessionBuildState {
7445 mob_tool_authority_context: Some(forged_projection),
7446 ..Default::default()
7447 })
7448 .expect_err("forged build state must be rejected by generated authority");
7449 assert!(
7453 err.to_string()
7454 .contains("generated session document authority rejected"),
7455 "unexpected error: {err}"
7456 );
7457 }
7458
7459 #[test]
7460 fn test_session_tool_visibility_state_roundtrip() {
7461 let mut session = Session::new();
7462 let state = SessionToolVisibilityState {
7463 inherited_base_filter: ToolFilter::Allow(["visible".to_string()].into_iter().collect()),
7464 active_filter: ToolFilter::Allow(
7465 ["visible".to_string(), "missing".to_string()]
7466 .into_iter()
7467 .collect(),
7468 ),
7469 staged_filter: ToolFilter::Allow(
7470 ["visible".to_string(), "missing".to_string()]
7471 .into_iter()
7472 .collect(),
7473 ),
7474 active_revision: 1,
7475 staged_revision: 2,
7476 ..Default::default()
7477 };
7478
7479 session
7480 .set_tool_visibility_state(
7481 AuthorizedSessionToolVisibilityState::from_generated_authority(state.clone()),
7482 )
7483 .expect("tool visibility state should serialize");
7484 assert_eq!(session.tool_visibility_state().unwrap(), Some(state));
7485 }
7486
7487 #[test]
7488 fn test_session_tool_visibility_state_malformed_returns_error() {
7489 let mut session = Session::new();
7490 session.metadata.insert(
7491 SESSION_TOOL_VISIBILITY_STATE_KEY.to_string(),
7492 serde_json::json!({
7493 "active_filter": {
7494 "unexpected_filter_kind": ["secret"]
7495 }
7496 }),
7497 );
7498
7499 assert!(
7500 session.tool_visibility_state().is_err(),
7501 "malformed canonical visibility metadata must not decode as absent/default"
7502 );
7503 }
7504
7505 #[test]
7506 fn test_session_serialization() {
7507 let mut session = Session::new();
7508 session.push(Message::User(UserMessage::text("Test".to_string())));
7509
7510 let json = serde_json::to_string(&session).unwrap();
7511 let parsed: Session = serde_json::from_str(&json).unwrap();
7512
7513 assert_eq!(parsed.id(), session.id());
7514 assert_eq!(parsed.messages().len(), 1);
7515 assert_eq!(parsed.version(), SESSION_VERSION);
7516 }
7517
7518 #[test]
7519 fn test_session_meta_from_session() {
7520 let mut session = Session::new();
7521 session.push(Message::User(UserMessage::text("Hello".to_string())));
7522 session.push(Message::BlockAssistant(BlockAssistantMessage {
7523 blocks: vec![AssistantBlock::Text {
7524 text: "Hi!".to_string(),
7525 meta: None,
7526 }],
7527 stop_reason: StopReason::EndTurn,
7528 identity: crate::types::TranscriptMessageIdentity::default(),
7529 created_at: crate::types::message_timestamp_now(),
7530 }));
7531 session.record_usage(Usage {
7532 input_tokens: 10,
7533 output_tokens: 5,
7534 cache_creation_tokens: None,
7535 cache_read_tokens: None,
7536 });
7537
7538 let meta = SessionMeta::from(&session);
7539 assert_eq!(meta.id, *session.id());
7540 assert_eq!(meta.message_count, 2);
7541 assert_eq!(meta.total_tokens, 15);
7542 }
7543
7544 #[test]
7545 fn system_context_state_preserves_applied_runtime_context() {
7546 let accepted_at = SystemTime::UNIX_EPOCH;
7547 let mut state = SessionSystemContextState::default();
7548 state
7549 .stage_append(
7550 &AppendSystemContextRequest {
7551 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7552 "Authoritative peer token is birch seventeen.".to_string(),
7553 ),
7554 source: Some(
7555 "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1"
7556 .to_string(),
7557 ),
7558 idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
7559 source_kind: SystemContextSource::Normal,
7560 peer_response_terminal: None,
7561 },
7562 accepted_at,
7563 )
7564 .expect("append should stage");
7565
7566 state.mark_pending_applied();
7567
7568 assert!(state.pending.is_empty());
7569 assert_eq!(state.applied.len(), 1);
7570 assert_eq!(
7571 state.applied[0].content.render_text(),
7572 "Authoritative peer token is birch seventeen."
7573 );
7574 assert_eq!(
7575 state.applied[0].source.as_deref(),
7576 Some("peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1")
7577 );
7578
7579 let round_tripped: SessionSystemContextState =
7580 serde_json::from_value(serde_json::to_value(&state).expect("serialize state"))
7581 .expect("deserialize state");
7582 assert_eq!(round_tripped.applied, state.applied);
7583 }
7584
7585 #[test]
7586 fn active_turn_system_context_is_discarded_when_not_applied() {
7587 let mut state = SessionSystemContextState::default();
7588 state
7589 .stage_active_turn_append(
7590 &AppendSystemContextRequest {
7591 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7592 "only for the active run".to_string(),
7593 ),
7594 source: Some("runtime:steer:input-1".to_string()),
7595 idempotency_key: Some("runtime:steer:input-1".to_string()),
7596 source_kind: SystemContextSource::RuntimeSteer,
7597 peer_response_terminal: None,
7598 },
7599 SystemTime::UNIX_EPOCH,
7600 )
7601 .expect("active context should stage");
7602
7603 let discarded = state.discard_unapplied_active_turn_pending();
7604
7605 assert_eq!(discarded.len(), 1);
7606 assert!(state.pending.is_empty());
7607 assert!(state.applied.is_empty());
7608 assert!(state.active_turn_pending_keys.is_empty());
7609 assert!(
7610 state.seen.is_empty(),
7611 "discarded active-turn context should not block later idempotency keys"
7612 );
7613 }
7614
7615 #[test]
7616 fn active_turn_system_context_can_roll_back_targeted_keys() {
7617 let mut state = SessionSystemContextState::default();
7618 for key in ["runtime:steer:input-1", "runtime:steer:input-2"] {
7619 state
7620 .stage_active_turn_append(
7621 &AppendSystemContextRequest {
7622 content: crate::lifecycle::run_primitive::CoreRenderable::text(format!(
7623 "context for {key}"
7624 )),
7625 source: Some(key.to_string()),
7626 idempotency_key: Some(key.to_string()),
7627 source_kind: SystemContextSource::RuntimeSteer,
7628 peer_response_terminal: None,
7629 },
7630 SystemTime::UNIX_EPOCH,
7631 )
7632 .expect("active context should stage");
7633 }
7634
7635 let discarded =
7636 state.discard_active_turn_pending_by_keys(&["runtime:steer:input-1".to_string()]);
7637
7638 assert_eq!(discarded.len(), 1);
7639 assert_eq!(
7640 discarded[0].idempotency_key.as_deref(),
7641 Some("runtime:steer:input-1")
7642 );
7643 assert_eq!(state.pending.len(), 1);
7644 assert_eq!(
7645 state.pending[0].idempotency_key.as_deref(),
7646 Some("runtime:steer:input-2")
7647 );
7648 assert!(!state.seen.contains_key("runtime:steer:input-1"));
7649 assert!(state.seen.contains_key("runtime:steer:input-2"));
7650 assert!(
7651 !state
7652 .active_turn_pending_keys
7653 .contains("runtime:steer:input-1")
7654 );
7655 assert!(
7656 state
7657 .active_turn_pending_keys
7658 .contains("runtime:steer:input-2")
7659 );
7660 }
7661
7662 #[test]
7663 fn active_turn_system_context_is_transient_when_boundary_consumes_it() {
7664 let mut state = SessionSystemContextState::default();
7665 state
7666 .stage_active_turn_append(
7667 &AppendSystemContextRequest {
7668 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7669 "visible to this run".to_string(),
7670 ),
7671 source: Some("runtime:steer:input-2".to_string()),
7672 idempotency_key: Some("runtime:steer:input-2".to_string()),
7673 source_kind: SystemContextSource::RuntimeSteer,
7674 peer_response_terminal: None,
7675 },
7676 SystemTime::UNIX_EPOCH,
7677 )
7678 .expect("active context should stage");
7679
7680 state.mark_pending_applied();
7681 let discarded = state.discard_unapplied_active_turn_pending();
7682
7683 assert!(discarded.is_empty());
7684 assert!(state.pending.is_empty());
7685 assert!(state.applied.is_empty());
7686 assert!(state.active_turn_pending_keys.is_empty());
7687 assert_eq!(
7688 state.seen.get("runtime:steer:input-2"),
7689 None,
7690 "consumed active-turn steer context must not become durable state"
7691 );
7692 }
7693
7694 #[test]
7695 fn discard_transient_runtime_steer_context_removes_steer_via_typed_marker() {
7696 let mut session = Session::new();
7697 session.set_system_prompt(format!(
7701 "base{}{}{}{}",
7702 SYSTEM_CONTEXT_SEPARATOR,
7703 render_system_context_block(&PendingSystemContextAppend {
7704 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7705 "old steer".to_string()
7706 ),
7707 source: Some("steer-source-old".to_string()),
7708 idempotency_key: Some("steer-key-old".to_string()),
7709 source_kind: SystemContextSource::RuntimeSteer,
7710 peer_response_terminal: None,
7711 accepted_at: SystemTime::UNIX_EPOCH,
7712 }),
7713 SYSTEM_CONTEXT_SEPARATOR,
7714 render_system_context_block(&PendingSystemContextAppend {
7715 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7716 "durable peer fact".to_string()
7717 ),
7718 source: Some("peer_response_terminal:analyst:req".to_string()),
7719 idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
7720 source_kind: SystemContextSource::Normal,
7721 peer_response_terminal: None,
7722 accepted_at: SystemTime::UNIX_EPOCH,
7723 })
7724 ));
7725 session
7726 .set_system_context_state(SessionSystemContextState {
7727 pending: vec![PendingSystemContextAppend {
7728 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7729 "pending steer".to_string(),
7730 ),
7731 source: Some("steer-source-pending".to_string()),
7732 idempotency_key: Some("steer-key-pending".to_string()),
7733 source_kind: SystemContextSource::RuntimeSteer,
7734 peer_response_terminal: None,
7735 accepted_at: SystemTime::UNIX_EPOCH,
7736 }],
7737 applied: vec![
7738 PendingSystemContextAppend {
7739 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7740 "old steer".to_string(),
7741 ),
7742 source: Some("steer-source-old".to_string()),
7743 idempotency_key: Some("steer-key-old".to_string()),
7744 source_kind: SystemContextSource::RuntimeSteer,
7745 peer_response_terminal: None,
7746 accepted_at: SystemTime::UNIX_EPOCH,
7747 },
7748 PendingSystemContextAppend {
7749 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7750 "durable peer fact".to_string(),
7751 ),
7752 source: Some("peer_response_terminal:analyst:req".to_string()),
7753 idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
7754 source_kind: SystemContextSource::Normal,
7755 peer_response_terminal: None,
7756 accepted_at: SystemTime::UNIX_EPOCH,
7757 },
7758 ],
7759 seen: BTreeMap::from([(
7760 "steer-key-old".to_string(),
7761 SeenSystemContextKey {
7762 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7763 "old steer".to_string(),
7764 ),
7765 source: Some("steer-source-old".to_string()),
7766 source_kind: SystemContextSource::RuntimeSteer,
7767 state: SeenSystemContextState::Applied,
7768 },
7769 )]),
7770 active_turn_pending_keys: BTreeSet::from(["steer-key-pending".to_string()]),
7771 })
7772 .expect("system context state should serialize");
7773
7774 let removed = session.discard_transient_runtime_steer_context();
7775
7776 assert!(removed >= 4);
7777 let system_prompt = match session.messages().first() {
7778 Some(Message::System(system)) => system.content.as_str(),
7779 other => panic!("expected system prompt, got {other:?}"),
7780 };
7781 assert!(!system_prompt.contains("old steer"));
7782 assert!(system_prompt.contains("durable peer fact"));
7783 let state = session.system_context_state().unwrap_or_default();
7784 assert!(state.pending.is_empty());
7785 assert_eq!(state.applied.len(), 1);
7786 assert_eq!(state.applied[0].content.render_text(), "durable peer fact");
7787 assert!(state.seen.is_empty());
7788 assert!(state.active_turn_pending_keys.is_empty());
7789 }
7790
7791 #[test]
7792 fn append_system_context_blocks_records_typed_applied_context() {
7793 let append = PendingSystemContextAppend {
7794 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7795 "Authoritative peer token is birch seventeen.".to_string(),
7796 ),
7797 source: Some(
7798 "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string(),
7799 ),
7800 idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
7801 source_kind: SystemContextSource::Normal,
7802 peer_response_terminal: None,
7803 accepted_at: SystemTime::UNIX_EPOCH,
7804 };
7805 let mut session = Session::new();
7806
7807 session.append_system_context_blocks(std::slice::from_ref(&append));
7808
7809 let state = session
7810 .system_context_state()
7811 .expect("append should persist typed context state");
7812 assert_eq!(state.applied, vec![append]);
7813 }
7814
7815 fn roster_append() -> PendingSystemContextAppend {
7816 PendingSystemContextAppend {
7817 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7818 "peer roster: lead-1, w-1".to_string(),
7819 ),
7820 source: Some("comms:roster".to_string()),
7821 idempotency_key: Some("comms:roster:v1".to_string()),
7822 source_kind: SystemContextSource::Normal,
7823 peer_response_terminal: None,
7824 accepted_at: SystemTime::UNIX_EPOCH,
7825 }
7826 }
7827
7828 fn resumed_session_with_context_appended_prompt(base: &str) -> Session {
7829 let mut session = Session::new();
7830 session.set_system_prompt(base.to_string());
7831 session.push(Message::User(UserMessage::text("hello".to_string())));
7832 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
7833 session
7834 }
7835
7836 #[test]
7837 fn reconcile_resumed_system_prompt_preserves_identical_base() {
7838 let mut session = Session::new();
7839 session.set_system_prompt("base prompt".to_string());
7840 session.push(Message::User(UserMessage::text("hello".to_string())));
7841 let digest_before = transcript_messages_digest(session.messages()).unwrap();
7842
7843 let outcome = session
7844 .reconcile_resumed_system_prompt("base prompt".to_string(), None)
7845 .expect("reconcile");
7846
7847 assert_eq!(
7848 outcome,
7849 ResumedSystemPromptReconciliation::PreservedContinuation
7850 );
7851 assert_eq!(
7852 transcript_messages_digest(session.messages()).unwrap(),
7853 digest_before,
7854 "identical base must leave the transcript revision unchanged"
7855 );
7856 }
7857
7858 #[test]
7859 fn reconcile_resumed_system_prompt_preserves_context_appended_base() {
7860 let mut session = resumed_session_with_context_appended_prompt("base prompt");
7861 let digest_before = transcript_messages_digest(session.messages()).unwrap();
7862
7863 let outcome = session
7864 .reconcile_resumed_system_prompt("base prompt".to_string(), None)
7865 .expect("reconcile");
7866
7867 assert_eq!(
7868 outcome,
7869 ResumedSystemPromptReconciliation::PreservedContinuation
7870 );
7871 assert_eq!(
7872 transcript_messages_digest(session.messages()).unwrap(),
7873 digest_before,
7874 "a base extended only by runtime context appends must stay untouched"
7875 );
7876 let system = match session.messages().first() {
7877 Some(Message::System(system)) => system.clone(),
7878 other => panic!("expected system message, got {other:?}"),
7879 };
7880 assert!(system.content.contains("peer roster: lead-1, w-1"));
7881 assert!(
7882 system.mutation_kind.is_runtime_context_append(),
7883 "the persisted mutation provenance must survive reconciliation"
7884 );
7885 }
7886
7887 #[test]
7888 fn reconcile_resumed_system_prompt_rewrites_changed_base_preserving_tail() {
7889 let mut session = resumed_session_with_context_appended_prompt("base prompt");
7890
7891 let outcome = session
7892 .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
7893 .expect("reconcile");
7894
7895 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
7896 let system_content = match session.messages().first() {
7897 Some(Message::System(system)) => system.content.clone(),
7898 other => panic!("expected system message, got {other:?}"),
7899 };
7900 assert!(
7901 system_content.starts_with("new base prompt"),
7902 "the changed base must be applied: {system_content}"
7903 );
7904 assert!(
7905 system_content.contains("peer roster: lead-1, w-1"),
7906 "the runtime-applied context tail must survive the base change: {system_content}"
7907 );
7908 let state = session
7909 .transcript_history_state()
7910 .expect("history state deserializes")
7911 .expect("rewrite must record transcript history");
7912 assert_eq!(state.commits.len(), 1);
7913 assert_eq!(
7914 state.commits[0].reason.kind,
7915 RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON
7916 );
7917 assert_eq!(
7918 state.head,
7919 transcript_messages_digest(session.messages()).unwrap(),
7920 "the committed head must match the rewritten transcript"
7921 );
7922 }
7923
7924 #[test]
7925 fn reconcile_resumed_system_prompt_inserts_prompt_on_promptless_transcript() {
7926 let mut session = Session::new();
7927 session.push(Message::User(UserMessage::text("hello".to_string())));
7928
7929 let outcome = session
7930 .reconcile_resumed_system_prompt("late prompt".to_string(), None)
7931 .expect("reconcile");
7932
7933 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
7934 assert!(matches!(
7935 session.messages().first(),
7936 Some(Message::System(system)) if system.content == "late prompt"
7937 ));
7938 let state = session
7939 .transcript_history_state()
7940 .expect("history state deserializes")
7941 .expect("insert must record transcript history");
7942 assert_eq!(state.commits.len(), 1);
7943 assert_eq!(
7944 state.commits[0].reason.kind,
7945 RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON
7946 );
7947 }
7948
7949 fn leading_system_content(session: &Session) -> String {
7950 match session.messages().first() {
7951 Some(Message::System(system)) => system.content.clone(),
7952 other => panic!("expected leading system message, got {other:?}"),
7953 }
7954 }
7955
7956 #[test]
7957 fn reconcile_resumed_system_prompt_preserves_full_context_prompt_from_empty_base() {
7958 let mut session = Session::new();
7963 session.push(Message::User(UserMessage::text("hello".to_string())));
7964 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
7965 let all_context_content = leading_system_content(&session);
7966 assert!(all_context_content.contains("peer roster: lead-1, w-1"));
7967
7968 let outcome = session
7969 .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
7970 .expect("reconcile");
7971
7972 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
7973 assert_eq!(
7974 leading_system_content(&session),
7975 format!("new base prompt{SYSTEM_CONTEXT_SEPARATOR}{all_context_content}"),
7976 "the all-context prompt must survive as the runtime tail of the new base"
7977 );
7978 }
7979
7980 #[test]
7981 fn reconcile_resumed_system_prompt_preserves_context_only_prompt_on_empty_base_resume() {
7982 let mut session = Session::new();
7985 session.push(Message::User(UserMessage::text("hello".to_string())));
7986 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
7987 let digest_before = transcript_messages_digest(session.messages()).unwrap();
7988
7989 let outcome = session
7990 .reconcile_resumed_system_prompt(String::new(), None)
7991 .expect("reconcile");
7992
7993 assert_eq!(
7994 outcome,
7995 ResumedSystemPromptReconciliation::PreservedContinuation
7996 );
7997 assert_eq!(
7998 transcript_messages_digest(session.messages()).unwrap(),
7999 digest_before
8000 );
8001 }
8002
8003 #[test]
8004 fn reconcile_resumed_system_prompt_applies_shortened_base_with_recorded_prior() {
8005 let full_base = format!("part one{SYSTEM_CONTEXT_SEPARATOR}part two");
8009 let mut session = Session::new();
8010 session.set_system_prompt(full_base.clone());
8011 session.push(Message::User(UserMessage::text("hello".to_string())));
8012 session
8013 .set_build_state(SessionBuildState {
8014 assembled_system_prompt: Some(full_base),
8015 ..Default::default()
8016 })
8017 .expect("build state");
8018
8019 let outcome = session
8020 .reconcile_resumed_system_prompt("part one".to_string(), None)
8021 .expect("reconcile");
8022
8023 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
8024 assert_eq!(leading_system_content(&session), "part one");
8025 }
8026
8027 #[test]
8028 fn reconcile_resumed_system_prompt_applies_shortened_base_without_context_provenance() {
8029 let full_base = format!("part one{SYSTEM_CONTEXT_SEPARATOR}part two");
8034 let mut session = Session::new();
8035 session.set_system_prompt(full_base);
8036 session.push(Message::User(UserMessage::text("hello".to_string())));
8037
8038 let outcome = session
8039 .reconcile_resumed_system_prompt("part one".to_string(), None)
8040 .expect("reconcile");
8041
8042 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
8043 assert_eq!(leading_system_content(&session), "part one");
8044 }
8045
8046 #[test]
8047 fn reconcile_resumed_system_prompt_preserves_appended_prompt_without_applied_records() {
8048 let mut session = resumed_session_with_context_appended_prompt("base prompt");
8053 session
8054 .set_system_context_state(SessionSystemContextState::default())
8055 .expect("sweep applied records");
8056 let digest_before = transcript_messages_digest(session.messages()).unwrap();
8057
8058 let outcome = session
8059 .reconcile_resumed_system_prompt("base prompt".to_string(), None)
8060 .expect("reconcile");
8061
8062 assert_eq!(
8063 outcome,
8064 ResumedSystemPromptReconciliation::PreservedContinuation
8065 );
8066 assert_eq!(
8067 transcript_messages_digest(session.messages()).unwrap(),
8068 digest_before
8069 );
8070 }
8071
8072 #[test]
8073 fn reconcile_resumed_system_prompt_clears_orphaned_applied_records_on_tail_drop() {
8074 let mut session = resumed_session_with_context_appended_prompt("base prompt");
8075 session.set_system_prompt(format!(
8080 "mutated base{SYSTEM_CONTEXT_SEPARATOR}stale-looking tail"
8081 ));
8082
8083 let outcome = session
8084 .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
8085 .expect("reconcile");
8086
8087 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
8088 assert_eq!(leading_system_content(&session), "new base prompt");
8089 let state = session.system_context_state().unwrap_or_default();
8090 assert!(
8091 state.applied.is_empty(),
8092 "orphaned applied records must be cleared so the context stays restorable"
8093 );
8094 assert!(
8095 state.seen.is_empty(),
8096 "orphaned idempotency keys must be cleared so keyed re-sends re-apply"
8097 );
8098
8099 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
8102 assert!(
8103 leading_system_content(&session).contains("peer roster: lead-1, w-1"),
8104 "re-sent keyed context must re-apply after the drop"
8105 );
8106 }
8107
8108 #[test]
8109 fn append_system_context_blocks_renders_pre_marked_pending_context() {
8110 let accepted_at = SystemTime::UNIX_EPOCH;
8111 let mut state = SessionSystemContextState::default();
8112 state
8113 .stage_append(
8114 &AppendSystemContextRequest {
8115 content: crate::lifecycle::run_primitive::CoreRenderable::text(
8116 "Apply this staged context at the request boundary.".to_string(),
8117 ),
8118 source: Some("rpc/session_inject_context".to_string()),
8119 idempotency_key: Some("ctx-boundary".to_string()),
8120 source_kind: SystemContextSource::Normal,
8121 peer_response_terminal: None,
8122 },
8123 accepted_at,
8124 )
8125 .expect("append should stage");
8126 let pending = state.pending.clone();
8127 state.mark_pending_applied();
8128 let mut session = Session::new();
8129 session
8130 .set_system_context_state(state)
8131 .expect("state should serialize");
8132
8133 session.append_system_context_blocks(&pending);
8134
8135 let system_prompt = session
8136 .messages()
8137 .first()
8138 .and_then(|message| match message {
8139 Message::System(system) => Some(system.content.as_str()),
8140 _ => None,
8141 })
8142 .unwrap_or_default();
8143 assert!(system_prompt.contains("Apply this staged context at the request boundary."));
8144 let state = session
8145 .system_context_state()
8146 .expect("append should persist typed context state");
8147 assert_eq!(state.applied.len(), 1);
8148 assert_eq!(
8149 state.seen["ctx-boundary"].state,
8150 SeenSystemContextState::Applied
8151 );
8152 }
8153
8154 #[test]
8155 fn append_system_context_blocks_renders_pre_marked_context_without_idempotency_key() {
8156 let accepted_at = SystemTime::UNIX_EPOCH;
8157 let mut state = SessionSystemContextState::default();
8158 state
8159 .stage_append(
8160 &AppendSystemContextRequest {
8161 content: crate::lifecycle::run_primitive::CoreRenderable::text(
8162 "Apply this unkeyed staged context at the request boundary.".to_string(),
8163 ),
8164 source: Some("rpc/session_inject_context".to_string()),
8165 idempotency_key: None,
8166 source_kind: SystemContextSource::Normal,
8167 peer_response_terminal: None,
8168 },
8169 accepted_at,
8170 )
8171 .expect("append should stage");
8172 let pending = state.pending.clone();
8173 state.mark_pending_applied();
8174 let mut session = Session::new();
8175 session
8176 .set_system_context_state(state)
8177 .expect("state should serialize");
8178
8179 session.append_system_context_blocks(&pending);
8180
8181 let system_prompt = session
8182 .messages()
8183 .first()
8184 .and_then(|message| match message {
8185 Message::System(system) => Some(system.content.as_str()),
8186 _ => None,
8187 })
8188 .unwrap_or_default();
8189 assert!(
8190 system_prompt.contains("Apply this unkeyed staged context at the request boundary.")
8191 );
8192 }
8193
8194 #[test]
8198 fn staged_system_context_carries_typed_renderable_to_render_seam() {
8199 use crate::lifecycle::run_primitive::CoreRenderable;
8200
8201 let accepted_at = SystemTime::UNIX_EPOCH;
8202 let mut state = SessionSystemContextState::default();
8203 let renderable = CoreRenderable::Json {
8204 value: serde_json::json!({"alert": "disk-full", "severity": 2}),
8205 };
8206 state
8207 .stage_append(
8208 &AppendSystemContextRequest {
8209 content: renderable.clone(),
8210 source: Some("ops/monitor".to_string()),
8211 idempotency_key: Some("alert-1".to_string()),
8212 source_kind: SystemContextSource::Normal,
8213 peer_response_terminal: None,
8214 },
8215 accepted_at,
8216 )
8217 .expect("typed renderable append should stage");
8218
8219 assert_eq!(state.pending.len(), 1);
8222 assert_eq!(state.pending[0].content, renderable);
8223
8224 let rendered = render_system_context_block(&state.pending[0]);
8227 assert!(rendered.starts_with(SYSTEM_CONTEXT_RENDER_LABEL));
8228 assert!(
8229 rendered.contains(renderable.render_text().trim()),
8230 "render seam must lower via CoreRenderable::render_text: {rendered}"
8231 );
8232 }
8233
8234 #[test]
8235 fn append_system_context_blocks_skips_duplicate_idempotency_key() {
8236 let first = PendingSystemContextAppend {
8237 content: crate::lifecycle::run_primitive::CoreRenderable::text(
8238 "Authoritative peer token is birch seventeen.".to_string(),
8239 ),
8240 source: Some("peer_response_terminal:analyst:req-1".to_string()),
8241 idempotency_key: Some("req-1".to_string()),
8242 source_kind: SystemContextSource::Normal,
8243 peer_response_terminal: None,
8244 accepted_at: SystemTime::UNIX_EPOCH,
8245 };
8246 let duplicate = PendingSystemContextAppend {
8247 accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
8248 ..first.clone()
8249 };
8250 let mut session = Session::new();
8251
8252 session.append_system_context_blocks(std::slice::from_ref(&first));
8253 session.append_system_context_blocks(std::slice::from_ref(&duplicate));
8254
8255 let state = session
8256 .system_context_state()
8257 .expect("append should persist typed context state");
8258 assert_eq!(state.applied, vec![first]);
8259 let system_prompt = session
8260 .messages()
8261 .first()
8262 .and_then(|message| match message {
8263 Message::System(system) => Some(system.content.as_str()),
8264 _ => None,
8265 })
8266 .unwrap_or_default();
8267 assert_eq!(
8268 system_prompt
8269 .matches("Authoritative peer token is birch seventeen.")
8270 .count(),
8271 1
8272 );
8273 }
8274
8275 #[test]
8276 fn append_system_context_blocks_skips_conflicting_duplicate_idempotency_key() {
8277 let first = PendingSystemContextAppend {
8278 content: crate::lifecycle::run_primitive::CoreRenderable::text(
8279 "Authoritative peer token is birch seventeen.".to_string(),
8280 ),
8281 source: Some("peer_response_terminal:analyst:req-1".to_string()),
8282 idempotency_key: Some("req-1".to_string()),
8283 source_kind: SystemContextSource::Normal,
8284 peer_response_terminal: None,
8285 accepted_at: SystemTime::UNIX_EPOCH,
8286 };
8287 let conflicting = PendingSystemContextAppend {
8288 content: crate::lifecycle::run_primitive::CoreRenderable::text(
8289 "Conflicting peer token should not reach the prompt.".to_string(),
8290 ),
8291 accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
8292 ..first.clone()
8293 };
8294 let mut session = Session::new();
8295
8296 session.append_system_context_blocks(std::slice::from_ref(&first));
8297 session.append_system_context_blocks(std::slice::from_ref(&conflicting));
8298
8299 let state = session
8300 .system_context_state()
8301 .expect("append should persist typed context state");
8302 assert_eq!(state.applied, vec![first]);
8303 let system_prompt = session
8304 .messages()
8305 .first()
8306 .and_then(|message| match message {
8307 Message::System(system) => Some(system.content.as_str()),
8308 _ => None,
8309 })
8310 .unwrap_or_default();
8311 assert!(system_prompt.contains("Authoritative peer token is birch seventeen."));
8312 assert!(!system_prompt.contains("Conflicting peer token should not reach the prompt."));
8313 }
8314
8315 #[test]
8327 fn realtime_transcript_assistant_transcript_delta_materializes_transcript_block() {
8328 let mut session = Session::new();
8329
8330 let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
8331 response_id: "resp_spoken".to_string(),
8332 delta_id: "evt_delta_spoken_1".to_string(),
8333 item_id: "item_spoken".to_string(),
8334 previous_item_id: None,
8335 content_index: 0,
8336 delta: "I said hi".to_string(),
8337 };
8338 assert!(
8339 session.append_realtime_transcript_event(delta).is_inert(),
8340 "delta alone is inert until turn-completed flushes"
8341 );
8342
8343 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
8344 response_id: "resp_spoken".to_string(),
8345 stop_reason: StopReason::EndTurn,
8346 usage: Usage::default(),
8347 };
8348 let outcome = session.append_realtime_transcript_event(terminal);
8349 assert_eq!(outcome.materialized_messages.len(), 1);
8350
8351 let messages = session.messages();
8353 assert_eq!(messages.len(), 1);
8354 match &messages[0] {
8355 Message::BlockAssistant(assistant) => {
8356 assert_eq!(assistant.blocks.len(), 1);
8357 match &assistant.blocks[0] {
8358 AssistantBlock::Transcript { text, source, .. } => {
8359 assert_eq!(text, "I said hi");
8360 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
8361 }
8362 other => unreachable!(
8363 "AssistantTranscriptDelta must materialize as AssistantBlock::Transcript, got {other:?}"
8364 ),
8365 }
8366 }
8367 other => unreachable!("expected BlockAssistant message, got {other:?}"),
8368 }
8369 }
8370
8371 #[test]
8372 fn round4_cc4_in_flight_response_ids_lists_distinct_unmaterialized_responses() {
8373 let mut session = Session::new();
8379
8380 for (i, response_id) in [
8384 ("resp_a", "resp_a"),
8385 ("resp_a_extra", "resp_a"),
8386 ("resp_b", "resp_b"),
8387 ("resp_c", "resp_c"),
8388 ]
8389 .iter()
8390 .enumerate()
8391 {
8392 let event = RealtimeTranscriptEvent::AssistantTranscriptDelta {
8393 response_id: response_id.1.to_string(),
8394 delta_id: format!("delta_{i}"),
8395 item_id: response_id.0.to_string(),
8396 previous_item_id: None,
8397 content_index: 0,
8398 delta: "x".to_string(),
8399 };
8400 let _ = session.append_realtime_transcript_event(event);
8401 }
8402
8403 let _ = session.append_realtime_transcript_event(
8405 RealtimeTranscriptEvent::AssistantTurnInterrupted {
8406 response_id: "resp_c".to_string(),
8407 },
8408 );
8409
8410 let _ = session.append_realtime_transcript_event(
8413 RealtimeTranscriptEvent::UserTranscriptFinal {
8414 item_id: "u_item".to_string(),
8415 previous_item_id: None,
8416 content_index: 0,
8417 text: "hi".to_string(),
8418 },
8419 );
8420
8421 let in_flight = session.in_flight_realtime_assistant_response_ids();
8422 assert!(in_flight.contains(&"resp_a".to_string()), "{in_flight:?}");
8423 assert!(in_flight.contains(&"resp_b".to_string()), "{in_flight:?}");
8424 assert!(
8425 !in_flight.contains(&"resp_c".to_string()),
8426 "discarded response must not appear in in_flight: {in_flight:?}"
8427 );
8428 assert_eq!(
8430 in_flight.iter().filter(|r| *r == "resp_a").count(),
8431 1,
8432 "distinct response_ids only: {in_flight:?}"
8433 );
8434 }
8435
8436 #[test]
8437 fn round4_cc2_assistant_turn_completed_after_transcript_deltas_materializes_transcript() {
8438 let mut session = Session::new();
8445
8446 let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
8447 response_id: "resp_cc2".to_string(),
8448 delta_id: "delta_cc2_1".to_string(),
8449 item_id: "item_cc2".to_string(),
8450 previous_item_id: None,
8451 content_index: 0,
8452 delta: "hello world".to_string(),
8453 };
8454 assert!(session.append_realtime_transcript_event(delta).is_inert());
8455
8456 assert_eq!(
8458 session.in_flight_realtime_assistant_response_ids(),
8459 vec!["resp_cc2".to_string()]
8460 );
8461
8462 let outcome = session.append_realtime_transcript_event(
8463 RealtimeTranscriptEvent::AssistantTurnCompleted {
8464 response_id: "resp_cc2".to_string(),
8465 stop_reason: StopReason::EndTurn,
8466 usage: Usage::default(),
8467 },
8468 );
8469 assert_eq!(outcome.materialized_messages.len(), 1);
8470
8471 assert!(
8473 session
8474 .in_flight_realtime_assistant_response_ids()
8475 .is_empty(),
8476 "materialized items must not appear in in_flight_realtime_assistant_response_ids"
8477 );
8478
8479 let messages = session.messages();
8480 let assistant = messages.iter().find_map(|m| match m {
8481 Message::BlockAssistant(a) => Some(a),
8482 _ => None,
8483 });
8484 let assistant = assistant.expect("assistant block message expected");
8485 assert_eq!(assistant.blocks.len(), 1);
8486 assert!(matches!(
8487 &assistant.blocks[0],
8488 AssistantBlock::Transcript {
8489 source: crate::types::TranscriptSource::Spoken,
8490 ..
8491 }
8492 ));
8493 }
8494
8495 #[test]
8496 fn realtime_transcript_assistant_text_delta_still_materializes_text_block() {
8497 let mut session = Session::new();
8501
8502 let delta = RealtimeTranscriptEvent::AssistantTextDelta {
8503 response_id: "resp_display".to_string(),
8504 delta_id: "evt_delta_display_1".to_string(),
8505 item_id: "item_display".to_string(),
8506 previous_item_id: None,
8507 content_index: 0,
8508 delta: "I wrote".to_string(),
8509 };
8510 let _ = session.append_realtime_transcript_event(delta);
8511
8512 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
8513 response_id: "resp_display".to_string(),
8514 stop_reason: StopReason::EndTurn,
8515 usage: Usage::default(),
8516 };
8517 let outcome = session.append_realtime_transcript_event(terminal);
8518 assert_eq!(outcome.materialized_messages.len(), 1);
8519
8520 let messages = session.messages();
8521 match &messages[0] {
8522 Message::BlockAssistant(assistant) => match &assistant.blocks[0] {
8523 AssistantBlock::Text { text, .. } => assert_eq!(text, "I wrote"),
8524 other => unreachable!(
8525 "AssistantTextDelta must keep materializing AssistantBlock::Text, got {other:?}"
8526 ),
8527 },
8528 other => unreachable!("expected BlockAssistant message, got {other:?}"),
8529 }
8530 }
8531
8532 #[test]
8533 fn round4_cc7_mixed_response_persists_text_and_transcript_in_order() {
8534 let mut session = Session::new();
8552
8553 let display_a = RealtimeTranscriptEvent::AssistantTextDelta {
8555 response_id: "resp_mixed_1".to_string(),
8556 delta_id: "delta_disp_1".to_string(),
8557 item_id: "item_display".to_string(),
8558 previous_item_id: None,
8559 content_index: 0,
8560 delta: "Here's the report:".to_string(),
8561 };
8562 assert!(
8563 session
8564 .append_realtime_transcript_event(display_a)
8565 .is_inert()
8566 );
8567
8568 let display_b = RealtimeTranscriptEvent::AssistantTextDelta {
8569 response_id: "resp_mixed_1".to_string(),
8570 delta_id: "delta_disp_2".to_string(),
8571 item_id: "item_display".to_string(),
8572 previous_item_id: None,
8573 content_index: 0,
8574 delta: " (still writing)".to_string(),
8575 };
8576 assert!(
8577 session
8578 .append_realtime_transcript_event(display_b)
8579 .is_inert()
8580 );
8581
8582 let spoken_a = RealtimeTranscriptEvent::AssistantTranscriptDelta {
8587 response_id: "resp_mixed_1".to_string(),
8588 delta_id: "delta_spoken_1".to_string(),
8589 item_id: "item_spoken".to_string(),
8590 previous_item_id: Some("item_display".to_string()),
8591 content_index: 0,
8592 delta: "I'm reading the report aloud:".to_string(),
8593 };
8594 assert!(
8595 session
8596 .append_realtime_transcript_event(spoken_a)
8597 .is_inert()
8598 );
8599
8600 let spoken_b = RealtimeTranscriptEvent::AssistantTranscriptDelta {
8601 response_id: "resp_mixed_1".to_string(),
8602 delta_id: "delta_spoken_2".to_string(),
8603 item_id: "item_spoken".to_string(),
8604 previous_item_id: Some("item_display".to_string()),
8605 content_index: 0,
8606 delta: " sentence two.".to_string(),
8607 };
8608 assert!(
8609 session
8610 .append_realtime_transcript_event(spoken_b)
8611 .is_inert()
8612 );
8613
8614 let outcome = session.append_realtime_transcript_event(
8617 RealtimeTranscriptEvent::AssistantTurnCompleted {
8618 response_id: "resp_mixed_1".to_string(),
8619 stop_reason: StopReason::EndTurn,
8620 usage: Usage {
8621 input_tokens: 11,
8622 output_tokens: 22,
8623 cache_creation_tokens: None,
8624 cache_read_tokens: None,
8625 },
8626 },
8627 );
8628 assert_eq!(outcome.materialized_messages.len(), 2);
8630
8631 let messages = session.messages();
8634 let assistants: Vec<&BlockAssistantMessage> = messages
8635 .iter()
8636 .filter_map(|m| match m {
8637 Message::BlockAssistant(a) => Some(a),
8638 _ => None,
8639 })
8640 .collect();
8641 assert_eq!(
8642 assistants.len(),
8643 1,
8644 "mixed display+spoken response under one response_id must produce exactly ONE BlockAssistant message, got: {assistants:?}"
8645 );
8646 let assistant = assistants[0];
8647 assert_eq!(
8648 assistant.blocks.len(),
8649 2,
8650 "mixed response message must carry both blocks: {:?}",
8651 assistant.blocks
8652 );
8653
8654 match &assistant.blocks[0] {
8656 AssistantBlock::Text { text, .. } => {
8657 assert_eq!(text, "Here's the report: (still writing)");
8658 }
8659 other => unreachable!(
8660 "first block must be AssistantBlock::Text (display lane), got {other:?}"
8661 ),
8662 }
8663 match &assistant.blocks[1] {
8665 AssistantBlock::Transcript { text, source, .. } => {
8666 assert_eq!(text, "I'm reading the report aloud: sentence two.");
8667 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
8668 }
8669 other => unreachable!(
8670 "second block must be AssistantBlock::Transcript {{ source: Spoken }}, got {other:?}"
8671 ),
8672 }
8673
8674 assert_eq!(session.usage.input_tokens, 11);
8676 assert_eq!(session.usage.output_tokens, 22);
8677 }
8678
8679 #[test]
8680 fn round5_r55_mixed_response_barge_in_preserves_display_drops_spoken() {
8681 let mut session = Session::new();
8697
8698 let display = RealtimeTranscriptEvent::AssistantTextDelta {
8699 response_id: "resp_mixed_2".to_string(),
8700 delta_id: "delta_disp_1".to_string(),
8701 item_id: "item_display_2".to_string(),
8702 previous_item_id: None,
8703 content_index: 0,
8704 delta: "Working on the report...".to_string(),
8705 };
8706 let _ = session.append_realtime_transcript_event(display);
8707
8708 let spoken = RealtimeTranscriptEvent::AssistantTranscriptDelta {
8709 response_id: "resp_mixed_2".to_string(),
8710 delta_id: "delta_spoken_1".to_string(),
8711 item_id: "item_spoken_2".to_string(),
8712 previous_item_id: Some("item_display_2".to_string()),
8713 content_index: 0,
8714 delta: "I'm reading the report".to_string(),
8715 };
8716 let _ = session.append_realtime_transcript_event(spoken);
8717
8718 let outcome = session.append_realtime_transcript_event(
8722 RealtimeTranscriptEvent::AssistantTurnInterrupted {
8723 response_id: "resp_mixed_2".to_string(),
8724 },
8725 );
8726 assert_eq!(
8727 outcome.materialized_messages.len(),
8728 1,
8729 "Display lane item must materialize on Interrupted: {outcome:?}"
8730 );
8731
8732 let late_completion = session.append_realtime_transcript_event(
8736 RealtimeTranscriptEvent::AssistantTurnCompleted {
8737 response_id: "resp_mixed_2".to_string(),
8738 stop_reason: StopReason::Cancelled,
8739 usage: Usage::default(),
8740 },
8741 );
8742 assert_eq!(
8743 late_completion.materialized_messages.len(),
8744 0,
8745 "post-barge-in TurnCompleted must not resurrect anything"
8746 );
8747
8748 let messages = session.messages();
8751 let assistants: Vec<&BlockAssistantMessage> = messages
8752 .iter()
8753 .filter_map(|m| match m {
8754 Message::BlockAssistant(a) => Some(a),
8755 _ => None,
8756 })
8757 .collect();
8758 assert_eq!(
8759 assistants.len(),
8760 1,
8761 "barge-in must commit exactly one BlockAssistant containing the Display lane: {assistants:?}"
8762 );
8763 let assistant = assistants[0];
8764 assert_eq!(assistant.blocks.len(), 1, "blocks: {:?}", assistant.blocks);
8765 match &assistant.blocks[0] {
8766 AssistantBlock::Text { text, .. } => {
8767 assert_eq!(text, "Working on the report...");
8768 }
8769 other => {
8770 unreachable!("Display lane must materialize as AssistantBlock::Text, got {other:?}")
8771 }
8772 }
8773 assert!(
8775 !assistant
8776 .blocks
8777 .iter()
8778 .any(|b| matches!(b, AssistantBlock::Transcript { .. })),
8779 "Spoken lane must be dropped on barge-in"
8780 );
8781
8782 assert!(
8785 !session
8786 .in_flight_realtime_assistant_response_ids()
8787 .contains(&"resp_mixed_2".to_string()),
8788 "barged-in response must not appear in in_flight_realtime_assistant_response_ids"
8789 );
8790 }
8791
8792 #[test]
8793 fn round5_r55_barge_in_preserves_display_lane_drops_spoken() {
8794 let mut session = Session::new();
8798
8799 let _ =
8800 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
8801 response_id: "resp_a".to_string(),
8802 delta_id: "delta_d_1".to_string(),
8803 item_id: "item_display".to_string(),
8804 previous_item_id: None,
8805 content_index: 0,
8806 delta: "display-text".to_string(),
8807 });
8808 let _ = session.append_realtime_transcript_event(
8809 RealtimeTranscriptEvent::AssistantTranscriptDelta {
8810 response_id: "resp_a".to_string(),
8811 delta_id: "delta_s_1".to_string(),
8812 item_id: "item_spoken".to_string(),
8813 previous_item_id: None,
8814 content_index: 0,
8815 delta: "spoken-transcript".to_string(),
8816 },
8817 );
8818
8819 let outcome = session.append_realtime_transcript_event(
8820 RealtimeTranscriptEvent::AssistantTurnInterrupted {
8821 response_id: "resp_a".to_string(),
8822 },
8823 );
8824 assert_eq!(outcome.materialized_messages.len(), 1);
8826
8827 let messages = session.messages();
8828 let assistants: Vec<&BlockAssistantMessage> = messages
8829 .iter()
8830 .filter_map(|m| match m {
8831 Message::BlockAssistant(a) => Some(a),
8832 _ => None,
8833 })
8834 .collect();
8835 assert_eq!(assistants.len(), 1);
8836 assert_eq!(assistants[0].blocks.len(), 1);
8838 match &assistants[0].blocks[0] {
8839 AssistantBlock::Text { text, .. } => assert_eq!(text, "display-text"),
8840 other => unreachable!("expected Text, got {other:?}"),
8841 }
8842 }
8843
8844 #[test]
8845 fn round5_r55_barge_in_finalizes_retained_display_into_committed_block() {
8846 let mut session = Session::new();
8851
8852 let _ =
8853 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
8854 response_id: "resp_a".to_string(),
8855 delta_id: "delta_d_1".to_string(),
8856 item_id: "item_display".to_string(),
8857 previous_item_id: None,
8858 content_index: 0,
8859 delta: "committed-display-text".to_string(),
8860 });
8861
8862 assert!(session.messages().is_empty());
8864
8865 let outcome = session.append_realtime_transcript_event(
8866 RealtimeTranscriptEvent::AssistantTurnInterrupted {
8867 response_id: "resp_a".to_string(),
8868 },
8869 );
8870 assert_eq!(
8871 outcome.materialized_messages.len(),
8872 1,
8873 "Interrupted must finalize retained Display lane immediately"
8874 );
8875
8876 let messages = session.messages();
8878 assert_eq!(messages.len(), 1);
8879 match &messages[0] {
8880 Message::BlockAssistant(assistant) => {
8881 assert_eq!(assistant.blocks.len(), 1);
8882 match &assistant.blocks[0] {
8883 AssistantBlock::Text { text, .. } => {
8884 assert_eq!(text, "committed-display-text");
8885 }
8886 other => unreachable!("expected Text, got {other:?}"),
8887 }
8888 }
8889 other => unreachable!("expected BlockAssistant, got {other:?}"),
8890 }
8891 }
8892
8893 #[test]
8894 fn round5_r56_truncation_promotes_default_lane_item_to_spoken() {
8895 let mut session = Session::new();
8902
8903 let _ = session.append_realtime_transcript_event(
8904 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
8905 response_id: "resp_a".to_string(),
8906 item_id: "item_a".to_string(),
8907 content_index: 0,
8908 text: "what was actually heard".to_string(),
8909 },
8910 );
8911
8912 let outcome = session.append_realtime_transcript_event(
8913 RealtimeTranscriptEvent::AssistantTurnCompleted {
8914 response_id: "resp_a".to_string(),
8915 stop_reason: StopReason::EndTurn,
8916 usage: Usage::default(),
8917 },
8918 );
8919 assert_eq!(outcome.materialized_messages.len(), 1);
8920
8921 assert_eq!(session.messages().len(), 1);
8922 match &session.messages()[0] {
8923 Message::BlockAssistant(assistant) => {
8924 assert_eq!(assistant.blocks.len(), 1);
8925 match &assistant.blocks[0] {
8926 AssistantBlock::Transcript { text, source, .. } => {
8927 assert_eq!(text, "what was actually heard");
8928 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
8929 }
8930 other => unreachable!(
8931 "truncation-only path must materialize as AssistantBlock::Transcript, got {other:?}"
8932 ),
8933 }
8934 }
8935 other => unreachable!("expected BlockAssistant, got {other:?}"),
8936 }
8937 }
8938
8939 #[test]
8940 fn round5_r56_truncation_after_display_delta_is_no_op_keeping_display_content() {
8941 let mut session = Session::new();
8949
8950 let _ =
8951 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
8952 response_id: "resp_a".to_string(),
8953 delta_id: "delta_d_1".to_string(),
8954 item_id: "item_a".to_string(),
8955 previous_item_id: None,
8956 content_index: 0,
8957 delta: "display-text-from-delta".to_string(),
8958 });
8959
8960 let _ = session.append_realtime_transcript_event(
8961 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
8962 response_id: "resp_a".to_string(),
8963 item_id: "item_a".to_string(),
8964 content_index: 0,
8965 text: "spoken-truncation-text".to_string(),
8966 },
8967 );
8968
8969 let _ = session.append_realtime_transcript_event(
8970 RealtimeTranscriptEvent::AssistantTurnCompleted {
8971 response_id: "resp_a".to_string(),
8972 stop_reason: StopReason::EndTurn,
8973 usage: Usage::default(),
8974 },
8975 );
8976
8977 assert_eq!(session.messages().len(), 1);
8980 match &session.messages()[0] {
8981 Message::BlockAssistant(assistant) => {
8982 assert_eq!(assistant.blocks.len(), 1);
8983 match &assistant.blocks[0] {
8984 AssistantBlock::Text { text, .. } => {
8985 assert_eq!(text, "display-text-from-delta");
8986 }
8987 other => unreachable!(
8988 "Display content must survive misrouted truncation, got {other:?}"
8989 ),
8990 }
8991 }
8992 other => unreachable!("expected BlockAssistant, got {other:?}"),
8993 }
8994 }
8995
8996 #[test]
9004 fn round5_r56_sibling_display_delta_skipped_on_spoken_item() {
9005 let mut session = Session::new();
9006
9007 let _ = session.append_realtime_transcript_event(
9009 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
9010 response_id: "resp_a".to_string(),
9011 item_id: "item_a".to_string(),
9012 content_index: 0,
9013 text: "what was actually heard".to_string(),
9014 },
9015 );
9016
9017 let _ =
9020 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
9021 response_id: "resp_a".to_string(),
9022 delta_id: "delta_d_1".to_string(),
9023 item_id: "item_a".to_string(),
9024 previous_item_id: None,
9025 content_index: 0,
9026 delta: "should-not-appear".to_string(),
9027 });
9028
9029 let _ = session.append_realtime_transcript_event(
9030 RealtimeTranscriptEvent::AssistantTurnCompleted {
9031 response_id: "resp_a".to_string(),
9032 stop_reason: StopReason::EndTurn,
9033 usage: Usage::default(),
9034 },
9035 );
9036
9037 assert_eq!(session.messages().len(), 1);
9040 match &session.messages()[0] {
9041 Message::BlockAssistant(assistant) => {
9042 assert_eq!(assistant.blocks.len(), 1);
9043 match &assistant.blocks[0] {
9044 AssistantBlock::Transcript { text, source, .. } => {
9045 assert_eq!(text, "what was actually heard");
9046 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
9047 }
9048 other => unreachable!(
9049 "Spoken-locked item must materialize as Transcript, got {other:?}"
9050 ),
9051 }
9052 }
9053 other => unreachable!("expected BlockAssistant, got {other:?}"),
9054 }
9055 }
9056
9057 #[test]
9064 fn round5_r56_sibling_spoken_delta_skipped_on_display_item() {
9065 let mut session = Session::new();
9066
9067 let _ =
9069 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
9070 response_id: "resp_a".to_string(),
9071 delta_id: "delta_d_1".to_string(),
9072 item_id: "item_a".to_string(),
9073 previous_item_id: None,
9074 content_index: 0,
9075 delta: "display-locked-text".to_string(),
9076 });
9077
9078 let _ = session.append_realtime_transcript_event(
9081 RealtimeTranscriptEvent::AssistantTranscriptDelta {
9082 response_id: "resp_a".to_string(),
9083 delta_id: "delta_s_1".to_string(),
9084 item_id: "item_a".to_string(),
9085 previous_item_id: None,
9086 content_index: 0,
9087 delta: "should-not-appear".to_string(),
9088 },
9089 );
9090
9091 let _ = session.append_realtime_transcript_event(
9092 RealtimeTranscriptEvent::AssistantTurnCompleted {
9093 response_id: "resp_a".to_string(),
9094 stop_reason: StopReason::EndTurn,
9095 usage: Usage::default(),
9096 },
9097 );
9098
9099 assert_eq!(session.messages().len(), 1);
9101 match &session.messages()[0] {
9102 Message::BlockAssistant(assistant) => {
9103 assert_eq!(assistant.blocks.len(), 1);
9104 match &assistant.blocks[0] {
9105 AssistantBlock::Text { text, .. } => {
9106 assert_eq!(text, "display-locked-text");
9107 }
9108 other => {
9109 unreachable!("Display-locked item must materialize as Text, got {other:?}")
9110 }
9111 }
9112 }
9113 other => unreachable!("expected BlockAssistant, got {other:?}"),
9114 }
9115 }
9116
9117 #[test]
9125 fn round5_r57_late_final_text_after_turn_completed_warns_and_skips() {
9126 let mut session = Session::new();
9127
9128 let _ = session.append_realtime_transcript_event(
9130 RealtimeTranscriptEvent::AssistantTranscriptDelta {
9131 response_id: "resp_a".to_string(),
9132 delta_id: "delta_s_1".to_string(),
9133 item_id: "item_a".to_string(),
9134 previous_item_id: None,
9135 content_index: 0,
9136 delta: "delta-accumulated".to_string(),
9137 },
9138 );
9139
9140 let commit_outcome = session.append_realtime_transcript_event(
9142 RealtimeTranscriptEvent::AssistantTurnCompleted {
9143 response_id: "resp_a".to_string(),
9144 stop_reason: StopReason::EndTurn,
9145 usage: Usage::default(),
9146 },
9147 );
9148 assert_eq!(commit_outcome.materialized_messages.len(), 1);
9149
9150 let late_outcome = session.append_realtime_transcript_event(
9154 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
9155 response_id: "resp_a".to_string(),
9156 item_id: "item_a".to_string(),
9157 content_index: 0,
9158 text: "authoritative-final-that-must-not-land".to_string(),
9159 },
9160 );
9161 assert!(
9162 late_outcome.is_inert(),
9163 "late FinalText after materialization must produce inert outcome"
9164 );
9165
9166 assert_eq!(session.messages().len(), 1);
9169 match &session.messages()[0] {
9170 Message::BlockAssistant(assistant) => {
9171 assert_eq!(assistant.blocks.len(), 1);
9172 match &assistant.blocks[0] {
9173 AssistantBlock::Transcript { text, .. } => {
9174 assert_eq!(
9175 text, "delta-accumulated",
9176 "canonical message must preserve delta-accumulated text; \
9177 append-only history forbids late FinalText repair"
9178 );
9179 }
9180 other => unreachable!("expected Transcript, got {other:?}"),
9181 }
9182 }
9183 other => unreachable!("expected BlockAssistant, got {other:?}"),
9184 }
9185 }
9186}