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
1031#[derive(Deserialize)]
1040#[serde(rename_all = "snake_case")]
1041struct SessionMetadataDocumentSerde {
1042 version: u32,
1043 id: SessionId,
1044 #[serde(default)]
1045 metadata: serde_json::Map<String, serde_json::Value>,
1046}
1047
1048#[derive(Debug, Clone)]
1057pub struct SessionMetadataDocument {
1058 session_id: SessionId,
1059 metadata: serde_json::Map<String, serde_json::Value>,
1060}
1061
1062impl SessionMetadataDocument {
1063 pub fn session_id(&self) -> &SessionId {
1065 &self.session_id
1066 }
1067
1068 pub fn session_metadata_value(&self) -> Option<&serde_json::Value> {
1071 self.metadata.get(SESSION_METADATA_KEY)
1072 }
1073
1074 pub fn lifecycle_terminal_value(&self) -> Option<&serde_json::Value> {
1077 self.metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY)
1078 }
1079
1080 pub fn try_into_view(self) -> Result<PersistedSessionMetadataView, serde_json::Error> {
1083 PersistedSessionMetadataView::try_from_metadata_map(self.session_id, &self.metadata)
1084 }
1085}
1086
1087pub fn session_metadata_document_from_slice(
1094 bytes: &[u8],
1095) -> Result<SessionMetadataDocument, serde_json::Error> {
1096 let serde_repr: SessionMetadataDocumentSerde = serde_json::from_slice(bytes)?;
1097 session_persistence_version_authority::restore_session_envelope_version(serde_repr.version)
1098 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1099 Ok(SessionMetadataDocument {
1100 session_id: serde_repr.id,
1101 metadata: serde_repr.metadata,
1102 })
1103}
1104
1105impl Session {
1106 pub(crate) fn from_head_parts(
1114 version: u32,
1115 id: SessionId,
1116 messages: Vec<Message>,
1117 created_at: SystemTime,
1118 updated_at: SystemTime,
1119 metadata: serde_json::Map<String, serde_json::Value>,
1120 usage: Usage,
1121 ) -> Result<Self, String> {
1122 let version =
1123 session_persistence_version_authority::restore_session_envelope_version(version)
1124 .map_err(|err| err.to_string())?;
1125 Ok(Self {
1126 version,
1127 id,
1128 messages: Arc::new(messages),
1129 created_at,
1130 updated_at,
1131 metadata,
1132 usage,
1133 })
1134 }
1135}
1136
1137pub const SESSION_SYSTEM_CONTEXT_STATE_KEY: &str = "session_system_context_state";
1139
1140pub const SESSION_DEFERRED_TURN_STATE_KEY: &str = "session_deferred_turn_state";
1142
1143pub const SESSION_BUILD_STATE_KEY: &str = "session_build_state";
1145
1146pub const SESSION_TOOL_VISIBILITY_STATE_KEY: &str = "session_tool_visibility_state_v1";
1148
1149pub const SESSION_LIFECYCLE_TERMINAL_KEY: &str = "session_lifecycle_terminal";
1151
1152pub const SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY: &str =
1160 "session_runtime_checkpoint_provenance_v1";
1161
1162pub const VIEW_IMAGE_TOOL_NAME: &str = "view_image";
1164
1165pub const SYSTEM_CONTEXT_SEPARATOR: &str = "\n\n---\n\n";
1167
1168#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1169#[error("metadata key `{key}` is reserved for session authority")]
1170pub struct ReservedSessionMetadataKey {
1171 key: String,
1172}
1173
1174impl ReservedSessionMetadataKey {
1175 fn new(key: &str) -> Self {
1176 Self {
1177 key: key.to_string(),
1178 }
1179 }
1180}
1181
1182fn is_session_authority_metadata_key(key: &str) -> bool {
1183 crate::surface_metadata::ReservedMetadataKey::is_session_authority(key)
1186}
1187
1188#[allow(clippy::panic)]
1189fn fail_closed_generated_restore(authority: &'static str, err: serde_json::Error) -> ! {
1190 tracing::error!(
1191 authority,
1192 error = %err,
1193 "generated authority rejected durable restore"
1194 );
1195 panic!("generated {authority} authority rejected durable restore: {err}");
1196}
1197
1198#[derive(Clone)]
1204pub struct SystemContextStateHandle {
1205 inner: Arc<std::sync::Mutex<SessionSystemContextState>>,
1206}
1207
1208impl std::fmt::Debug for SystemContextStateHandle {
1209 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1210 f.debug_struct("SystemContextStateHandle")
1211 .field("inner", &"<Arc<Mutex<SessionSystemContextState>>>")
1212 .finish()
1213 }
1214}
1215
1216impl SystemContextStateHandle {
1217 pub fn new(state: SessionSystemContextState) -> Result<Self, serde_json::Error> {
1218 let state = system_context_authority::restore_system_context_state(state)
1219 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1220 Ok(Self {
1221 inner: Arc::new(std::sync::Mutex::new(state)),
1222 })
1223 }
1224
1225 pub fn from_shared_authority_state(
1226 inner: Arc<std::sync::Mutex<SessionSystemContextState>>,
1227 ) -> Self {
1228 Self { inner }
1229 }
1230
1231 pub fn snapshot(&self) -> SessionSystemContextState {
1232 match self.inner.lock() {
1233 Ok(guard) => guard.clone(),
1234 Err(poisoned) => {
1235 tracing::warn!("system-context state lock poisoned while reading snapshot");
1236 poisoned.into_inner().clone()
1237 }
1238 }
1239 }
1240
1241 pub fn replace_from_generated_restore(
1242 &self,
1243 state: SessionSystemContextState,
1244 ) -> Result<(), serde_json::Error> {
1245 let state = system_context_authority::restore_system_context_state(state)
1246 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1247 match self.inner.lock() {
1248 Ok(mut guard) => {
1249 *guard = state;
1250 }
1251 Err(poisoned) => {
1252 tracing::warn!("system-context state lock poisoned while restoring state");
1253 *poisoned.into_inner() = state;
1254 }
1255 }
1256 Ok(())
1257 }
1258
1259 pub fn replace_from_generated_restore_if_changed(
1260 &self,
1261 state: SessionSystemContextState,
1262 ) -> Result<bool, serde_json::Error> {
1263 let state = system_context_authority::restore_system_context_state(state)
1264 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1265 let mut guard = match self.inner.lock() {
1266 Ok(guard) => guard,
1267 Err(poisoned) => {
1268 tracing::warn!(
1269 "system-context state lock poisoned while replacing generated-restored state"
1270 );
1271 poisoned.into_inner()
1272 }
1273 };
1274 if *guard == state {
1275 return Ok(false);
1276 }
1277 *guard = state;
1278 Ok(true)
1279 }
1280
1281 pub fn replace_from_generated_restore_if_current(
1282 &self,
1283 current: &SessionSystemContextState,
1284 replacement: SessionSystemContextState,
1285 ) -> Result<bool, serde_json::Error> {
1286 let replacement = system_context_authority::restore_system_context_state(replacement)
1287 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1288 let mut guard = match self.inner.lock() {
1289 Ok(guard) => guard,
1290 Err(poisoned) => {
1291 tracing::warn!(
1292 "system-context state lock poisoned while conditionally replacing generated-restored state"
1293 );
1294 poisoned.into_inner()
1295 }
1296 };
1297 if *guard != *current {
1298 return Ok(false);
1299 }
1300 *guard = replacement;
1301 Ok(true)
1302 }
1303
1304 pub fn stage_append_with_snapshot(
1305 &self,
1306 req: &AppendSystemContextRequest,
1307 accepted_at: SystemTime,
1308 ) -> Result<
1309 (
1310 crate::service::AppendSystemContextStatus,
1311 SessionSystemContextState,
1312 SessionSystemContextState,
1313 ),
1314 SystemContextStageError,
1315 > {
1316 let mut guard = match self.inner.lock() {
1317 Ok(guard) => guard,
1318 Err(poisoned) => {
1319 tracing::warn!("system-context state lock poisoned while staging append");
1320 poisoned.into_inner()
1321 }
1322 };
1323 let snapshot = guard.clone();
1324 let status = guard.stage_append(req, accepted_at)?;
1325 let staged = guard.clone();
1326 Ok((status, snapshot, staged))
1327 }
1328
1329 pub fn stage_active_turn_appends_with_snapshot(
1330 &self,
1331 appends: Vec<(AppendSystemContextRequest, SystemTime)>,
1332 ) -> Result<(SessionSystemContextState, SessionSystemContextState), SystemContextStageError>
1333 {
1334 let mut guard = match self.inner.lock() {
1335 Ok(guard) => guard,
1336 Err(poisoned) => {
1337 tracing::warn!(
1338 "system-context state lock poisoned while staging active-turn appends"
1339 );
1340 poisoned.into_inner()
1341 }
1342 };
1343 let snapshot = guard.clone();
1344 let mut candidate = snapshot.clone();
1345 for (req, accepted_at) in appends {
1346 candidate.stage_active_turn_append(&req, accepted_at)?;
1347 }
1348 *guard = candidate.clone();
1349 let staged = candidate;
1350 Ok((snapshot, staged))
1351 }
1352
1353 pub fn discard_unapplied_active_turn_pending(&self) -> usize {
1354 let discarded = match self.inner.lock() {
1355 Ok(mut guard) => guard.discard_unapplied_active_turn_pending(),
1356 Err(poisoned) => {
1357 tracing::warn!(
1358 "system-context state lock poisoned while discarding active-turn context"
1359 );
1360 poisoned
1361 .into_inner()
1362 .discard_unapplied_active_turn_pending()
1363 }
1364 };
1365 discarded.len()
1366 }
1367
1368 pub fn discard_active_turn_pending_by_keys(
1369 &self,
1370 idempotency_keys: &[String],
1371 ) -> Vec<PendingSystemContextAppend> {
1372 match self.inner.lock() {
1373 Ok(mut guard) => guard.discard_active_turn_pending_by_keys(idempotency_keys),
1374 Err(poisoned) => {
1375 tracing::warn!(
1376 "system-context state lock poisoned while discarding active-turn pending appends"
1377 );
1378 poisoned
1379 .into_inner()
1380 .discard_active_turn_pending_by_keys(idempotency_keys)
1381 }
1382 }
1383 }
1384
1385 pub fn stage_active_turn_append(
1386 &self,
1387 req: &AppendSystemContextRequest,
1388 accepted_at: SystemTime,
1389 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
1390 match self.inner.lock() {
1391 Ok(mut guard) => guard.stage_active_turn_append(req, accepted_at),
1392 Err(poisoned) => {
1393 tracing::warn!(
1394 "system-context state lock poisoned while staging active-turn context"
1395 );
1396 poisoned
1397 .into_inner()
1398 .stage_active_turn_append(req, accepted_at)
1399 }
1400 }
1401 }
1402}
1403
1404#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
1408#[serde(rename_all = "snake_case")]
1409pub struct SessionSystemContextState {
1410 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1411 pub(crate) pending: Vec<PendingSystemContextAppend>,
1412 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1413 pub(crate) applied: Vec<PendingSystemContextAppend>,
1414 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1415 pub(crate) seen: std::collections::BTreeMap<String, SeenSystemContextKey>,
1416 #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
1417 pub(crate) active_turn_pending_keys: std::collections::BTreeSet<String>,
1418}
1419
1420#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1430#[serde(rename_all = "snake_case")]
1431pub enum SystemContextSource {
1432 #[default]
1434 Normal,
1435 RuntimeSteer,
1438}
1439
1440impl From<SystemContextSource> for session_document::SystemContextSource {
1441 fn from(value: SystemContextSource) -> Self {
1442 match value {
1443 SystemContextSource::Normal => Self::Normal,
1444 SystemContextSource::RuntimeSteer => Self::RuntimeSteer,
1445 }
1446 }
1447}
1448
1449impl SystemContextSource {
1450 #[must_use]
1453 pub fn is_normal(&self) -> bool {
1454 matches!(self, Self::Normal)
1455 }
1456
1457 #[must_use]
1459 pub fn is_runtime_steer(&self) -> bool {
1460 matches!(self, Self::RuntimeSteer)
1461 }
1462}
1463
1464#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1468#[serde(rename_all = "snake_case")]
1469pub struct PendingSystemContextAppend {
1470 pub content: crate::lifecycle::run_primitive::CoreRenderable,
1478 #[serde(default, skip_serializing_if = "Option::is_none")]
1479 pub source: Option<String>,
1480 #[serde(default, skip_serializing_if = "Option::is_none")]
1481 pub idempotency_key: Option<String>,
1482 #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
1484 pub source_kind: SystemContextSource,
1485 #[serde(default, skip_serializing_if = "Option::is_none")]
1493 pub peer_response_terminal: Option<crate::handles::PeerResponseTerminalFact>,
1494 pub accepted_at: SystemTime,
1495}
1496
1497#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1512#[serde(rename_all = "snake_case")]
1513pub enum SessionLifecycleTerminal {
1514 Active,
1516 Archived,
1518}
1519
1520impl SessionLifecycleTerminal {
1521 #[must_use]
1523 pub fn is_archived(self) -> bool {
1524 matches!(self, Self::Archived)
1525 }
1526}
1527
1528impl From<SessionLifecycleTerminal> for session_document::SessionDocumentLifecycle {
1529 fn from(value: SessionLifecycleTerminal) -> Self {
1530 match value {
1531 SessionLifecycleTerminal::Active => Self::Active,
1532 SessionLifecycleTerminal::Archived => Self::Archived,
1533 }
1534 }
1535}
1536
1537impl From<session_document::SessionDocumentLifecycle> for SessionLifecycleTerminal {
1538 fn from(value: session_document::SessionDocumentLifecycle) -> Self {
1539 match value {
1540 session_document::SessionDocumentLifecycle::Active => Self::Active,
1541 session_document::SessionDocumentLifecycle::Archived => Self::Archived,
1542 }
1543 }
1544}
1545
1546#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
1548#[serde(rename_all = "snake_case")]
1549pub struct SessionDeferredTurnState {
1550 #[serde(default, skip_serializing_if = "DeferredFirstTurnPhase::is_inactive")]
1551 pub(crate) first_turn_phase: DeferredFirstTurnPhase,
1552 #[serde(default, skip_serializing_if = "Option::is_none")]
1553 pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
1554 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1555 pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
1556}
1557
1558#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
1560#[serde(rename_all = "snake_case")]
1561pub enum DeferredFirstTurnPhase {
1562 #[default]
1564 Inactive,
1565 Pending,
1567 Consumed,
1569}
1570
1571impl DeferredFirstTurnPhase {
1572 pub fn is_inactive(&self) -> bool {
1573 matches!(self, Self::Inactive)
1574 }
1575}
1576
1577impl From<DeferredFirstTurnPhase> for session_document::SessionFirstTurnPhase {
1578 fn from(value: DeferredFirstTurnPhase) -> Self {
1579 match value {
1580 DeferredFirstTurnPhase::Inactive => Self::Inactive,
1581 DeferredFirstTurnPhase::Pending => Self::Pending,
1582 DeferredFirstTurnPhase::Consumed => Self::Consumed,
1583 }
1584 }
1585}
1586
1587impl From<session_document::SessionFirstTurnPhase> for DeferredFirstTurnPhase {
1588 fn from(value: session_document::SessionFirstTurnPhase) -> Self {
1589 match value {
1590 session_document::SessionFirstTurnPhase::Inactive => Self::Inactive,
1591 session_document::SessionFirstTurnPhase::Pending => Self::Pending,
1592 session_document::SessionFirstTurnPhase::Consumed => Self::Consumed,
1593 }
1594 }
1595}
1596
1597fn is_default_hook_run_overrides(value: &crate::HookRunOverrides) -> bool {
1598 value == &crate::HookRunOverrides::default()
1599}
1600
1601fn is_default_call_timeout_override(value: &crate::CallTimeoutOverride) -> bool {
1602 value == &crate::CallTimeoutOverride::default()
1603}
1604
1605fn is_tool_filter_all(value: &ToolFilter) -> bool {
1606 matches!(value, ToolFilter::All)
1607}
1608
1609fn is_zero(value: &u64) -> bool {
1610 *value == 0
1611}
1612
1613pub fn capability_base_filter_for_image_tool_results(image_tool_results: bool) -> ToolFilter {
1615 if image_tool_results {
1616 ToolFilter::All
1617 } else {
1618 ToolFilter::Deny([VIEW_IMAGE_TOOL_NAME.to_string()].into_iter().collect())
1619 }
1620}
1621
1622#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1629#[serde(rename_all = "snake_case")]
1630pub struct ToolVisibilityWitness {
1631 #[serde(default, skip_serializing_if = "Option::is_none")]
1632 pub last_seen_provenance: Option<ToolProvenance>,
1633}
1634
1635impl ToolVisibilityWitness {
1636 pub fn has_identity_witness(&self) -> bool {
1637 self.last_seen_provenance.is_some()
1638 }
1639}
1640
1641#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1647#[serde(rename_all = "snake_case")]
1648pub struct DeferredToolLoadAuthority {
1649 pub name: ToolName,
1650 pub witness: ToolVisibilityWitness,
1651}
1652
1653impl DeferredToolLoadAuthority {
1654 pub fn new(name: impl Into<ToolName>, witness: ToolVisibilityWitness) -> Self {
1655 Self {
1656 name: name.into(),
1657 witness,
1658 }
1659 }
1660
1661 pub fn into_parts(self) -> (ToolName, ToolVisibilityWitness) {
1662 (self.name, self.witness)
1663 }
1664}
1665
1666#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1669#[serde(rename_all = "snake_case")]
1670pub struct WitnessedToolFilter {
1671 pub filter: ToolFilter,
1672 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1673 pub witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1674}
1675
1676impl WitnessedToolFilter {
1677 pub fn new(filter: ToolFilter, witnesses: BTreeMap<ToolName, ToolVisibilityWitness>) -> Self {
1678 Self { filter, witnesses }
1679 }
1680
1681 pub fn into_parts(self) -> (ToolFilter, BTreeMap<ToolName, ToolVisibilityWitness>) {
1682 (self.filter, self.witnesses)
1683 }
1684}
1685
1686#[derive(Debug, Clone, PartialEq, Eq)]
1693pub struct InheritedToolVisibilityAuthority {
1694 filter: ToolFilter,
1695 witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1696}
1697
1698impl InheritedToolVisibilityAuthority {
1699 pub(crate) fn from_generated_composition_authority(
1700 filter: ToolFilter,
1701 witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1702 ) -> Self {
1703 Self { filter, witnesses }
1704 }
1705
1706 pub fn filter(&self) -> &ToolFilter {
1707 &self.filter
1708 }
1709
1710 pub fn witnesses(&self) -> &BTreeMap<ToolName, ToolVisibilityWitness> {
1711 &self.witnesses
1712 }
1713
1714 pub(crate) fn into_initial_visibility_state(self) -> SessionToolVisibilityState {
1715 SessionToolVisibilityState {
1716 inherited_base_filter: self.filter,
1717 filter_witnesses: self.witnesses,
1718 ..Default::default()
1719 }
1720 }
1721}
1722
1723#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1725#[serde(rename_all = "snake_case")]
1726pub struct SessionToolVisibilityState {
1727 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
1728 pub capability_base_filter: ToolFilter,
1729 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
1730 pub inherited_base_filter: ToolFilter,
1731 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
1732 pub active_filter: ToolFilter,
1733 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
1734 pub staged_filter: ToolFilter,
1735 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1736 pub active_requested_deferred_names: BTreeSet<ToolName>,
1737 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1738 pub staged_requested_deferred_names: BTreeSet<ToolName>,
1739 #[serde(default, skip_serializing_if = "is_zero")]
1740 pub active_revision: u64,
1741 #[serde(default, skip_serializing_if = "is_zero")]
1742 pub staged_revision: u64,
1743 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1744 pub requested_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1745 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1746 pub filter_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1747}
1748
1749#[derive(Debug, Clone, PartialEq, Eq)]
1755pub struct AuthorizedSessionToolVisibilityState {
1756 state: SessionToolVisibilityState,
1757}
1758
1759impl AuthorizedSessionToolVisibilityState {
1760 pub(crate) fn from_generated_authority(state: SessionToolVisibilityState) -> Self {
1761 Self { state }
1762 }
1763
1764 pub fn as_state(&self) -> &SessionToolVisibilityState {
1765 &self.state
1766 }
1767
1768 pub fn into_state(self) -> SessionToolVisibilityState {
1769 self.state
1770 }
1771}
1772
1773#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1776#[serde(rename_all = "snake_case")]
1777pub struct SessionBuildState {
1778 #[serde(
1779 default,
1780 skip_serializing_if = "crate::config::SystemPromptOverride::is_inherit"
1781 )]
1782 pub system_prompt: crate::config::SystemPromptOverride,
1783 #[serde(default, skip_serializing_if = "Option::is_none")]
1784 pub output_schema: Option<crate::OutputSchema>,
1785 #[serde(default, skip_serializing_if = "is_default_hook_run_overrides")]
1786 pub hooks_override: crate::HookRunOverrides,
1787 #[serde(default, skip_serializing_if = "Option::is_none")]
1788 pub budget_limits: Option<crate::BudgetLimits>,
1789 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1790 pub recoverable_tool_defs: Vec<ToolDef>,
1791 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1792 pub silent_comms_intents: Vec<String>,
1793 #[serde(default, skip_serializing_if = "Option::is_none")]
1794 pub max_inline_peer_notifications: Option<i32>,
1795 #[serde(default, skip_serializing_if = "Option::is_none")]
1796 pub app_context: Option<serde_json::Value>,
1797 #[serde(default, skip_serializing_if = "Option::is_none")]
1798 pub additional_instructions: Option<Vec<String>>,
1799 #[serde(default, skip_serializing_if = "Option::is_none")]
1800 pub shell_env: Option<HashMap<String, String>>,
1801 #[serde(default, skip_serializing_if = "Option::is_none")]
1807 pub mob_tool_authority_context: Option<MobToolAuthorityContext>,
1808 #[serde(default, skip_serializing_if = "is_default_call_timeout_override")]
1809 pub call_timeout_override: crate::CallTimeoutOverride,
1810 #[serde(default, skip_serializing_if = "Option::is_none")]
1817 pub assembled_system_prompt: Option<String>,
1818}
1819
1820#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1822#[serde(rename_all = "snake_case")]
1823pub struct PendingDeferredPrompt {
1824 pub prompt: ContentInput,
1825 pub accepted_at: SystemTime,
1826}
1827
1828#[derive(Debug, Clone, Serialize, Deserialize)]
1830#[serde(rename_all = "snake_case")]
1831pub struct PendingToolResultsMessage {
1832 pub results: Vec<ToolResult>,
1833 pub accepted_at: SystemTime,
1834}
1835
1836impl PartialEq for PendingToolResultsMessage {
1837 fn eq(&self, other: &Self) -> bool {
1838 self.accepted_at == other.accepted_at
1839 && serde_json::to_value(&self.results).ok() == serde_json::to_value(&other.results).ok()
1840 }
1841}
1842
1843#[derive(Debug, Clone, Default, PartialEq)]
1845pub struct ConsumedDeferredTurnInputs {
1846 pub(crate) restore_first_turn_pending: bool,
1847 pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
1848 pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
1849}
1850
1851impl ConsumedDeferredTurnInputs {
1852 pub fn is_empty(&self) -> bool {
1853 !self.restore_first_turn_pending
1854 && self.pending_initial_prompt.is_none()
1855 && self.pending_tool_results.is_empty()
1856 }
1857
1858 pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
1859 self.pending_initial_prompt.as_ref()
1860 }
1861
1862 pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
1863 &self.pending_tool_results
1864 }
1865}
1866
1867#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1869#[serde(rename_all = "snake_case")]
1870pub struct SeenSystemContextKey {
1871 pub content: crate::lifecycle::run_primitive::CoreRenderable,
1873 #[serde(default, skip_serializing_if = "Option::is_none")]
1874 pub source: Option<String>,
1875 #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
1878 pub source_kind: SystemContextSource,
1879 pub state: SeenSystemContextState,
1880}
1881
1882#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1884#[serde(rename_all = "snake_case")]
1885pub enum SeenSystemContextState {
1886 Pending,
1887 Applied,
1888}
1889
1890impl SessionSystemContextState {
1891 pub fn pending(&self) -> &[PendingSystemContextAppend] {
1892 &self.pending
1893 }
1894
1895 pub fn applied(&self) -> &[PendingSystemContextAppend] {
1896 &self.applied
1897 }
1898
1899 pub fn seen(&self) -> &BTreeMap<String, SeenSystemContextKey> {
1900 &self.seen
1901 }
1902
1903 pub fn active_turn_pending_keys(&self) -> &BTreeSet<String> {
1904 &self.active_turn_pending_keys
1905 }
1906
1907 pub fn pending_len(&self) -> usize {
1908 self.pending.len()
1909 }
1910
1911 pub fn applied_len(&self) -> usize {
1912 self.applied.len()
1913 }
1914
1915 pub fn active_turn_pending_len(&self) -> usize {
1916 self.active_turn_pending_keys.len()
1917 }
1918
1919 pub fn realtime_projection_appends(&self) -> Vec<PendingSystemContextAppend> {
1920 self.applied
1921 .iter()
1922 .chain(self.pending.iter())
1923 .cloned()
1924 .collect()
1925 }
1926
1927 pub fn stage_append(
1929 &mut self,
1930 req: &AppendSystemContextRequest,
1931 accepted_at: SystemTime,
1932 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
1933 system_context_authority::stage_append(self, req, accepted_at, false)
1934 }
1935
1936 fn stage_append_with_generated_authority(
1937 &mut self,
1938 req: &AppendSystemContextRequest,
1939 accepted_at: SystemTime,
1940 active_turn_scoped: bool,
1941 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
1942 system_context_authority::stage_append(self, req, accepted_at, active_turn_scoped)
1943 }
1944
1945 pub fn stage_active_turn_append(
1952 &mut self,
1953 req: &AppendSystemContextRequest,
1954 accepted_at: SystemTime,
1955 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
1956 self.stage_append_with_generated_authority(req, accepted_at, true)
1957 }
1958
1959 pub fn mark_pending_applied(&mut self) {
1961 system_context_authority::mark_pending_applied(self);
1962 }
1963
1964 pub fn discard_unapplied_active_turn_pending(&mut self) -> Vec<PendingSystemContextAppend> {
1967 system_context_authority::discard_unapplied_active_turn_pending(self)
1968 }
1969
1970 pub fn discard_active_turn_pending_by_keys(
1977 &mut self,
1978 idempotency_keys: &[String],
1979 ) -> Vec<PendingSystemContextAppend> {
1980 system_context_authority::discard_active_turn_pending_by_keys(self, idempotency_keys)
1981 }
1982
1983 pub fn restore_from_snapshot(self) -> Result<Self, SystemContextStageError> {
1987 system_context_authority::restore_system_context_state(self)
1988 }
1989
1990 pub fn record_applied_blocks(
1994 &mut self,
1995 appends: &[PendingSystemContextAppend],
1996 current_system_prompt: &str,
1997 ) -> Vec<PendingSystemContextAppend> {
1998 system_context_authority::record_applied_system_context_blocks(
1999 self,
2000 appends,
2001 current_system_prompt,
2002 )
2003 }
2004}
2005
2006const SESSION_DOCUMENT_FIRST_TURN_KEY: &str = "first_turn";
2011
2012fn usize_to_u64(value: usize) -> u64 {
2013 u64::try_from(value).unwrap_or(u64::MAX)
2014}
2015
2016fn validate_deferred_turn_snapshot(
2024 state: SessionDeferredTurnState,
2025) -> Result<SessionDeferredTurnState, session_document::SessionDocumentError> {
2026 let mut authority = session_document::SessionDocumentMachineAuthority::new();
2027 let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
2028 authority.recover_session_first_turn_phase(
2032 key,
2033 state.first_turn_phase.into(),
2034 state.pending_initial_prompt.is_some(),
2035 usize_to_u64(state.pending_tool_results.len()),
2036 )?;
2037 Ok(state)
2038}
2039
2040impl SessionDeferredTurnState {
2041 pub fn first_turn_phase(&self) -> DeferredFirstTurnPhase {
2042 self.first_turn_phase
2043 }
2044
2045 pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
2046 self.pending_initial_prompt.as_ref()
2047 }
2048
2049 pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
2050 &self.pending_tool_results
2051 }
2052
2053 pub fn pending_tool_results_len(&self) -> usize {
2054 self.pending_tool_results.len()
2055 }
2056
2057 pub(crate) fn pending_initial_prompt_mut_for_blob_rewrite(
2058 &mut self,
2059 ) -> Option<&mut PendingDeferredPrompt> {
2060 self.pending_initial_prompt.as_mut()
2061 }
2062
2063 pub(crate) fn pending_tool_results_mut_for_blob_rewrite(
2064 &mut self,
2065 ) -> &mut [PendingToolResultsMessage] {
2066 &mut self.pending_tool_results
2067 }
2068
2069 fn document_authority(
2079 &self,
2080 ) -> (
2081 session_document::SessionDocumentMachineAuthority,
2082 session_document::SessionDocumentKey,
2083 ) {
2084 let mut authority = session_document::SessionDocumentMachineAuthority::new();
2085 let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
2086 if let Err(err) = authority.recover_session_first_turn_phase(
2087 key.clone(),
2088 self.first_turn_phase.into(),
2089 self.pending_initial_prompt.is_some(),
2090 usize_to_u64(self.pending_tool_results.len()),
2091 ) {
2092 tracing::warn!(
2093 error = %err,
2094 "generated session document authority rejected first-turn recovery"
2095 );
2096 }
2097 (authority, key)
2098 }
2099
2100 fn mirror_first_turn_phase(
2103 &mut self,
2104 effects: &[session_document::SessionDocumentEffect],
2105 ) -> Option<bool> {
2106 for effect in effects {
2107 if let session_document::SessionDocumentEffect::SessionFirstTurnPhaseResolved {
2108 phase,
2109 was_pending,
2110 } = effect
2111 {
2112 self.first_turn_phase = (*phase).into();
2113 return Some(*was_pending);
2114 }
2115 }
2116 None
2117 }
2118
2119 pub fn mark_initial_turn_pending(&mut self) {
2121 let (mut authority, key) = self.document_authority();
2122 match authority.mark_session_initial_turn_pending(key) {
2123 Ok(effects) => {
2124 self.mirror_first_turn_phase(&effects);
2125 }
2126 Err(err) => tracing::warn!(
2127 error = %err,
2128 "generated session document authority rejected pending mark"
2129 ),
2130 }
2131 }
2132
2133 pub fn mark_initial_turn_started(&mut self) -> bool {
2137 let (mut authority, key) = self.document_authority();
2138 match authority.start_session_initial_turn(key) {
2139 Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
2140 Err(err) => {
2141 tracing::warn!(
2142 error = %err,
2143 "generated session document authority rejected first-turn start"
2144 );
2145 false
2146 }
2147 }
2148 }
2149
2150 pub fn restore_initial_turn_pending(&mut self) {
2152 let (mut authority, key) = self.document_authority();
2157 match authority.restore_session_consumed_inputs(
2158 key.clone(),
2159 true,
2160 self.pending_initial_prompt.is_some(),
2161 usize_to_u64(self.pending_tool_results.len()),
2162 ) {
2163 Ok(_) => {
2164 if let Some(phase) = authority.session_first_turn_phase_for(&key) {
2167 self.first_turn_phase = phase.into();
2168 }
2169 }
2170 Err(err) => tracing::warn!(
2171 error = %err,
2172 "generated session document authority rejected pending restore"
2173 ),
2174 }
2175 }
2176
2177 pub fn allows_initial_turn_overrides(&self) -> bool {
2179 let (mut authority, key) = self.document_authority();
2180 match authority.resolve_session_first_turn_overrides_allowed(key) {
2181 Ok(effects) => effects
2182 .iter()
2183 .find_map(|effect| {
2184 match effect {
2185 session_document::SessionDocumentEffect::SessionFirstTurnOverridesResolved {
2186 allowed,
2187 } => Some(*allowed),
2188 _ => None,
2189 }
2190 })
2191 .unwrap_or(false),
2192 Err(err) => {
2193 tracing::warn!(
2194 error = %err,
2195 "generated session document authority rejected override resolution"
2196 );
2197 false
2198 }
2199 }
2200 }
2201
2202 pub fn stage_initial_prompt(&mut self, prompt: ContentInput, accepted_at: SystemTime) {
2204 let prompt_has_content = prompt.has_images() || !prompt.text_content().trim().is_empty();
2205 let (mut authority, key) = self.document_authority();
2206 match authority.stage_session_initial_prompt(key, prompt_has_content) {
2207 Ok(effects) => {
2208 let decision = effects.iter().find_map(|effect| {
2209 match effect {
2210 session_document::SessionDocumentEffect::SessionInitialPromptStageResolved {
2211 decision,
2212 } => Some(*decision),
2213 _ => None,
2214 }
2215 });
2216 match decision {
2217 Some(session_document::SessionInitialPromptStageDecision::Store) => {
2218 self.pending_initial_prompt = Some(PendingDeferredPrompt {
2219 prompt,
2220 accepted_at,
2221 });
2222 }
2223 Some(session_document::SessionInitialPromptStageDecision::Clear) => {
2224 self.pending_initial_prompt = None;
2225 }
2226 None => tracing::warn!(
2227 "generated session document authority returned no prompt-stage decision"
2228 ),
2229 }
2230 }
2231 Err(err) => tracing::warn!(
2232 error = %err,
2233 "generated session document authority rejected initial prompt stage"
2234 ),
2235 }
2236 }
2237
2238 pub fn stage_tool_results(
2240 &mut self,
2241 results: Vec<ToolResult>,
2242 accepted_at: SystemTime,
2243 ) -> usize {
2244 let (mut authority, key) = self.document_authority();
2245 let accepted = match authority.stage_session_tool_results(key, usize_to_u64(results.len()))
2246 {
2247 Ok(effects) => effects.iter().find_map(|effect| match effect {
2248 session_document::SessionDocumentEffect::SessionToolResultsStageResolved {
2249 accepted_count,
2250 } => Some(*accepted_count),
2251 _ => None,
2252 }),
2253 Err(err) => {
2254 tracing::warn!(
2255 error = %err,
2256 "generated session document authority rejected tool-results stage"
2257 );
2258 return 0;
2259 }
2260 };
2261 let Some(accepted) = accepted else {
2262 tracing::warn!(
2263 "generated session document authority returned no tool-results decision"
2264 );
2265 return 0;
2266 };
2267 if accepted == 0 {
2268 return 0;
2269 }
2270 let accepted = usize::try_from(accepted).unwrap_or(usize::MAX);
2271 self.pending_tool_results.push(PendingToolResultsMessage {
2272 results,
2273 accepted_at,
2274 });
2275 accepted
2276 }
2277
2278 pub fn has_pending_tool_results(&self) -> bool {
2280 !self.pending_tool_results.is_empty()
2281 }
2282
2283 pub fn consume_for_started_turn(&mut self) -> ConsumedDeferredTurnInputs {
2285 let (mut authority, key) = self.document_authority();
2286 let was_pending = match authority.consume_session_deferred_inputs(key) {
2287 Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
2288 Err(err) => {
2289 tracing::warn!(
2290 error = %err,
2291 "generated session document authority rejected started-turn consumption"
2292 );
2293 return ConsumedDeferredTurnInputs::default();
2294 }
2295 };
2296 ConsumedDeferredTurnInputs {
2297 restore_first_turn_pending: was_pending,
2298 pending_initial_prompt: self.pending_initial_prompt.take(),
2299 pending_tool_results: std::mem::take(&mut self.pending_tool_results),
2300 }
2301 }
2302
2303 pub fn restore_consumed_turn_inputs(&mut self, consumed: ConsumedDeferredTurnInputs) {
2305 if consumed.is_empty() {
2306 return;
2307 }
2308 let (mut authority, key) = self.document_authority();
2309 let effects = match authority.restore_session_consumed_inputs(
2310 key,
2311 consumed.restore_first_turn_pending,
2312 consumed.pending_initial_prompt.is_some(),
2313 usize_to_u64(consumed.pending_tool_results.len()),
2314 ) {
2315 Ok(effects) => effects,
2316 Err(err) => {
2317 tracing::warn!(
2318 error = %err,
2319 "generated session document authority rejected consumed input restore"
2320 );
2321 return;
2322 }
2323 };
2324 let Some((restore_first_turn_pending, restore_initial_prompt, restore_tool_results)) =
2325 effects.iter().find_map(|effect| match effect {
2326 session_document::SessionDocumentEffect::SessionConsumedInputsRestoreResolved {
2327 restore_first_turn_pending,
2328 restore_initial_prompt,
2329 restore_tool_results,
2330 } => Some((
2331 *restore_first_turn_pending,
2332 *restore_initial_prompt,
2333 *restore_tool_results,
2334 )),
2335 _ => None,
2336 })
2337 else {
2338 tracing::warn!(
2339 "generated session document authority returned no consumed-input restore decision"
2340 );
2341 return;
2342 };
2343 if restore_first_turn_pending {
2344 self.restore_initial_turn_pending();
2345 }
2346 if restore_initial_prompt && self.pending_initial_prompt.is_none() {
2347 self.pending_initial_prompt = consumed.pending_initial_prompt;
2348 }
2349 if restore_tool_results {
2350 let mut restored = consumed.pending_tool_results;
2351 restored.extend(std::mem::take(&mut self.pending_tool_results));
2352 self.pending_tool_results = restored;
2353 }
2354 }
2355}
2356
2357#[derive(Debug, Clone, PartialEq, Eq)]
2359pub enum SystemContextStageError {
2360 InvalidRequest(String),
2361 Conflict {
2362 key: String,
2363 existing_text: String,
2364 existing_source: Option<String>,
2365 },
2366}
2367
2368impl std::fmt::Display for SystemContextStageError {
2369 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2370 match self {
2371 Self::InvalidRequest(message) => {
2372 write!(f, "invalid system-context append request: {message}")
2373 }
2374 Self::Conflict { key, .. } => {
2375 write!(
2376 f,
2377 "system-context append conflict for idempotency key `{key}`"
2378 )
2379 }
2380 }
2381 }
2382}
2383
2384impl std::error::Error for SystemContextStageError {}
2385
2386fn render_system_context_block(append: &PendingSystemContextAppend) -> String {
2394 let mut rendered = String::from(SYSTEM_CONTEXT_RENDER_LABEL);
2395 if let Some(source) = &append.source {
2396 rendered.push_str("\nsource: ");
2397 rendered.push_str(source);
2398 }
2399 rendered.push_str("\n\n");
2400 rendered.push_str(append.content.render_text().trim());
2403 rendered
2404}
2405
2406const SYSTEM_CONTEXT_RENDER_LABEL: &str = "[Runtime System Context]";
2411
2412fn render_system_context_blocks_joined(appends: &[PendingSystemContextAppend]) -> String {
2418 appends
2419 .iter()
2420 .map(render_system_context_block)
2421 .collect::<Vec<_>>()
2422 .join(SYSTEM_CONTEXT_SEPARATOR)
2423}
2424
2425fn compose_system_prompt_with_context_tail(base: &str, tail: &str) -> String {
2430 if tail.is_empty() {
2431 return base.to_string();
2432 }
2433 if base.is_empty() {
2434 return tail
2435 .strip_prefix(SYSTEM_CONTEXT_SEPARATOR)
2436 .unwrap_or(tail)
2437 .to_string();
2438 }
2439 format!("{base}{tail}")
2440}
2441
2442fn persisted_prompt_is_admitted_context_append_continuation(
2453 assembled_base: &str,
2454 persisted_content: &str,
2455 persisted_mutation_kind: crate::types::SystemPromptMutationKind,
2456) -> bool {
2457 let content_identical = persisted_content == assembled_base;
2458 let content_extends = persisted_content.starts_with(assembled_base);
2459 let appended_starts_with_separator = content_extends
2460 && persisted_content[assembled_base.len()..].starts_with(SYSTEM_CONTEXT_SEPARATOR);
2461 let mut authority = session_document::SessionDocumentMachineAuthority::new();
2462 match authority.resolve_system_context_persist_append_admission(
2463 true,
2464 content_identical,
2465 content_extends,
2466 appended_starts_with_separator,
2467 persisted_mutation_kind.is_runtime_context_append(),
2468 ) {
2469 Ok(effects) => effects.into_iter().any(|effect| {
2470 matches!(
2471 effect,
2472 session_document::SessionDocumentEffect::SystemContextPersistAppendAdmissionResolved {
2473 admission: session_document::SystemContextPersistAppendAdmission::Admit,
2474 }
2475 )
2476 }),
2477 Err(error) => {
2478 tracing::warn!(
2479 error = %error,
2480 "session document authority refused resume prompt continuation admission; \
2481 falling back to audited rewrite"
2482 );
2483 false
2484 }
2485 }
2486}
2487
2488mod system_context_authority {
2499 use super::{
2500 AppendSystemContextRequest, BTreeSet, PendingSystemContextAppend, SeenSystemContextKey,
2501 SeenSystemContextState, SessionSystemContextState, SystemContextSource,
2502 SystemContextStageError, SystemTime, render_system_context_block, session_document,
2503 usize_to_u64,
2504 };
2505 use crate::service::AppendSystemContextStatus;
2506
2507 fn document_authority() -> session_document::SessionDocumentMachineAuthority {
2508 session_document::SessionDocumentMachineAuthority::new()
2509 }
2510
2511 fn resolve_append_decision(
2513 trimmed_text_byte_count: u64,
2514 idempotency_key_present: bool,
2515 existing_key_matches: bool,
2516 existing_key_conflicts: bool,
2517 active_turn_scoped: bool,
2518 ) -> Result<session_document::SystemContextAppendDecision, SystemContextStageError> {
2519 let mut authority = document_authority();
2520 let effects = authority
2521 .resolve_system_context_append(
2522 trimmed_text_byte_count,
2523 idempotency_key_present,
2524 existing_key_matches,
2525 existing_key_conflicts,
2526 active_turn_scoped,
2527 )
2528 .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
2529 effects
2530 .into_iter()
2531 .find_map(|effect| match effect {
2532 session_document::SessionDocumentEffect::SystemContextAppendResolved {
2533 decision,
2534 ..
2535 } => Some(decision),
2536 _ => None,
2537 })
2538 .ok_or_else(|| {
2539 SystemContextStageError::InvalidRequest(
2540 "generated session document authority returned no append decision".to_string(),
2541 )
2542 })
2543 }
2544
2545 fn pending_apply_item(source_kind: SystemContextSource) -> Option<(bool, bool, bool)> {
2548 let mut authority = document_authority();
2549 match authority.resolve_system_context_pending_apply_item(source_kind.into()) {
2550 Ok(effects) => effects.into_iter().find_map(|effect| {
2551 match effect {
2552 session_document::SessionDocumentEffect::SystemContextPendingApplyItemResolved {
2553 promote_to_applied,
2554 mark_seen_applied,
2555 remove_seen,
2556 } => Some((promote_to_applied, mark_seen_applied, remove_seen)),
2557 _ => None,
2558 }
2559 }),
2560 Err(err) => {
2561 tracing::warn!(
2562 error = %err,
2563 "generated session document authority rejected system-context apply item"
2564 );
2565 None
2566 }
2567 }
2568 }
2569
2570 fn steer_cleanup_discards(source_kind: SystemContextSource) -> bool {
2573 let mut authority = document_authority();
2574 match authority.resolve_system_context_steer_cleanup_item(source_kind.into()) {
2575 Ok(effects) => effects
2576 .into_iter()
2577 .find_map(|effect| {
2578 match effect {
2579 session_document::SessionDocumentEffect::SystemContextSteerCleanupItemResolved {
2580 discard,
2581 } => Some(discard),
2582 _ => None,
2583 }
2584 })
2585 .unwrap_or(false),
2586 Err(err) => {
2587 tracing::warn!(
2588 error = %err,
2589 "generated session document authority rejected system-context steer cleanup item"
2590 );
2591 false
2592 }
2593 }
2594 }
2595
2596 pub(super) fn restore_system_context_state(
2597 state: SessionSystemContextState,
2598 ) -> Result<SessionSystemContextState, SystemContextStageError> {
2599 let active_keys_have_known_pending_or_seen =
2600 state.active_turn_pending_keys.iter().all(|key| {
2601 state.seen.contains_key(key)
2602 || state
2603 .pending
2604 .iter()
2605 .any(|append| append.idempotency_key.as_ref() == Some(key))
2606 });
2607 let seen_keys_match_known_appends = state.seen.iter().all(|(key, seen)| {
2608 state
2609 .pending
2610 .iter()
2611 .chain(state.applied.iter())
2612 .any(|append| {
2613 append.idempotency_key.as_ref() == Some(key)
2614 && seen.content == append.content
2615 && seen.source.as_deref() == append.source.as_deref()
2616 })
2617 });
2618 let mut authority = document_authority();
2619 authority
2620 .restore_system_context_snapshot(
2621 active_keys_have_known_pending_or_seen,
2622 seen_keys_match_known_appends,
2623 )
2624 .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
2625 Ok(state)
2626 }
2627
2628 pub(super) fn stage_append(
2629 state: &mut SessionSystemContextState,
2630 req: &AppendSystemContextRequest,
2631 accepted_at: SystemTime,
2632 active_turn_scoped: bool,
2633 ) -> Result<AppendSystemContextStatus, SystemContextStageError> {
2634 let rendered_text = req.content.render_text();
2638 let rendered_len = rendered_text.trim().len();
2639 let existing = req
2640 .idempotency_key
2641 .as_ref()
2642 .and_then(|key| state.seen.get(key));
2643 let existing_key_matches = existing.is_some_and(|existing| {
2644 existing.content == req.content && existing.source.as_deref() == req.source.as_deref()
2645 });
2646 let existing_key_conflicts = existing.is_some() && !existing_key_matches;
2647 let decision = resolve_append_decision(
2648 usize_to_u64(rendered_len),
2649 req.idempotency_key.is_some(),
2650 existing_key_matches,
2651 existing_key_conflicts,
2652 active_turn_scoped,
2653 )?;
2654
2655 match decision {
2656 session_document::SystemContextAppendDecision::RejectEmpty => {
2657 return Err(SystemContextStageError::InvalidRequest(
2658 "system context text must not be empty".to_string(),
2659 ));
2660 }
2661 session_document::SystemContextAppendDecision::RejectConflict => {
2662 let Some(key) = req.idempotency_key.as_ref() else {
2663 return Err(SystemContextStageError::InvalidRequest(
2664 "generated system-context authority rejected append without a key"
2665 .to_string(),
2666 ));
2667 };
2668 let Some(existing) = existing else {
2669 return Err(SystemContextStageError::InvalidRequest(
2670 "generated system-context authority rejected append without a conflict"
2671 .to_string(),
2672 ));
2673 };
2674 return Err(SystemContextStageError::Conflict {
2675 key: key.clone(),
2676 existing_text: existing.content.render_text(),
2677 existing_source: existing.source.clone(),
2678 });
2679 }
2680 session_document::SystemContextAppendDecision::Duplicate => {
2681 return Ok(AppendSystemContextStatus::Duplicate);
2682 }
2683 session_document::SystemContextAppendDecision::Staged => {}
2684 }
2685
2686 let append = PendingSystemContextAppend {
2687 content: req.content.clone(),
2688 source: req.source.clone(),
2689 idempotency_key: req.idempotency_key.clone(),
2690 source_kind: req.source_kind,
2691 peer_response_terminal: req.peer_response_terminal.clone(),
2695 accepted_at,
2696 };
2697 if let Some(key) = req.idempotency_key.as_ref() {
2698 state.seen.insert(
2699 key.clone(),
2700 SeenSystemContextKey {
2701 content: append.content.clone(),
2702 source: append.source.clone(),
2703 source_kind: append.source_kind,
2704 state: SeenSystemContextState::Pending,
2705 },
2706 );
2707 }
2708 if active_turn_scoped && let Some(key) = req.idempotency_key.as_ref() {
2709 state.active_turn_pending_keys.insert(key.clone());
2710 }
2711 state.pending.push(append);
2712 Ok(AppendSystemContextStatus::Staged)
2713 }
2714
2715 pub(super) fn mark_pending_applied(state: &mut SessionSystemContextState) {
2716 let pending = std::mem::take(&mut state.pending);
2719 let mut seen_to_remove = Vec::new();
2720 for append in &pending {
2721 let Some((promote_to_applied, mark_seen_applied, remove_seen)) =
2722 pending_apply_item(append.source_kind)
2723 else {
2724 continue;
2725 };
2726 if promote_to_applied && !state.applied.contains(append) {
2727 state.applied.push(append.clone());
2728 }
2729 if let Some(key) = append.idempotency_key.as_ref() {
2730 if remove_seen {
2731 seen_to_remove.push(key.clone());
2732 } else if mark_seen_applied && let Some(seen) = state.seen.get_mut(key) {
2733 seen.state = SeenSystemContextState::Applied;
2734 }
2735 }
2736 }
2737 for key in seen_to_remove {
2738 state.seen.remove(&key);
2739 }
2740 state.active_turn_pending_keys.clear();
2741 }
2742
2743 pub(super) fn discard_unapplied_active_turn_pending(
2744 state: &mut SessionSystemContextState,
2745 ) -> Vec<PendingSystemContextAppend> {
2746 if state.active_turn_pending_keys.is_empty() {
2747 return Vec::new();
2748 }
2749 let active_keys = std::mem::take(&mut state.active_turn_pending_keys);
2750 let mut discarded = Vec::new();
2751 state.pending.retain(|append| {
2752 let should_discard = append
2753 .idempotency_key
2754 .as_ref()
2755 .is_some_and(|key| active_keys.contains(key));
2756 if should_discard {
2757 discarded.push(append.clone());
2758 }
2759 !should_discard
2760 });
2761
2762 for append in &discarded {
2763 if let Some(key) = append.idempotency_key.as_ref()
2764 && state
2765 .seen
2766 .get(key)
2767 .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
2768 {
2769 state.seen.remove(key);
2770 }
2771 }
2772
2773 discarded
2774 }
2775
2776 pub(super) fn discard_active_turn_pending_by_keys(
2777 state: &mut SessionSystemContextState,
2778 idempotency_keys: &[String],
2779 ) -> Vec<PendingSystemContextAppend> {
2780 if idempotency_keys.is_empty() || state.active_turn_pending_keys.is_empty() {
2781 return Vec::new();
2782 }
2783 let requested_keys: BTreeSet<&str> = idempotency_keys.iter().map(String::as_str).collect();
2784 let mut discarded = Vec::new();
2785 let mut discarded_keys = Vec::new();
2786 state.pending.retain(|append| {
2787 let should_discard = append.idempotency_key.as_ref().is_some_and(|key| {
2788 requested_keys.contains(key.as_str())
2789 && state.active_turn_pending_keys.contains(key)
2790 });
2791 if should_discard {
2792 if let Some(key) = append.idempotency_key.as_ref() {
2793 discarded_keys.push(key.clone());
2794 }
2795 discarded.push(append.clone());
2796 }
2797 !should_discard
2798 });
2799
2800 for key in discarded_keys {
2801 state.active_turn_pending_keys.remove(&key);
2802 if state
2803 .seen
2804 .get(&key)
2805 .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
2806 {
2807 state.seen.remove(&key);
2808 }
2809 }
2810
2811 discarded
2812 }
2813
2814 pub(super) fn discard_transient_runtime_steer_state(
2815 state: &mut SessionSystemContextState,
2816 ) -> usize {
2817 let mut removed = 0usize;
2818
2819 let before_pending = state.pending.len();
2820 state
2821 .pending
2822 .retain(|append| !steer_cleanup_discards(append.source_kind));
2823 removed += before_pending.saturating_sub(state.pending.len());
2824
2825 let before_applied = state.applied.len();
2826 state
2827 .applied
2828 .retain(|append| !steer_cleanup_discards(append.source_kind));
2829 removed += before_applied.saturating_sub(state.applied.len());
2830
2831 let before_seen = state.seen.len();
2832 state
2833 .seen
2834 .retain(|_key, seen| !steer_cleanup_discards(seen.source_kind));
2835 removed += before_seen.saturating_sub(state.seen.len());
2836
2837 let before_active = state.active_turn_pending_keys.len();
2841 let steer_keys: BTreeSet<String> = state
2842 .seen
2843 .iter()
2844 .filter(|(_key, seen)| steer_cleanup_discards(seen.source_kind))
2845 .map(|(key, _seen)| key.clone())
2846 .collect();
2847 state
2851 .active_turn_pending_keys
2852 .retain(|key| state.seen.contains_key(key) && !steer_keys.contains(key));
2853 removed += before_active.saturating_sub(state.active_turn_pending_keys.len());
2854
2855 removed
2856 }
2857
2858 pub(super) fn remove_runtime_steer_blocks_for_rendered(
2859 system_prompt: &str,
2860 runtime_steer_appends: &[PendingSystemContextAppend],
2861 ) -> (String, usize) {
2862 if runtime_steer_appends.is_empty() {
2863 return (system_prompt.to_string(), 0);
2864 }
2865 let steer_blocks: BTreeSet<String> = runtime_steer_appends
2869 .iter()
2870 .map(render_system_context_block)
2871 .collect();
2872 let parts = system_prompt
2873 .split(super::SYSTEM_CONTEXT_SEPARATOR)
2874 .map(str::to_string)
2875 .collect::<Vec<_>>();
2876 let original_len = parts.len();
2877 let retained = parts
2878 .into_iter()
2879 .filter(|part| !steer_blocks.contains(part))
2880 .collect::<Vec<_>>();
2881 let removed = original_len.saturating_sub(retained.len());
2882 (retained.join(super::SYSTEM_CONTEXT_SEPARATOR), removed)
2883 }
2884
2885 pub(super) fn record_applied_system_context_blocks(
2886 state: &mut SessionSystemContextState,
2887 appends: &[PendingSystemContextAppend],
2888 current_system_prompt: &str,
2889 ) -> Vec<PendingSystemContextAppend> {
2890 let mut new_appends: Vec<PendingSystemContextAppend> = Vec::new();
2891 for append in appends {
2892 if append.content.render_text().trim().is_empty() {
2893 continue;
2894 }
2895 let rendered = render_system_context_block(append);
2896 if let Some(key) = append.idempotency_key.as_ref() {
2897 if let Some(existing) = state.seen.get(key)
2898 && !seen_system_context_matches(existing, append)
2899 {
2900 tracing::warn!(
2901 idempotency_key = %key,
2902 "skipping conflicting runtime system-context append"
2903 );
2904 continue;
2905 }
2906 if let Some(existing) = state
2907 .applied
2908 .iter()
2909 .find(|applied| applied.idempotency_key.as_ref() == Some(key))
2910 && !pending_system_context_matches(existing, append)
2911 {
2912 tracing::warn!(
2913 idempotency_key = %key,
2914 "skipping conflicting runtime system-context append"
2915 );
2916 continue;
2917 }
2918 if let Some(existing) = new_appends
2919 .iter()
2920 .find(|pending| pending.idempotency_key.as_ref() == Some(key))
2921 {
2922 if !pending_system_context_matches(existing, append) {
2923 tracing::warn!(
2924 idempotency_key = %key,
2925 "skipping conflicting runtime system-context append"
2926 );
2927 }
2928 continue;
2929 }
2930 if current_system_prompt.contains(&rendered) {
2931 record_applied_append(state, append);
2932 continue;
2933 }
2934 } else if new_appends.contains(append) || current_system_prompt.contains(&rendered) {
2935 continue;
2936 }
2937 record_applied_append(state, append);
2938 new_appends.push(append.clone());
2939 }
2940 new_appends
2941 }
2942
2943 fn record_applied_append(
2944 state: &mut SessionSystemContextState,
2945 append: &PendingSystemContextAppend,
2946 ) {
2947 if let Some(key) = append.idempotency_key.as_ref() {
2948 state.seen.insert(
2949 key.clone(),
2950 SeenSystemContextKey {
2951 content: append.content.clone(),
2952 source: append.source.clone(),
2953 source_kind: append.source_kind,
2954 state: SeenSystemContextState::Applied,
2955 },
2956 );
2957 if state
2958 .applied
2959 .iter()
2960 .any(|applied| applied.idempotency_key.as_ref() == Some(key))
2961 {
2962 return;
2963 }
2964 } else if state.applied.contains(append) {
2965 return;
2966 }
2967 state.applied.push(append.clone());
2968 }
2969
2970 fn seen_system_context_matches(
2971 seen: &SeenSystemContextKey,
2972 append: &PendingSystemContextAppend,
2973 ) -> bool {
2974 seen.content == append.content && seen.source.as_deref() == append.source.as_deref()
2975 }
2976
2977 fn pending_system_context_matches(
2978 existing: &PendingSystemContextAppend,
2979 append: &PendingSystemContextAppend,
2980 ) -> bool {
2981 existing.content == append.content && existing.source.as_deref() == append.source.as_deref()
2982 }
2983}
2984
2985impl Session {
2986 pub fn new() -> Self {
2988 let now = SystemTime::now();
2989 Self {
2990 version: session_version(),
2991 id: SessionId::new(),
2992 messages: Arc::new(Vec::new()),
2993 created_at: now,
2994 updated_at: now,
2995 metadata: serde_json::Map::new(),
2996 usage: Usage::default(),
2997 }
2998 }
2999
3000 pub fn with_id(id: SessionId) -> Self {
3002 let mut session = Self::new();
3003 session.id = id;
3004 session
3005 }
3006
3007 pub fn id(&self) -> &SessionId {
3009 &self.id
3010 }
3011
3012 pub fn version(&self) -> u32 {
3014 self.version
3015 }
3016
3017 pub fn messages(&self) -> &[Message] {
3019 &self.messages
3020 }
3021
3022 pub(crate) fn replace_messages_internal(
3028 &mut self,
3029 messages: Vec<Message>,
3030 reason: TranscriptRewriteReason,
3031 ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError> {
3032 if transcript_messages_digest(self.messages()).ok()
3033 == transcript_messages_digest(&messages).ok()
3034 {
3035 return Ok(None);
3036 }
3037 let commit = self.commit_transcript_rewrite(
3038 TranscriptRewriteSelection::MessageRange {
3039 start: 0,
3040 end: self.messages.len(),
3041 },
3042 messages,
3043 reason,
3044 Some("meerkat-core".to_string()),
3045 None,
3046 )?;
3047 Ok(Some(commit))
3048 }
3049
3050 pub(crate) fn retain_messages_internal<F>(
3052 &mut self,
3053 mut retain: F,
3054 reason: TranscriptRewriteReason,
3055 ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError>
3056 where
3057 F: FnMut(&Message) -> bool,
3058 {
3059 let retained = self
3060 .messages
3061 .iter()
3062 .filter(|message| retain(message))
3063 .cloned()
3064 .collect::<Vec<_>>();
3065 if retained.len() == self.messages.len()
3066 && transcript_messages_digest(self.messages()).ok()
3067 == transcript_messages_digest(&retained).ok()
3068 {
3069 return Ok(None);
3070 }
3071 self.replace_messages_internal(retained, reason)
3072 }
3073
3074 pub fn replace_synthetic_notices(
3084 &mut self,
3085 kind: crate::types::SystemNoticeKind,
3086 replacements: Vec<Message>,
3087 ) -> Result<(), TranscriptEditError> {
3088 for (index, message) in replacements.iter().enumerate() {
3089 let matches_kind =
3090 matches!(message, Message::SystemNotice(notice) if notice.kind == kind);
3091 if !matches_kind {
3092 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
3093 "replacement {index} for synthetic notice kind {kind:?} is not a system notice of that kind"
3094 )));
3095 }
3096 }
3097 self.retain_messages_internal(
3098 |message| !matches!(message, Message::SystemNotice(notice) if notice.kind == kind),
3099 TranscriptRewriteReason::new("synthetic_notice_cleanup"),
3100 )?;
3101 for message in replacements {
3102 self.push(message);
3103 }
3104 Ok(())
3105 }
3106
3107 pub fn created_at(&self) -> SystemTime {
3109 self.created_at
3110 }
3111
3112 pub fn updated_at(&self) -> SystemTime {
3114 self.updated_at
3115 }
3116
3117 pub fn push(&mut self, message: Message) {
3121 Arc::make_mut(&mut self.messages).push(message);
3122 self.updated_at = SystemTime::now();
3123 self.refresh_transcript_head_after_message_mutation();
3124 }
3125
3126 pub fn push_batch(&mut self, messages: Vec<Message>) {
3130 if messages.is_empty() {
3131 return;
3132 }
3133 let inner = Arc::make_mut(&mut self.messages);
3134 inner.extend(messages);
3135 self.updated_at = SystemTime::now();
3136 self.refresh_transcript_head_after_message_mutation();
3137 }
3138
3139 pub async fn externalize_media(
3150 &mut self,
3151 blob_store: &dyn crate::BlobStore,
3152 start: usize,
3153 ) -> Result<(), crate::blob::BlobStoreError> {
3154 let previous_digest = if self
3155 .metadata
3156 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
3157 {
3158 transcript_messages_digest(self.messages()).ok()
3159 } else {
3160 None
3161 };
3162 let messages = Arc::make_mut(&mut self.messages);
3163 crate::image_content::externalize_messages_from(blob_store, messages, start).await?;
3164 if let Some(previous_digest) = previous_digest
3165 && transcript_messages_digest(self.messages()).ok().as_ref() != Some(&previous_digest)
3166 {
3167 self.refresh_transcript_head_after_message_mutation();
3168 }
3169 Ok(())
3170 }
3171
3172 pub fn touch(&mut self) {
3176 self.updated_at = SystemTime::now();
3177 }
3178
3179 pub fn last_n(&self, n: usize) -> &[Message] {
3181 let start = self.messages.len().saturating_sub(n);
3182 &self.messages[start..]
3183 }
3184
3185 pub fn total_tokens(&self) -> u64 {
3187 self.usage.total_tokens()
3188 }
3189
3190 pub fn total_usage(&self) -> Usage {
3192 self.usage.clone()
3193 }
3194
3195 pub fn record_usage(&mut self, turn_usage: Usage) {
3197 self.usage.add(&turn_usage);
3198 self.updated_at = SystemTime::now();
3199 }
3200
3201 pub fn append_external_user_content(&mut self, content: ContentInput) {
3203 self.push(Message::User(UserMessage::with_blocks(
3204 content.into_blocks(),
3205 )));
3206 }
3207
3208 pub fn append_external_assistant_blocks(
3210 &mut self,
3211 blocks: Vec<AssistantBlock>,
3212 stop_reason: StopReason,
3213 usage: Usage,
3214 ) {
3215 if !blocks.is_empty() {
3216 self.push(Message::BlockAssistant(BlockAssistantMessage::new(
3217 blocks,
3218 stop_reason,
3219 )));
3220 }
3221 if usage != Usage::default() {
3222 self.record_usage(usage);
3223 }
3224 }
3225
3226 pub fn append_realtime_transcript_event(
3234 &mut self,
3235 event: RealtimeTranscriptEvent,
3236 ) -> RealtimeTranscriptApplyOutcome {
3237 let mut state = self.realtime_transcript_state();
3238 let commit =
3239 realtime_transcript_revision::apply_realtime_transcript_event(&mut state, event)
3240 .unwrap_or_else(|err| {
3241 fail_closed_generated_restore(
3242 "realtime-transcript",
3243 <serde_json::Error as serde::de::Error>::custom(err),
3244 )
3245 });
3246 self.store_realtime_transcript_state(&state);
3247 self.push_batch(commit.messages);
3248 if commit.usage != Usage::default() {
3249 self.record_usage(commit.usage);
3250 }
3251 commit.outcome
3252 }
3253
3254 #[must_use]
3273 pub fn in_flight_realtime_assistant_response_ids(&self) -> Vec<String> {
3274 let state = self.realtime_transcript_state();
3275 realtime_transcript_revision::in_flight_realtime_assistant_response_ids(&state)
3276 }
3277
3278 fn realtime_transcript_state(&self) -> SessionRealtimeTranscriptState {
3279 match self.try_realtime_transcript_state() {
3280 Ok(Some(state)) => state,
3281 Ok(None) => SessionRealtimeTranscriptState::default(),
3282 Err(err) => fail_closed_generated_restore("realtime-transcript", err),
3283 }
3284 }
3285
3286 fn try_realtime_transcript_state(
3287 &self,
3288 ) -> Result<Option<SessionRealtimeTranscriptState>, serde_json::Error> {
3289 self.metadata
3290 .get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY)
3291 .map(|value| {
3292 let state = serde_json::from_value(value.clone())?;
3293 realtime_transcript_revision::restore_realtime_transcript_state(state)
3294 .map_err(<serde_json::Error as serde::de::Error>::custom)
3295 })
3296 .transpose()
3297 }
3298
3299 fn store_realtime_transcript_state(&mut self, state: &SessionRealtimeTranscriptState) {
3300 match serde_json::to_value(state) {
3301 Ok(value) => self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value),
3302 Err(error) => {
3303 tracing::warn!(error = %error, "failed to serialize realtime transcript state");
3304 }
3305 }
3306 }
3307
3308 fn apply_authorized_system_prompt(
3309 &mut self,
3310 prompt: session_durable_config_authority::AuthorizedSystemPrompt,
3311 ) {
3312 use crate::types::SystemMessage;
3313
3314 let mutation_kind = prompt.mutation_kind();
3319 let (prompt, _replacing_existing) = prompt.into_parts();
3320 let message = SystemMessage::with_mutation_kind(prompt, mutation_kind);
3321 let inner = Arc::make_mut(&mut self.messages);
3322 if let Some(Message::System(_)) = inner.first() {
3324 inner[0] = Message::System(message);
3325 } else {
3326 inner.insert(0, Message::System(message));
3327 }
3328 self.updated_at = SystemTime::now();
3329 self.refresh_transcript_head_after_message_mutation();
3330 }
3331
3332 pub fn set_system_prompt_with_source(
3334 &mut self,
3335 prompt: String,
3336 source: session_durable_config_authority::SessionSystemPromptSource,
3337 ) -> Result<(), session_durable_config_authority::SessionDurableConfigAuthorityError> {
3338 let replacing_existing = matches!(self.messages.first(), Some(Message::System(_)));
3339 let prompt = session_durable_config_authority::authorize_system_prompt_mutation(
3340 prompt,
3341 source,
3342 replacing_existing,
3343 )?;
3344 self.apply_authorized_system_prompt(prompt);
3345 Ok(())
3346 }
3347
3348 pub fn set_system_prompt(&mut self, prompt: String) {
3350 if let Err(err) = self.set_system_prompt_with_source(
3351 prompt,
3352 session_durable_config_authority::SessionSystemPromptSource::DirectMutation,
3353 ) {
3354 tracing::warn!(error = %err, "generated session durable-config authority rejected system prompt mutation");
3355 }
3356 }
3357
3358 pub fn discard_transient_runtime_steer_context(&mut self) -> usize {
3364 let mut removed = 0usize;
3365
3366 let mut state = match self.try_system_context_state() {
3367 Ok(state) => state.unwrap_or_default(),
3368 Err(err) => {
3369 tracing::warn!(
3370 error = %err,
3371 "generated system-context authority rejected runtime steer cleanup state"
3372 );
3373 return removed;
3374 }
3375 };
3376
3377 let runtime_steer_appends = state
3382 .pending
3383 .iter()
3384 .chain(state.applied.iter())
3385 .filter(|append| append.source_kind.is_runtime_steer())
3386 .cloned()
3387 .collect::<Vec<_>>();
3388 if let Some(Message::System(system)) = self.messages.first() {
3389 let (retained_prompt, removed_blocks) =
3390 system_context_authority::remove_runtime_steer_blocks_for_rendered(
3391 &system.content,
3392 &runtime_steer_appends,
3393 );
3394 if removed_blocks > 0 {
3395 removed += removed_blocks;
3396 if let Err(err) = self.set_system_prompt_with_source(
3397 retained_prompt,
3398 session_durable_config_authority::SessionSystemPromptSource::RuntimeSteerCleanup,
3399 ) {
3400 tracing::warn!(
3401 error = %err,
3402 "generated session durable-config authority rejected runtime steer prompt cleanup"
3403 );
3404 }
3405 }
3406 }
3407
3408 removed += system_context_authority::discard_transient_runtime_steer_state(&mut state);
3409
3410 if removed > 0
3411 && let Err(err) = self.set_system_context_state(state)
3412 {
3413 tracing::warn!(
3414 error = %err,
3415 "failed to persist runtime steer context cleanup"
3416 );
3417 }
3418
3419 removed
3420 }
3421
3422 pub fn append_system_context_blocks(&mut self, appends: &[PendingSystemContextAppend]) {
3424 if appends.is_empty() {
3425 return;
3426 }
3427
3428 let current_system_prompt = self
3429 .messages
3430 .first()
3431 .and_then(|message| match message {
3432 Message::System(system) => Some(system.content.as_str()),
3433 _ => None,
3434 })
3435 .unwrap_or_default();
3436 let mut state = match self.try_system_context_state() {
3437 Ok(state) => state.unwrap_or_default(),
3438 Err(err) => {
3439 tracing::warn!(
3440 error = %err,
3441 "generated system-context authority rejected applied context state"
3442 );
3443 return;
3444 }
3445 };
3446 let new_appends = system_context_authority::record_applied_system_context_blocks(
3447 &mut state,
3448 appends,
3449 current_system_prompt,
3450 );
3451 if new_appends.is_empty() {
3452 if let Err(err) = self.set_system_context_state(state) {
3453 tracing::warn!(error = %err, "failed to persist applied system-context state");
3454 }
3455 return;
3456 }
3457
3458 let rendered = render_system_context_blocks_joined(&new_appends);
3459
3460 let next = match self.messages.first() {
3461 Some(Message::System(sys)) if !sys.content.is_empty() => {
3462 format!("{}{}{}", sys.content, SYSTEM_CONTEXT_SEPARATOR, rendered)
3463 }
3464 _ => rendered,
3465 };
3466 if let Err(err) = self.set_system_prompt_with_source(
3467 next,
3468 session_durable_config_authority::SessionSystemPromptSource::RuntimeContextAppend,
3469 ) {
3470 tracing::warn!(
3471 error = %err,
3472 "generated session durable-config authority rejected system-context prompt append"
3473 );
3474 return;
3475 }
3476 if let Err(err) = self.set_system_context_state(state) {
3477 tracing::warn!(error = %err, "failed to persist applied system-context state");
3478 }
3479 }
3480
3481 pub fn reconcile_resumed_system_prompt(
3507 &mut self,
3508 assembled_base: String,
3509 actor: Option<String>,
3510 ) -> Result<ResumedSystemPromptReconciliation, TranscriptEditError> {
3511 let persisted = match self.messages.first() {
3512 Some(Message::System(system)) => Some((system.content.clone(), system.mutation_kind)),
3513 _ => None,
3514 };
3515
3516 let Some((persisted_content, persisted_mutation_kind)) = persisted else {
3517 if assembled_base.is_empty() {
3518 return Ok(ResumedSystemPromptReconciliation::NoChange);
3519 }
3520 self.commit_resume_system_prompt_rewrite(assembled_base, false, actor)?;
3524 return Ok(ResumedSystemPromptReconciliation::RewrittenBase);
3525 };
3526
3527 if persisted_content == assembled_base {
3528 return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
3529 }
3530
3531 if let Some(tail) = self.verified_runtime_context_tail(&persisted_content) {
3540 let expected = compose_system_prompt_with_context_tail(&assembled_base, &tail);
3541 if expected == persisted_content {
3542 return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
3543 }
3544 self.commit_resume_system_prompt_rewrite(expected, true, actor)?;
3545 return Ok(ResumedSystemPromptReconciliation::RewrittenBase);
3546 }
3547
3548 if persisted_prompt_is_admitted_context_append_continuation(
3556 &assembled_base,
3557 &persisted_content,
3558 persisted_mutation_kind,
3559 ) {
3560 return Ok(ResumedSystemPromptReconciliation::PreservedContinuation);
3561 }
3562
3563 let dropping_applied_context = persisted_mutation_kind.is_runtime_context_append()
3570 || self
3571 .system_context_state()
3572 .is_some_and(|state| !state.applied.is_empty());
3573 self.commit_resume_system_prompt_rewrite(assembled_base, true, actor)?;
3574 if dropping_applied_context {
3575 tracing::warn!(
3576 session_id = %self.id,
3577 "resume base-prompt refresh dropped an unverifiable runtime system-context tail; \
3578 clearing applied-append records so keyed re-sends can restore the context"
3579 );
3580 self.clear_applied_system_context_records();
3581 }
3582 Ok(ResumedSystemPromptReconciliation::RewrittenBase)
3583 }
3584
3585 fn verified_runtime_context_tail(&self, persisted_content: &str) -> Option<String> {
3595 if let Some(prior_base) = self
3596 .build_state()
3597 .and_then(|state| state.assembled_system_prompt)
3598 {
3599 if persisted_content == prior_base {
3600 return Some(String::new());
3601 }
3602 if let Some(appended) = persisted_content.strip_prefix(prior_base.as_str())
3603 && appended.starts_with(SYSTEM_CONTEXT_SEPARATOR)
3604 {
3605 return Some(appended.to_string());
3606 }
3607 }
3610 let rendered_tail = self
3611 .system_context_state()
3612 .map(|state| render_system_context_blocks_joined(&state.applied))
3613 .unwrap_or_default();
3614 if rendered_tail.is_empty() {
3615 return None;
3616 }
3617 if persisted_content == rendered_tail {
3618 return Some(format!("{SYSTEM_CONTEXT_SEPARATOR}{rendered_tail}"));
3622 }
3623 let with_separator = format!("{SYSTEM_CONTEXT_SEPARATOR}{rendered_tail}");
3624 persisted_content
3625 .ends_with(&with_separator)
3626 .then_some(with_separator)
3627 }
3628
3629 fn commit_resume_system_prompt_rewrite(
3632 &mut self,
3633 content: String,
3634 replacing_existing: bool,
3635 actor: Option<String>,
3636 ) -> Result<(), TranscriptEditError> {
3637 let authorized = session_durable_config_authority::authorize_system_prompt_mutation(
3638 content,
3639 session_durable_config_authority::SessionSystemPromptSource::ExplicitBuild,
3640 replacing_existing,
3641 )
3642 .map_err(|err| {
3643 TranscriptEditError::HistoryStateMalformed(format!(
3644 "generated session durable-config authority rejected resume system prompt refresh: {err}"
3645 ))
3646 })?;
3647 let mutation_kind = authorized.mutation_kind();
3648 let (content, _replacing_existing) = authorized.into_parts();
3649 let replacement = Message::System(crate::types::SystemMessage::with_mutation_kind(
3650 content,
3651 mutation_kind,
3652 ));
3653 let end = usize::from(replacing_existing);
3654 self.commit_transcript_rewrite(
3655 TranscriptRewriteSelection::MessageRange { start: 0, end },
3656 vec![replacement],
3657 TranscriptRewriteReason::new(RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON),
3658 actor,
3659 None,
3660 )?;
3661 Ok(())
3662 }
3663
3664 fn clear_applied_system_context_records(&mut self) {
3668 let mut state = match self.try_system_context_state() {
3669 Ok(Some(state)) => state,
3670 Ok(None) => return,
3671 Err(error) => {
3672 tracing::warn!(
3673 session_id = %self.id,
3674 error = %error,
3675 "failed to read system-context state while clearing orphaned applied records"
3676 );
3677 return;
3678 }
3679 };
3680 if state.applied.is_empty() {
3681 return;
3682 }
3683 let dropped_keys: Vec<String> = state
3684 .applied
3685 .iter()
3686 .filter_map(|append| append.idempotency_key.clone())
3687 .collect();
3688 state.applied.clear();
3689 for key in &dropped_keys {
3690 state.seen.remove(key);
3691 }
3692 if let Err(error) = self.set_system_context_state(state) {
3693 tracing::warn!(
3694 session_id = %self.id,
3695 error = %error,
3696 "failed to persist cleared applied system-context records after resume prompt refresh"
3697 );
3698 }
3699 }
3700
3701 pub fn last_assistant_text(&self) -> Option<String> {
3708 self.messages.iter().rev().find_map(|m| match m {
3709 Message::BlockAssistant(a) => {
3710 let mut buf = String::new();
3711 for block in &a.blocks {
3712 match block {
3713 crate::types::AssistantBlock::Text { text, .. }
3714 | crate::types::AssistantBlock::Transcript { text, .. } => {
3715 buf.push_str(text);
3716 }
3717 _ => {}
3718 }
3719 }
3720 if buf.is_empty() { None } else { Some(buf) }
3721 }
3722 _ => None,
3723 })
3724 }
3725
3726 pub fn tool_call_count(&self) -> usize {
3728 self.messages
3729 .iter()
3730 .filter_map(|m| match m {
3731 Message::BlockAssistant(a) => Some(
3732 a.blocks
3733 .iter()
3734 .filter(|b| matches!(b, crate::types::AssistantBlock::ToolUse { .. }))
3735 .count(),
3736 ),
3737 _ => None,
3738 })
3739 .sum()
3740 }
3741
3742 pub fn metadata(&self) -> &serde_json::Map<String, serde_json::Value> {
3744 &self.metadata
3745 }
3746
3747 fn set_metadata_unchecked(&mut self, key: &str, value: serde_json::Value) {
3748 self.metadata.insert(key.to_string(), value);
3749 self.updated_at = SystemTime::now();
3750 }
3751
3752 #[cfg(test)]
3753 pub(crate) fn set_metadata_unchecked_for_test(&mut self, key: &str, value: serde_json::Value) {
3754 self.set_metadata_unchecked(key, value);
3755 }
3756
3757 fn fork_metadata_projection(&self) -> serde_json::Map<String, serde_json::Value> {
3758 let mut metadata = self.metadata.clone();
3759 metadata.retain(|key, _| !is_session_authority_metadata_key(key));
3760 metadata
3761 }
3762
3763 fn remove_metadata_unchecked(&mut self, key: &str) {
3764 self.metadata.remove(key);
3765 self.updated_at = SystemTime::now();
3766 }
3767
3768 pub fn try_set_metadata(
3770 &mut self,
3771 key: &str,
3772 value: serde_json::Value,
3773 ) -> Result<(), ReservedSessionMetadataKey> {
3774 if is_session_authority_metadata_key(key) {
3775 return Err(ReservedSessionMetadataKey::new(key));
3776 }
3777 self.set_metadata_unchecked(key, value);
3778 Ok(())
3779 }
3780
3781 pub fn set_metadata(&mut self, key: &str, value: serde_json::Value) {
3786 if let Err(err) = self.try_set_metadata(key, value) {
3787 tracing::warn!(error = %err, "rejected raw session metadata mutation");
3788 }
3789 }
3790
3791 pub fn backfill_metadata_if_absent(&mut self, key: &str, value: serde_json::Value) -> bool {
3797 if is_session_authority_metadata_key(key) {
3798 tracing::warn!(
3799 metadata_key = key,
3800 "rejected raw session metadata backfill for authority key"
3801 );
3802 return false;
3803 }
3804 if self.metadata.contains_key(key) {
3805 false
3806 } else {
3807 self.metadata.insert(key.to_string(), value);
3808 true
3809 }
3810 }
3811
3812 pub fn remove_metadata(&mut self, key: &str) {
3814 if is_session_authority_metadata_key(key) {
3815 tracing::warn!(
3816 metadata_key = key,
3817 "rejected raw session metadata removal for authority key"
3818 );
3819 return;
3820 }
3821 self.metadata.remove(key);
3822 self.updated_at = SystemTime::now();
3823 }
3824
3825 pub fn set_session_metadata(
3827 &mut self,
3828 metadata: SessionMetadata,
3829 ) -> Result<(), serde_json::Error> {
3830 let metadata =
3831 session_durable_config_authority::authorize_session_metadata_persist(metadata)
3832 .map_err(<serde_json::Error as serde::ser::Error>::custom)?
3833 .into_metadata();
3834 let value = serde_json::to_value(metadata)?;
3835 self.set_metadata_unchecked(SESSION_METADATA_KEY, value);
3836 Ok(())
3837 }
3838
3839 pub fn session_metadata(&self) -> Option<SessionMetadata> {
3844 match self.try_session_metadata() {
3845 Ok(metadata) => metadata,
3846 Err(err) => fail_closed_generated_restore("session-metadata", err),
3847 }
3848 }
3849
3850 pub fn try_session_metadata(&self) -> Result<Option<SessionMetadata>, serde_json::Error> {
3852 try_session_metadata_from_map(&self.metadata)
3853 }
3854
3855 pub fn set_system_context_state(
3857 &mut self,
3858 state: SessionSystemContextState,
3859 ) -> Result<(), serde_json::Error> {
3860 let state = system_context_authority::restore_system_context_state(state)
3861 .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
3862 let value = serde_json::to_value(state)?;
3863 self.set_metadata_unchecked(SESSION_SYSTEM_CONTEXT_STATE_KEY, value);
3864 Ok(())
3865 }
3866
3867 pub fn try_system_context_state(
3869 &self,
3870 ) -> Result<Option<SessionSystemContextState>, serde_json::Error> {
3871 self.metadata
3872 .get(SESSION_SYSTEM_CONTEXT_STATE_KEY)
3873 .map(|value| {
3874 let state = serde_json::from_value(value.clone())?;
3875 system_context_authority::restore_system_context_state(state)
3876 .map_err(<serde_json::Error as serde::de::Error>::custom)
3877 })
3878 .transpose()
3879 }
3880
3881 pub fn system_context_state(&self) -> Option<SessionSystemContextState> {
3887 match self.try_system_context_state() {
3888 Ok(state) => state,
3889 Err(err) => fail_closed_generated_restore("system-context", err),
3890 }
3891 }
3892
3893 pub fn set_deferred_turn_state(
3895 &mut self,
3896 state: SessionDeferredTurnState,
3897 ) -> Result<(), serde_json::Error> {
3898 let state = validate_deferred_turn_snapshot(state)
3899 .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
3900 let value = serde_json::to_value(state)?;
3901 self.set_metadata_unchecked(SESSION_DEFERRED_TURN_STATE_KEY, value);
3902 Ok(())
3903 }
3904
3905 pub fn try_deferred_turn_state(
3907 &self,
3908 ) -> Result<Option<SessionDeferredTurnState>, serde_json::Error> {
3909 self.metadata
3910 .get(SESSION_DEFERRED_TURN_STATE_KEY)
3911 .map(|value| {
3912 let state = serde_json::from_value(value.clone())?;
3913 validate_deferred_turn_snapshot(state)
3914 .map_err(<serde_json::Error as serde::de::Error>::custom)
3915 })
3916 .transpose()
3917 }
3918
3919 pub fn deferred_turn_state(&self) -> Option<SessionDeferredTurnState> {
3925 match self.try_deferred_turn_state() {
3926 Ok(state) => state,
3927 Err(err) => fail_closed_generated_restore("deferred-turn", err),
3928 }
3929 }
3930
3931 pub fn set_lifecycle_terminal(
3940 &mut self,
3941 terminal: SessionLifecycleTerminal,
3942 ) -> Result<(), serde_json::Error> {
3943 let value = serde_json::to_value(terminal)?;
3944 self.set_metadata_unchecked(SESSION_LIFECYCLE_TERMINAL_KEY, value);
3945 Ok(())
3946 }
3947
3948 pub fn try_lifecycle_terminal(
3953 &self,
3954 ) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
3955 try_lifecycle_terminal_from_map(&self.metadata)
3956 }
3957
3958 pub fn lifecycle_terminal(&self) -> Option<SessionLifecycleTerminal> {
3964 match self.try_lifecycle_terminal() {
3965 Ok(state) => state,
3966 Err(err) => fail_closed_generated_restore("session-lifecycle-terminal", err),
3967 }
3968 }
3969
3970 pub fn set_build_state(&mut self, state: SessionBuildState) -> Result<(), serde_json::Error> {
3972 let state = session_durable_config_authority::authorize_session_build_state_persist(state)
3973 .map_err(<serde_json::Error as serde::ser::Error>::custom)?
3974 .into_state();
3975 let value = serde_json::to_value(state)?;
3976 self.set_metadata_unchecked(SESSION_BUILD_STATE_KEY, value);
3977 Ok(())
3978 }
3979
3980 pub fn build_state(&self) -> Option<SessionBuildState> {
3985 match self.try_build_state() {
3986 Ok(state) => state,
3987 Err(err) => fail_closed_generated_restore("session-build-state", err),
3988 }
3989 }
3990
3991 pub fn try_build_state(&self) -> Result<Option<SessionBuildState>, serde_json::Error> {
3993 let Some(value) = self.metadata.get(SESSION_BUILD_STATE_KEY) else {
3994 return Ok(None);
3995 };
3996 let state = serde_json::from_value::<SessionBuildState>(value.clone())?;
3997 session_durable_config_authority::restore_session_build_state(state)
3998 .map(Some)
3999 .map_err(<serde_json::Error as serde::de::Error>::custom)
4000 }
4001
4002 pub fn set_tool_visibility_state(
4004 &mut self,
4005 state: AuthorizedSessionToolVisibilityState,
4006 ) -> Result<(), serde_json::Error> {
4007 let value = serde_json::to_value(state.into_state())?;
4008 self.set_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY, value);
4009 Ok(())
4010 }
4011
4012 #[cfg(test)]
4017 pub(crate) fn clear_tool_visibility_state(&mut self) {
4018 self.remove_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY);
4019 }
4020
4021 pub fn tool_visibility_state(
4023 &self,
4024 ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
4025 self.try_tool_visibility_state()
4026 }
4027
4028 pub fn try_tool_visibility_state(
4031 &self,
4032 ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
4033 self.metadata
4034 .get(SESSION_TOOL_VISIBILITY_STATE_KEY)
4035 .map(|value| serde_json::from_value(value.clone()))
4036 .transpose()
4037 }
4038
4039 pub fn transcript_history_state(
4041 &self,
4042 ) -> Result<Option<TranscriptHistoryState>, serde_json::Error> {
4043 self.metadata
4044 .get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
4045 .map(|value| serde_json::from_value(value.clone()))
4046 .transpose()
4047 }
4048
4049 pub fn validate_transcript_history_state(&self) -> Result<(), TranscriptEditError> {
4051 let Some(state) = self
4052 .transcript_history_state()
4053 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
4054 else {
4055 return Ok(());
4056 };
4057 validate_transcript_history_state(&state)
4058 }
4059
4060 pub fn clear_transcript_history_state(&mut self) {
4063 self.remove_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY);
4064 }
4065
4066 pub fn set_runtime_checkpoint_provenance(&mut self) {
4072 self.set_metadata_unchecked(
4073 SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY,
4074 serde_json::Value::Bool(true),
4075 );
4076 }
4077
4078 pub fn clear_runtime_checkpoint_provenance(&mut self) {
4082 self.remove_metadata_unchecked(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY);
4083 }
4084
4085 pub fn has_runtime_checkpoint_provenance(&self) -> bool {
4089 self.metadata
4090 .get(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY)
4091 .and_then(serde_json::Value::as_bool)
4092 .unwrap_or(false)
4093 }
4094
4095 pub fn transcript_revision_body(
4097 &self,
4098 revision: &str,
4099 ) -> Result<Option<TranscriptRevisionBody>, serde_json::Error> {
4100 Ok(self.transcript_history_state()?.and_then(|state| {
4101 state
4102 .revisions
4103 .into_iter()
4104 .find(|body| body.revision == revision)
4105 }))
4106 }
4107
4108 pub fn transcript_revision_messages(
4110 &self,
4111 revision: &str,
4112 ) -> Result<Option<Vec<Message>>, serde_json::Error> {
4113 Ok(self
4114 .transcript_revision_body(revision)?
4115 .map(|body| body.messages))
4116 }
4117
4118 pub fn apply_transcript_history_state(
4120 &mut self,
4121 state: TranscriptHistoryState,
4122 ) -> Result<(), TranscriptEditError> {
4123 validate_transcript_history_state(&state)?;
4124 let head_body = state
4125 .revisions
4126 .iter()
4127 .find(|body| body.revision == state.head)
4128 .ok_or_else(|| {
4129 TranscriptEditError::HistoryStateMalformed(format!(
4130 "missing transcript head body {}",
4131 state.head
4132 ))
4133 })?
4134 .clone();
4135 let value = serde_json::to_value(&state)
4136 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4137 self.set_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, value);
4138 let mut updated_at = head_body.created_at;
4139 for commit in &state.commits {
4140 if commit.committed_at > updated_at {
4141 updated_at = commit.committed_at;
4142 }
4143 }
4144 self.messages = Arc::new(head_body.messages);
4145 self.updated_at = updated_at;
4146 Ok(())
4147 }
4148
4149 pub fn transcript_revision(&self) -> Result<String, serde_json::Error> {
4152 if let Some(state) = self.transcript_history_state()? {
4153 Ok(state.head)
4154 } else {
4155 transcript_messages_digest(self.messages())
4156 }
4157 }
4158
4159 pub fn commit_transcript_rewrite(
4161 &mut self,
4162 selection: TranscriptRewriteSelection,
4163 replacement: Vec<Message>,
4164 reason: TranscriptRewriteReason,
4165 actor: Option<String>,
4166 expected_parent_revision: Option<String>,
4167 ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
4168 let parent_revision = self
4169 .transcript_revision()
4170 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4171 if let Some(expected) = expected_parent_revision
4172 && expected != parent_revision
4173 {
4174 return Err(TranscriptEditError::RevisionConflict {
4175 expected,
4176 actual: parent_revision,
4177 });
4178 }
4179
4180 let (start, end) = selection.bounds();
4181 let message_count = self.messages.len();
4182 if start > end || end > message_count {
4183 return Err(TranscriptEditError::InvalidRewriteRange {
4184 start,
4185 end,
4186 message_count,
4187 });
4188 }
4189
4190 let replacement_len = replacement.len();
4191 let mut rewritten = Vec::with_capacity(
4192 start
4193 .saturating_add(replacement_len)
4194 .saturating_add(message_count.saturating_sub(end)),
4195 );
4196 rewritten.extend_from_slice(&self.messages[..start]);
4197 rewritten.extend(replacement);
4198 rewritten.extend_from_slice(&self.messages[end..]);
4199 validate_transcript_tool_result_shape(&rewritten)?;
4200
4201 let original_span_digest = transcript_messages_digest(&self.messages[start..end])
4202 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4203 let replacement_digest =
4204 transcript_messages_digest(&rewritten[start..start + replacement_len])
4205 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4206 let revision = transcript_messages_digest(&rewritten)
4207 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4208 if revision == parent_revision {
4209 return Err(TranscriptEditError::NoOpRewrite { revision });
4210 }
4211
4212 let commit = TranscriptRewriteCommit {
4213 parent_revision,
4214 revision: revision.clone(),
4215 selection,
4216 original_span_digest,
4217 replacement_digest,
4218 messages_before: message_count,
4219 messages_after: rewritten.len(),
4220 reason,
4221 actor,
4222 committed_at: SystemTime::now(),
4223 };
4224
4225 let mut state = self
4226 .transcript_history_state()
4227 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
4228 .unwrap_or_else(|| TranscriptHistoryState {
4229 head: commit.parent_revision.clone(),
4230 commits: Vec::new(),
4231 revisions: Vec::new(),
4232 });
4233 if !state
4234 .revisions
4235 .iter()
4236 .any(|body| body.revision == commit.parent_revision)
4237 {
4238 state.revisions.push(TranscriptRevisionBody {
4239 revision: commit.parent_revision.clone(),
4240 parent_revision: None,
4241 messages: self.messages().to_vec(),
4242 created_at: self.updated_at,
4243 });
4244 }
4245 if !state
4246 .revisions
4247 .iter()
4248 .any(|body| body.revision == commit.revision)
4249 {
4250 state.revisions.push(TranscriptRevisionBody {
4251 revision: commit.revision.clone(),
4252 parent_revision: Some(commit.parent_revision.clone()),
4253 messages: rewritten.clone(),
4254 created_at: commit.committed_at,
4255 });
4256 }
4257 state.head = revision;
4258 state.commits.push(commit.clone());
4259 let value = serde_json::to_value(state)
4260 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
4261 self.set_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, value);
4262
4263 self.messages = Arc::new(rewritten);
4264 self.updated_at = SystemTime::now();
4265 Ok(commit)
4266 }
4267
4268 fn refresh_transcript_head_after_message_mutation(&mut self) {
4269 if !self
4270 .metadata
4271 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
4272 {
4273 return;
4274 }
4275 let Ok(Some(mut state)) = self.transcript_history_state() else {
4276 tracing::warn!(
4277 session_id = %self.id,
4278 "transcript history state is malformed; leaving head unchanged after message mutation"
4279 );
4280 return;
4281 };
4282 let Ok(head) = transcript_messages_digest(self.messages()) else {
4283 tracing::warn!(
4284 session_id = %self.id,
4285 "failed to digest transcript after message mutation; leaving head unchanged"
4286 );
4287 return;
4288 };
4289 let previous_head = state.head.clone();
4290 if !state.revisions.iter().any(|body| body.revision == head) {
4291 state.revisions.push(TranscriptRevisionBody {
4292 revision: head.clone(),
4293 parent_revision: Some(previous_head),
4294 messages: self.messages().to_vec(),
4295 created_at: SystemTime::now(),
4296 });
4297 }
4298 state.head = head;
4299 match serde_json::to_value(state) {
4300 Ok(value) => self.set_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, value),
4301 Err(error) => {
4302 tracing::warn!(
4303 session_id = %self.id,
4304 error = %error,
4305 "failed to serialize transcript history state after message mutation"
4306 );
4307 }
4308 }
4309 }
4310
4311 pub fn set_mob_tool_authority_context(
4319 &mut self,
4320 authority_context: Option<MobToolAuthorityContext>,
4321 ) -> Result<(), serde_json::Error> {
4322 if let Some(authority_context) = authority_context.as_ref()
4323 && !authority_context.is_generated_authority_context()
4324 {
4325 return Err(<serde_json::Error as serde::de::Error>::custom(
4326 "mob authority context was not minted by generated authority",
4327 ));
4328 }
4329 let mut build_state = self.build_state().ok_or_else(|| {
4330 <serde_json::Error as serde::de::Error>::custom(format!(
4331 "session {} is missing session build state",
4332 self.id
4333 ))
4334 })?;
4335 build_state.mob_tool_authority_context = authority_context;
4336 self.set_build_state(build_state)
4337 }
4338
4339 pub fn mob_tool_authority_context(&self) -> Option<MobToolAuthorityContext> {
4344 self.build_state()
4345 .and_then(|state| state.mob_tool_authority_context)
4346 .filter(MobToolAuthorityContext::is_generated_authority_context)
4347 }
4348
4349 pub fn fork_at(&self, index: usize) -> Self {
4354 let now = SystemTime::now();
4355 let truncated = self.messages[..index.min(self.messages.len())].to_vec();
4356 Self {
4357 version: session_version(),
4358 id: SessionId::new(),
4359 messages: Arc::new(truncated),
4360 created_at: now,
4361 updated_at: now,
4362 metadata: self.fork_metadata_projection(),
4363 usage: self.usage.clone(),
4364 }
4365 }
4366
4367 pub fn fork_replacing(
4374 &self,
4375 message_index: usize,
4376 replacement: TranscriptReplacement,
4377 ) -> Result<Self, TranscriptEditError> {
4378 let Some(original) = self.messages.get(message_index) else {
4379 return Err(TranscriptEditError::MessageIndexOutOfBounds {
4380 message_index,
4381 message_count: self.messages.len(),
4382 });
4383 };
4384
4385 let replacement_message = match replacement {
4386 TranscriptReplacement::Message { message } => message,
4387 TranscriptReplacement::UserContentBlock { block_index, block } => {
4388 let Message::User(user) = original else {
4389 return Err(TranscriptEditError::MessageRoleMismatch {
4390 message_index,
4391 expected: "user",
4392 actual: message_role_name(original),
4393 });
4394 };
4395 if block_index >= user.content.len() {
4396 return Err(TranscriptEditError::BlockIndexOutOfBounds {
4397 block_kind: "user content block",
4398 block_index,
4399 block_count: user.content.len(),
4400 });
4401 }
4402 let mut edited = user.clone();
4403 edited.content[block_index] = block;
4404 Message::User(edited)
4405 }
4406 TranscriptReplacement::AssistantBlock { block_index, block } => {
4407 let Message::BlockAssistant(assistant) = original else {
4408 return Err(TranscriptEditError::MessageRoleMismatch {
4409 message_index,
4410 expected: "block_assistant",
4411 actual: message_role_name(original),
4412 });
4413 };
4414 if block_index >= assistant.blocks.len() {
4415 return Err(TranscriptEditError::BlockIndexOutOfBounds {
4416 block_kind: "assistant block",
4417 block_index,
4418 block_count: assistant.blocks.len(),
4419 });
4420 }
4421 let mut edited = assistant.clone();
4422 edited.blocks[block_index] = block;
4423 Message::BlockAssistant(edited)
4424 }
4425 TranscriptReplacement::ToolResultContentBlock {
4426 result_index,
4427 block_index,
4428 block,
4429 } => {
4430 let Message::ToolResults {
4431 results,
4432 created_at,
4433 } = original
4434 else {
4435 return Err(TranscriptEditError::MessageRoleMismatch {
4436 message_index,
4437 expected: "tool_results",
4438 actual: message_role_name(original),
4439 });
4440 };
4441 let Some(result) = results.get(result_index) else {
4442 return Err(TranscriptEditError::BlockIndexOutOfBounds {
4443 block_kind: "tool result",
4444 block_index: result_index,
4445 block_count: results.len(),
4446 });
4447 };
4448 if block_index >= result.content.len() {
4449 return Err(TranscriptEditError::BlockIndexOutOfBounds {
4450 block_kind: "tool result content block",
4451 block_index,
4452 block_count: result.content.len(),
4453 });
4454 }
4455 let mut edited_results = results.clone();
4456 edited_results[result_index].content[block_index] = block;
4457 Message::ToolResults {
4458 results: edited_results,
4459 created_at: *created_at,
4460 }
4461 }
4462 };
4463
4464 let mut forked = self.fork_at(message_index);
4465 forked.push(replacement_message);
4466 Ok(forked)
4467 }
4468
4469 pub fn fork(&self) -> Self {
4474 let now = SystemTime::now();
4475 Self {
4476 version: session_version(),
4477 id: SessionId::new(),
4478 messages: Arc::clone(&self.messages),
4479 created_at: now,
4480 updated_at: now,
4481 metadata: self.fork_metadata_projection(),
4482 usage: self.usage.clone(),
4483 }
4484 }
4485}
4486
4487impl Default for Session {
4488 fn default() -> Self {
4489 Self::new()
4490 }
4491}
4492
4493#[derive(Debug, Clone, Serialize, Deserialize)]
4495#[serde(rename_all = "snake_case")]
4496pub struct SessionMeta {
4497 pub id: SessionId,
4498 pub created_at: SystemTime,
4499 pub updated_at: SystemTime,
4500 pub message_count: usize,
4501 pub total_tokens: u64,
4502 #[serde(default)]
4503 pub metadata: serde_json::Map<String, serde_json::Value>,
4504}
4505
4506#[derive(Debug, Clone, Serialize, Deserialize)]
4508#[serde(rename_all = "snake_case")]
4509pub struct SessionMetadata {
4510 pub schema_version: u32,
4517 pub model: String,
4518 pub max_tokens: u32,
4519 #[serde(default = "crate::config::default_structured_output_retries")]
4520 pub structured_output_retries: u32,
4521 pub provider: Provider,
4522 #[serde(default, skip_serializing_if = "Option::is_none")]
4523 pub self_hosted_server_id: Option<String>,
4524 #[serde(default, skip_serializing_if = "Option::is_none")]
4527 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
4528 pub tooling: SessionTooling,
4529 #[serde(default)]
4530 pub keep_alive: bool,
4531 pub comms_name: Option<String>,
4532 #[serde(default, skip_serializing_if = "Option::is_none")]
4534 pub peer_meta: Option<PeerMeta>,
4535 #[serde(default, skip_serializing_if = "Option::is_none")]
4541 pub realm_id: Option<crate::RealmId>,
4542 #[serde(default, skip_serializing_if = "Option::is_none")]
4544 pub instance_id: Option<String>,
4545 #[serde(default, skip_serializing_if = "Option::is_none")]
4547 pub backend: Option<String>,
4548 #[serde(default, skip_serializing_if = "Option::is_none")]
4550 pub config_generation: Option<u64>,
4551 #[serde(default, skip_serializing_if = "Option::is_none")]
4561 pub auth_binding: Option<crate::AuthBindingRef>,
4562 #[serde(default, skip_serializing_if = "Option::is_none")]
4575 pub mob_member_binding: Option<crate::MobMemberBinding>,
4576}
4577
4578#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
4580#[serde(rename_all = "snake_case")]
4581pub struct SessionLlmIdentity {
4582 pub model: String,
4583 pub provider: Provider,
4584 #[serde(default, skip_serializing_if = "Option::is_none")]
4585 pub self_hosted_server_id: Option<String>,
4586 #[serde(default, skip_serializing_if = "Option::is_none")]
4588 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
4589 #[serde(default, skip_serializing_if = "Option::is_none")]
4602 pub auth_binding: Option<crate::AuthBindingRef>,
4603}
4604
4605pub struct SessionLlmIdentityOverride<'a> {
4613 pub model: Option<&'a str>,
4614 pub provider: Option<Provider>,
4615 pub provider_params:
4616 Option<TurnMetadataOverride<&'a crate::lifecycle::run_primitive::ProviderParamsOverride>>,
4617 pub auth_binding: Option<TurnMetadataOverride<&'a crate::AuthBindingRef>>,
4618}
4619
4620#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
4621pub enum SessionLlmIdentityOverrideError {
4622 #[error("provider override requires model on an existing session")]
4623 ProviderRequiresModel,
4624 #[error("{0}")]
4625 ProviderModelMismatch(String),
4626 #[error("self-hosted provider requires a registered model alias; '{model}' is not configured")]
4627 MissingSelfHostedAlias { model: String },
4628}
4629
4630pub fn resolve_session_llm_identity_override(
4638 current: &SessionLlmIdentity,
4639 registry: &crate::ModelRegistry,
4640 overrides: SessionLlmIdentityOverride<'_>,
4641) -> Result<SessionLlmIdentity, SessionLlmIdentityOverrideError> {
4642 if overrides.provider.is_some() && overrides.model.is_none() {
4643 return Err(SessionLlmIdentityOverrideError::ProviderRequiresModel);
4644 }
4645
4646 let model = overrides
4647 .model
4648 .map(str::to_string)
4649 .unwrap_or_else(|| current.model.clone());
4650 let provider = if let Some(provider) = overrides.provider {
4651 provider
4652 } else if overrides.model.is_some() {
4653 registry
4654 .entry(&model)
4655 .map_or(current.provider, |entry| entry.provider)
4656 } else {
4657 current.provider
4658 };
4659
4660 if (overrides.model.is_some() || overrides.provider.is_some())
4661 && let Some(reason) = registry.provider_override_mismatch_reason(provider, &model)
4662 {
4663 return Err(SessionLlmIdentityOverrideError::ProviderModelMismatch(
4664 reason,
4665 ));
4666 }
4667
4668 let provider_params = match overrides.provider_params {
4669 Some(TurnMetadataOverride::Clear) => None,
4670 Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
4671 None => current.provider_params.clone(),
4672 };
4673 let self_hosted_server_id = if provider == Provider::SelfHosted {
4674 if overrides.model.is_none() {
4675 current.self_hosted_server_id.clone().or_else(|| {
4676 registry
4677 .entry_for_provider(Provider::SelfHosted, &model)
4678 .and_then(|entry| entry.self_hosted.as_ref())
4679 .map(|server| server.server_id.clone())
4680 })
4681 } else {
4682 let entry = registry
4683 .entry_for_provider(Provider::SelfHosted, &model)
4684 .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
4685 model: model.clone(),
4686 })?;
4687 entry
4688 .self_hosted
4689 .as_ref()
4690 .map(|server| server.server_id.clone())
4691 }
4692 } else {
4693 None
4694 };
4695
4696 let auth_binding = match overrides.auth_binding {
4697 Some(TurnMetadataOverride::Clear) => None,
4698 Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
4699 None if provider != current.provider => None,
4702 None => current.auth_binding.clone(),
4703 };
4704
4705 Ok(SessionLlmIdentity {
4706 model,
4707 provider,
4708 self_hosted_server_id,
4709 provider_params,
4710 auth_binding,
4711 })
4712}
4713
4714#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
4721#[serde(rename_all = "snake_case")]
4722pub struct SessionLlmRequestPolicy {
4723 pub model: String,
4724 #[serde(default, skip_serializing_if = "Option::is_none")]
4726 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
4727 #[serde(default, skip_serializing_if = "Option::is_none")]
4729 pub provider_tool_defaults: Option<crate::lifecycle::run_primitive::ProviderTag>,
4730}
4731
4732impl SessionMetadata {
4733 pub fn llm_identity(&self) -> SessionLlmIdentity {
4735 SessionLlmIdentity {
4736 model: self.model.clone(),
4737 provider: self.provider,
4738 self_hosted_server_id: self.self_hosted_server_id.clone(),
4739 provider_params: self.provider_params.clone(),
4740 auth_binding: self.auth_binding.clone(),
4741 }
4742 }
4743
4744 pub fn apply_llm_identity(&mut self, identity: &SessionLlmIdentity) {
4746 self.model = identity.model.clone();
4747 self.provider = identity.provider;
4748 self.self_hosted_server_id = identity.self_hosted_server_id.clone();
4749 self.provider_params = identity.provider_params.clone();
4750 self.auth_binding = identity.auth_binding.clone();
4751 }
4752}
4753
4754pub const SESSION_METADATA_KEY: &str = "session_metadata";
4756
4757#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
4765#[serde(rename_all = "snake_case")]
4766pub enum ToolCategoryOverride {
4767 #[default]
4769 Inherit,
4770 Enable,
4772 Disable,
4774}
4775
4776impl ToolCategoryOverride {
4777 #[must_use]
4783 pub fn resolve(self, runtime_default: bool) -> bool {
4784 match self {
4785 Self::Enable => true,
4786 Self::Disable => false,
4787 Self::Inherit => runtime_default,
4788 }
4789 }
4790
4791 #[must_use]
4797 pub fn to_override(self) -> Option<bool> {
4798 match self {
4799 Self::Enable => Some(true),
4800 Self::Disable => Some(false),
4801 Self::Inherit => None,
4802 }
4803 }
4804
4805 #[must_use]
4813 pub fn from_effective(enabled: bool) -> Self {
4814 if enabled { Self::Enable } else { Self::Disable }
4815 }
4816
4817 #[must_use]
4827 pub fn from_override(value: Option<bool>) -> Self {
4828 match value {
4829 Some(true) => Self::Enable,
4830 Some(false) => Self::Disable,
4831 None => Self::Inherit,
4832 }
4833 }
4834}
4835
4836#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
4843#[serde(rename_all = "snake_case")]
4844pub struct SessionTooling {
4845 #[serde(default)]
4846 pub builtins: ToolCategoryOverride,
4847 #[serde(default)]
4848 pub shell: ToolCategoryOverride,
4849 #[serde(default)]
4850 pub comms: ToolCategoryOverride,
4851 #[serde(default)]
4853 pub mob: ToolCategoryOverride,
4854 #[serde(default)]
4856 pub memory: ToolCategoryOverride,
4857 #[serde(default)]
4859 pub schedule: ToolCategoryOverride,
4860 #[serde(default)]
4862 pub workgraph: ToolCategoryOverride,
4863 #[serde(default)]
4865 pub image_generation: ToolCategoryOverride,
4866 #[serde(default)]
4868 pub web_search: ToolCategoryOverride,
4869 #[serde(default, skip_serializing_if = "Option::is_none")]
4879 pub tool_access_policy: Option<crate::ops::ToolAccessPolicy>,
4880 #[serde(default, skip_serializing_if = "Option::is_none")]
4882 pub active_skills: Option<Vec<crate::skills::SkillKey>>,
4883}
4884
4885impl From<&Session> for SessionMeta {
4886 fn from(session: &Session) -> Self {
4887 Self {
4888 id: session.id.clone(),
4889 created_at: session.created_at,
4890 updated_at: session.updated_at,
4891 message_count: session.messages.len(),
4892 total_tokens: session.total_tokens(),
4893 metadata: session.metadata.clone(),
4894 }
4895 }
4896}
4897
4898pub fn try_session_metadata_from_map(
4907 metadata: &serde_json::Map<String, serde_json::Value>,
4908) -> Result<Option<SessionMetadata>, serde_json::Error> {
4909 let Some(value) = metadata.get(SESSION_METADATA_KEY) else {
4910 return Ok(None);
4911 };
4912 let mut metadata = serde_json::from_value::<SessionMetadata>(value.clone())?;
4913 metadata.schema_version =
4914 session_persistence_version_authority::restore_session_metadata_schema_version(
4915 metadata.schema_version,
4916 )
4917 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
4918 session_durable_config_authority::restore_session_metadata(metadata)
4919 .map(Some)
4920 .map_err(<serde_json::Error as serde::de::Error>::custom)
4921}
4922
4923pub fn try_lifecycle_terminal_from_map(
4930 metadata: &serde_json::Map<String, serde_json::Value>,
4931) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
4932 match metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY) {
4933 Some(value) => serde_json::from_value(value.clone()).map(Some),
4934 None => Ok(None),
4935 }
4936}
4937
4938#[derive(Debug, Clone)]
4946pub struct PersistedSessionMetadataView {
4947 pub session_id: SessionId,
4948 pub session_metadata: Option<SessionMetadata>,
4949 pub lifecycle_terminal: Option<SessionLifecycleTerminal>,
4950}
4951
4952impl PersistedSessionMetadataView {
4953 pub fn try_from_metadata_map(
4959 session_id: SessionId,
4960 metadata: &serde_json::Map<String, serde_json::Value>,
4961 ) -> Result<Self, serde_json::Error> {
4962 Ok(Self {
4963 session_id,
4964 session_metadata: try_session_metadata_from_map(metadata)?,
4965 lifecycle_terminal: try_lifecycle_terminal_from_map(metadata)?,
4966 })
4967 }
4968
4969 pub fn try_from_session(session: &Session) -> Result<Self, serde_json::Error> {
4971 Ok(Self {
4972 session_id: session.id().clone(),
4973 session_metadata: session.try_session_metadata()?,
4974 lifecycle_terminal: session.try_lifecycle_terminal()?,
4975 })
4976 }
4977
4978 pub fn mob_member_binding(&self) -> Option<&crate::MobMemberBinding> {
4981 self.session_metadata.as_ref()?.mob_member_binding.as_ref()
4982 }
4983}
4984
4985#[cfg(test)]
4986#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
4987mod tests {
4988 use super::*;
4989 use crate::realtime_transcript::RealtimeTranscriptRole;
4990 use crate::types::{
4991 AssistantBlock, BlockAssistantMessage, ContentBlock, StopReason, SystemMessage, Usage,
4992 UserMessage,
4993 };
4994 use std::sync::Arc;
4995
4996 fn block_assistant_text(message: &BlockAssistantMessage) -> String {
4997 message
4998 .blocks
4999 .iter()
5000 .filter_map(|block| match block {
5001 AssistantBlock::Text { text, .. } => Some(text.as_str()),
5002 _ => None,
5003 })
5004 .collect()
5005 }
5006
5007 #[test]
5008 fn transcript_digest_is_content_addressed() {
5009 let base_time = crate::types::message_timestamp_now();
5010 let stamped = vec![
5011 Message::User(UserMessage::text("turn one".to_string())),
5012 Message::BlockAssistant(BlockAssistantMessage {
5013 blocks: vec![AssistantBlock::Text {
5014 text: "answer one".to_string(),
5015 meta: None,
5016 }],
5017 stop_reason: StopReason::EndTurn,
5018 identity: crate::types::TranscriptMessageIdentity {
5019 interaction_id: None,
5020 run_id: Some(crate::lifecycle::RunId::new()),
5021 },
5022 created_at: base_time,
5023 }),
5024 ];
5025 let mut restamped = stamped.clone();
5026 for message in &mut restamped {
5027 match message {
5028 Message::User(user) => {
5029 user.created_at = base_time + chrono::Duration::hours(2);
5030 }
5031 Message::BlockAssistant(assistant) => {
5032 assistant.identity = crate::types::TranscriptMessageIdentity {
5033 interaction_id: None,
5034 run_id: Some(crate::lifecycle::RunId::new()),
5035 };
5036 assistant.created_at = base_time + chrono::Duration::hours(2);
5037 }
5038 _ => {}
5039 }
5040 }
5041 assert_eq!(
5042 transcript_messages_digest(&stamped).expect("digest"),
5043 transcript_messages_digest(&restamped).expect("digest"),
5044 "bookkeeping variance must not fork the transcript revision"
5045 );
5046
5047 let mut content_changed = stamped.clone();
5048 if let Message::User(user) = &mut content_changed[0] {
5049 user.content = vec![ContentBlock::Text {
5050 text: "a different turn".to_string(),
5051 }];
5052 }
5053 assert_ne!(
5054 transcript_messages_digest(&stamped).expect("digest"),
5055 transcript_messages_digest(&content_changed).expect("digest"),
5056 "content changes must fork the transcript revision"
5057 );
5058 }
5059
5060 fn legacy_rewrite_fixture() -> (TranscriptRewriteCommit, Vec<Message>, Vec<Message>) {
5061 let parent_messages = vec![
5062 Message::User(UserMessage::text("before rewrite".to_string())),
5063 Message::User(UserMessage::text("retained tail".to_string())),
5064 ];
5065 let revision_messages = vec![
5066 Message::User(UserMessage::text("after rewrite".to_string())),
5067 Message::User(UserMessage::text("retained tail".to_string())),
5068 ];
5069 let parent_revision =
5072 legacy_transcript_messages_digest(&parent_messages).expect("legacy parent digest");
5073 let revision =
5074 legacy_transcript_messages_digest(&revision_messages).expect("legacy revision digest");
5075 let commit = TranscriptRewriteCommit {
5076 parent_revision,
5077 revision,
5078 selection: TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
5079 original_span_digest: legacy_transcript_messages_digest(&parent_messages[0..1])
5080 .expect("legacy span digest"),
5081 replacement_digest: legacy_transcript_messages_digest(&revision_messages[0..1])
5082 .expect("legacy replacement digest"),
5083 messages_before: 2,
5084 messages_after: 2,
5085 reason: TranscriptRewriteReason::new("compaction"),
5086 actor: Some("legacy-test".to_string()),
5087 committed_at: SystemTime::now(),
5088 };
5089 (commit, parent_messages, revision_messages)
5090 }
5091
5092 #[test]
5093 fn legacy_transcript_history_state_heals_to_content_addressed_on_parse() {
5094 let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
5095 let state = TranscriptHistoryState {
5096 head: commit.revision.clone(),
5097 commits: vec![commit.clone()],
5098 revisions: vec![
5099 TranscriptRevisionBody {
5100 revision: commit.parent_revision.clone(),
5101 parent_revision: None,
5102 messages: parent_messages.clone(),
5103 created_at: SystemTime::now(),
5104 },
5105 TranscriptRevisionBody {
5106 revision: commit.revision.clone(),
5107 parent_revision: Some(commit.parent_revision),
5108 messages: revision_messages.clone(),
5109 created_at: SystemTime::now(),
5110 },
5111 ],
5112 };
5113 let value = serde_json::to_value(&state).expect("serialize legacy state");
5114 let healed: TranscriptHistoryState =
5115 serde_json::from_value(value).expect("parse legacy state");
5116
5117 let content_parent =
5118 transcript_messages_digest(&parent_messages).expect("content parent digest");
5119 let content_revision =
5120 transcript_messages_digest(&revision_messages).expect("content revision digest");
5121 assert_eq!(healed.head, content_revision, "head must re-derive");
5122 assert_eq!(healed.commits[0].parent_revision, content_parent);
5123 assert_eq!(healed.commits[0].revision, content_revision);
5124 assert_eq!(healed.revisions[0].revision, content_parent);
5125 assert_eq!(healed.revisions[1].revision, content_revision);
5126 assert_eq!(
5127 healed.revisions[1].parent_revision.as_deref(),
5128 Some(content_parent.as_str())
5129 );
5130 validate_transcript_history_state(&healed).expect("healed graph must validate");
5131
5132 let mut session = Session::new();
5135 session
5136 .apply_transcript_history_state(healed)
5137 .expect("apply healed graph");
5138 session
5139 .commit_transcript_rewrite(
5140 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
5141 vec![Message::User(UserMessage::text(
5142 "rewritten again".to_string(),
5143 ))],
5144 TranscriptRewriteReason::new("unit-test"),
5145 None,
5146 None,
5147 )
5148 .expect("extend healed graph with a new rewrite");
5149 session
5150 .validate_transcript_history_state()
5151 .expect("extended graph must validate");
5152 }
5153
5154 #[test]
5155 fn legacy_transcript_rewrite_record_heals_on_parse() {
5156 let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
5157 let record_value = serde_json::json!({
5158 "commit": commit,
5159 "parent_body": TranscriptRevisionBody {
5160 revision: commit.parent_revision.clone(),
5161 parent_revision: None,
5162 messages: parent_messages,
5163 created_at: SystemTime::now(),
5164 },
5165 "revision_body": TranscriptRevisionBody {
5166 revision: commit.revision.clone(),
5167 parent_revision: Some(commit.parent_revision),
5168 messages: revision_messages.clone(),
5169 created_at: SystemTime::now(),
5170 },
5171 });
5172 let healed: TranscriptRewriteRecord =
5173 serde_json::from_value(record_value).expect("parse legacy record");
5174 assert_eq!(
5175 healed.commit.revision,
5176 transcript_messages_digest(&revision_messages).expect("content digest")
5177 );
5178 TranscriptRewriteRecord::new(healed.commit, healed.parent_body, healed.revision_body)
5180 .expect("healed record must validate");
5181 }
5182
5183 #[test]
5184 fn corrupt_transcript_history_strings_stay_untouched_and_fail_validation() {
5185 let (commit, parent_messages, _revision_messages) = legacy_rewrite_fixture();
5186 let bogus = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
5187 let state = TranscriptHistoryState {
5188 head: bogus.to_string(),
5189 commits: Vec::new(),
5190 revisions: vec![TranscriptRevisionBody {
5191 revision: bogus.to_string(),
5192 parent_revision: None,
5193 messages: parent_messages,
5194 created_at: SystemTime::now(),
5195 }],
5196 };
5197 let _ = commit;
5198 let value = serde_json::to_value(&state).expect("serialize corrupt state");
5199 let parsed: TranscriptHistoryState =
5200 serde_json::from_value(value).expect("corrupt strings still parse");
5201 assert_eq!(
5202 parsed.head, bogus,
5203 "unverifiable strings must not be rewritten"
5204 );
5205 assert!(
5206 validate_transcript_history_state(&parsed).is_err(),
5207 "corrupt graph must keep failing validation"
5208 );
5209 }
5210
5211 #[test]
5215 fn replace_synthetic_notices_leaves_only_replacements_of_kind() {
5216 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
5217
5218 let mut session = Session::new();
5219 session.push(Message::User(UserMessage::text("hello".to_string())));
5220 session.push(Message::SystemNotice(SystemNoticeMessage::new(
5221 SystemNoticeKind::McpPending,
5222 "stale one",
5223 )));
5224 session.push(Message::SystemNotice(SystemNoticeMessage::new(
5225 SystemNoticeKind::McpPending,
5226 "stale two",
5227 )));
5228 session.push(Message::SystemNotice(SystemNoticeMessage::new(
5230 SystemNoticeKind::BackgroundJob,
5231 "other-kind",
5232 )));
5233
5234 session
5235 .replace_synthetic_notices(
5236 SystemNoticeKind::McpPending,
5237 vec![Message::SystemNotice(SystemNoticeMessage::new(
5238 SystemNoticeKind::McpPending,
5239 "fresh",
5240 ))],
5241 )
5242 .expect("notice refresh succeeds");
5243
5244 let mcp_pending: Vec<&SystemNoticeMessage> = session
5245 .messages()
5246 .iter()
5247 .filter_map(|message| match message {
5248 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending => {
5249 Some(notice)
5250 }
5251 _ => None,
5252 })
5253 .collect();
5254 assert_eq!(mcp_pending.len(), 1, "exactly one notice of the kind");
5255 assert_eq!(mcp_pending[0].body.as_deref(), Some("fresh"));
5256 assert!(
5257 session.messages().iter().any(|message| matches!(
5258 message,
5259 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::BackgroundJob
5260 )),
5261 "other-kind notices are untouched"
5262 );
5263
5264 session
5266 .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
5267 .expect("pure strip succeeds");
5268 assert!(
5269 !session.messages().iter().any(|message| matches!(
5270 message,
5271 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending
5272 )),
5273 "empty replacement clears the kind"
5274 );
5275 }
5276
5277 #[test]
5281 fn replace_synthetic_notices_rejects_mismatched_kind_without_mutation() {
5282 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
5283
5284 let mut session = Session::new();
5285 session.push(Message::SystemNotice(SystemNoticeMessage::new(
5286 SystemNoticeKind::McpPending,
5287 "stale",
5288 )));
5289 let before = session.messages().to_vec();
5290
5291 let err = session
5292 .replace_synthetic_notices(
5293 SystemNoticeKind::McpPending,
5294 vec![Message::User(UserMessage::text("not a notice".to_string()))],
5295 )
5296 .expect_err("mismatched replacement must fail typed");
5297 assert!(
5298 matches!(err, TranscriptEditError::InvalidTranscriptShape(_)),
5299 "expected InvalidTranscriptShape, got {err:?}"
5300 );
5301 assert_eq!(
5302 session.messages(),
5303 before.as_slice(),
5304 "fault must leave the transcript unchanged (no partial strip)"
5305 );
5306 }
5307
5308 #[test]
5309 fn transcript_rewrite_preserves_full_assistant_block_trace() {
5310 let mut session = Session::new();
5311 session.push(Message::User(UserMessage::text(
5312 "run the trace".to_string(),
5313 )));
5314 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
5315 vec![AssistantBlock::Text {
5316 text: "original assistant trace".to_string(),
5317 meta: None,
5318 }],
5319 StopReason::EndTurn,
5320 )));
5321
5322 let parent_revision = session.transcript_revision().expect("parent revision");
5323 let replacement = vec![
5324 Message::BlockAssistant(BlockAssistantMessage::new(
5325 vec![
5326 AssistantBlock::Text {
5327 text: "compacted assistant trace".to_string(),
5328 meta: None,
5329 },
5330 AssistantBlock::ToolUse {
5331 id: "toolu_trace".to_string(),
5332 name: "trace_probe".to_string(),
5333 args: serde_json::value::RawValue::from_string(
5334 r#"{"path":"N-3"}"#.to_string(),
5335 )
5336 .expect("valid tool args"),
5337 meta: None,
5338 },
5339 ],
5340 StopReason::ToolUse,
5341 )),
5342 Message::tool_results(vec![ToolResult::new(
5343 "toolu_trace".to_string(),
5344 "trace complete".to_string(),
5345 false,
5346 )]),
5347 ];
5348
5349 let commit = session
5350 .commit_transcript_rewrite(
5351 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5352 replacement,
5353 TranscriptRewriteReason::new("compaction"),
5354 Some("unit-test".to_string()),
5355 Some(parent_revision.clone()),
5356 )
5357 .expect("rewrite should commit");
5358
5359 assert_eq!(commit.parent_revision, parent_revision);
5360 let current = session
5361 .transcript_revision_messages(&commit.revision)
5362 .expect("history state should decode")
5363 .expect("current revision should be retained");
5364 let Message::BlockAssistant(assistant) = ¤t[1] else {
5365 panic!("replacement should remain a block assistant message");
5366 };
5367 assert!(assistant.blocks.iter().any(|block| matches!(
5368 block,
5369 AssistantBlock::ToolUse { name, args, .. }
5370 if name == "trace_probe" && args.get().contains("\"N-3\"")
5371 )));
5372
5373 let parent = session
5374 .transcript_revision_messages(&parent_revision)
5375 .expect("history state should decode")
5376 .expect("parent revision should remain retained");
5377 assert!(matches!(
5378 &parent[1],
5379 Message::BlockAssistant(assistant)
5380 if block_assistant_text(assistant).contains("original assistant trace")
5381 ));
5382 }
5383
5384 #[test]
5385 fn transcript_rewrite_rejects_trailing_block_assistant_tool_call() {
5386 let mut session = Session::new();
5387 session.push(Message::User(UserMessage::text("question".to_string())));
5388 session.push(Message::BlockAssistant(BlockAssistantMessage {
5389 blocks: vec![AssistantBlock::Text {
5390 text: "plain answer".to_string(),
5391 meta: None,
5392 }],
5393 stop_reason: StopReason::EndTurn,
5394 identity: crate::types::TranscriptMessageIdentity::default(),
5395 created_at: crate::types::message_timestamp_now(),
5396 }));
5397 let parent_revision = session.transcript_revision().expect("parent revision");
5398
5399 let err = session
5400 .commit_transcript_rewrite(
5401 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5402 vec![Message::BlockAssistant(BlockAssistantMessage::new(
5403 vec![AssistantBlock::ToolUse {
5404 id: "toolu_1".to_string(),
5405 name: "lookup".to_string(),
5406 args: serde_json::value::RawValue::from_string("{}".to_string())
5407 .expect("valid args"),
5408 meta: None,
5409 }],
5410 StopReason::ToolUse,
5411 ))],
5412 TranscriptRewriteReason::new("compaction"),
5413 Some("unit-test".to_string()),
5414 Some(parent_revision),
5415 )
5416 .expect_err("rewrite should reject trailing unresolved block-assistant tool call");
5417 assert!(matches!(
5418 err,
5419 TranscriptEditError::InvalidTranscriptShape(_)
5420 ));
5421 }
5422
5423 #[test]
5424 fn transcript_rewrite_rejects_no_op_self_edge() {
5425 let mut session = Session::new();
5426 session.push(Message::User(UserMessage::text(
5427 "keep this exact transcript".to_string(),
5428 )));
5429 session.push(Message::BlockAssistant(BlockAssistantMessage {
5430 blocks: vec![AssistantBlock::Text {
5431 text: "unchanged".to_string(),
5432 meta: None,
5433 }],
5434 stop_reason: StopReason::EndTurn,
5435 identity: crate::types::TranscriptMessageIdentity::default(),
5436 created_at: crate::types::message_timestamp_now(),
5437 }));
5438
5439 let parent_revision = session.transcript_revision().expect("parent revision");
5440 let err = session
5441 .commit_transcript_rewrite(
5442 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5443 vec![session.messages()[1].clone()],
5444 TranscriptRewriteReason::new("retry"),
5445 Some("unit-test".to_string()),
5446 Some(parent_revision.clone()),
5447 )
5448 .expect_err("same-content rewrite should not emit a self-edge commit");
5449
5450 assert!(matches!(
5451 err,
5452 TranscriptEditError::NoOpRewrite { revision } if revision == parent_revision
5453 ));
5454 assert!(
5455 session
5456 .transcript_history_state()
5457 .expect("history state should decode")
5458 .is_none()
5459 );
5460 }
5461
5462 #[test]
5463 fn transcript_rewrite_run_boundary_guard_accepts_rewrite_then_append() {
5464 let mut original = Session::new();
5465 original.push(Message::User(UserMessage::text("question".to_string())));
5466 original.push(Message::BlockAssistant(BlockAssistantMessage {
5467 blocks: vec![AssistantBlock::Text {
5468 text: "verbose answer".to_string(),
5469 meta: None,
5470 }],
5471 stop_reason: StopReason::EndTurn,
5472 identity: crate::types::TranscriptMessageIdentity::default(),
5473 created_at: crate::types::message_timestamp_now(),
5474 }));
5475
5476 let parent_revision = original.transcript_revision().expect("parent revision");
5477 let mut incoming = original.clone();
5478 incoming
5479 .commit_transcript_rewrite(
5480 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5481 vec![Message::BlockAssistant(BlockAssistantMessage {
5482 blocks: vec![AssistantBlock::Text {
5483 text: "compact answer".to_string(),
5484 meta: None,
5485 }],
5486 stop_reason: StopReason::EndTurn,
5487 identity: crate::types::TranscriptMessageIdentity::default(),
5488 created_at: crate::types::message_timestamp_now(),
5489 })],
5490 TranscriptRewriteReason::new("compaction"),
5491 Some("unit-test".to_string()),
5492 Some(parent_revision),
5493 )
5494 .expect("rewrite should commit");
5495 incoming.push(Message::User(UserMessage::text("follow-up".to_string())));
5496 incoming.push(Message::BlockAssistant(BlockAssistantMessage {
5497 blocks: vec![AssistantBlock::Text {
5498 text: "follow-up answer".to_string(),
5499 meta: None,
5500 }],
5501 stop_reason: StopReason::EndTurn,
5502 identity: crate::types::TranscriptMessageIdentity::default(),
5503 created_at: crate::types::message_timestamp_now(),
5504 }));
5505
5506 crate::session_store::run_boundary_snapshot_save_guard(&incoming, Some(&original))
5507 .expect("rewrite plus appended turn should be a valid run-boundary commit");
5508 }
5509
5510 #[test]
5511 fn transcript_rewrite_rejects_orphaned_tool_results() {
5512 let mut session = Session::new();
5513 session.push(Message::User(UserMessage::text("use a tool".to_string())));
5514 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
5515 vec![AssistantBlock::ToolUse {
5516 id: "toolu_1".to_string(),
5517 name: "lookup".to_string(),
5518 args: serde_json::value::RawValue::from_string("{}".to_string())
5519 .expect("valid args"),
5520 meta: None,
5521 }],
5522 StopReason::ToolUse,
5523 )));
5524 session.push(Message::tool_results(vec![ToolResult::new(
5525 "toolu_1".to_string(),
5526 "done".to_string(),
5527 false,
5528 )]));
5529 let parent_revision = session.transcript_revision().expect("parent revision");
5530
5531 let err = session
5532 .commit_transcript_rewrite(
5533 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5534 vec![Message::BlockAssistant(BlockAssistantMessage {
5535 blocks: vec![AssistantBlock::Text {
5536 text: "no tool after all".to_string(),
5537 meta: None,
5538 }],
5539 stop_reason: StopReason::EndTurn,
5540 identity: crate::types::TranscriptMessageIdentity::default(),
5541 created_at: crate::types::message_timestamp_now(),
5542 })],
5543 TranscriptRewriteReason::new("compaction"),
5544 Some("unit-test".to_string()),
5545 Some(parent_revision),
5546 )
5547 .expect_err("rewrite should reject stranded tool results");
5548 assert!(matches!(
5549 err,
5550 TranscriptEditError::InvalidTranscriptShape(_)
5551 ));
5552 }
5553
5554 #[test]
5555 fn transcript_rewrite_rejects_trailing_assistant_tool_call() {
5556 let mut session = Session::new();
5557 session.push(Message::User(UserMessage::text("question".to_string())));
5558 session.push(Message::BlockAssistant(BlockAssistantMessage {
5559 blocks: vec![AssistantBlock::Text {
5560 text: "plain answer".to_string(),
5561 meta: None,
5562 }],
5563 stop_reason: StopReason::EndTurn,
5564 identity: crate::types::TranscriptMessageIdentity::default(),
5565 created_at: crate::types::message_timestamp_now(),
5566 }));
5567 let parent_revision = session.transcript_revision().expect("parent revision");
5568
5569 let err = session
5570 .commit_transcript_rewrite(
5571 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5572 vec![Message::BlockAssistant(BlockAssistantMessage {
5573 blocks: vec![AssistantBlock::ToolUse {
5574 id: "toolu_1".to_string(),
5575 name: "lookup".to_string(),
5576 args: serde_json::value::RawValue::from_string("{}".to_string())
5577 .expect("valid args"),
5578 meta: None,
5579 }],
5580 stop_reason: StopReason::ToolUse,
5581 identity: crate::types::TranscriptMessageIdentity::default(),
5582 created_at: crate::types::message_timestamp_now(),
5583 })],
5584 TranscriptRewriteReason::new("compaction"),
5585 Some("unit-test".to_string()),
5586 Some(parent_revision),
5587 )
5588 .expect_err("rewrite should reject trailing unresolved tool call");
5589 assert!(matches!(
5590 err,
5591 TranscriptEditError::InvalidTranscriptShape(_)
5592 ));
5593 }
5594
5595 #[test]
5596 fn transcript_rewrite_rejects_duplicate_tool_results() {
5597 let mut session = Session::new();
5598 session.push(Message::User(UserMessage::text("use a tool".to_string())));
5599 session.push(Message::BlockAssistant(BlockAssistantMessage {
5600 blocks: vec![AssistantBlock::Text {
5601 text: "plain answer".to_string(),
5602 meta: None,
5603 }],
5604 stop_reason: StopReason::EndTurn,
5605 identity: crate::types::TranscriptMessageIdentity::default(),
5606 created_at: crate::types::message_timestamp_now(),
5607 }));
5608 let parent_revision = session.transcript_revision().expect("parent revision");
5609
5610 let err = session
5611 .commit_transcript_rewrite(
5612 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5613 vec![
5614 Message::BlockAssistant(BlockAssistantMessage::new(
5615 vec![AssistantBlock::ToolUse {
5616 id: "toolu_1".to_string(),
5617 name: "lookup".to_string(),
5618 args: serde_json::value::RawValue::from_string("{}".to_string())
5619 .expect("valid args"),
5620 meta: None,
5621 }],
5622 StopReason::ToolUse,
5623 )),
5624 Message::tool_results(vec![
5625 ToolResult::new("toolu_1".to_string(), "one".to_string(), false),
5626 ToolResult::new("toolu_1".to_string(), "two".to_string(), false),
5627 ]),
5628 ],
5629 TranscriptRewriteReason::new("compaction"),
5630 Some("unit-test".to_string()),
5631 Some(parent_revision),
5632 )
5633 .expect_err("rewrite should reject duplicate tool results");
5634 assert!(matches!(
5635 err,
5636 TranscriptEditError::InvalidTranscriptShape(_)
5637 ));
5638 }
5639
5640 #[test]
5641 fn transcript_rewrite_record_rejects_prefix_or_suffix_tampering() {
5642 let mut session = Session::new();
5643 session.push(Message::System(SystemMessage::new("keep prefix")));
5644 session.push(Message::BlockAssistant(BlockAssistantMessage {
5645 blocks: vec![AssistantBlock::Text {
5646 text: "verbose answer".to_string(),
5647 meta: None,
5648 }],
5649 stop_reason: StopReason::EndTurn,
5650 identity: crate::types::TranscriptMessageIdentity::default(),
5651 created_at: crate::types::message_timestamp_now(),
5652 }));
5653 session.push(Message::User(UserMessage::text("keep suffix".to_string())));
5654
5655 let parent_revision = session.transcript_revision().expect("parent revision");
5656 let commit = session
5657 .commit_transcript_rewrite(
5658 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5659 vec![Message::BlockAssistant(BlockAssistantMessage {
5660 blocks: vec![AssistantBlock::Text {
5661 text: "compact answer".to_string(),
5662 meta: None,
5663 }],
5664 stop_reason: StopReason::EndTurn,
5665 identity: crate::types::TranscriptMessageIdentity::default(),
5666 created_at: crate::types::message_timestamp_now(),
5667 })],
5668 TranscriptRewriteReason::new("compaction"),
5669 Some("unit-test".to_string()),
5670 Some(parent_revision),
5671 )
5672 .expect("rewrite should commit");
5673 let state = session
5674 .transcript_history_state()
5675 .expect("history state should decode")
5676 .expect("history state should exist");
5677 let parent_body = state
5678 .revisions
5679 .iter()
5680 .find(|body| body.revision == commit.parent_revision)
5681 .expect("parent body retained")
5682 .clone();
5683 let revision_body = state
5684 .revisions
5685 .iter()
5686 .find(|body| body.revision == commit.revision)
5687 .expect("revision body retained")
5688 .clone();
5689
5690 let mut forged_body = revision_body;
5691 forged_body.messages[0] = Message::System(SystemMessage::new("tampered prefix"));
5692 forged_body.revision =
5693 transcript_messages_digest(&forged_body.messages).expect("forged digest");
5694 let mut forged_commit = commit;
5695 forged_commit.revision = forged_body.revision.clone();
5696 let err = TranscriptRewriteRecord::new(forged_commit, parent_body, forged_body)
5697 .expect_err("record validation must reject changes outside selected span");
5698 assert!(
5699 err.to_string().contains("before the selected span"),
5700 "unexpected error: {err}"
5701 );
5702 }
5703
5704 #[test]
5705 fn transcript_rewrite_replay_allows_normal_turn_revisions_between_rewrites() {
5706 let mut session = Session::new();
5707 session.push(Message::User(UserMessage::text("first".to_string())));
5708 session.push(Message::BlockAssistant(BlockAssistantMessage {
5709 blocks: vec![AssistantBlock::Text {
5710 text: "verbose first answer".to_string(),
5711 meta: None,
5712 }],
5713 stop_reason: StopReason::EndTurn,
5714 identity: crate::types::TranscriptMessageIdentity::default(),
5715 created_at: crate::types::message_timestamp_now(),
5716 }));
5717
5718 let first_parent = session.transcript_revision().expect("first parent");
5719 let first_commit = session
5720 .commit_transcript_rewrite(
5721 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5722 vec![Message::BlockAssistant(BlockAssistantMessage {
5723 blocks: vec![AssistantBlock::Text {
5724 text: "compact first answer".to_string(),
5725 meta: None,
5726 }],
5727 stop_reason: StopReason::EndTurn,
5728 identity: crate::types::TranscriptMessageIdentity::default(),
5729 created_at: crate::types::message_timestamp_now(),
5730 })],
5731 TranscriptRewriteReason::new("compaction"),
5732 Some("unit-test".to_string()),
5733 Some(first_parent),
5734 )
5735 .expect("first rewrite");
5736
5737 session.push(Message::User(UserMessage::text("normal turn".to_string())));
5738 session.push(Message::BlockAssistant(BlockAssistantMessage {
5739 blocks: vec![AssistantBlock::Text {
5740 text: "verbose second 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 let bridge_parent = session
5748 .transcript_revision()
5749 .expect("normal turn should advance transcript head");
5750 assert_ne!(bridge_parent, first_commit.revision);
5751 validate_transcript_history_state(
5752 &session
5753 .transcript_history_state()
5754 .expect("history state should decode")
5755 .expect("history state should exist"),
5756 )
5757 .expect("normal turn head may legitimately differ from last rewrite commit");
5758
5759 let second_commit = session
5760 .commit_transcript_rewrite(
5761 TranscriptRewriteSelection::MessageRange { start: 3, end: 4 },
5762 vec![Message::BlockAssistant(BlockAssistantMessage {
5763 blocks: vec![AssistantBlock::Text {
5764 text: "compact second answer".to_string(),
5765 meta: None,
5766 }],
5767 stop_reason: StopReason::EndTurn,
5768 identity: crate::types::TranscriptMessageIdentity::default(),
5769 created_at: crate::types::message_timestamp_now(),
5770 })],
5771 TranscriptRewriteReason::new("compaction"),
5772 Some("unit-test".to_string()),
5773 Some(bridge_parent.clone()),
5774 )
5775 .expect("second rewrite");
5776
5777 let state = session
5778 .transcript_history_state()
5779 .expect("history state should decode")
5780 .expect("history state should exist");
5781 let records = state.commits.iter().map(|commit| {
5782 let parent_body = state
5783 .revisions
5784 .iter()
5785 .find(|body| body.revision == commit.parent_revision)
5786 .expect("parent body retained")
5787 .clone();
5788 let revision_body = state
5789 .revisions
5790 .iter()
5791 .find(|body| body.revision == commit.revision)
5792 .expect("revision body retained")
5793 .clone();
5794 TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
5795 .expect("record should validate")
5796 });
5797
5798 let replayed = TranscriptHistoryState::from_rewrite_records(records)
5799 .expect("rewrite replay should accept normal-turn bridge revisions")
5800 .expect("rewrite records should exist");
5801 assert_eq!(replayed.head, second_commit.revision);
5802 assert!(
5803 replayed
5804 .revisions
5805 .iter()
5806 .any(|body| body.revision == bridge_parent)
5807 );
5808 }
5809
5810 #[test]
5811 fn transcript_rewrite_replay_rejects_branched_rewrite_records() {
5812 let mut base = Session::new();
5813 base.push(Message::User(UserMessage::text("question".to_string())));
5814 base.push(Message::BlockAssistant(BlockAssistantMessage {
5815 blocks: vec![AssistantBlock::Text {
5816 text: "verbose answer".to_string(),
5817 meta: None,
5818 }],
5819 stop_reason: StopReason::EndTurn,
5820 identity: crate::types::TranscriptMessageIdentity::default(),
5821 created_at: crate::types::message_timestamp_now(),
5822 }));
5823 let parent = base.transcript_revision().expect("parent revision");
5824
5825 let mut first = base.clone();
5826 let first_commit = first
5827 .commit_transcript_rewrite(
5828 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5829 vec![Message::BlockAssistant(BlockAssistantMessage {
5830 blocks: vec![AssistantBlock::Text {
5831 text: "first compact answer".to_string(),
5832 meta: None,
5833 }],
5834 stop_reason: StopReason::EndTurn,
5835 identity: crate::types::TranscriptMessageIdentity::default(),
5836 created_at: crate::types::message_timestamp_now(),
5837 })],
5838 TranscriptRewriteReason::new("compaction"),
5839 Some("unit-test".to_string()),
5840 Some(parent.clone()),
5841 )
5842 .expect("first rewrite");
5843 let first_state = first
5844 .transcript_history_state()
5845 .expect("first state decodes")
5846 .expect("first state exists");
5847
5848 let mut second = base;
5849 let second_commit = second
5850 .commit_transcript_rewrite(
5851 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5852 vec![Message::BlockAssistant(BlockAssistantMessage {
5853 blocks: vec![AssistantBlock::Text {
5854 text: "second compact answer".to_string(),
5855 meta: None,
5856 }],
5857 stop_reason: StopReason::EndTurn,
5858 identity: crate::types::TranscriptMessageIdentity::default(),
5859 created_at: crate::types::message_timestamp_now(),
5860 })],
5861 TranscriptRewriteReason::new("compaction"),
5862 Some("unit-test".to_string()),
5863 Some(parent),
5864 )
5865 .expect("second rewrite");
5866 let second_state = second
5867 .transcript_history_state()
5868 .expect("second state decodes")
5869 .expect("second state exists");
5870
5871 let record = |state: &TranscriptHistoryState, commit: &TranscriptRewriteCommit| {
5872 let parent_body = state
5873 .revisions
5874 .iter()
5875 .find(|body| body.revision == commit.parent_revision)
5876 .expect("parent body retained")
5877 .clone();
5878 let revision_body = state
5879 .revisions
5880 .iter()
5881 .find(|body| body.revision == commit.revision)
5882 .expect("revision body retained")
5883 .clone();
5884 TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
5885 .expect("record should validate")
5886 };
5887
5888 let err = TranscriptHistoryState::from_rewrite_records(vec![
5889 record(&first_state, &first_commit),
5890 record(&second_state, &second_commit),
5891 ])
5892 .expect_err("branched rewrite records must not replay as a linear source history");
5893 assert!(
5894 err.to_string().contains("does not extend transcript head"),
5895 "unexpected error: {err}"
5896 );
5897 }
5898
5899 #[test]
5900 fn internal_message_rewrites_refresh_transcript_history_head() {
5901 let mut session = Session::new();
5902 session.push(Message::User(UserMessage::text("question".to_string())));
5903 session.push(Message::BlockAssistant(BlockAssistantMessage {
5904 blocks: vec![AssistantBlock::Text {
5905 text: "verbose answer".to_string(),
5906 meta: None,
5907 }],
5908 stop_reason: StopReason::EndTurn,
5909 identity: crate::types::TranscriptMessageIdentity::default(),
5910 created_at: crate::types::message_timestamp_now(),
5911 }));
5912
5913 let parent = session.transcript_revision().expect("parent revision");
5914 session
5915 .commit_transcript_rewrite(
5916 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5917 vec![Message::BlockAssistant(BlockAssistantMessage {
5918 blocks: vec![AssistantBlock::Text {
5919 text: "compact answer".to_string(),
5920 meta: None,
5921 }],
5922 stop_reason: StopReason::EndTurn,
5923 identity: crate::types::TranscriptMessageIdentity::default(),
5924 created_at: crate::types::message_timestamp_now(),
5925 })],
5926 TranscriptRewriteReason::new("compaction"),
5927 Some("unit-test".to_string()),
5928 Some(parent),
5929 )
5930 .expect("rewrite should commit");
5931
5932 session.push(Message::User(UserMessage::text(
5933 "notice-bearing turn".to_string(),
5934 )));
5935 session
5936 .retain_messages_internal(
5937 |message| {
5938 !matches!(
5939 message,
5940 Message::User(user)
5941 if user.content.iter().any(|block| matches!(
5942 block,
5943 ContentBlock::Text { text } if text.contains("notice-bearing")
5944 ))
5945 )
5946 },
5947 TranscriptRewriteReason::new("synthetic_notice_cleanup"),
5948 )
5949 .expect("retain should commit internal rewrite");
5950 let retained_digest =
5951 transcript_messages_digest(session.messages()).expect("retained digest");
5952 assert_eq!(
5953 session.transcript_revision().expect("retained head"),
5954 retained_digest
5955 );
5956
5957 session
5958 .replace_messages_internal(
5959 vec![
5960 Message::User(UserMessage::text("compacted question".to_string())),
5961 Message::BlockAssistant(BlockAssistantMessage {
5962 blocks: vec![AssistantBlock::Text {
5963 text: "compacted answer".to_string(),
5964 meta: None,
5965 }],
5966 stop_reason: StopReason::EndTurn,
5967 identity: crate::types::TranscriptMessageIdentity::default(),
5968 created_at: crate::types::message_timestamp_now(),
5969 }),
5970 ],
5971 TranscriptRewriteReason::new("compaction"),
5972 )
5973 .expect("replace should commit internal rewrite");
5974 let replaced_digest =
5975 transcript_messages_digest(session.messages()).expect("replaced digest");
5976 assert_eq!(
5977 session.transcript_revision().expect("replaced head"),
5978 replaced_digest
5979 );
5980 let state = session
5981 .transcript_history_state()
5982 .expect("history state should decode")
5983 .expect("history state should exist");
5984 assert!(
5985 state
5986 .revisions
5987 .iter()
5988 .any(|body| body.revision == replaced_digest)
5989 );
5990 validate_transcript_history_state(&state).expect("history state remains valid");
5991 }
5992
5993 #[test]
5994 fn set_system_prompt_refreshes_transcript_history_head_after_rewrite() {
5995 let mut session = Session::new();
5996 session.push(Message::User(UserMessage::text("question".to_string())));
5997 session.push(Message::BlockAssistant(BlockAssistantMessage {
5998 blocks: vec![AssistantBlock::Text {
5999 text: "verbose answer".to_string(),
6000 meta: None,
6001 }],
6002 stop_reason: StopReason::EndTurn,
6003 identity: crate::types::TranscriptMessageIdentity::default(),
6004 created_at: crate::types::message_timestamp_now(),
6005 }));
6006
6007 let parent = session.transcript_revision().expect("parent revision");
6008 let rewrite = session
6009 .commit_transcript_rewrite(
6010 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6011 vec![Message::BlockAssistant(BlockAssistantMessage {
6012 blocks: vec![AssistantBlock::Text {
6013 text: "compact answer".to_string(),
6014 meta: None,
6015 }],
6016 stop_reason: StopReason::EndTurn,
6017 identity: crate::types::TranscriptMessageIdentity::default(),
6018 created_at: crate::types::message_timestamp_now(),
6019 })],
6020 TranscriptRewriteReason::new("compaction"),
6021 Some("unit-test".to_string()),
6022 Some(parent),
6023 )
6024 .expect("rewrite should commit");
6025
6026 session.set_system_prompt("durable system prompt".to_string());
6027
6028 let head = session
6029 .transcript_revision()
6030 .expect("system prompt should refresh transcript head");
6031 assert_ne!(head, rewrite.revision);
6032 assert_eq!(
6033 head,
6034 transcript_messages_digest(session.messages()).expect("current digest")
6035 );
6036 let head_messages = session
6037 .transcript_revision_messages(&head)
6038 .expect("history state should decode")
6039 .expect("refreshed head body should be retained");
6040 assert_eq!(
6041 serde_json::to_value(&head_messages).expect("head serializes"),
6042 serde_json::to_value(session.messages()).expect("session serializes")
6043 );
6044 validate_transcript_history_state(
6045 &session
6046 .transcript_history_state()
6047 .expect("history state should decode")
6048 .expect("history state should exist"),
6049 )
6050 .expect("history state remains valid after system prompt update");
6051 }
6052
6053 #[test]
6054 fn apply_transcript_history_state_uses_latest_commit_time_for_restored_head() {
6055 let mut session = Session::new();
6056 session.push(Message::User(UserMessage::text("question".to_string())));
6057 session.push(Message::BlockAssistant(BlockAssistantMessage {
6058 blocks: vec![AssistantBlock::Text {
6059 text: "verbose answer".to_string(),
6060 meta: None,
6061 }],
6062 stop_reason: StopReason::EndTurn,
6063 identity: crate::types::TranscriptMessageIdentity::default(),
6064 created_at: crate::types::message_timestamp_now(),
6065 }));
6066 let original_messages = session.messages().to_vec();
6067 let parent = session.transcript_revision().expect("parent revision");
6068 let compact = session
6069 .commit_transcript_rewrite(
6070 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6071 vec![Message::BlockAssistant(BlockAssistantMessage {
6072 blocks: vec![AssistantBlock::Text {
6073 text: "compact answer".to_string(),
6074 meta: None,
6075 }],
6076 stop_reason: StopReason::EndTurn,
6077 identity: crate::types::TranscriptMessageIdentity::default(),
6078 created_at: crate::types::message_timestamp_now(),
6079 })],
6080 TranscriptRewriteReason::new("compaction"),
6081 Some("unit-test".to_string()),
6082 Some(parent.clone()),
6083 )
6084 .expect("rewrite should commit");
6085
6086 std::thread::sleep(std::time::Duration::from_millis(2));
6087 let restore = session
6088 .commit_transcript_rewrite(
6089 TranscriptRewriteSelection::MessageRange {
6090 start: 0,
6091 end: session.messages().len(),
6092 },
6093 original_messages.clone(),
6094 TranscriptRewriteReason::new("restore"),
6095 Some("unit-test".to_string()),
6096 Some(compact.revision),
6097 )
6098 .expect("restore should commit");
6099 assert_eq!(restore.revision, parent);
6100
6101 let state = session
6102 .transcript_history_state()
6103 .expect("history state should decode")
6104 .expect("history state should exist");
6105 let restored_body_created_at = state
6106 .revisions
6107 .iter()
6108 .find(|body| body.revision == restore.revision)
6109 .expect("restored body should be retained")
6110 .created_at;
6111 assert!(
6112 restored_body_created_at < restore.committed_at,
6113 "test requires restore commit to be newer than retained body"
6114 );
6115
6116 let mut replayed = Session::new();
6117 replayed
6118 .apply_transcript_history_state(state)
6119 .expect("replay should materialize restored head");
6120 assert_eq!(
6121 serde_json::to_value(replayed.messages()).expect("replayed serializes"),
6122 serde_json::to_value(&original_messages).expect("original serializes")
6123 );
6124 assert_eq!(replayed.updated_at(), restore.committed_at);
6125 }
6126
6127 #[test]
6128 fn test_session_new() {
6129 let session = Session::new();
6130 assert_eq!(session.version(), SESSION_VERSION);
6131 assert!(session.messages().is_empty());
6132 assert!(session.created_at() <= session.updated_at());
6133 }
6134
6135 #[test]
6136 fn llm_identity_model_override_switches_to_catalog_provider() {
6137 let registry = crate::ModelRegistry::from_config(
6138 &crate::Config::default(),
6139 *crate::model_profile::test_catalog::TEST_CATALOG,
6140 )
6141 .unwrap();
6142 let current = SessionLlmIdentity {
6143 model: "test-anthropic-default".to_string(),
6144 provider: Provider::Anthropic,
6145 self_hosted_server_id: None,
6146 provider_params: None,
6147 auth_binding: Some(crate::AuthBindingRef {
6148 realm: crate::RealmId::parse("tenant_a").unwrap(),
6149 binding: crate::BindingId::parse("anthropic_default").unwrap(),
6150 profile: None,
6151 origin: crate::BindingOrigin::Configured,
6152 }),
6153 };
6154
6155 let resolved = resolve_session_llm_identity_override(
6156 ¤t,
6157 ®istry,
6158 SessionLlmIdentityOverride {
6159 model: Some("test-openai-default"),
6160 provider: None,
6161 provider_params: None,
6162 auth_binding: None,
6163 },
6164 )
6165 .unwrap();
6166
6167 assert_eq!(resolved.model, "test-openai-default");
6168 assert_eq!(resolved.provider, Provider::OpenAI);
6169 assert!(
6170 resolved.auth_binding.is_none(),
6171 "provider switches must not inherit a binding from the previous provider"
6172 );
6173 }
6174
6175 #[test]
6176 fn llm_identity_model_override_keeps_uncatalogued_model_on_current_provider() {
6177 let registry = crate::ModelRegistry::from_config(
6178 &crate::Config::default(),
6179 *crate::model_profile::test_catalog::TEST_CATALOG,
6180 )
6181 .unwrap();
6182 let current = SessionLlmIdentity {
6183 model: "custom-model".to_string(),
6184 provider: Provider::Anthropic,
6185 self_hosted_server_id: None,
6186 provider_params: None,
6187 auth_binding: None,
6188 };
6189
6190 let resolved = resolve_session_llm_identity_override(
6191 ¤t,
6192 ®istry,
6193 SessionLlmIdentityOverride {
6194 model: Some("uncatalogued-custom-model"),
6195 provider: None,
6196 provider_params: None,
6197 auth_binding: None,
6198 },
6199 )
6200 .unwrap();
6201
6202 assert_eq!(resolved.model, "uncatalogued-custom-model");
6203 assert_eq!(resolved.provider, Provider::Anthropic);
6204 }
6205
6206 #[test]
6207 fn realtime_transcript_append_is_idempotent_by_provider_item_and_delta_id() {
6208 let mut session = Session::new();
6209
6210 let user = RealtimeTranscriptEvent::UserTranscriptFinal {
6211 item_id: "item_user".to_string(),
6212 previous_item_id: None,
6213 content_index: 0,
6214 text: "hello".to_string(),
6215 };
6216 assert!(
6217 !session
6218 .append_realtime_transcript_event(user.clone())
6219 .is_inert()
6220 );
6221 assert!(session.append_realtime_transcript_event(user).is_inert());
6222
6223 let delta = RealtimeTranscriptEvent::AssistantTextDelta {
6224 response_id: "resp_assistant".to_string(),
6225 delta_id: "evt_delta_1".to_string(),
6226 item_id: "item_assistant".to_string(),
6227 previous_item_id: Some("item_user".to_string()),
6228 content_index: 0,
6229 delta: "hi".to_string(),
6230 };
6231 assert!(
6232 session
6233 .append_realtime_transcript_event(delta.clone())
6234 .is_inert()
6235 );
6236 assert!(session.append_realtime_transcript_event(delta).is_inert());
6237
6238 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
6239 response_id: "resp_assistant".to_string(),
6240 stop_reason: StopReason::EndTurn,
6241 usage: Usage::default(),
6242 };
6243 assert!(
6244 !session
6245 .append_realtime_transcript_event(terminal.clone())
6246 .is_inert()
6247 );
6248 assert!(
6249 session
6250 .append_realtime_transcript_event(terminal)
6251 .is_inert()
6252 );
6253
6254 assert_eq!(session.messages().len(), 2);
6255 assert!(matches!(
6256 &session.messages()[0],
6257 Message::User(user) if user.text_content() == "hello"
6258 ));
6259 assert!(matches!(
6260 &session.messages()[1],
6261 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "hi"
6262 ));
6263 }
6264
6265 #[test]
6270 fn realtime_transcript_final_text_overrides_partial_delta_and_promotes_to_spoken_lane() {
6271 let mut session = Session::new();
6272
6273 assert!(
6276 session
6277 .append_realtime_transcript_event(
6278 RealtimeTranscriptEvent::AssistantTranscriptDelta {
6279 response_id: "resp_a".to_string(),
6280 delta_id: "evt_1".to_string(),
6281 item_id: "item_a".to_string(),
6282 previous_item_id: None,
6283 content_index: 0,
6284 delta: "incom".to_string(),
6285 }
6286 )
6287 .is_inert()
6288 );
6289
6290 assert!(
6292 session
6293 .append_realtime_transcript_event(
6294 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
6295 response_id: "resp_a".to_string(),
6296 item_id: "item_a".to_string(),
6297 content_index: 0,
6298 text: "complete answer".to_string(),
6299 }
6300 )
6301 .is_inert()
6302 );
6303
6304 let outcome = session.append_realtime_transcript_event(
6306 RealtimeTranscriptEvent::AssistantTurnCompleted {
6307 response_id: "resp_a".to_string(),
6308 stop_reason: StopReason::EndTurn,
6309 usage: Usage::default(),
6310 },
6311 );
6312 assert!(!outcome.is_inert());
6313
6314 assert_eq!(session.messages().len(), 1);
6317 match &session.messages()[0] {
6318 Message::BlockAssistant(assistant) => {
6319 let mut found_transcript = false;
6320 for block in &assistant.blocks {
6321 if let AssistantBlock::Transcript { text, .. } = block {
6322 assert_eq!(text, "complete answer");
6323 found_transcript = true;
6324 }
6325 }
6326 assert!(
6327 found_transcript,
6328 "AssistantTranscriptFinalText must promote to the Spoken lane and \
6329 materialize as AssistantBlock::Transcript"
6330 );
6331 }
6332 other => unreachable!("expected BlockAssistant, got {other:?}"),
6333 }
6334 }
6335
6336 #[test]
6339 fn realtime_transcript_final_text_creates_item_when_no_delta_staged() {
6340 let mut session = Session::new();
6341
6342 assert!(
6343 session
6344 .append_realtime_transcript_event(
6345 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
6346 response_id: "resp_a".to_string(),
6347 item_id: "item_a".to_string(),
6348 content_index: 0,
6349 text: "spoken-final-only".to_string(),
6350 }
6351 )
6352 .is_inert()
6353 );
6354
6355 let outcome = session.append_realtime_transcript_event(
6356 RealtimeTranscriptEvent::AssistantTurnCompleted {
6357 response_id: "resp_a".to_string(),
6358 stop_reason: StopReason::EndTurn,
6359 usage: Usage::default(),
6360 },
6361 );
6362 assert!(!outcome.is_inert());
6363
6364 assert_eq!(session.messages().len(), 1);
6365 match &session.messages()[0] {
6366 Message::BlockAssistant(assistant) => {
6367 let has_transcript = assistant.blocks.iter().any(|b| {
6368 matches!(b, AssistantBlock::Transcript { text, .. } if text == "spoken-final-only")
6369 });
6370 assert!(
6371 has_transcript,
6372 "final-only provider path must materialize as Transcript on the Spoken lane"
6373 );
6374 }
6375 other => unreachable!("expected BlockAssistant, got {other:?}"),
6376 }
6377 }
6378
6379 #[test]
6380 fn realtime_transcript_append_orders_causally_equivalent_out_of_order_items() {
6381 let mut session = Session::new();
6382
6383 assert!(
6384 session
6385 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6386 response_id: "resp_assistant".to_string(),
6387 delta_id: "evt_delta_1".to_string(),
6388 item_id: "item_assistant".to_string(),
6389 previous_item_id: Some("item_user".to_string()),
6390 content_index: 0,
6391 delta: "answer".to_string(),
6392 })
6393 .is_inert()
6394 );
6395 assert!(
6396 session
6397 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
6398 response_id: "resp_assistant".to_string(),
6399 stop_reason: StopReason::EndTurn,
6400 usage: Usage::default(),
6401 })
6402 .is_inert()
6403 );
6404
6405 let outcome = session.append_realtime_transcript_event(
6406 RealtimeTranscriptEvent::UserTranscriptFinal {
6407 item_id: "item_user".to_string(),
6408 previous_item_id: None,
6409 content_index: 0,
6410 text: "question".to_string(),
6411 },
6412 );
6413
6414 assert_eq!(outcome.materialized_messages.len(), 2);
6415 assert_eq!(session.messages().len(), 2);
6416 assert!(matches!(
6417 &session.messages()[0],
6418 Message::User(user) if user.text_content() == "question"
6419 ));
6420 assert!(matches!(
6421 &session.messages()[1],
6422 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer"
6423 ));
6424 }
6425
6426 #[test]
6427 fn realtime_transcript_replay_of_seen_provider_items_is_inert() {
6428 let mut session = Session::new();
6429 let events = vec![
6430 RealtimeTranscriptEvent::UserTranscriptFinal {
6431 item_id: "item_user".to_string(),
6432 previous_item_id: None,
6433 content_index: 0,
6434 text: "hello".to_string(),
6435 },
6436 RealtimeTranscriptEvent::AssistantTextDelta {
6437 response_id: "resp_assistant".to_string(),
6438 delta_id: "evt_delta_1".to_string(),
6439 item_id: "item_assistant".to_string(),
6440 previous_item_id: Some("item_user".to_string()),
6441 content_index: 0,
6442 delta: "world".to_string(),
6443 },
6444 RealtimeTranscriptEvent::AssistantTurnCompleted {
6445 response_id: "resp_assistant".to_string(),
6446 stop_reason: StopReason::EndTurn,
6447 usage: Usage::default(),
6448 },
6449 ];
6450
6451 for event in events.iter().cloned() {
6452 let _ = session.append_realtime_transcript_event(event);
6453 }
6454 let first_messages = serde_json::to_value(session.messages()).unwrap();
6455
6456 for event in events {
6457 assert!(session.append_realtime_transcript_event(event).is_inert());
6458 }
6459
6460 assert_eq!(
6461 serde_json::to_value(session.messages()).unwrap(),
6462 first_messages
6463 );
6464 }
6465
6466 #[test]
6467 fn realtime_transcript_user_final_replay_cannot_erase_existing_segment() {
6468 let mut session = Session::new();
6469
6470 let user = RealtimeTranscriptEvent::UserTranscriptFinal {
6471 item_id: "item_user".to_string(),
6472 previous_item_id: None,
6473 content_index: 0,
6474 text: "remember amber lantern".to_string(),
6475 };
6476 assert!(
6477 !session
6478 .append_realtime_transcript_event(user.clone())
6479 .is_inert()
6480 );
6481 let first_messages = serde_json::to_value(session.messages()).unwrap();
6482
6483 assert!(
6484 session
6485 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
6486 item_id: "item_user".to_string(),
6487 previous_item_id: None,
6488 content_index: 0,
6489 text: String::new(),
6490 })
6491 .is_inert()
6492 );
6493 assert!(session.append_realtime_transcript_event(user).is_inert());
6494 assert_eq!(
6495 serde_json::to_value(session.messages()).unwrap(),
6496 first_messages
6497 );
6498 }
6499
6500 #[test]
6501 fn realtime_transcript_empty_user_final_can_be_filled_by_later_nonempty_replay() {
6502 let mut session = Session::new();
6503
6504 assert!(
6505 session
6506 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
6507 item_id: "item_user".to_string(),
6508 previous_item_id: None,
6509 content_index: 0,
6510 text: String::new(),
6511 })
6512 .is_inert()
6513 );
6514 assert!(session.messages().is_empty());
6515
6516 let outcome = session.append_realtime_transcript_event(
6517 RealtimeTranscriptEvent::UserTranscriptFinal {
6518 item_id: "item_user".to_string(),
6519 previous_item_id: None,
6520 content_index: 0,
6521 text: "remember amber lantern".to_string(),
6522 },
6523 );
6524 assert_eq!(outcome.materialized_messages.len(), 1);
6525 assert_eq!(session.messages().len(), 1);
6526 assert!(matches!(
6527 &session.messages()[0],
6528 Message::User(user) if user.text_content() == "remember amber lantern"
6529 ));
6530 }
6531
6532 #[test]
6533 fn realtime_transcript_skipped_provider_items_preserve_causal_order_without_content() {
6534 let mut session = Session::new();
6535
6536 let assistant_delta = RealtimeTranscriptEvent::AssistantTextDelta {
6537 response_id: "resp_assistant".to_string(),
6538 delta_id: "evt_delta_1".to_string(),
6539 item_id: "item_assistant".to_string(),
6540 previous_item_id: Some("item_tool".to_string()),
6541 content_index: 0,
6542 delta: "done".to_string(),
6543 };
6544 assert!(
6545 session
6546 .append_realtime_transcript_event(assistant_delta.clone())
6547 .is_inert()
6548 );
6549 let assistant_complete = RealtimeTranscriptEvent::AssistantTurnCompleted {
6550 response_id: "resp_assistant".to_string(),
6551 stop_reason: StopReason::EndTurn,
6552 usage: Usage::default(),
6553 };
6554 assert!(
6555 session
6556 .append_realtime_transcript_event(assistant_complete.clone())
6557 .is_inert()
6558 );
6559
6560 let skipped = RealtimeTranscriptEvent::ItemSkipped {
6561 item_id: "item_tool".to_string(),
6562 previous_item_id: Some("item_user".to_string()),
6563 };
6564 assert!(
6565 session
6566 .append_realtime_transcript_event(skipped.clone())
6567 .is_inert(),
6568 "a skipped provider item must not append transcript content"
6569 );
6570 assert!(session.messages().is_empty());
6571
6572 let outcome = session.append_realtime_transcript_event(
6573 RealtimeTranscriptEvent::UserTranscriptFinal {
6574 item_id: "item_user".to_string(),
6575 previous_item_id: None,
6576 content_index: 0,
6577 text: "please use the tool".to_string(),
6578 },
6579 );
6580 assert_eq!(outcome.materialized_messages.len(), 2);
6581 assert_eq!(session.messages().len(), 2);
6582 assert!(matches!(
6583 &session.messages()[0],
6584 Message::User(user) if user.text_content() == "please use the tool"
6585 ));
6586 assert!(matches!(
6587 &session.messages()[1],
6588 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "done"
6589 ));
6590
6591 let first_messages = serde_json::to_value(session.messages()).unwrap();
6592 assert!(session.append_realtime_transcript_event(skipped).is_inert());
6593 assert!(
6594 session
6595 .append_realtime_transcript_event(assistant_delta)
6596 .is_inert()
6597 );
6598 assert!(
6599 session
6600 .append_realtime_transcript_event(assistant_complete)
6601 .is_inert()
6602 );
6603 assert_eq!(
6604 serde_json::to_value(session.messages()).unwrap(),
6605 first_messages
6606 );
6607 }
6608
6609 #[test]
6610 fn realtime_transcript_interrupted_assistant_item_unblocks_later_provider_items() {
6611 let mut session = Session::new();
6618
6619 let _ = session.append_realtime_transcript_event(
6620 RealtimeTranscriptEvent::UserTranscriptFinal {
6621 item_id: "item_repeat".to_string(),
6622 previous_item_id: None,
6623 content_index: 0,
6624 text: "repeat until stop".to_string(),
6625 },
6626 );
6627 assert!(
6628 session
6629 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6630 response_id: "resp_loop".to_string(),
6631 delta_id: "evt_loop_1".to_string(),
6632 item_id: "item_loop".to_string(),
6633 previous_item_id: Some("item_repeat".to_string()),
6634 content_index: 0,
6635 delta: "Looping now".to_string(),
6636 })
6637 .is_inert()
6638 );
6639 assert!(
6640 session
6641 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
6642 item_id: "item_stop".to_string(),
6643 previous_item_id: Some("item_loop".to_string()),
6644 content_index: 0,
6645 text: "Stop.".to_string(),
6646 })
6647 .is_inert(),
6648 "the stop turn waits until the interrupted assistant provider item is resolved"
6649 );
6650
6651 let outcome = session.append_realtime_transcript_event(
6652 RealtimeTranscriptEvent::AssistantTurnInterrupted {
6653 response_id: "resp_loop".to_string(),
6654 },
6655 );
6656
6657 assert_eq!(outcome.materialized_messages.len(), 2);
6660 assert_eq!(session.messages().len(), 3);
6662 assert!(matches!(
6663 &session.messages()[0],
6664 Message::User(user) if user.text_content() == "repeat until stop"
6665 ));
6666 match &session.messages()[1] {
6667 Message::BlockAssistant(assistant) => {
6668 let text = block_assistant_text(assistant);
6669 assert_eq!(text, "Looping now");
6670 }
6671 other => unreachable!(
6672 "Display lane assistant item must be retained on Interrupted, got {other:?}"
6673 ),
6674 }
6675 assert!(matches!(
6676 &session.messages()[2],
6677 Message::User(user) if user.text_content() == "Stop."
6678 ));
6679 }
6680
6681 #[test]
6682 fn realtime_transcript_late_interrupted_assistant_delta_stays_noncanonical() {
6683 let mut session = Session::new();
6684
6685 let _ = session.append_realtime_transcript_event(
6686 RealtimeTranscriptEvent::UserTranscriptFinal {
6687 item_id: "item_repeat".to_string(),
6688 previous_item_id: None,
6689 content_index: 0,
6690 text: "repeat until stop".to_string(),
6691 },
6692 );
6693 assert!(
6694 session
6695 .append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
6696 item_id: "item_loop".to_string(),
6697 previous_item_id: Some("item_repeat".to_string()),
6698 role: RealtimeTranscriptRole::Assistant,
6699 response_id: None,
6700 })
6701 .is_inert(),
6702 "provider can observe an assistant item before the adapter learns its response id"
6703 );
6704 assert!(
6705 session
6706 .append_realtime_transcript_event(
6707 RealtimeTranscriptEvent::AssistantTurnInterrupted {
6708 response_id: "resp_loop".to_string(),
6709 }
6710 )
6711 .is_inert(),
6712 "an interruption can arrive before delayed transcript deltas for the response"
6713 );
6714 assert!(
6715 session
6716 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
6717 item_id: "item_stop".to_string(),
6718 previous_item_id: Some("item_loop".to_string()),
6719 content_index: 0,
6720 text: "Stop.".to_string(),
6721 })
6722 .is_inert(),
6723 "the stop turn waits for the provider's interrupted assistant item anchor"
6724 );
6725
6726 let late_delta_outcome =
6727 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6728 response_id: "resp_loop".to_string(),
6729 delta_id: "evt_loop_late".to_string(),
6730 item_id: "item_loop".to_string(),
6731 previous_item_id: Some("item_repeat".to_string()),
6732 content_index: 0,
6733 delta: "Looping now".to_string(),
6734 });
6735 assert_eq!(late_delta_outcome.materialized_messages.len(), 1);
6736 assert!(matches!(
6737 &session.messages()[1],
6738 Message::User(user) if user.text_content() == "Stop."
6739 ));
6740 assert!(
6741 session
6742 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
6743 response_id: "resp_loop".to_string(),
6744 stop_reason: StopReason::EndTurn,
6745 usage: Usage::default(),
6746 })
6747 .is_inert(),
6748 "late completion for an interrupted response must not resurrect its deltas"
6749 );
6750 assert!(
6751 session
6752 .messages()
6753 .iter()
6754 .filter_map(|message| match message {
6755 Message::BlockAssistant(assistant) => Some(block_assistant_text(assistant)),
6756 _ => None,
6757 })
6758 .all(|text| !text.contains("Looping now")),
6759 "late interrupted assistant text must remain non-canonical"
6760 );
6761 }
6762
6763 #[test]
6764 fn realtime_transcript_completion_only_finalizes_matching_response() {
6765 let mut session = Session::new();
6766
6767 let _ = session.append_realtime_transcript_event(
6768 RealtimeTranscriptEvent::UserTranscriptFinal {
6769 item_id: "item_user".to_string(),
6770 previous_item_id: None,
6771 content_index: 0,
6772 text: "question".to_string(),
6773 },
6774 );
6775 assert!(
6776 session
6777 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6778 response_id: "resp_a".to_string(),
6779 delta_id: "evt_a".to_string(),
6780 item_id: "item_a".to_string(),
6781 previous_item_id: Some("item_user".to_string()),
6782 content_index: 0,
6783 delta: "answer a".to_string(),
6784 })
6785 .is_inert()
6786 );
6787
6788 assert!(
6789 session
6790 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
6791 response_id: "resp_b".to_string(),
6792 stop_reason: StopReason::EndTurn,
6793 usage: Usage::default(),
6794 })
6795 .is_inert(),
6796 "a completion for another response must not finalize buffered assistant text"
6797 );
6798 assert_eq!(session.messages().len(), 1);
6799
6800 let outcome = session.append_realtime_transcript_event(
6801 RealtimeTranscriptEvent::AssistantTurnCompleted {
6802 response_id: "resp_a".to_string(),
6803 stop_reason: StopReason::EndTurn,
6804 usage: Usage::default(),
6805 },
6806 );
6807 assert_eq!(outcome.materialized_messages.len(), 1);
6808 assert_eq!(session.messages().len(), 2);
6809 assert!(matches!(
6810 &session.messages()[1],
6811 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer a"
6812 ));
6813 }
6814
6815 #[test]
6816 fn realtime_transcript_completion_before_later_delta_is_response_scoped() {
6817 let mut session = Session::new();
6818
6819 let _ = session.append_realtime_transcript_event(
6820 RealtimeTranscriptEvent::UserTranscriptFinal {
6821 item_id: "item_user".to_string(),
6822 previous_item_id: None,
6823 content_index: 0,
6824 text: "question".to_string(),
6825 },
6826 );
6827 assert!(
6828 session
6829 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
6830 response_id: "resp_a".to_string(),
6831 stop_reason: StopReason::EndTurn,
6832 usage: Usage::default(),
6833 })
6834 .is_inert()
6835 );
6836 assert!(
6837 session
6838 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6839 response_id: "resp_b".to_string(),
6840 delta_id: "evt_b".to_string(),
6841 item_id: "item_b".to_string(),
6842 previous_item_id: Some("item_user".to_string()),
6843 content_index: 0,
6844 delta: "wrong response".to_string(),
6845 })
6846 .is_inert(),
6847 "a later delta for another response must not be finalized by resp_a's pending completion"
6848 );
6849
6850 let outcome =
6851 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6852 response_id: "resp_a".to_string(),
6853 delta_id: "evt_a".to_string(),
6854 item_id: "item_a".to_string(),
6855 previous_item_id: Some("item_user".to_string()),
6856 content_index: 0,
6857 delta: "right response".to_string(),
6858 });
6859
6860 assert_eq!(outcome.materialized_messages.len(), 1);
6861 assert_eq!(session.messages().len(), 2);
6862 assert!(matches!(
6863 &session.messages()[1],
6864 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "right response"
6865 ));
6866 }
6867
6868 #[test]
6869 fn realtime_transcript_late_duplicate_completion_cannot_finalize_unrelated_response() {
6870 let mut session = Session::new();
6871
6872 let _ = session.append_realtime_transcript_event(
6873 RealtimeTranscriptEvent::UserTranscriptFinal {
6874 item_id: "item_user".to_string(),
6875 previous_item_id: None,
6876 content_index: 0,
6877 text: "question".to_string(),
6878 },
6879 );
6880 let _ =
6881 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6882 response_id: "resp_a".to_string(),
6883 delta_id: "evt_a".to_string(),
6884 item_id: "item_a".to_string(),
6885 previous_item_id: Some("item_user".to_string()),
6886 content_index: 0,
6887 delta: "first".to_string(),
6888 });
6889 let _ = session.append_realtime_transcript_event(
6890 RealtimeTranscriptEvent::AssistantTurnCompleted {
6891 response_id: "resp_a".to_string(),
6892 stop_reason: StopReason::EndTurn,
6893 usage: Usage::default(),
6894 },
6895 );
6896 assert_eq!(session.messages().len(), 2);
6897
6898 assert!(
6899 session
6900 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6901 response_id: "resp_b".to_string(),
6902 delta_id: "evt_b".to_string(),
6903 item_id: "item_b".to_string(),
6904 previous_item_id: Some("item_a".to_string()),
6905 content_index: 0,
6906 delta: "second".to_string(),
6907 })
6908 .is_inert()
6909 );
6910 assert!(
6911 session
6912 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
6913 response_id: "resp_a".to_string(),
6914 stop_reason: StopReason::EndTurn,
6915 usage: Usage::default(),
6916 })
6917 .is_inert(),
6918 "a duplicate late terminal for resp_a must not finalize resp_b"
6919 );
6920 assert_eq!(session.messages().len(), 2);
6921
6922 let outcome = session.append_realtime_transcript_event(
6923 RealtimeTranscriptEvent::AssistantTurnCompleted {
6924 response_id: "resp_b".to_string(),
6925 stop_reason: StopReason::EndTurn,
6926 usage: Usage::default(),
6927 },
6928 );
6929 assert_eq!(outcome.materialized_messages.len(), 1);
6930 assert_eq!(session.messages().len(), 3);
6931 }
6932
6933 #[test]
6934 fn realtime_transcript_interruption_discards_only_matching_response() {
6935 let mut session = Session::new();
6941
6942 let _ = session.append_realtime_transcript_event(
6943 RealtimeTranscriptEvent::UserTranscriptFinal {
6944 item_id: "item_user".to_string(),
6945 previous_item_id: None,
6946 content_index: 0,
6947 text: "question".to_string(),
6948 },
6949 );
6950 let _ =
6951 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6952 response_id: "resp_a".to_string(),
6953 delta_id: "evt_a".to_string(),
6954 item_id: "item_a".to_string(),
6955 previous_item_id: Some("item_user".to_string()),
6956 content_index: 0,
6957 delta: "interrupted display".to_string(),
6958 });
6959 let _ =
6960 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6961 response_id: "resp_b".to_string(),
6962 delta_id: "evt_b".to_string(),
6963 item_id: "item_b".to_string(),
6964 previous_item_id: Some("item_user".to_string()),
6965 content_index: 0,
6966 delta: "keep me".to_string(),
6967 });
6968
6969 let interrupt_outcome = session.append_realtime_transcript_event(
6972 RealtimeTranscriptEvent::AssistantTurnInterrupted {
6973 response_id: "resp_a".to_string(),
6974 },
6975 );
6976 assert_eq!(
6977 interrupt_outcome.materialized_messages.len(),
6978 1,
6979 "resp_a's Display item commits on Interrupted"
6980 );
6981
6982 let outcome = session.append_realtime_transcript_event(
6983 RealtimeTranscriptEvent::AssistantTurnCompleted {
6984 response_id: "resp_b".to_string(),
6985 stop_reason: StopReason::EndTurn,
6986 usage: Usage::default(),
6987 },
6988 );
6989 assert_eq!(
6990 outcome.materialized_messages.len(),
6991 1,
6992 "resp_b commits on its TurnCompleted, untouched by resp_a's Interrupted"
6993 );
6994
6995 assert_eq!(session.messages().len(), 3);
6997 assert!(matches!(
6998 &session.messages()[1],
6999 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "interrupted display"
7000 ));
7001 assert!(matches!(
7002 &session.messages()[2],
7003 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "keep me"
7004 ));
7005 }
7006
7007 #[test]
7010 fn test_fork_shares_arc_no_clone() {
7011 let mut session = Session::new();
7012 for i in 0..100 {
7013 session.push(Message::User(UserMessage::text(format!("Message {i}"))));
7014 }
7015
7016 let forked = session.fork();
7018
7019 assert!(Arc::ptr_eq(&session.messages, &forked.messages));
7021 assert_eq!(forked.messages().len(), 100);
7022 }
7023
7024 #[test]
7025 fn test_fork_at_shares_arc_prefix() {
7026 let mut session = Session::new();
7027 for i in 0..100 {
7028 session.push(Message::User(UserMessage::text(format!("Message {i}"))));
7029 }
7030
7031 let forked = session.fork_at(50);
7033 assert_eq!(forked.messages().len(), 50);
7034
7035 assert_eq!(session.messages().len(), 100);
7037 }
7038
7039 #[test]
7040 fn test_fork_at_resets_transcript_history_state_for_branch_identity() {
7041 let mut session = Session::new();
7042 session.push(Message::User(UserMessage::text(
7043 "summarize this".to_string(),
7044 )));
7045 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
7046 vec![AssistantBlock::Text {
7047 text: "long assistant trace".to_string(),
7048 meta: None,
7049 }],
7050 StopReason::EndTurn,
7051 )));
7052 let parent_revision = session.transcript_revision().expect("parent revision");
7053 session
7054 .commit_transcript_rewrite(
7055 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
7056 vec![Message::BlockAssistant(BlockAssistantMessage::new(
7057 vec![AssistantBlock::Text {
7058 text: "compact trace".to_string(),
7059 meta: None,
7060 }],
7061 StopReason::EndTurn,
7062 ))],
7063 TranscriptRewriteReason::new("compaction"),
7064 Some("test".to_string()),
7065 Some(parent_revision),
7066 )
7067 .expect("rewrite should commit");
7068
7069 let source_head = session.transcript_revision().expect("source head");
7070 let mut forked = session.fork_at(1);
7071 assert_ne!(forked.id(), session.id());
7072 assert!(
7073 !forked
7074 .metadata()
7075 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
7076 );
7077 assert_eq!(
7078 forked.transcript_revision().expect("fork head"),
7079 transcript_messages_digest(forked.messages()).expect("fork digest")
7080 );
7081 assert!(
7082 forked
7083 .transcript_revision_messages(&source_head)
7084 .expect("fork history lookup")
7085 .is_none()
7086 );
7087
7088 let fork_parent = forked.transcript_revision().expect("fork parent");
7089 let commit = forked
7090 .commit_transcript_rewrite(
7091 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
7092 vec![Message::User(UserMessage::text(
7093 "branch prompt".to_string(),
7094 ))],
7095 TranscriptRewriteReason::new("branch_edit"),
7096 Some("test".to_string()),
7097 Some(fork_parent.clone()),
7098 )
7099 .expect("fork rewrite should use fork-local parent");
7100 assert_eq!(commit.parent_revision, fork_parent);
7101 }
7102
7103 #[test]
7104 fn test_push_cow_behavior() {
7105 let mut session = Session::new();
7106 session.push(Message::User(UserMessage::text("First".to_string())));
7107
7108 let forked = session.fork();
7110 assert!(Arc::ptr_eq(&session.messages, &forked.messages));
7111
7112 session.push(Message::User(UserMessage::text("Second".to_string())));
7114
7115 assert!(!Arc::ptr_eq(&session.messages, &forked.messages));
7117 assert_eq!(session.messages().len(), 2);
7118 assert_eq!(forked.messages().len(), 1);
7119 }
7120
7121 #[test]
7124 fn test_push_batch_single_timestamp() {
7125 let mut session = Session::new();
7126 let initial_updated = session.updated_at();
7127
7128 session.push_batch(vec![
7130 Message::User(UserMessage::text("First".to_string())),
7131 Message::User(UserMessage::text("Second".to_string())),
7132 Message::User(UserMessage::text("Third".to_string())),
7133 ]);
7134
7135 assert_eq!(session.messages().len(), 3);
7136 assert!(session.updated_at() >= initial_updated);
7138 }
7139
7140 #[test]
7141 fn test_touch_updates_timestamp() {
7142 let mut session = Session::new();
7143 let initial = session.updated_at();
7144
7145 std::thread::sleep(std::time::Duration::from_millis(10));
7146
7147 session.touch();
7149
7150 assert!(session.updated_at() > initial);
7151 }
7152
7153 #[test]
7154 fn test_session_push() {
7155 let mut session = Session::new();
7156 let initial_updated = session.updated_at();
7157
7158 std::thread::sleep(std::time::Duration::from_millis(10));
7160
7161 session.push(Message::User(UserMessage::text("Hello".to_string())));
7162
7163 assert_eq!(session.messages().len(), 1);
7164 assert!(session.updated_at() > initial_updated);
7165 }
7166
7167 #[test]
7168 fn test_session_fork() {
7169 let mut session = Session::new();
7170 session.push(Message::System(SystemMessage::new("System prompt")));
7171 session.push(Message::User(UserMessage::text("Hello".to_string())));
7172 session.push(Message::BlockAssistant(BlockAssistantMessage {
7173 blocks: vec![AssistantBlock::Text {
7174 text: "Hi!".to_string(),
7175 meta: None,
7176 }],
7177 stop_reason: StopReason::EndTurn,
7178 identity: crate::types::TranscriptMessageIdentity::default(),
7179 created_at: crate::types::message_timestamp_now(),
7180 }));
7181
7182 let forked = session.fork_at(2);
7184 assert_eq!(forked.messages().len(), 2);
7185 assert_ne!(forked.id(), session.id());
7186
7187 let full_fork = session.fork();
7189 assert_eq!(full_fork.messages().len(), 3);
7190 }
7191
7192 #[test]
7193 fn test_session_forks_drop_generated_authority_metadata() {
7194 let mut session = Session::new();
7195 session.push(Message::User(UserMessage::text("original")));
7196 session.set_metadata("ordinary", serde_json::json!("keep"));
7197 session
7198 .set_build_state(SessionBuildState::default())
7199 .expect("build state should serialize");
7200 session
7201 .set_system_context_state(SessionSystemContextState::default())
7202 .expect("system-context state should serialize");
7203 session
7204 .set_deferred_turn_state(SessionDeferredTurnState::default())
7205 .expect("deferred-turn state should serialize");
7206 session
7207 .set_tool_visibility_state(
7208 AuthorizedSessionToolVisibilityState::from_generated_authority(
7209 SessionToolVisibilityState::default(),
7210 ),
7211 )
7212 .expect("visibility state should serialize");
7213 let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
7214 item_id: "rt-item".to_string(),
7215 previous_item_id: None,
7216 role: RealtimeTranscriptRole::User,
7217 response_id: None,
7218 });
7219 assert!(
7220 session
7221 .metadata()
7222 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
7223 "test setup should install realtime transcript authority state"
7224 );
7225
7226 let forked_at = session.fork_at(1);
7227 let full_fork = session.fork();
7228 let replaced = session
7229 .fork_replacing(
7230 0,
7231 TranscriptReplacement::Message {
7232 message: Message::User(UserMessage::text("replacement")),
7233 },
7234 )
7235 .expect("replacement fork should succeed");
7236
7237 for forked in [&forked_at, &full_fork, &replaced] {
7238 assert_eq!(forked.metadata().get("ordinary").unwrap(), "keep");
7239 assert!(
7240 !forked.metadata().contains_key(SESSION_BUILD_STATE_KEY),
7241 "forked sessions must not raw-copy durable build-state authority"
7242 );
7243 assert!(
7244 !forked
7245 .metadata()
7246 .contains_key(SESSION_SYSTEM_CONTEXT_STATE_KEY),
7247 "forked sessions must not raw-copy system-context authority state"
7248 );
7249 assert!(
7250 !forked
7251 .metadata()
7252 .contains_key(SESSION_DEFERRED_TURN_STATE_KEY),
7253 "forked sessions must not raw-copy deferred-turn authority state"
7254 );
7255 assert!(
7256 !forked
7257 .metadata()
7258 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY),
7259 "forked sessions must not raw-copy tool-visibility authority state"
7260 );
7261 assert!(
7262 !forked
7263 .metadata()
7264 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
7265 "forked sessions must not raw-copy realtime transcript authority state"
7266 );
7267 }
7268 }
7269
7270 #[test]
7271 fn test_session_metadata() {
7272 let mut session = Session::new();
7273 session.set_metadata("key", serde_json::json!("value"));
7274
7275 assert_eq!(session.metadata().get("key").unwrap(), "value");
7276 }
7277
7278 #[test]
7279 fn session_metadata_realm_id_is_back_read_compatible_string() {
7280 let metadata = SessionMetadata {
7283 schema_version: SESSION_METADATA_SCHEMA_VERSION,
7284 model: "test-model".to_string(),
7285 max_tokens: 1024,
7286 structured_output_retries: 2,
7287 provider: Provider::Other,
7288 self_hosted_server_id: None,
7289 provider_params: None,
7290 tooling: SessionTooling::default(),
7291 keep_alive: false,
7292 comms_name: None,
7293 peer_meta: None,
7294 realm_id: Some(crate::RealmId::parse("env_default").unwrap()),
7295 instance_id: None,
7296 backend: None,
7297 config_generation: None,
7298 auth_binding: None,
7299 mob_member_binding: None,
7300 };
7301 let value = serde_json::to_value(&metadata).unwrap();
7302 assert_eq!(
7303 value.get("realm_id"),
7304 Some(&serde_json::json!("env_default")),
7305 "typed realm_id must serialize as a bare slug string"
7306 );
7307
7308 let legacy = serde_json::json!({
7311 "schema_version": SESSION_METADATA_SCHEMA_VERSION,
7312 "model": "test-model",
7313 "max_tokens": 1024,
7314 "structured_output_retries": 2,
7315 "provider": "other",
7316 "tooling": SessionTooling::default(),
7317 "keep_alive": false,
7318 "comms_name": null,
7319 "realm_id": "legacy_realm",
7320 });
7321 let restored: SessionMetadata = serde_json::from_value(legacy).unwrap();
7322 assert_eq!(
7323 restored.realm_id.as_ref().map(crate::RealmId::as_str),
7324 Some("legacy_realm")
7325 );
7326 }
7327
7328 #[test]
7333 fn session_tooling_tool_access_policy_round_trip_and_absent_default() {
7334 let legacy = serde_json::json!({});
7336 let restored: SessionTooling = serde_json::from_value(legacy).unwrap();
7337 assert_eq!(restored.tool_access_policy, None);
7338
7339 let value = serde_json::to_value(SessionTooling::default()).unwrap();
7341 assert!(
7342 value.get("tool_access_policy").is_none(),
7343 "None policy must not serialize"
7344 );
7345
7346 let tooling = SessionTooling {
7348 tool_access_policy: Some(crate::ops::ToolAccessPolicy::AllowList(
7349 ["read_file", "send_message"].into_iter().collect(),
7350 )),
7351 ..SessionTooling::default()
7352 };
7353 let value = serde_json::to_value(&tooling).unwrap();
7354 let restored: SessionTooling = serde_json::from_value(value).unwrap();
7355 assert_eq!(restored.tool_access_policy, tooling.tool_access_policy);
7356 }
7357
7358 #[test]
7359 fn lifecycle_terminal_typed_round_trip() {
7360 let mut session = Session::new();
7361 assert_eq!(session.lifecycle_terminal(), None);
7362
7363 session
7364 .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
7365 .expect("typed terminal write should serialize");
7366 assert_eq!(
7367 session.lifecycle_terminal(),
7368 Some(SessionLifecycleTerminal::Archived)
7369 );
7370 assert!(
7371 session
7372 .lifecycle_terminal()
7373 .is_some_and(SessionLifecycleTerminal::is_archived)
7374 );
7375 assert_eq!(
7377 session
7378 .metadata()
7379 .get(SESSION_LIFECYCLE_TERMINAL_KEY)
7380 .unwrap(),
7381 &serde_json::json!("archived")
7382 );
7383 }
7384
7385 #[test]
7386 fn lifecycle_terminal_key_rejects_raw_mutation() {
7387 let mut session = Session::new();
7388 assert!(
7389 session
7390 .try_set_metadata(
7391 SESSION_LIFECYCLE_TERMINAL_KEY,
7392 serde_json::json!("archived")
7393 )
7394 .is_err(),
7395 "the typed lifecycle-terminal key is reserved for session authority"
7396 );
7397 }
7398
7399 #[test]
7400 fn test_session_metadata_backfill_preserves_timestamp() {
7401 let mut session = Session::new();
7402 let initial_updated = session.updated_at();
7403
7404 std::thread::sleep(std::time::Duration::from_millis(10));
7405
7406 assert!(session.backfill_metadata_if_absent("key", serde_json::json!("value")));
7407 assert_eq!(session.metadata().get("key").unwrap(), "value");
7408 assert_eq!(session.updated_at(), initial_updated);
7409 assert!(!session.backfill_metadata_if_absent("key", serde_json::json!("other")));
7410 assert_eq!(session.metadata().get("key").unwrap(), "value");
7411 assert_eq!(session.updated_at(), initial_updated);
7412 }
7413
7414 #[test]
7415 fn test_reserved_generated_authority_metadata_rejects_raw_mutation() {
7416 let mut session = Session::new();
7417
7418 assert!(
7419 session
7420 .try_set_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY, serde_json::json!({}))
7421 .is_err()
7422 );
7423 assert!(
7424 session
7425 .try_set_metadata(SESSION_METADATA_KEY, serde_json::json!({}))
7426 .is_err()
7427 );
7428 assert!(
7429 session
7430 .try_set_metadata(SESSION_BUILD_STATE_KEY, serde_json::json!({}))
7431 .is_err()
7432 );
7433 session
7434 .set_session_metadata(SessionMetadata {
7435 schema_version: SESSION_METADATA_SCHEMA_VERSION,
7436 model: "test-model".to_string(),
7437 max_tokens: 1024,
7438 structured_output_retries: 2,
7439 provider: Provider::Other,
7440 self_hosted_server_id: None,
7441 provider_params: None,
7442 tooling: SessionTooling::default(),
7443 keep_alive: false,
7444 comms_name: None,
7445 peer_meta: None,
7446 realm_id: None,
7447 instance_id: None,
7448 backend: None,
7449 config_generation: None,
7450 auth_binding: None,
7451 mob_member_binding: None,
7452 })
7453 .expect("typed metadata setter should route through generated authority");
7454 session
7455 .set_build_state(SessionBuildState::default())
7456 .expect("typed build-state setter should route through generated authority");
7457 session.remove_metadata(SESSION_METADATA_KEY);
7458 session.remove_metadata(SESSION_BUILD_STATE_KEY);
7459 assert!(
7460 session.metadata().contains_key(SESSION_METADATA_KEY),
7461 "raw removal must not delete generated-authority session metadata"
7462 );
7463 assert!(
7464 session.metadata().contains_key(SESSION_BUILD_STATE_KEY),
7465 "raw removal must not delete generated-authority build state"
7466 );
7467 session.set_metadata(SESSION_DEFERRED_TURN_STATE_KEY, serde_json::json!({}));
7468 assert!(
7469 !session
7470 .metadata()
7471 .contains_key(SESSION_DEFERRED_TURN_STATE_KEY)
7472 );
7473 assert!(
7474 !session.backfill_metadata_if_absent(
7475 SESSION_SYSTEM_CONTEXT_STATE_KEY,
7476 serde_json::json!({})
7477 )
7478 );
7479
7480 let state = SessionSystemContextState::default();
7481 session
7482 .set_system_context_state(state.clone())
7483 .expect("typed setter should route through generated authority");
7484 session.remove_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY);
7485 assert_eq!(
7486 session
7487 .try_system_context_state()
7488 .expect("typed state should restore"),
7489 Some(state)
7490 );
7491
7492 session.metadata.insert(
7493 SESSION_SYSTEM_CONTEXT_STATE_KEY.to_string(),
7494 serde_json::json!("not-a-state"),
7495 );
7496 assert!(
7497 session.try_system_context_state().is_err(),
7498 "malformed generated authority state must not decode as absent/default"
7499 );
7500
7501 session.metadata.insert(
7502 SESSION_METADATA_KEY.to_string(),
7503 serde_json::json!("not-metadata"),
7504 );
7505 assert!(
7506 session.try_session_metadata().is_err(),
7507 "malformed session metadata must not decode as absent/default"
7508 );
7509
7510 session.metadata.insert(
7511 SESSION_BUILD_STATE_KEY.to_string(),
7512 serde_json::json!("not-build-state"),
7513 );
7514 assert!(
7515 session.try_build_state().is_err(),
7516 "malformed build state must not decode as absent/default"
7517 );
7518
7519 assert!(
7520 session
7521 .try_set_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY, serde_json::json!({}))
7522 .is_err()
7523 );
7524 session
7525 .set_tool_visibility_state(
7526 AuthorizedSessionToolVisibilityState::from_generated_authority(
7527 SessionToolVisibilityState::default(),
7528 ),
7529 )
7530 .expect("typed visibility setter should route through typed authority handoff");
7531 session.remove_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY);
7532 assert!(
7533 session
7534 .metadata()
7535 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
7536 );
7537 session.clear_tool_visibility_state();
7538 assert!(
7539 !session
7540 .metadata()
7541 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
7542 );
7543 assert!(
7544 session
7545 .try_set_metadata(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, serde_json::json!({}))
7546 .is_err()
7547 );
7548 let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
7549 item_id: "rt-item".to_string(),
7550 previous_item_id: None,
7551 role: RealtimeTranscriptRole::User,
7552 response_id: None,
7553 });
7554 assert!(
7555 session
7556 .metadata()
7557 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
7558 "typed realtime transcript append should retain authority to persist its state"
7559 );
7560 session.metadata.insert(
7561 SESSION_REALTIME_TRANSCRIPT_STATE_KEY.to_string(),
7562 serde_json::json!("not-a-state"),
7563 );
7564 assert!(
7565 session.try_realtime_transcript_state().is_err(),
7566 "malformed realtime generated authority state must not decode as absent/default"
7567 );
7568 }
7569
7570 #[test]
7571 fn test_session_mob_tool_authority_context_persists_projection_without_authority_seal() {
7572 let mut session = Session::new();
7573 session
7574 .set_build_state(SessionBuildState::default())
7575 .expect("session build state should serialize");
7576 let authority = MobToolAuthorityContext::generated_for_test(
7577 crate::service::OpaquePrincipalToken::new("opaque-principal"),
7578 false,
7579 false,
7580 false,
7581 std::collections::BTreeSet::from(["mob-a".to_string()]),
7582 std::collections::BTreeMap::new(),
7583 None,
7584 Some("audit-1".to_string()),
7585 );
7586
7587 session
7588 .set_mob_tool_authority_context(Some(authority))
7589 .expect("authority should serialize");
7590 assert!(session.mob_tool_authority_context().is_none());
7591 let stored = session
7592 .build_state()
7593 .and_then(|state| state.mob_tool_authority_context)
7594 .expect("stored projection should deserialize");
7595 assert!(!stored.is_generated_authority_context());
7596 assert!(!stored.can_manage_mob("mob-a"));
7597
7598 session
7599 .set_mob_tool_authority_context(None)
7600 .expect("authority should clear");
7601 assert!(session.mob_tool_authority_context().is_none());
7602 }
7603
7604 #[test]
7605 fn test_session_build_state_rejects_forged_mob_authority_projection() {
7606 let mut session = Session::new();
7607 let authority = MobToolAuthorityContext::generated_for_test(
7608 crate::service::OpaquePrincipalToken::new("opaque-principal"),
7609 false,
7610 false,
7611 false,
7612 std::collections::BTreeSet::from(["mob-a".to_string()]),
7613 std::collections::BTreeMap::new(),
7614 None,
7615 Some("audit-1".to_string()),
7616 );
7617 let forged_projection: MobToolAuthorityContext =
7618 serde_json::from_value(serde_json::to_value(authority).expect("serialize authority"))
7619 .expect("deserialize projection");
7620 assert!(!forged_projection.is_generated_authority_context());
7621
7622 let err = session
7623 .set_build_state(SessionBuildState {
7624 mob_tool_authority_context: Some(forged_projection),
7625 ..Default::default()
7626 })
7627 .expect_err("forged build state must be rejected by generated authority");
7628 assert!(
7632 err.to_string()
7633 .contains("generated session document authority rejected"),
7634 "unexpected error: {err}"
7635 );
7636 }
7637
7638 #[test]
7639 fn test_session_tool_visibility_state_roundtrip() {
7640 let mut session = Session::new();
7641 let state = SessionToolVisibilityState {
7642 inherited_base_filter: ToolFilter::Allow(["visible".to_string()].into_iter().collect()),
7643 active_filter: ToolFilter::Allow(
7644 ["visible".to_string(), "missing".to_string()]
7645 .into_iter()
7646 .collect(),
7647 ),
7648 staged_filter: ToolFilter::Allow(
7649 ["visible".to_string(), "missing".to_string()]
7650 .into_iter()
7651 .collect(),
7652 ),
7653 active_revision: 1,
7654 staged_revision: 2,
7655 ..Default::default()
7656 };
7657
7658 session
7659 .set_tool_visibility_state(
7660 AuthorizedSessionToolVisibilityState::from_generated_authority(state.clone()),
7661 )
7662 .expect("tool visibility state should serialize");
7663 assert_eq!(session.tool_visibility_state().unwrap(), Some(state));
7664 }
7665
7666 #[test]
7667 fn test_session_tool_visibility_state_malformed_returns_error() {
7668 let mut session = Session::new();
7669 session.metadata.insert(
7670 SESSION_TOOL_VISIBILITY_STATE_KEY.to_string(),
7671 serde_json::json!({
7672 "active_filter": {
7673 "unexpected_filter_kind": ["secret"]
7674 }
7675 }),
7676 );
7677
7678 assert!(
7679 session.tool_visibility_state().is_err(),
7680 "malformed canonical visibility metadata must not decode as absent/default"
7681 );
7682 }
7683
7684 #[test]
7685 fn test_session_serialization() {
7686 let mut session = Session::new();
7687 session.push(Message::User(UserMessage::text("Test".to_string())));
7688
7689 let json = serde_json::to_string(&session).unwrap();
7690 let parsed: Session = serde_json::from_str(&json).unwrap();
7691
7692 assert_eq!(parsed.id(), session.id());
7693 assert_eq!(parsed.messages().len(), 1);
7694 assert_eq!(parsed.version(), SESSION_VERSION);
7695 }
7696
7697 #[test]
7698 fn test_session_meta_from_session() {
7699 let mut session = Session::new();
7700 session.push(Message::User(UserMessage::text("Hello".to_string())));
7701 session.push(Message::BlockAssistant(BlockAssistantMessage {
7702 blocks: vec![AssistantBlock::Text {
7703 text: "Hi!".to_string(),
7704 meta: None,
7705 }],
7706 stop_reason: StopReason::EndTurn,
7707 identity: crate::types::TranscriptMessageIdentity::default(),
7708 created_at: crate::types::message_timestamp_now(),
7709 }));
7710 session.record_usage(Usage {
7711 input_tokens: 10,
7712 output_tokens: 5,
7713 cache_creation_tokens: None,
7714 cache_read_tokens: None,
7715 });
7716
7717 let meta = SessionMeta::from(&session);
7718 assert_eq!(meta.id, *session.id());
7719 assert_eq!(meta.message_count, 2);
7720 assert_eq!(meta.total_tokens, 15);
7721 }
7722
7723 #[test]
7724 fn system_context_state_preserves_applied_runtime_context() {
7725 let accepted_at = SystemTime::UNIX_EPOCH;
7726 let mut state = SessionSystemContextState::default();
7727 state
7728 .stage_append(
7729 &AppendSystemContextRequest {
7730 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7731 "Authoritative peer token is birch seventeen.".to_string(),
7732 ),
7733 source: Some(
7734 "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1"
7735 .to_string(),
7736 ),
7737 idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
7738 source_kind: SystemContextSource::Normal,
7739 peer_response_terminal: None,
7740 },
7741 accepted_at,
7742 )
7743 .expect("append should stage");
7744
7745 state.mark_pending_applied();
7746
7747 assert!(state.pending.is_empty());
7748 assert_eq!(state.applied.len(), 1);
7749 assert_eq!(
7750 state.applied[0].content.render_text(),
7751 "Authoritative peer token is birch seventeen."
7752 );
7753 assert_eq!(
7754 state.applied[0].source.as_deref(),
7755 Some("peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1")
7756 );
7757
7758 let round_tripped: SessionSystemContextState =
7759 serde_json::from_value(serde_json::to_value(&state).expect("serialize state"))
7760 .expect("deserialize state");
7761 assert_eq!(round_tripped.applied, state.applied);
7762 }
7763
7764 #[test]
7765 fn active_turn_system_context_is_discarded_when_not_applied() {
7766 let mut state = SessionSystemContextState::default();
7767 state
7768 .stage_active_turn_append(
7769 &AppendSystemContextRequest {
7770 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7771 "only for the active run".to_string(),
7772 ),
7773 source: Some("runtime:steer:input-1".to_string()),
7774 idempotency_key: Some("runtime:steer:input-1".to_string()),
7775 source_kind: SystemContextSource::RuntimeSteer,
7776 peer_response_terminal: None,
7777 },
7778 SystemTime::UNIX_EPOCH,
7779 )
7780 .expect("active context should stage");
7781
7782 let discarded = state.discard_unapplied_active_turn_pending();
7783
7784 assert_eq!(discarded.len(), 1);
7785 assert!(state.pending.is_empty());
7786 assert!(state.applied.is_empty());
7787 assert!(state.active_turn_pending_keys.is_empty());
7788 assert!(
7789 state.seen.is_empty(),
7790 "discarded active-turn context should not block later idempotency keys"
7791 );
7792 }
7793
7794 #[test]
7795 fn active_turn_system_context_can_roll_back_targeted_keys() {
7796 let mut state = SessionSystemContextState::default();
7797 for key in ["runtime:steer:input-1", "runtime:steer:input-2"] {
7798 state
7799 .stage_active_turn_append(
7800 &AppendSystemContextRequest {
7801 content: crate::lifecycle::run_primitive::CoreRenderable::text(format!(
7802 "context for {key}"
7803 )),
7804 source: Some(key.to_string()),
7805 idempotency_key: Some(key.to_string()),
7806 source_kind: SystemContextSource::RuntimeSteer,
7807 peer_response_terminal: None,
7808 },
7809 SystemTime::UNIX_EPOCH,
7810 )
7811 .expect("active context should stage");
7812 }
7813
7814 let discarded =
7815 state.discard_active_turn_pending_by_keys(&["runtime:steer:input-1".to_string()]);
7816
7817 assert_eq!(discarded.len(), 1);
7818 assert_eq!(
7819 discarded[0].idempotency_key.as_deref(),
7820 Some("runtime:steer:input-1")
7821 );
7822 assert_eq!(state.pending.len(), 1);
7823 assert_eq!(
7824 state.pending[0].idempotency_key.as_deref(),
7825 Some("runtime:steer:input-2")
7826 );
7827 assert!(!state.seen.contains_key("runtime:steer:input-1"));
7828 assert!(state.seen.contains_key("runtime:steer:input-2"));
7829 assert!(
7830 !state
7831 .active_turn_pending_keys
7832 .contains("runtime:steer:input-1")
7833 );
7834 assert!(
7835 state
7836 .active_turn_pending_keys
7837 .contains("runtime:steer:input-2")
7838 );
7839 }
7840
7841 #[test]
7842 fn active_turn_system_context_is_transient_when_boundary_consumes_it() {
7843 let mut state = SessionSystemContextState::default();
7844 state
7845 .stage_active_turn_append(
7846 &AppendSystemContextRequest {
7847 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7848 "visible to this run".to_string(),
7849 ),
7850 source: Some("runtime:steer:input-2".to_string()),
7851 idempotency_key: Some("runtime:steer:input-2".to_string()),
7852 source_kind: SystemContextSource::RuntimeSteer,
7853 peer_response_terminal: None,
7854 },
7855 SystemTime::UNIX_EPOCH,
7856 )
7857 .expect("active context should stage");
7858
7859 state.mark_pending_applied();
7860 let discarded = state.discard_unapplied_active_turn_pending();
7861
7862 assert!(discarded.is_empty());
7863 assert!(state.pending.is_empty());
7864 assert!(state.applied.is_empty());
7865 assert!(state.active_turn_pending_keys.is_empty());
7866 assert_eq!(
7867 state.seen.get("runtime:steer:input-2"),
7868 None,
7869 "consumed active-turn steer context must not become durable state"
7870 );
7871 }
7872
7873 #[test]
7874 fn discard_transient_runtime_steer_context_removes_steer_via_typed_marker() {
7875 let mut session = Session::new();
7876 session.set_system_prompt(format!(
7880 "base{}{}{}{}",
7881 SYSTEM_CONTEXT_SEPARATOR,
7882 render_system_context_block(&PendingSystemContextAppend {
7883 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7884 "old steer".to_string()
7885 ),
7886 source: Some("steer-source-old".to_string()),
7887 idempotency_key: Some("steer-key-old".to_string()),
7888 source_kind: SystemContextSource::RuntimeSteer,
7889 peer_response_terminal: None,
7890 accepted_at: SystemTime::UNIX_EPOCH,
7891 }),
7892 SYSTEM_CONTEXT_SEPARATOR,
7893 render_system_context_block(&PendingSystemContextAppend {
7894 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7895 "durable peer fact".to_string()
7896 ),
7897 source: Some("peer_response_terminal:analyst:req".to_string()),
7898 idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
7899 source_kind: SystemContextSource::Normal,
7900 peer_response_terminal: None,
7901 accepted_at: SystemTime::UNIX_EPOCH,
7902 })
7903 ));
7904 session
7905 .set_system_context_state(SessionSystemContextState {
7906 pending: vec![PendingSystemContextAppend {
7907 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7908 "pending steer".to_string(),
7909 ),
7910 source: Some("steer-source-pending".to_string()),
7911 idempotency_key: Some("steer-key-pending".to_string()),
7912 source_kind: SystemContextSource::RuntimeSteer,
7913 peer_response_terminal: None,
7914 accepted_at: SystemTime::UNIX_EPOCH,
7915 }],
7916 applied: vec![
7917 PendingSystemContextAppend {
7918 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7919 "old steer".to_string(),
7920 ),
7921 source: Some("steer-source-old".to_string()),
7922 idempotency_key: Some("steer-key-old".to_string()),
7923 source_kind: SystemContextSource::RuntimeSteer,
7924 peer_response_terminal: None,
7925 accepted_at: SystemTime::UNIX_EPOCH,
7926 },
7927 PendingSystemContextAppend {
7928 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7929 "durable peer fact".to_string(),
7930 ),
7931 source: Some("peer_response_terminal:analyst:req".to_string()),
7932 idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
7933 source_kind: SystemContextSource::Normal,
7934 peer_response_terminal: None,
7935 accepted_at: SystemTime::UNIX_EPOCH,
7936 },
7937 ],
7938 seen: BTreeMap::from([(
7939 "steer-key-old".to_string(),
7940 SeenSystemContextKey {
7941 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7942 "old steer".to_string(),
7943 ),
7944 source: Some("steer-source-old".to_string()),
7945 source_kind: SystemContextSource::RuntimeSteer,
7946 state: SeenSystemContextState::Applied,
7947 },
7948 )]),
7949 active_turn_pending_keys: BTreeSet::from(["steer-key-pending".to_string()]),
7950 })
7951 .expect("system context state should serialize");
7952
7953 let removed = session.discard_transient_runtime_steer_context();
7954
7955 assert!(removed >= 4);
7956 let system_prompt = match session.messages().first() {
7957 Some(Message::System(system)) => system.content.as_str(),
7958 other => panic!("expected system prompt, got {other:?}"),
7959 };
7960 assert!(!system_prompt.contains("old steer"));
7961 assert!(system_prompt.contains("durable peer fact"));
7962 let state = session.system_context_state().unwrap_or_default();
7963 assert!(state.pending.is_empty());
7964 assert_eq!(state.applied.len(), 1);
7965 assert_eq!(state.applied[0].content.render_text(), "durable peer fact");
7966 assert!(state.seen.is_empty());
7967 assert!(state.active_turn_pending_keys.is_empty());
7968 }
7969
7970 #[test]
7971 fn append_system_context_blocks_records_typed_applied_context() {
7972 let append = PendingSystemContextAppend {
7973 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7974 "Authoritative peer token is birch seventeen.".to_string(),
7975 ),
7976 source: Some(
7977 "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string(),
7978 ),
7979 idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
7980 source_kind: SystemContextSource::Normal,
7981 peer_response_terminal: None,
7982 accepted_at: SystemTime::UNIX_EPOCH,
7983 };
7984 let mut session = Session::new();
7985
7986 session.append_system_context_blocks(std::slice::from_ref(&append));
7987
7988 let state = session
7989 .system_context_state()
7990 .expect("append should persist typed context state");
7991 assert_eq!(state.applied, vec![append]);
7992 }
7993
7994 fn roster_append() -> PendingSystemContextAppend {
7995 PendingSystemContextAppend {
7996 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7997 "peer roster: lead-1, w-1".to_string(),
7998 ),
7999 source: Some("comms:roster".to_string()),
8000 idempotency_key: Some("comms:roster:v1".to_string()),
8001 source_kind: SystemContextSource::Normal,
8002 peer_response_terminal: None,
8003 accepted_at: SystemTime::UNIX_EPOCH,
8004 }
8005 }
8006
8007 fn resumed_session_with_context_appended_prompt(base: &str) -> Session {
8008 let mut session = Session::new();
8009 session.set_system_prompt(base.to_string());
8010 session.push(Message::User(UserMessage::text("hello".to_string())));
8011 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
8012 session
8013 }
8014
8015 #[test]
8016 fn reconcile_resumed_system_prompt_preserves_identical_base() {
8017 let mut session = Session::new();
8018 session.set_system_prompt("base prompt".to_string());
8019 session.push(Message::User(UserMessage::text("hello".to_string())));
8020 let digest_before = transcript_messages_digest(session.messages()).unwrap();
8021
8022 let outcome = session
8023 .reconcile_resumed_system_prompt("base prompt".to_string(), None)
8024 .expect("reconcile");
8025
8026 assert_eq!(
8027 outcome,
8028 ResumedSystemPromptReconciliation::PreservedContinuation
8029 );
8030 assert_eq!(
8031 transcript_messages_digest(session.messages()).unwrap(),
8032 digest_before,
8033 "identical base must leave the transcript revision unchanged"
8034 );
8035 }
8036
8037 #[test]
8038 fn reconcile_resumed_system_prompt_preserves_context_appended_base() {
8039 let mut session = resumed_session_with_context_appended_prompt("base prompt");
8040 let digest_before = transcript_messages_digest(session.messages()).unwrap();
8041
8042 let outcome = session
8043 .reconcile_resumed_system_prompt("base prompt".to_string(), None)
8044 .expect("reconcile");
8045
8046 assert_eq!(
8047 outcome,
8048 ResumedSystemPromptReconciliation::PreservedContinuation
8049 );
8050 assert_eq!(
8051 transcript_messages_digest(session.messages()).unwrap(),
8052 digest_before,
8053 "a base extended only by runtime context appends must stay untouched"
8054 );
8055 let system = match session.messages().first() {
8056 Some(Message::System(system)) => system.clone(),
8057 other => panic!("expected system message, got {other:?}"),
8058 };
8059 assert!(system.content.contains("peer roster: lead-1, w-1"));
8060 assert!(
8061 system.mutation_kind.is_runtime_context_append(),
8062 "the persisted mutation provenance must survive reconciliation"
8063 );
8064 }
8065
8066 #[test]
8067 fn reconcile_resumed_system_prompt_rewrites_changed_base_preserving_tail() {
8068 let mut session = resumed_session_with_context_appended_prompt("base prompt");
8069
8070 let outcome = session
8071 .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
8072 .expect("reconcile");
8073
8074 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
8075 let system_content = match session.messages().first() {
8076 Some(Message::System(system)) => system.content.clone(),
8077 other => panic!("expected system message, got {other:?}"),
8078 };
8079 assert!(
8080 system_content.starts_with("new base prompt"),
8081 "the changed base must be applied: {system_content}"
8082 );
8083 assert!(
8084 system_content.contains("peer roster: lead-1, w-1"),
8085 "the runtime-applied context tail must survive the base change: {system_content}"
8086 );
8087 let state = session
8088 .transcript_history_state()
8089 .expect("history state deserializes")
8090 .expect("rewrite must record transcript history");
8091 assert_eq!(state.commits.len(), 1);
8092 assert_eq!(
8093 state.commits[0].reason.kind,
8094 RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON
8095 );
8096 assert_eq!(
8097 state.head,
8098 transcript_messages_digest(session.messages()).unwrap(),
8099 "the committed head must match the rewritten transcript"
8100 );
8101 }
8102
8103 #[test]
8104 fn reconcile_resumed_system_prompt_inserts_prompt_on_promptless_transcript() {
8105 let mut session = Session::new();
8106 session.push(Message::User(UserMessage::text("hello".to_string())));
8107
8108 let outcome = session
8109 .reconcile_resumed_system_prompt("late prompt".to_string(), None)
8110 .expect("reconcile");
8111
8112 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
8113 assert!(matches!(
8114 session.messages().first(),
8115 Some(Message::System(system)) if system.content == "late prompt"
8116 ));
8117 let state = session
8118 .transcript_history_state()
8119 .expect("history state deserializes")
8120 .expect("insert must record transcript history");
8121 assert_eq!(state.commits.len(), 1);
8122 assert_eq!(
8123 state.commits[0].reason.kind,
8124 RESUME_SYSTEM_PROMPT_REFRESH_REWRITE_REASON
8125 );
8126 }
8127
8128 fn leading_system_content(session: &Session) -> String {
8129 match session.messages().first() {
8130 Some(Message::System(system)) => system.content.clone(),
8131 other => panic!("expected leading system message, got {other:?}"),
8132 }
8133 }
8134
8135 #[test]
8136 fn reconcile_resumed_system_prompt_preserves_full_context_prompt_from_empty_base() {
8137 let mut session = Session::new();
8142 session.push(Message::User(UserMessage::text("hello".to_string())));
8143 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
8144 let all_context_content = leading_system_content(&session);
8145 assert!(all_context_content.contains("peer roster: lead-1, w-1"));
8146
8147 let outcome = session
8148 .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
8149 .expect("reconcile");
8150
8151 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
8152 assert_eq!(
8153 leading_system_content(&session),
8154 format!("new base prompt{SYSTEM_CONTEXT_SEPARATOR}{all_context_content}"),
8155 "the all-context prompt must survive as the runtime tail of the new base"
8156 );
8157 }
8158
8159 #[test]
8160 fn reconcile_resumed_system_prompt_preserves_context_only_prompt_on_empty_base_resume() {
8161 let mut session = Session::new();
8164 session.push(Message::User(UserMessage::text("hello".to_string())));
8165 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
8166 let digest_before = transcript_messages_digest(session.messages()).unwrap();
8167
8168 let outcome = session
8169 .reconcile_resumed_system_prompt(String::new(), None)
8170 .expect("reconcile");
8171
8172 assert_eq!(
8173 outcome,
8174 ResumedSystemPromptReconciliation::PreservedContinuation
8175 );
8176 assert_eq!(
8177 transcript_messages_digest(session.messages()).unwrap(),
8178 digest_before
8179 );
8180 }
8181
8182 #[test]
8183 fn reconcile_resumed_system_prompt_applies_shortened_base_with_recorded_prior() {
8184 let full_base = format!("part one{SYSTEM_CONTEXT_SEPARATOR}part two");
8188 let mut session = Session::new();
8189 session.set_system_prompt(full_base.clone());
8190 session.push(Message::User(UserMessage::text("hello".to_string())));
8191 session
8192 .set_build_state(SessionBuildState {
8193 assembled_system_prompt: Some(full_base),
8194 ..Default::default()
8195 })
8196 .expect("build state");
8197
8198 let outcome = session
8199 .reconcile_resumed_system_prompt("part one".to_string(), None)
8200 .expect("reconcile");
8201
8202 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
8203 assert_eq!(leading_system_content(&session), "part one");
8204 }
8205
8206 #[test]
8207 fn reconcile_resumed_system_prompt_applies_shortened_base_without_context_provenance() {
8208 let full_base = format!("part one{SYSTEM_CONTEXT_SEPARATOR}part two");
8213 let mut session = Session::new();
8214 session.set_system_prompt(full_base);
8215 session.push(Message::User(UserMessage::text("hello".to_string())));
8216
8217 let outcome = session
8218 .reconcile_resumed_system_prompt("part one".to_string(), None)
8219 .expect("reconcile");
8220
8221 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
8222 assert_eq!(leading_system_content(&session), "part one");
8223 }
8224
8225 #[test]
8226 fn reconcile_resumed_system_prompt_preserves_appended_prompt_without_applied_records() {
8227 let mut session = resumed_session_with_context_appended_prompt("base prompt");
8232 session
8233 .set_system_context_state(SessionSystemContextState::default())
8234 .expect("sweep applied records");
8235 let digest_before = transcript_messages_digest(session.messages()).unwrap();
8236
8237 let outcome = session
8238 .reconcile_resumed_system_prompt("base prompt".to_string(), None)
8239 .expect("reconcile");
8240
8241 assert_eq!(
8242 outcome,
8243 ResumedSystemPromptReconciliation::PreservedContinuation
8244 );
8245 assert_eq!(
8246 transcript_messages_digest(session.messages()).unwrap(),
8247 digest_before
8248 );
8249 }
8250
8251 #[test]
8252 fn reconcile_resumed_system_prompt_clears_orphaned_applied_records_on_tail_drop() {
8253 let mut session = resumed_session_with_context_appended_prompt("base prompt");
8254 session.set_system_prompt(format!(
8259 "mutated base{SYSTEM_CONTEXT_SEPARATOR}stale-looking tail"
8260 ));
8261
8262 let outcome = session
8263 .reconcile_resumed_system_prompt("new base prompt".to_string(), None)
8264 .expect("reconcile");
8265
8266 assert_eq!(outcome, ResumedSystemPromptReconciliation::RewrittenBase);
8267 assert_eq!(leading_system_content(&session), "new base prompt");
8268 let state = session.system_context_state().unwrap_or_default();
8269 assert!(
8270 state.applied.is_empty(),
8271 "orphaned applied records must be cleared so the context stays restorable"
8272 );
8273 assert!(
8274 state.seen.is_empty(),
8275 "orphaned idempotency keys must be cleared so keyed re-sends re-apply"
8276 );
8277
8278 session.append_system_context_blocks(std::slice::from_ref(&roster_append()));
8281 assert!(
8282 leading_system_content(&session).contains("peer roster: lead-1, w-1"),
8283 "re-sent keyed context must re-apply after the drop"
8284 );
8285 }
8286
8287 #[test]
8288 fn append_system_context_blocks_renders_pre_marked_pending_context() {
8289 let accepted_at = SystemTime::UNIX_EPOCH;
8290 let mut state = SessionSystemContextState::default();
8291 state
8292 .stage_append(
8293 &AppendSystemContextRequest {
8294 content: crate::lifecycle::run_primitive::CoreRenderable::text(
8295 "Apply this staged context at the request boundary.".to_string(),
8296 ),
8297 source: Some("rpc/session_inject_context".to_string()),
8298 idempotency_key: Some("ctx-boundary".to_string()),
8299 source_kind: SystemContextSource::Normal,
8300 peer_response_terminal: None,
8301 },
8302 accepted_at,
8303 )
8304 .expect("append should stage");
8305 let pending = state.pending.clone();
8306 state.mark_pending_applied();
8307 let mut session = Session::new();
8308 session
8309 .set_system_context_state(state)
8310 .expect("state should serialize");
8311
8312 session.append_system_context_blocks(&pending);
8313
8314 let system_prompt = session
8315 .messages()
8316 .first()
8317 .and_then(|message| match message {
8318 Message::System(system) => Some(system.content.as_str()),
8319 _ => None,
8320 })
8321 .unwrap_or_default();
8322 assert!(system_prompt.contains("Apply this staged context at the request boundary."));
8323 let state = session
8324 .system_context_state()
8325 .expect("append should persist typed context state");
8326 assert_eq!(state.applied.len(), 1);
8327 assert_eq!(
8328 state.seen["ctx-boundary"].state,
8329 SeenSystemContextState::Applied
8330 );
8331 }
8332
8333 #[test]
8334 fn append_system_context_blocks_renders_pre_marked_context_without_idempotency_key() {
8335 let accepted_at = SystemTime::UNIX_EPOCH;
8336 let mut state = SessionSystemContextState::default();
8337 state
8338 .stage_append(
8339 &AppendSystemContextRequest {
8340 content: crate::lifecycle::run_primitive::CoreRenderable::text(
8341 "Apply this unkeyed staged context at the request boundary.".to_string(),
8342 ),
8343 source: Some("rpc/session_inject_context".to_string()),
8344 idempotency_key: None,
8345 source_kind: SystemContextSource::Normal,
8346 peer_response_terminal: None,
8347 },
8348 accepted_at,
8349 )
8350 .expect("append should stage");
8351 let pending = state.pending.clone();
8352 state.mark_pending_applied();
8353 let mut session = Session::new();
8354 session
8355 .set_system_context_state(state)
8356 .expect("state should serialize");
8357
8358 session.append_system_context_blocks(&pending);
8359
8360 let system_prompt = session
8361 .messages()
8362 .first()
8363 .and_then(|message| match message {
8364 Message::System(system) => Some(system.content.as_str()),
8365 _ => None,
8366 })
8367 .unwrap_or_default();
8368 assert!(
8369 system_prompt.contains("Apply this unkeyed staged context at the request boundary.")
8370 );
8371 }
8372
8373 #[test]
8377 fn staged_system_context_carries_typed_renderable_to_render_seam() {
8378 use crate::lifecycle::run_primitive::CoreRenderable;
8379
8380 let accepted_at = SystemTime::UNIX_EPOCH;
8381 let mut state = SessionSystemContextState::default();
8382 let renderable = CoreRenderable::Json {
8383 value: serde_json::json!({"alert": "disk-full", "severity": 2}),
8384 };
8385 state
8386 .stage_append(
8387 &AppendSystemContextRequest {
8388 content: renderable.clone(),
8389 source: Some("ops/monitor".to_string()),
8390 idempotency_key: Some("alert-1".to_string()),
8391 source_kind: SystemContextSource::Normal,
8392 peer_response_terminal: None,
8393 },
8394 accepted_at,
8395 )
8396 .expect("typed renderable append should stage");
8397
8398 assert_eq!(state.pending.len(), 1);
8401 assert_eq!(state.pending[0].content, renderable);
8402
8403 let rendered = render_system_context_block(&state.pending[0]);
8406 assert!(rendered.starts_with(SYSTEM_CONTEXT_RENDER_LABEL));
8407 assert!(
8408 rendered.contains(renderable.render_text().trim()),
8409 "render seam must lower via CoreRenderable::render_text: {rendered}"
8410 );
8411 }
8412
8413 #[test]
8414 fn append_system_context_blocks_skips_duplicate_idempotency_key() {
8415 let first = PendingSystemContextAppend {
8416 content: crate::lifecycle::run_primitive::CoreRenderable::text(
8417 "Authoritative peer token is birch seventeen.".to_string(),
8418 ),
8419 source: Some("peer_response_terminal:analyst:req-1".to_string()),
8420 idempotency_key: Some("req-1".to_string()),
8421 source_kind: SystemContextSource::Normal,
8422 peer_response_terminal: None,
8423 accepted_at: SystemTime::UNIX_EPOCH,
8424 };
8425 let duplicate = PendingSystemContextAppend {
8426 accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
8427 ..first.clone()
8428 };
8429 let mut session = Session::new();
8430
8431 session.append_system_context_blocks(std::slice::from_ref(&first));
8432 session.append_system_context_blocks(std::slice::from_ref(&duplicate));
8433
8434 let state = session
8435 .system_context_state()
8436 .expect("append should persist typed context state");
8437 assert_eq!(state.applied, vec![first]);
8438 let system_prompt = session
8439 .messages()
8440 .first()
8441 .and_then(|message| match message {
8442 Message::System(system) => Some(system.content.as_str()),
8443 _ => None,
8444 })
8445 .unwrap_or_default();
8446 assert_eq!(
8447 system_prompt
8448 .matches("Authoritative peer token is birch seventeen.")
8449 .count(),
8450 1
8451 );
8452 }
8453
8454 #[test]
8455 fn append_system_context_blocks_skips_conflicting_duplicate_idempotency_key() {
8456 let first = PendingSystemContextAppend {
8457 content: crate::lifecycle::run_primitive::CoreRenderable::text(
8458 "Authoritative peer token is birch seventeen.".to_string(),
8459 ),
8460 source: Some("peer_response_terminal:analyst:req-1".to_string()),
8461 idempotency_key: Some("req-1".to_string()),
8462 source_kind: SystemContextSource::Normal,
8463 peer_response_terminal: None,
8464 accepted_at: SystemTime::UNIX_EPOCH,
8465 };
8466 let conflicting = PendingSystemContextAppend {
8467 content: crate::lifecycle::run_primitive::CoreRenderable::text(
8468 "Conflicting peer token should not reach the prompt.".to_string(),
8469 ),
8470 accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
8471 ..first.clone()
8472 };
8473 let mut session = Session::new();
8474
8475 session.append_system_context_blocks(std::slice::from_ref(&first));
8476 session.append_system_context_blocks(std::slice::from_ref(&conflicting));
8477
8478 let state = session
8479 .system_context_state()
8480 .expect("append should persist typed context state");
8481 assert_eq!(state.applied, vec![first]);
8482 let system_prompt = session
8483 .messages()
8484 .first()
8485 .and_then(|message| match message {
8486 Message::System(system) => Some(system.content.as_str()),
8487 _ => None,
8488 })
8489 .unwrap_or_default();
8490 assert!(system_prompt.contains("Authoritative peer token is birch seventeen."));
8491 assert!(!system_prompt.contains("Conflicting peer token should not reach the prompt."));
8492 }
8493
8494 #[test]
8506 fn realtime_transcript_assistant_transcript_delta_materializes_transcript_block() {
8507 let mut session = Session::new();
8508
8509 let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
8510 response_id: "resp_spoken".to_string(),
8511 delta_id: "evt_delta_spoken_1".to_string(),
8512 item_id: "item_spoken".to_string(),
8513 previous_item_id: None,
8514 content_index: 0,
8515 delta: "I said hi".to_string(),
8516 };
8517 assert!(
8518 session.append_realtime_transcript_event(delta).is_inert(),
8519 "delta alone is inert until turn-completed flushes"
8520 );
8521
8522 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
8523 response_id: "resp_spoken".to_string(),
8524 stop_reason: StopReason::EndTurn,
8525 usage: Usage::default(),
8526 };
8527 let outcome = session.append_realtime_transcript_event(terminal);
8528 assert_eq!(outcome.materialized_messages.len(), 1);
8529
8530 let messages = session.messages();
8532 assert_eq!(messages.len(), 1);
8533 match &messages[0] {
8534 Message::BlockAssistant(assistant) => {
8535 assert_eq!(assistant.blocks.len(), 1);
8536 match &assistant.blocks[0] {
8537 AssistantBlock::Transcript { text, source, .. } => {
8538 assert_eq!(text, "I said hi");
8539 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
8540 }
8541 other => unreachable!(
8542 "AssistantTranscriptDelta must materialize as AssistantBlock::Transcript, got {other:?}"
8543 ),
8544 }
8545 }
8546 other => unreachable!("expected BlockAssistant message, got {other:?}"),
8547 }
8548 }
8549
8550 #[test]
8551 fn round4_cc4_in_flight_response_ids_lists_distinct_unmaterialized_responses() {
8552 let mut session = Session::new();
8558
8559 for (i, response_id) in [
8563 ("resp_a", "resp_a"),
8564 ("resp_a_extra", "resp_a"),
8565 ("resp_b", "resp_b"),
8566 ("resp_c", "resp_c"),
8567 ]
8568 .iter()
8569 .enumerate()
8570 {
8571 let event = RealtimeTranscriptEvent::AssistantTranscriptDelta {
8572 response_id: response_id.1.to_string(),
8573 delta_id: format!("delta_{i}"),
8574 item_id: response_id.0.to_string(),
8575 previous_item_id: None,
8576 content_index: 0,
8577 delta: "x".to_string(),
8578 };
8579 let _ = session.append_realtime_transcript_event(event);
8580 }
8581
8582 let _ = session.append_realtime_transcript_event(
8584 RealtimeTranscriptEvent::AssistantTurnInterrupted {
8585 response_id: "resp_c".to_string(),
8586 },
8587 );
8588
8589 let _ = session.append_realtime_transcript_event(
8592 RealtimeTranscriptEvent::UserTranscriptFinal {
8593 item_id: "u_item".to_string(),
8594 previous_item_id: None,
8595 content_index: 0,
8596 text: "hi".to_string(),
8597 },
8598 );
8599
8600 let in_flight = session.in_flight_realtime_assistant_response_ids();
8601 assert!(in_flight.contains(&"resp_a".to_string()), "{in_flight:?}");
8602 assert!(in_flight.contains(&"resp_b".to_string()), "{in_flight:?}");
8603 assert!(
8604 !in_flight.contains(&"resp_c".to_string()),
8605 "discarded response must not appear in in_flight: {in_flight:?}"
8606 );
8607 assert_eq!(
8609 in_flight.iter().filter(|r| *r == "resp_a").count(),
8610 1,
8611 "distinct response_ids only: {in_flight:?}"
8612 );
8613 }
8614
8615 #[test]
8616 fn round4_cc2_assistant_turn_completed_after_transcript_deltas_materializes_transcript() {
8617 let mut session = Session::new();
8624
8625 let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
8626 response_id: "resp_cc2".to_string(),
8627 delta_id: "delta_cc2_1".to_string(),
8628 item_id: "item_cc2".to_string(),
8629 previous_item_id: None,
8630 content_index: 0,
8631 delta: "hello world".to_string(),
8632 };
8633 assert!(session.append_realtime_transcript_event(delta).is_inert());
8634
8635 assert_eq!(
8637 session.in_flight_realtime_assistant_response_ids(),
8638 vec!["resp_cc2".to_string()]
8639 );
8640
8641 let outcome = session.append_realtime_transcript_event(
8642 RealtimeTranscriptEvent::AssistantTurnCompleted {
8643 response_id: "resp_cc2".to_string(),
8644 stop_reason: StopReason::EndTurn,
8645 usage: Usage::default(),
8646 },
8647 );
8648 assert_eq!(outcome.materialized_messages.len(), 1);
8649
8650 assert!(
8652 session
8653 .in_flight_realtime_assistant_response_ids()
8654 .is_empty(),
8655 "materialized items must not appear in in_flight_realtime_assistant_response_ids"
8656 );
8657
8658 let messages = session.messages();
8659 let assistant = messages.iter().find_map(|m| match m {
8660 Message::BlockAssistant(a) => Some(a),
8661 _ => None,
8662 });
8663 let assistant = assistant.expect("assistant block message expected");
8664 assert_eq!(assistant.blocks.len(), 1);
8665 assert!(matches!(
8666 &assistant.blocks[0],
8667 AssistantBlock::Transcript {
8668 source: crate::types::TranscriptSource::Spoken,
8669 ..
8670 }
8671 ));
8672 }
8673
8674 #[test]
8675 fn realtime_transcript_assistant_text_delta_still_materializes_text_block() {
8676 let mut session = Session::new();
8680
8681 let delta = RealtimeTranscriptEvent::AssistantTextDelta {
8682 response_id: "resp_display".to_string(),
8683 delta_id: "evt_delta_display_1".to_string(),
8684 item_id: "item_display".to_string(),
8685 previous_item_id: None,
8686 content_index: 0,
8687 delta: "I wrote".to_string(),
8688 };
8689 let _ = session.append_realtime_transcript_event(delta);
8690
8691 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
8692 response_id: "resp_display".to_string(),
8693 stop_reason: StopReason::EndTurn,
8694 usage: Usage::default(),
8695 };
8696 let outcome = session.append_realtime_transcript_event(terminal);
8697 assert_eq!(outcome.materialized_messages.len(), 1);
8698
8699 let messages = session.messages();
8700 match &messages[0] {
8701 Message::BlockAssistant(assistant) => match &assistant.blocks[0] {
8702 AssistantBlock::Text { text, .. } => assert_eq!(text, "I wrote"),
8703 other => unreachable!(
8704 "AssistantTextDelta must keep materializing AssistantBlock::Text, got {other:?}"
8705 ),
8706 },
8707 other => unreachable!("expected BlockAssistant message, got {other:?}"),
8708 }
8709 }
8710
8711 #[test]
8712 fn round4_cc7_mixed_response_persists_text_and_transcript_in_order() {
8713 let mut session = Session::new();
8731
8732 let display_a = RealtimeTranscriptEvent::AssistantTextDelta {
8734 response_id: "resp_mixed_1".to_string(),
8735 delta_id: "delta_disp_1".to_string(),
8736 item_id: "item_display".to_string(),
8737 previous_item_id: None,
8738 content_index: 0,
8739 delta: "Here's the report:".to_string(),
8740 };
8741 assert!(
8742 session
8743 .append_realtime_transcript_event(display_a)
8744 .is_inert()
8745 );
8746
8747 let display_b = RealtimeTranscriptEvent::AssistantTextDelta {
8748 response_id: "resp_mixed_1".to_string(),
8749 delta_id: "delta_disp_2".to_string(),
8750 item_id: "item_display".to_string(),
8751 previous_item_id: None,
8752 content_index: 0,
8753 delta: " (still writing)".to_string(),
8754 };
8755 assert!(
8756 session
8757 .append_realtime_transcript_event(display_b)
8758 .is_inert()
8759 );
8760
8761 let spoken_a = RealtimeTranscriptEvent::AssistantTranscriptDelta {
8766 response_id: "resp_mixed_1".to_string(),
8767 delta_id: "delta_spoken_1".to_string(),
8768 item_id: "item_spoken".to_string(),
8769 previous_item_id: Some("item_display".to_string()),
8770 content_index: 0,
8771 delta: "I'm reading the report aloud:".to_string(),
8772 };
8773 assert!(
8774 session
8775 .append_realtime_transcript_event(spoken_a)
8776 .is_inert()
8777 );
8778
8779 let spoken_b = RealtimeTranscriptEvent::AssistantTranscriptDelta {
8780 response_id: "resp_mixed_1".to_string(),
8781 delta_id: "delta_spoken_2".to_string(),
8782 item_id: "item_spoken".to_string(),
8783 previous_item_id: Some("item_display".to_string()),
8784 content_index: 0,
8785 delta: " sentence two.".to_string(),
8786 };
8787 assert!(
8788 session
8789 .append_realtime_transcript_event(spoken_b)
8790 .is_inert()
8791 );
8792
8793 let outcome = session.append_realtime_transcript_event(
8796 RealtimeTranscriptEvent::AssistantTurnCompleted {
8797 response_id: "resp_mixed_1".to_string(),
8798 stop_reason: StopReason::EndTurn,
8799 usage: Usage {
8800 input_tokens: 11,
8801 output_tokens: 22,
8802 cache_creation_tokens: None,
8803 cache_read_tokens: None,
8804 },
8805 },
8806 );
8807 assert_eq!(outcome.materialized_messages.len(), 2);
8809
8810 let messages = session.messages();
8813 let assistants: Vec<&BlockAssistantMessage> = messages
8814 .iter()
8815 .filter_map(|m| match m {
8816 Message::BlockAssistant(a) => Some(a),
8817 _ => None,
8818 })
8819 .collect();
8820 assert_eq!(
8821 assistants.len(),
8822 1,
8823 "mixed display+spoken response under one response_id must produce exactly ONE BlockAssistant message, got: {assistants:?}"
8824 );
8825 let assistant = assistants[0];
8826 assert_eq!(
8827 assistant.blocks.len(),
8828 2,
8829 "mixed response message must carry both blocks: {:?}",
8830 assistant.blocks
8831 );
8832
8833 match &assistant.blocks[0] {
8835 AssistantBlock::Text { text, .. } => {
8836 assert_eq!(text, "Here's the report: (still writing)");
8837 }
8838 other => unreachable!(
8839 "first block must be AssistantBlock::Text (display lane), got {other:?}"
8840 ),
8841 }
8842 match &assistant.blocks[1] {
8844 AssistantBlock::Transcript { text, source, .. } => {
8845 assert_eq!(text, "I'm reading the report aloud: sentence two.");
8846 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
8847 }
8848 other => unreachable!(
8849 "second block must be AssistantBlock::Transcript {{ source: Spoken }}, got {other:?}"
8850 ),
8851 }
8852
8853 assert_eq!(session.usage.input_tokens, 11);
8855 assert_eq!(session.usage.output_tokens, 22);
8856 }
8857
8858 #[test]
8859 fn round5_r55_mixed_response_barge_in_preserves_display_drops_spoken() {
8860 let mut session = Session::new();
8876
8877 let display = RealtimeTranscriptEvent::AssistantTextDelta {
8878 response_id: "resp_mixed_2".to_string(),
8879 delta_id: "delta_disp_1".to_string(),
8880 item_id: "item_display_2".to_string(),
8881 previous_item_id: None,
8882 content_index: 0,
8883 delta: "Working on the report...".to_string(),
8884 };
8885 let _ = session.append_realtime_transcript_event(display);
8886
8887 let spoken = RealtimeTranscriptEvent::AssistantTranscriptDelta {
8888 response_id: "resp_mixed_2".to_string(),
8889 delta_id: "delta_spoken_1".to_string(),
8890 item_id: "item_spoken_2".to_string(),
8891 previous_item_id: Some("item_display_2".to_string()),
8892 content_index: 0,
8893 delta: "I'm reading the report".to_string(),
8894 };
8895 let _ = session.append_realtime_transcript_event(spoken);
8896
8897 let outcome = session.append_realtime_transcript_event(
8901 RealtimeTranscriptEvent::AssistantTurnInterrupted {
8902 response_id: "resp_mixed_2".to_string(),
8903 },
8904 );
8905 assert_eq!(
8906 outcome.materialized_messages.len(),
8907 1,
8908 "Display lane item must materialize on Interrupted: {outcome:?}"
8909 );
8910
8911 let late_completion = session.append_realtime_transcript_event(
8915 RealtimeTranscriptEvent::AssistantTurnCompleted {
8916 response_id: "resp_mixed_2".to_string(),
8917 stop_reason: StopReason::Cancelled,
8918 usage: Usage::default(),
8919 },
8920 );
8921 assert_eq!(
8922 late_completion.materialized_messages.len(),
8923 0,
8924 "post-barge-in TurnCompleted must not resurrect anything"
8925 );
8926
8927 let messages = session.messages();
8930 let assistants: Vec<&BlockAssistantMessage> = messages
8931 .iter()
8932 .filter_map(|m| match m {
8933 Message::BlockAssistant(a) => Some(a),
8934 _ => None,
8935 })
8936 .collect();
8937 assert_eq!(
8938 assistants.len(),
8939 1,
8940 "barge-in must commit exactly one BlockAssistant containing the Display lane: {assistants:?}"
8941 );
8942 let assistant = assistants[0];
8943 assert_eq!(assistant.blocks.len(), 1, "blocks: {:?}", assistant.blocks);
8944 match &assistant.blocks[0] {
8945 AssistantBlock::Text { text, .. } => {
8946 assert_eq!(text, "Working on the report...");
8947 }
8948 other => {
8949 unreachable!("Display lane must materialize as AssistantBlock::Text, got {other:?}")
8950 }
8951 }
8952 assert!(
8954 !assistant
8955 .blocks
8956 .iter()
8957 .any(|b| matches!(b, AssistantBlock::Transcript { .. })),
8958 "Spoken lane must be dropped on barge-in"
8959 );
8960
8961 assert!(
8964 !session
8965 .in_flight_realtime_assistant_response_ids()
8966 .contains(&"resp_mixed_2".to_string()),
8967 "barged-in response must not appear in in_flight_realtime_assistant_response_ids"
8968 );
8969 }
8970
8971 #[test]
8972 fn round5_r55_barge_in_preserves_display_lane_drops_spoken() {
8973 let mut session = Session::new();
8977
8978 let _ =
8979 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
8980 response_id: "resp_a".to_string(),
8981 delta_id: "delta_d_1".to_string(),
8982 item_id: "item_display".to_string(),
8983 previous_item_id: None,
8984 content_index: 0,
8985 delta: "display-text".to_string(),
8986 });
8987 let _ = session.append_realtime_transcript_event(
8988 RealtimeTranscriptEvent::AssistantTranscriptDelta {
8989 response_id: "resp_a".to_string(),
8990 delta_id: "delta_s_1".to_string(),
8991 item_id: "item_spoken".to_string(),
8992 previous_item_id: None,
8993 content_index: 0,
8994 delta: "spoken-transcript".to_string(),
8995 },
8996 );
8997
8998 let outcome = session.append_realtime_transcript_event(
8999 RealtimeTranscriptEvent::AssistantTurnInterrupted {
9000 response_id: "resp_a".to_string(),
9001 },
9002 );
9003 assert_eq!(outcome.materialized_messages.len(), 1);
9005
9006 let messages = session.messages();
9007 let assistants: Vec<&BlockAssistantMessage> = messages
9008 .iter()
9009 .filter_map(|m| match m {
9010 Message::BlockAssistant(a) => Some(a),
9011 _ => None,
9012 })
9013 .collect();
9014 assert_eq!(assistants.len(), 1);
9015 assert_eq!(assistants[0].blocks.len(), 1);
9017 match &assistants[0].blocks[0] {
9018 AssistantBlock::Text { text, .. } => assert_eq!(text, "display-text"),
9019 other => unreachable!("expected Text, got {other:?}"),
9020 }
9021 }
9022
9023 #[test]
9024 fn round5_r55_barge_in_finalizes_retained_display_into_committed_block() {
9025 let mut session = Session::new();
9030
9031 let _ =
9032 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
9033 response_id: "resp_a".to_string(),
9034 delta_id: "delta_d_1".to_string(),
9035 item_id: "item_display".to_string(),
9036 previous_item_id: None,
9037 content_index: 0,
9038 delta: "committed-display-text".to_string(),
9039 });
9040
9041 assert!(session.messages().is_empty());
9043
9044 let outcome = session.append_realtime_transcript_event(
9045 RealtimeTranscriptEvent::AssistantTurnInterrupted {
9046 response_id: "resp_a".to_string(),
9047 },
9048 );
9049 assert_eq!(
9050 outcome.materialized_messages.len(),
9051 1,
9052 "Interrupted must finalize retained Display lane immediately"
9053 );
9054
9055 let messages = session.messages();
9057 assert_eq!(messages.len(), 1);
9058 match &messages[0] {
9059 Message::BlockAssistant(assistant) => {
9060 assert_eq!(assistant.blocks.len(), 1);
9061 match &assistant.blocks[0] {
9062 AssistantBlock::Text { text, .. } => {
9063 assert_eq!(text, "committed-display-text");
9064 }
9065 other => unreachable!("expected Text, got {other:?}"),
9066 }
9067 }
9068 other => unreachable!("expected BlockAssistant, got {other:?}"),
9069 }
9070 }
9071
9072 #[test]
9073 fn round5_r56_truncation_promotes_default_lane_item_to_spoken() {
9074 let mut session = Session::new();
9081
9082 let _ = session.append_realtime_transcript_event(
9083 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
9084 response_id: "resp_a".to_string(),
9085 item_id: "item_a".to_string(),
9086 content_index: 0,
9087 text: "what was actually heard".to_string(),
9088 },
9089 );
9090
9091 let outcome = 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 assert_eq!(outcome.materialized_messages.len(), 1);
9099
9100 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::Transcript { text, source, .. } => {
9106 assert_eq!(text, "what was actually heard");
9107 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
9108 }
9109 other => unreachable!(
9110 "truncation-only path must materialize as AssistantBlock::Transcript, got {other:?}"
9111 ),
9112 }
9113 }
9114 other => unreachable!("expected BlockAssistant, got {other:?}"),
9115 }
9116 }
9117
9118 #[test]
9119 fn round5_r56_truncation_after_display_delta_is_no_op_keeping_display_content() {
9120 let mut session = Session::new();
9128
9129 let _ =
9130 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
9131 response_id: "resp_a".to_string(),
9132 delta_id: "delta_d_1".to_string(),
9133 item_id: "item_a".to_string(),
9134 previous_item_id: None,
9135 content_index: 0,
9136 delta: "display-text-from-delta".to_string(),
9137 });
9138
9139 let _ = session.append_realtime_transcript_event(
9140 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
9141 response_id: "resp_a".to_string(),
9142 item_id: "item_a".to_string(),
9143 content_index: 0,
9144 text: "spoken-truncation-text".to_string(),
9145 },
9146 );
9147
9148 let _ = session.append_realtime_transcript_event(
9149 RealtimeTranscriptEvent::AssistantTurnCompleted {
9150 response_id: "resp_a".to_string(),
9151 stop_reason: StopReason::EndTurn,
9152 usage: Usage::default(),
9153 },
9154 );
9155
9156 assert_eq!(session.messages().len(), 1);
9159 match &session.messages()[0] {
9160 Message::BlockAssistant(assistant) => {
9161 assert_eq!(assistant.blocks.len(), 1);
9162 match &assistant.blocks[0] {
9163 AssistantBlock::Text { text, .. } => {
9164 assert_eq!(text, "display-text-from-delta");
9165 }
9166 other => unreachable!(
9167 "Display content must survive misrouted truncation, got {other:?}"
9168 ),
9169 }
9170 }
9171 other => unreachable!("expected BlockAssistant, got {other:?}"),
9172 }
9173 }
9174
9175 #[test]
9183 fn round5_r56_sibling_display_delta_skipped_on_spoken_item() {
9184 let mut session = Session::new();
9185
9186 let _ = session.append_realtime_transcript_event(
9188 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
9189 response_id: "resp_a".to_string(),
9190 item_id: "item_a".to_string(),
9191 content_index: 0,
9192 text: "what was actually heard".to_string(),
9193 },
9194 );
9195
9196 let _ =
9199 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
9200 response_id: "resp_a".to_string(),
9201 delta_id: "delta_d_1".to_string(),
9202 item_id: "item_a".to_string(),
9203 previous_item_id: None,
9204 content_index: 0,
9205 delta: "should-not-appear".to_string(),
9206 });
9207
9208 let _ = session.append_realtime_transcript_event(
9209 RealtimeTranscriptEvent::AssistantTurnCompleted {
9210 response_id: "resp_a".to_string(),
9211 stop_reason: StopReason::EndTurn,
9212 usage: Usage::default(),
9213 },
9214 );
9215
9216 assert_eq!(session.messages().len(), 1);
9219 match &session.messages()[0] {
9220 Message::BlockAssistant(assistant) => {
9221 assert_eq!(assistant.blocks.len(), 1);
9222 match &assistant.blocks[0] {
9223 AssistantBlock::Transcript { text, source, .. } => {
9224 assert_eq!(text, "what was actually heard");
9225 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
9226 }
9227 other => unreachable!(
9228 "Spoken-locked item must materialize as Transcript, got {other:?}"
9229 ),
9230 }
9231 }
9232 other => unreachable!("expected BlockAssistant, got {other:?}"),
9233 }
9234 }
9235
9236 #[test]
9243 fn round5_r56_sibling_spoken_delta_skipped_on_display_item() {
9244 let mut session = Session::new();
9245
9246 let _ =
9248 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
9249 response_id: "resp_a".to_string(),
9250 delta_id: "delta_d_1".to_string(),
9251 item_id: "item_a".to_string(),
9252 previous_item_id: None,
9253 content_index: 0,
9254 delta: "display-locked-text".to_string(),
9255 });
9256
9257 let _ = session.append_realtime_transcript_event(
9260 RealtimeTranscriptEvent::AssistantTranscriptDelta {
9261 response_id: "resp_a".to_string(),
9262 delta_id: "delta_s_1".to_string(),
9263 item_id: "item_a".to_string(),
9264 previous_item_id: None,
9265 content_index: 0,
9266 delta: "should-not-appear".to_string(),
9267 },
9268 );
9269
9270 let _ = session.append_realtime_transcript_event(
9271 RealtimeTranscriptEvent::AssistantTurnCompleted {
9272 response_id: "resp_a".to_string(),
9273 stop_reason: StopReason::EndTurn,
9274 usage: Usage::default(),
9275 },
9276 );
9277
9278 assert_eq!(session.messages().len(), 1);
9280 match &session.messages()[0] {
9281 Message::BlockAssistant(assistant) => {
9282 assert_eq!(assistant.blocks.len(), 1);
9283 match &assistant.blocks[0] {
9284 AssistantBlock::Text { text, .. } => {
9285 assert_eq!(text, "display-locked-text");
9286 }
9287 other => {
9288 unreachable!("Display-locked item must materialize as Text, got {other:?}")
9289 }
9290 }
9291 }
9292 other => unreachable!("expected BlockAssistant, got {other:?}"),
9293 }
9294 }
9295
9296 #[test]
9304 fn round5_r57_late_final_text_after_turn_completed_warns_and_skips() {
9305 let mut session = Session::new();
9306
9307 let _ = session.append_realtime_transcript_event(
9309 RealtimeTranscriptEvent::AssistantTranscriptDelta {
9310 response_id: "resp_a".to_string(),
9311 delta_id: "delta_s_1".to_string(),
9312 item_id: "item_a".to_string(),
9313 previous_item_id: None,
9314 content_index: 0,
9315 delta: "delta-accumulated".to_string(),
9316 },
9317 );
9318
9319 let commit_outcome = session.append_realtime_transcript_event(
9321 RealtimeTranscriptEvent::AssistantTurnCompleted {
9322 response_id: "resp_a".to_string(),
9323 stop_reason: StopReason::EndTurn,
9324 usage: Usage::default(),
9325 },
9326 );
9327 assert_eq!(commit_outcome.materialized_messages.len(), 1);
9328
9329 let late_outcome = session.append_realtime_transcript_event(
9333 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
9334 response_id: "resp_a".to_string(),
9335 item_id: "item_a".to_string(),
9336 content_index: 0,
9337 text: "authoritative-final-that-must-not-land".to_string(),
9338 },
9339 );
9340 assert!(
9341 late_outcome.is_inert(),
9342 "late FinalText after materialization must produce inert outcome"
9343 );
9344
9345 assert_eq!(session.messages().len(), 1);
9348 match &session.messages()[0] {
9349 Message::BlockAssistant(assistant) => {
9350 assert_eq!(assistant.blocks.len(), 1);
9351 match &assistant.blocks[0] {
9352 AssistantBlock::Transcript { text, .. } => {
9353 assert_eq!(
9354 text, "delta-accumulated",
9355 "canonical message must preserve delta-accumulated text; \
9356 append-only history forbids late FinalText repair"
9357 );
9358 }
9359 other => unreachable!("expected Transcript, got {other:?}"),
9360 }
9361 }
9362 other => unreachable!("expected BlockAssistant, got {other:?}"),
9363 }
9364 }
9365
9366 fn metadata_seam_session_metadata() -> SessionMetadata {
9367 SessionMetadata {
9368 schema_version: SESSION_METADATA_SCHEMA_VERSION,
9369 model: "test-model".to_string(),
9370 max_tokens: 1024,
9371 structured_output_retries: 2,
9372 provider: Provider::Anthropic,
9373 self_hosted_server_id: None,
9374 provider_params: None,
9375 tooling: SessionTooling::default(),
9376 keep_alive: false,
9377 comms_name: Some("team/reviewer/alice".to_string()),
9378 peer_meta: None,
9379 realm_id: None,
9380 instance_id: None,
9381 backend: None,
9382 config_generation: None,
9383 auth_binding: None,
9384 mob_member_binding: Some(crate::MobMemberBinding {
9385 mob_id: "team".to_string(),
9386 role: "reviewer".to_string(),
9387 member: "alice".to_string(),
9388 }),
9389 }
9390 }
9391
9392 #[test]
9397 fn session_metadata_document_lockstep_with_full_envelope() {
9398 let mut session = Session::new();
9399 session.push(Message::User(UserMessage::text("hello".to_string())));
9400 session
9401 .set_session_metadata(metadata_seam_session_metadata())
9402 .expect("session metadata should persist");
9403 session
9404 .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
9405 .expect("lifecycle terminal should persist");
9406
9407 let bytes = serde_json::to_vec(&session).expect("session should serialize");
9408 let document = session_metadata_document_from_slice(&bytes)
9409 .expect("partial decode must accept the canonical envelope");
9410
9411 assert_eq!(document.session_id(), session.id());
9412 assert_eq!(
9413 document.session_metadata_value(),
9414 session.metadata().get(SESSION_METADATA_KEY),
9415 "partial decode must project the identical raw session-metadata value"
9416 );
9417 assert_eq!(
9418 document.lifecycle_terminal_value(),
9419 session.metadata().get(SESSION_LIFECYCLE_TERMINAL_KEY),
9420 "partial decode must project the identical raw lifecycle-terminal value"
9421 );
9422
9423 let view = document
9424 .try_into_view()
9425 .expect("typed view must decode from the partial document");
9426 let full_view =
9427 PersistedSessionMetadataView::try_from_session(&session).expect("full-session view");
9428 assert_eq!(view.session_id, full_view.session_id);
9429 assert_eq!(
9430 view.session_metadata.as_ref().map(|m| m.model.clone()),
9431 full_view.session_metadata.as_ref().map(|m| m.model.clone())
9432 );
9433 assert_eq!(
9434 view.mob_member_binding(),
9435 full_view.mob_member_binding(),
9436 "typed binding must be identical across the two decode paths"
9437 );
9438 assert_eq!(
9439 view.lifecycle_terminal,
9440 Some(SessionLifecycleTerminal::Archived)
9441 );
9442 assert_eq!(
9443 full_view.lifecycle_terminal,
9444 Some(SessionLifecycleTerminal::Archived)
9445 );
9446 }
9447
9448 #[test]
9451 fn session_metadata_document_fails_closed_on_envelope_version() {
9452 let session = Session::new();
9453 let mut value = serde_json::to_value(&session).expect("session should serialize");
9454 value["version"] = serde_json::json!(SESSION_VERSION + 999);
9455 let bytes = serde_json::to_vec(&value).expect("mangled envelope should serialize");
9456
9457 session_metadata_document_from_slice(&bytes)
9458 .expect_err("an unsupported envelope version must fail the partial decode closed");
9459 }
9460
9461 #[test]
9464 fn persisted_session_metadata_view_fails_closed_on_corrupt_values() {
9465 let session_id = SessionId::new();
9466
9467 let mut corrupt_metadata = serde_json::Map::new();
9468 corrupt_metadata.insert(SESSION_METADATA_KEY.to_string(), serde_json::json!(42));
9469 PersistedSessionMetadataView::try_from_metadata_map(session_id.clone(), &corrupt_metadata)
9470 .expect_err("corrupt session_metadata must fail the view decode closed");
9471
9472 let mut corrupt_terminal = serde_json::Map::new();
9473 corrupt_terminal.insert(
9474 SESSION_LIFECYCLE_TERMINAL_KEY.to_string(),
9475 serde_json::json!("definitely-not-a-terminal"),
9476 );
9477 PersistedSessionMetadataView::try_from_metadata_map(session_id, &corrupt_terminal)
9478 .expect_err("corrupt lifecycle terminal must fail the view decode closed");
9479 }
9480
9481 #[test]
9483 fn persisted_session_metadata_view_reads_absent_facts_as_none() {
9484 let view = PersistedSessionMetadataView::try_from_metadata_map(
9485 SessionId::new(),
9486 &serde_json::Map::new(),
9487 )
9488 .expect("empty metadata map must decode");
9489 assert!(view.session_metadata.is_none());
9490 assert!(view.lifecycle_terminal.is_none());
9491 assert!(view.mob_member_binding().is_none());
9492 }
9493}