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
127impl std::fmt::Display for TranscriptRewriteReason {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 match &self.note {
132 Some(note) => write!(f, "{}: {note}", self.kind),
133 None => f.write_str(&self.kind),
134 }
135 }
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
140#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
141#[serde(rename_all = "snake_case")]
142pub struct TranscriptRewriteCommit {
143 pub parent_revision: String,
144 pub revision: String,
145 pub selection: TranscriptRewriteSelection,
146 pub original_span_digest: String,
147 pub replacement_digest: String,
148 pub messages_before: usize,
149 pub messages_after: usize,
150 pub reason: TranscriptRewriteReason,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub actor: Option<String>,
153 #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
154 pub committed_at: SystemTime,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
159#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
160#[serde(rename_all = "snake_case")]
161pub struct TranscriptRevisionBody {
162 pub revision: String,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub parent_revision: Option<String>,
165 #[cfg_attr(feature = "schema", schemars(with = "Vec<serde_json::Value>"))]
166 pub messages: Vec<Message>,
167 #[cfg_attr(feature = "schema", schemars(with = "SchemaSystemTime"))]
168 pub created_at: SystemTime,
169}
170
171#[cfg(feature = "schema")]
172#[allow(dead_code)]
173#[derive(schemars::JsonSchema)]
174#[schemars(rename = "SystemTime")]
175struct SchemaSystemTime {
176 secs_since_epoch: u64,
177 nanos_since_epoch: u32,
178}
179
180#[derive(Debug, Clone, Serialize)]
182#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
183#[serde(rename_all = "snake_case")]
184pub struct TranscriptRewriteRecord {
185 pub commit: TranscriptRewriteCommit,
186 pub parent_body: TranscriptRevisionBody,
187 pub revision_body: TranscriptRevisionBody,
188}
189
190impl<'de> Deserialize<'de> for TranscriptRewriteRecord {
191 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
192 where
193 D: Deserializer<'de>,
194 {
195 #[derive(Deserialize)]
196 #[serde(rename_all = "snake_case")]
197 struct Wire {
198 commit: TranscriptRewriteCommit,
199 parent_body: TranscriptRevisionBody,
200 revision_body: TranscriptRevisionBody,
201 }
202 let wire = Wire::deserialize(deserializer)?;
203 let mut revisions = vec![wire.parent_body, wire.revision_body];
204 let mut commits = vec![wire.commit];
205 heal_legacy_revision_strings(&mut revisions, &mut commits, None)
206 .map_err(serde::de::Error::custom)?;
207 let mut revisions = revisions.into_iter();
208 let parent_body = revisions
209 .next()
210 .ok_or_else(|| serde::de::Error::custom("rewrite record lost its parent body"))?;
211 let revision_body = revisions
212 .next()
213 .ok_or_else(|| serde::de::Error::custom("rewrite record lost its revision body"))?;
214 let commit = commits
215 .into_iter()
216 .next()
217 .ok_or_else(|| serde::de::Error::custom("rewrite record lost its commit"))?;
218 Ok(Self {
219 commit,
220 parent_body,
221 revision_body,
222 })
223 }
224}
225
226impl TranscriptRewriteRecord {
227 pub fn new(
228 commit: TranscriptRewriteCommit,
229 parent_body: TranscriptRevisionBody,
230 revision_body: TranscriptRevisionBody,
231 ) -> Result<Self, TranscriptEditError> {
232 validate_transcript_rewrite_record(&commit, &parent_body, &revision_body)?;
233 Ok(Self {
234 commit,
235 parent_body,
236 revision_body,
237 })
238 }
239}
240
241#[derive(Debug, Clone, Serialize)]
243#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
244#[serde(rename_all = "snake_case")]
245pub struct TranscriptHistoryState {
246 pub head: String,
247 #[serde(default, skip_serializing_if = "Vec::is_empty")]
248 pub commits: Vec<TranscriptRewriteCommit>,
249 #[serde(default, skip_serializing_if = "Vec::is_empty")]
250 pub revisions: Vec<TranscriptRevisionBody>,
251}
252
253impl<'de> Deserialize<'de> for TranscriptHistoryState {
254 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
255 where
256 D: Deserializer<'de>,
257 {
258 #[derive(Deserialize)]
259 #[serde(rename_all = "snake_case")]
260 struct Wire {
261 head: String,
262 #[serde(default)]
263 commits: Vec<TranscriptRewriteCommit>,
264 #[serde(default)]
265 revisions: Vec<TranscriptRevisionBody>,
266 }
267 let wire = Wire::deserialize(deserializer)?;
268 let mut state = TranscriptHistoryState {
269 head: wire.head,
270 commits: wire.commits,
271 revisions: wire.revisions,
272 };
273 let head_is_current = match state
276 .revisions
277 .iter()
278 .find(|body| body.revision == state.head)
279 {
280 Some(head_body) => {
281 transcript_messages_digest(&head_body.messages).map_err(serde::de::Error::custom)?
282 == state.head
283 }
284 None => true,
285 };
286 if !head_is_current {
287 let TranscriptHistoryState {
288 head,
289 commits,
290 revisions,
291 } = &mut state;
292 heal_legacy_revision_strings(revisions, commits, Some(head))
293 .map_err(serde::de::Error::custom)?;
294 }
295 Ok(state)
296 }
297}
298
299fn heal_legacy_revision_strings(
308 revisions: &mut [TranscriptRevisionBody],
309 commits: &mut [TranscriptRewriteCommit],
310 head: Option<&mut String>,
311) -> Result<(), serde_json::Error> {
312 let mut remap: BTreeMap<String, String> = BTreeMap::new();
313 for body in revisions.iter() {
314 let content = transcript_messages_digest(&body.messages)?;
315 if body.revision == content {
316 continue;
317 }
318 if body.revision == legacy_transcript_messages_digest(&body.messages)? {
319 remap.insert(body.revision.clone(), content);
320 }
321 }
322 if remap.is_empty() {
323 return Ok(());
324 }
325 for body in revisions.iter_mut() {
326 if let Some(current) = remap.get(&body.revision) {
327 body.revision = current.clone();
328 }
329 if let Some(parent) = body.parent_revision.as_ref()
330 && let Some(current) = remap.get(parent)
331 {
332 body.parent_revision = Some(current.clone());
333 }
334 }
335 for commit in commits.iter_mut() {
336 if let Some(current) = remap.get(&commit.parent_revision) {
337 commit.parent_revision = current.clone();
338 }
339 if let Some(current) = remap.get(&commit.revision) {
340 commit.revision = current.clone();
341 }
342 heal_legacy_commit_span_digests(commit, revisions)?;
343 }
344 if let Some(head) = head
345 && let Some(current) = remap.get(head.as_str())
346 {
347 *head = current.clone();
348 }
349 Ok(())
350}
351
352fn heal_legacy_commit_span_digests(
358 commit: &mut TranscriptRewriteCommit,
359 revisions: &[TranscriptRevisionBody],
360) -> Result<(), serde_json::Error> {
361 let Some(parent_body) = revisions
362 .iter()
363 .find(|body| body.revision == commit.parent_revision)
364 else {
365 return Ok(());
366 };
367 let Some(revision_body) = revisions
368 .iter()
369 .find(|body| body.revision == commit.revision)
370 else {
371 return Ok(());
372 };
373 let (start, end) = commit.selection.bounds();
374 if start > end || end > parent_body.messages.len() {
375 return Ok(());
376 }
377 let removed_len = end - start;
378 let Some(retained_len) = commit.messages_before.checked_sub(removed_len) else {
379 return Ok(());
380 };
381 let Some(replacement_len) = commit.messages_after.checked_sub(retained_len) else {
382 return Ok(());
383 };
384 let Some(replacement_end) = start.checked_add(replacement_len) else {
385 return Ok(());
386 };
387 if replacement_end > revision_body.messages.len() {
388 return Ok(());
389 }
390 let original_span = &parent_body.messages[start..end];
391 if commit.original_span_digest == legacy_transcript_messages_digest(original_span)? {
392 commit.original_span_digest = transcript_messages_digest(original_span)?;
393 }
394 let replacement_span = &revision_body.messages[start..replacement_end];
395 if commit.replacement_digest == legacy_transcript_messages_digest(replacement_span)? {
396 commit.replacement_digest = transcript_messages_digest(replacement_span)?;
397 }
398 Ok(())
399}
400
401impl TranscriptHistoryState {
402 pub fn from_rewrite_records<I>(records: I) -> Result<Option<Self>, TranscriptEditError>
404 where
405 I: IntoIterator<Item = TranscriptRewriteRecord>,
406 {
407 let mut state: Option<Self> = None;
408 for record in records {
409 validate_transcript_rewrite_record(
410 &record.commit,
411 &record.parent_body,
412 &record.revision_body,
413 )?;
414 let state = state.get_or_insert_with(|| Self {
415 head: record.commit.parent_revision.clone(),
416 commits: Vec::new(),
417 revisions: Vec::new(),
418 });
419 if record.commit.parent_revision != state.head {
420 if revision_body_extends_head(&record.parent_body, &state.revisions, &state.head)? {
421 state.head = record.commit.parent_revision.clone();
422 } else {
423 return Err(TranscriptEditError::HistoryStateMalformed(format!(
424 "rewrite record parent {} does not extend transcript head {}",
425 record.commit.parent_revision, state.head
426 )));
427 }
428 }
429 if !state
430 .revisions
431 .iter()
432 .any(|body| body.revision == record.parent_body.revision)
433 {
434 state.revisions.push(record.parent_body);
435 }
436 if !state
437 .revisions
438 .iter()
439 .any(|body| body.revision == record.revision_body.revision)
440 {
441 state.revisions.push(record.revision_body);
442 }
443 state.head = record.commit.revision.clone();
444 state.commits.push(record.commit);
445 }
446 Ok(state)
447 }
448}
449
450#[derive(Debug, Clone, thiserror::Error)]
452pub enum TranscriptEditError {
453 #[error("message index {message_index} out of bounds for {message_count} messages")]
454 MessageIndexOutOfBounds {
455 message_index: usize,
456 message_count: usize,
457 },
458 #[error("{block_kind} index {block_index} out of bounds for {block_count} blocks")]
459 BlockIndexOutOfBounds {
460 block_kind: &'static str,
461 block_index: usize,
462 block_count: usize,
463 },
464 #[error("replacement expected {expected} at message index {message_index}, found {actual}")]
465 MessageRoleMismatch {
466 message_index: usize,
467 expected: &'static str,
468 actual: &'static str,
469 },
470 #[error("invalid transcript rewrite range {start}..{end} for {message_count} messages")]
471 InvalidRewriteRange {
472 start: usize,
473 end: usize,
474 message_count: usize,
475 },
476 #[error("transcript rewrite does not change transcript revision {revision}")]
477 NoOpRewrite { revision: String },
478 #[error("transcript rewrite parent revision mismatch: expected {expected}, actual {actual}")]
479 RevisionConflict { expected: String, actual: String },
480 #[error("transcript history state is malformed: {0}")]
481 HistoryStateMalformed(String),
482 #[error("invalid transcript shape after rewrite: {0}")]
483 InvalidTranscriptShape(String),
484}
485
486fn message_role_name(message: &Message) -> &'static str {
487 match message {
488 Message::System(_) => "system",
489 Message::SystemNotice(_) => "system_notice",
490 Message::User(_) => "user",
491 Message::BlockAssistant(_) => "block_assistant",
492 Message::ToolResults { .. } => "tool_results",
493 }
494}
495
496fn assistant_tool_use_ids(message: &Message) -> Vec<&str> {
497 match message {
498 Message::BlockAssistant(assistant) => assistant
499 .blocks
500 .iter()
501 .filter_map(|block| match block {
502 AssistantBlock::ToolUse { id, .. } => Some(id.as_str()),
503 _ => None,
504 })
505 .collect(),
506 _ => Vec::new(),
507 }
508}
509
510fn validate_transcript_tool_result_shape(messages: &[Message]) -> Result<(), TranscriptEditError> {
511 for (index, message) in messages.iter().enumerate() {
512 if let Message::ToolResults { results, .. } = message {
513 let Some(previous) = index
514 .checked_sub(1)
515 .and_then(|previous| messages.get(previous))
516 else {
517 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
518 "tool_results at message {index} has no preceding assistant tool-use message"
519 )));
520 };
521 let expected = assistant_tool_use_ids(previous);
522 if expected.is_empty() {
523 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
524 "tool_results at message {index} follows {}, not an assistant tool-use message",
525 message_role_name(previous)
526 )));
527 }
528 let actual = results
529 .iter()
530 .map(|result| result.tool_use_id.as_str())
531 .collect::<Vec<_>>();
532 let actual_set = actual.iter().copied().collect::<BTreeSet<_>>();
533 let expected_set = expected.iter().copied().collect::<BTreeSet<_>>();
534 if actual.len() != actual_set.len() {
535 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
536 "tool_results at message {index} contains duplicate tool ids"
537 )));
538 }
539 if expected.len() != expected_set.len() {
540 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
541 "assistant tool-use message before tool_results at message {index} contains duplicate tool ids"
542 )));
543 }
544 if actual_set != expected_set {
545 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
546 "tool_results at message {index} resolve tool ids {actual_set:?}, expected {expected_set:?}"
547 )));
548 }
549 }
550
551 let tool_use_ids = assistant_tool_use_ids(message);
552 if tool_use_ids.is_empty() {
553 continue;
554 }
555 let Some(next) = messages.get(index + 1) else {
556 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
557 "assistant tool-use message {index} has no following tool_results"
558 )));
559 };
560 if !matches!(next, Message::ToolResults { .. }) {
561 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
562 "assistant tool-use message {index} is followed by {}, not tool_results",
563 message_role_name(next)
564 )));
565 }
566 }
567 Ok(())
568}
569
570fn canonicalize_digest_image_blocks(blocks: &mut [crate::types::ContentBlock]) {
571 for block in blocks.iter_mut() {
572 if let crate::types::ContentBlock::Image {
573 media_type,
574 data: crate::types::ImageData::Inline { data },
575 } = block
576 {
577 let blob_id = crate::blob::content_blob_id(media_type, data);
580 *block = crate::types::ContentBlock::Image {
581 media_type: media_type.clone(),
582 data: crate::types::ImageData::Blob { blob_id },
583 };
584 }
585 }
586}
587
588fn canonicalize_message_images_for_digest(messages: &[Message]) -> Vec<Message> {
597 let mut canonical = messages.to_vec();
598 for message in &mut canonical {
599 match message {
600 Message::User(user) => canonicalize_digest_image_blocks(&mut user.content),
601 Message::ToolResults { results, .. } => {
602 for result in results.iter_mut() {
603 canonicalize_digest_image_blocks(&mut result.content);
604 }
605 }
606 Message::SystemNotice(notice) => {
607 for block in &mut notice.blocks {
608 match block {
609 crate::types::SystemNoticeBlock::Comms { content, .. }
610 | crate::types::SystemNoticeBlock::ExternalEvent { content, .. } => {
611 canonicalize_digest_image_blocks(content);
612 }
613 _ => {}
614 }
615 }
616 }
617 _ => {}
618 }
619 }
620 canonical
621}
622
623fn digest_timestamp_sentinel() -> crate::types::MessageTimestamp {
627 chrono::DateTime::<chrono::Utc>::UNIX_EPOCH
628}
629
630fn canonicalize_messages_for_digest(messages: &[Message]) -> Vec<Message> {
648 let mut canonical = canonicalize_message_images_for_digest(messages);
649 for message in &mut canonical {
650 match message {
651 Message::System(system) => {
652 system.created_at = digest_timestamp_sentinel();
653 }
654 Message::SystemNotice(notice) => {
655 notice.created_at = digest_timestamp_sentinel();
656 }
657 Message::User(user) => {
658 user.identity = crate::types::TranscriptMessageIdentity::default();
659 user.created_at = digest_timestamp_sentinel();
660 }
661 Message::BlockAssistant(assistant) => {
662 assistant.identity = crate::types::TranscriptMessageIdentity::default();
663 assistant.created_at = digest_timestamp_sentinel();
664 }
665 Message::ToolResults { created_at, .. } => {
666 *created_at = digest_timestamp_sentinel();
667 }
668 }
669 }
670 canonical
671}
672
673pub fn transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
674 sha256_json_digest(&canonicalize_messages_for_digest(messages))
675}
676
677fn legacy_transcript_messages_digest(messages: &[Message]) -> Result<String, serde_json::Error> {
686 sha256_json_digest(&canonicalize_message_images_for_digest(messages))
687}
688
689fn validate_transcript_rewrite_record(
690 commit: &TranscriptRewriteCommit,
691 parent_body: &TranscriptRevisionBody,
692 revision_body: &TranscriptRevisionBody,
693) -> Result<(), TranscriptEditError> {
694 if parent_body.revision != commit.parent_revision {
695 return Err(TranscriptEditError::HistoryStateMalformed(format!(
696 "parent body revision {} does not match commit parent {}",
697 parent_body.revision, commit.parent_revision
698 )));
699 }
700 if revision_body.revision != commit.revision {
701 return Err(TranscriptEditError::HistoryStateMalformed(format!(
702 "revision body {} does not match commit revision {}",
703 revision_body.revision, commit.revision
704 )));
705 }
706 if commit.parent_revision == commit.revision {
707 return Err(TranscriptEditError::NoOpRewrite {
708 revision: commit.revision.clone(),
709 });
710 }
711 let parent_digest = transcript_messages_digest(&parent_body.messages)
712 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
713 if parent_digest != commit.parent_revision {
714 return Err(TranscriptEditError::HistoryStateMalformed(format!(
715 "parent body digest {parent_digest} does not match commit parent {}",
716 commit.parent_revision
717 )));
718 }
719 let revision_digest = transcript_messages_digest(&revision_body.messages)
720 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
721 if revision_digest != commit.revision {
722 return Err(TranscriptEditError::HistoryStateMalformed(format!(
723 "revision body digest {revision_digest} does not match commit revision {}",
724 commit.revision
725 )));
726 }
727 let (start, end) = commit.selection.bounds();
728 if start > end || end > parent_body.messages.len() {
729 return Err(TranscriptEditError::InvalidRewriteRange {
730 start,
731 end,
732 message_count: parent_body.messages.len(),
733 });
734 }
735 if commit.messages_before != parent_body.messages.len()
736 || commit.messages_after != revision_body.messages.len()
737 {
738 return Err(TranscriptEditError::HistoryStateMalformed(format!(
739 "commit message counts {} -> {} do not match revision bodies {} -> {}",
740 commit.messages_before,
741 commit.messages_after,
742 parent_body.messages.len(),
743 revision_body.messages.len()
744 )));
745 }
746 let original_span_digest = transcript_messages_digest(&parent_body.messages[start..end])
747 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
748 if original_span_digest != commit.original_span_digest {
749 return Err(TranscriptEditError::HistoryStateMalformed(format!(
750 "original span digest {original_span_digest} does not match commit digest {}",
751 commit.original_span_digest
752 )));
753 }
754 let removed_len = end - start;
755 let retained_len = commit
756 .messages_before
757 .checked_sub(removed_len)
758 .ok_or_else(|| {
759 TranscriptEditError::HistoryStateMalformed(
760 "commit removed more messages than it recorded before rewrite".to_string(),
761 )
762 })?;
763 let replacement_len = commit
764 .messages_after
765 .checked_sub(retained_len)
766 .ok_or_else(|| {
767 TranscriptEditError::HistoryStateMalformed(
768 "commit message counts cannot describe a replacement span".to_string(),
769 )
770 })?;
771 let replacement_end = start.checked_add(replacement_len).ok_or_else(|| {
772 TranscriptEditError::HistoryStateMalformed("replacement span end overflowed".to_string())
773 })?;
774 if replacement_end > revision_body.messages.len() {
775 return Err(TranscriptEditError::InvalidRewriteRange {
776 start,
777 end: replacement_end,
778 message_count: revision_body.messages.len(),
779 });
780 }
781 let parent_prefix_digest = transcript_messages_digest(&parent_body.messages[..start])
782 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
783 let revision_prefix_digest = transcript_messages_digest(&revision_body.messages[..start])
784 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
785 if parent_prefix_digest != revision_prefix_digest {
786 return Err(TranscriptEditError::HistoryStateMalformed(
787 "rewrite revision changed messages before the selected span".to_string(),
788 ));
789 }
790 let parent_suffix_digest = transcript_messages_digest(&parent_body.messages[end..])
791 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
792 let revision_suffix_digest =
793 transcript_messages_digest(&revision_body.messages[replacement_end..])
794 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
795 if parent_suffix_digest != revision_suffix_digest {
796 return Err(TranscriptEditError::HistoryStateMalformed(
797 "rewrite revision changed messages after the selected span".to_string(),
798 ));
799 }
800 let replacement_digest =
801 transcript_messages_digest(&revision_body.messages[start..replacement_end])
802 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
803 if replacement_digest != commit.replacement_digest {
804 return Err(TranscriptEditError::HistoryStateMalformed(format!(
805 "replacement span digest {replacement_digest} does not match commit digest {}",
806 commit.replacement_digest
807 )));
808 }
809 Ok(())
810}
811
812fn validate_transcript_history_state(
813 state: &TranscriptHistoryState,
814) -> Result<(), TranscriptEditError> {
815 if state
816 .revisions
817 .iter()
818 .all(|body| body.revision != state.head)
819 {
820 return Err(TranscriptEditError::HistoryStateMalformed(format!(
821 "missing transcript head body {}",
822 state.head
823 )));
824 }
825 for body in &state.revisions {
826 let digest = transcript_messages_digest(&body.messages)
827 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
828 if digest != body.revision {
829 return Err(TranscriptEditError::HistoryStateMalformed(format!(
830 "transcript revision body {} has digest {digest}",
831 body.revision
832 )));
833 }
834 }
835 for commit in &state.commits {
836 let parent_body = state
837 .revisions
838 .iter()
839 .find(|body| body.revision == commit.parent_revision)
840 .ok_or_else(|| {
841 TranscriptEditError::HistoryStateMalformed(format!(
842 "missing parent transcript body {}",
843 commit.parent_revision
844 ))
845 })?;
846 let revision_body = state
847 .revisions
848 .iter()
849 .find(|body| body.revision == commit.revision)
850 .ok_or_else(|| {
851 TranscriptEditError::HistoryStateMalformed(format!(
852 "missing transcript revision body {}",
853 commit.revision
854 ))
855 })?;
856 validate_transcript_rewrite_record(commit, parent_body, revision_body)?;
857 }
858 let Some(first_commit) = state.commits.first() else {
859 return Ok(());
860 };
861 let mut expected_head = first_commit.parent_revision.clone();
862 for commit in &state.commits {
863 let parent_body = state
864 .revisions
865 .iter()
866 .find(|body| body.revision == commit.parent_revision)
867 .ok_or_else(|| {
868 TranscriptEditError::HistoryStateMalformed(format!(
869 "missing parent transcript body {}",
870 commit.parent_revision
871 ))
872 })?;
873 if commit.parent_revision != expected_head
874 && !revision_body_extends_head(parent_body, &state.revisions, &expected_head)?
875 {
876 return Err(TranscriptEditError::HistoryStateMalformed(format!(
877 "rewrite commit parent {} does not extend transcript head {}",
878 commit.parent_revision, expected_head
879 )));
880 }
881 expected_head = commit.revision.clone();
882 }
883 let mut cursor = state.head.clone();
884 while cursor != expected_head {
885 let Some(head_body) = state.revisions.iter().find(|body| body.revision == cursor) else {
886 break;
887 };
888 match head_body.parent_revision.as_deref() {
889 Some(parent) => cursor = parent.to_string(),
890 None => break,
891 }
892 }
893 if cursor != expected_head {
894 return Err(TranscriptEditError::HistoryStateMalformed(format!(
895 "transcript head {} does not extend the rewrite chain",
896 state.head
897 )));
898 }
899 Ok(())
900}
901
902fn revision_body_extends_head(
903 candidate: &TranscriptRevisionBody,
904 revisions: &[TranscriptRevisionBody],
905 head: &str,
906) -> Result<bool, TranscriptEditError> {
907 if candidate.parent_revision.as_deref() == Some(head) {
908 return Ok(true);
909 }
910 let Some(head_body) = revisions.iter().find(|body| body.revision == head) else {
911 return Ok(false);
912 };
913 if candidate.messages.len() < head_body.messages.len() {
914 return Ok(false);
915 }
916 let prefix_digest = transcript_messages_digest(&candidate.messages[..head_body.messages.len()])
917 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
918 Ok(prefix_digest == head)
919}
920
921fn sha256_json_digest<T: Serialize + ?Sized>(value: &T) -> Result<String, serde_json::Error> {
922 let bytes = serde_json::to_vec(value)?;
923 let digest = Sha256::digest(bytes);
924 let mut out = String::with_capacity(digest.len() * 2);
925 const HEX: &[u8; 16] = b"0123456789abcdef";
926 for byte in digest {
927 out.push(HEX[(byte >> 4) as usize] as char);
928 out.push(HEX[(byte & 0x0f) as usize] as char);
929 }
930 Ok(format!("sha256:{out}"))
931}
932
933#[derive(Debug, Clone)]
937pub struct Session {
938 version: u32,
941 id: SessionId,
943 pub(crate) messages: Arc<Vec<Message>>,
945 created_at: SystemTime,
947 updated_at: SystemTime,
949 metadata: serde_json::Map<String, serde_json::Value>,
951 usage: Usage,
953}
954
955#[derive(Serialize, Deserialize)]
957#[serde(rename_all = "snake_case")]
958struct SessionSerde {
959 version: u32,
960 id: SessionId,
961 messages: Vec<Message>,
962 created_at: SystemTime,
963 updated_at: SystemTime,
964 #[serde(default)]
965 metadata: serde_json::Map<String, serde_json::Value>,
966 #[serde(default)]
967 usage: Usage,
968}
969
970impl Serialize for Session {
971 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
972 where
973 S: Serializer,
974 {
975 let serde_repr = SessionSerde {
976 version: self.version,
977 id: self.id.clone(),
978 messages: (*self.messages).clone(),
979 created_at: self.created_at,
980 updated_at: self.updated_at,
981 metadata: self.metadata.clone(),
982 usage: self.usage.clone(),
983 };
984 serde_repr.serialize(serializer)
985 }
986}
987
988impl<'de> Deserialize<'de> for Session {
989 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
990 where
991 D: Deserializer<'de>,
992 {
993 let serde_repr = SessionSerde::deserialize(deserializer)?;
994 let version = session_persistence_version_authority::restore_session_envelope_version(
995 serde_repr.version,
996 )
997 .map_err(<D::Error as serde::de::Error>::custom)?;
998 Ok(Session {
999 version,
1000 id: serde_repr.id,
1001 messages: Arc::new(serde_repr.messages),
1002 created_at: serde_repr.created_at,
1003 updated_at: serde_repr.updated_at,
1004 metadata: serde_repr.metadata,
1005 usage: serde_repr.usage,
1006 })
1007 }
1008}
1009
1010pub const SESSION_SYSTEM_CONTEXT_STATE_KEY: &str = "session_system_context_state";
1012
1013pub const SESSION_DEFERRED_TURN_STATE_KEY: &str = "session_deferred_turn_state";
1015
1016pub const SESSION_BUILD_STATE_KEY: &str = "session_build_state";
1018
1019pub const SESSION_TOOL_VISIBILITY_STATE_KEY: &str = "session_tool_visibility_state_v1";
1021
1022pub const SESSION_LIFECYCLE_TERMINAL_KEY: &str = "session_lifecycle_terminal";
1024
1025pub const SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY: &str =
1033 "session_runtime_checkpoint_provenance_v1";
1034
1035pub const VIEW_IMAGE_TOOL_NAME: &str = "view_image";
1037
1038pub const SYSTEM_CONTEXT_SEPARATOR: &str = "\n\n---\n\n";
1040
1041#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1042#[error("metadata key `{key}` is reserved for session authority")]
1043pub struct ReservedSessionMetadataKey {
1044 key: String,
1045}
1046
1047impl ReservedSessionMetadataKey {
1048 fn new(key: &str) -> Self {
1049 Self {
1050 key: key.to_string(),
1051 }
1052 }
1053}
1054
1055fn is_session_authority_metadata_key(key: &str) -> bool {
1056 crate::surface_metadata::ReservedMetadataKey::is_session_authority(key)
1059}
1060
1061#[allow(clippy::panic)]
1062fn fail_closed_generated_restore(authority: &'static str, err: serde_json::Error) -> ! {
1063 tracing::error!(
1064 authority,
1065 error = %err,
1066 "generated authority rejected durable restore"
1067 );
1068 panic!("generated {authority} authority rejected durable restore: {err}");
1069}
1070
1071#[derive(Clone)]
1077pub struct SystemContextStateHandle {
1078 inner: Arc<std::sync::Mutex<SessionSystemContextState>>,
1079}
1080
1081impl std::fmt::Debug for SystemContextStateHandle {
1082 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1083 f.debug_struct("SystemContextStateHandle")
1084 .field("inner", &"<Arc<Mutex<SessionSystemContextState>>>")
1085 .finish()
1086 }
1087}
1088
1089impl SystemContextStateHandle {
1090 pub fn new(state: SessionSystemContextState) -> Result<Self, serde_json::Error> {
1091 let state = system_context_authority::restore_system_context_state(state)
1092 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1093 Ok(Self {
1094 inner: Arc::new(std::sync::Mutex::new(state)),
1095 })
1096 }
1097
1098 pub fn from_shared_authority_state(
1099 inner: Arc<std::sync::Mutex<SessionSystemContextState>>,
1100 ) -> Self {
1101 Self { inner }
1102 }
1103
1104 pub fn snapshot(&self) -> SessionSystemContextState {
1105 match self.inner.lock() {
1106 Ok(guard) => guard.clone(),
1107 Err(poisoned) => {
1108 tracing::warn!("system-context state lock poisoned while reading snapshot");
1109 poisoned.into_inner().clone()
1110 }
1111 }
1112 }
1113
1114 pub fn replace_from_generated_restore(
1115 &self,
1116 state: SessionSystemContextState,
1117 ) -> Result<(), serde_json::Error> {
1118 let state = system_context_authority::restore_system_context_state(state)
1119 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1120 match self.inner.lock() {
1121 Ok(mut guard) => {
1122 *guard = state;
1123 }
1124 Err(poisoned) => {
1125 tracing::warn!("system-context state lock poisoned while restoring state");
1126 *poisoned.into_inner() = state;
1127 }
1128 }
1129 Ok(())
1130 }
1131
1132 pub fn replace_from_generated_restore_if_changed(
1133 &self,
1134 state: SessionSystemContextState,
1135 ) -> Result<bool, serde_json::Error> {
1136 let state = system_context_authority::restore_system_context_state(state)
1137 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1138 let mut guard = match self.inner.lock() {
1139 Ok(guard) => guard,
1140 Err(poisoned) => {
1141 tracing::warn!(
1142 "system-context state lock poisoned while replacing generated-restored state"
1143 );
1144 poisoned.into_inner()
1145 }
1146 };
1147 if *guard == state {
1148 return Ok(false);
1149 }
1150 *guard = state;
1151 Ok(true)
1152 }
1153
1154 pub fn replace_from_generated_restore_if_current(
1155 &self,
1156 current: &SessionSystemContextState,
1157 replacement: SessionSystemContextState,
1158 ) -> Result<bool, serde_json::Error> {
1159 let replacement = system_context_authority::restore_system_context_state(replacement)
1160 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
1161 let mut guard = match self.inner.lock() {
1162 Ok(guard) => guard,
1163 Err(poisoned) => {
1164 tracing::warn!(
1165 "system-context state lock poisoned while conditionally replacing generated-restored state"
1166 );
1167 poisoned.into_inner()
1168 }
1169 };
1170 if *guard != *current {
1171 return Ok(false);
1172 }
1173 *guard = replacement;
1174 Ok(true)
1175 }
1176
1177 pub fn stage_append_with_snapshot(
1178 &self,
1179 req: &AppendSystemContextRequest,
1180 accepted_at: SystemTime,
1181 ) -> Result<
1182 (
1183 crate::service::AppendSystemContextStatus,
1184 SessionSystemContextState,
1185 SessionSystemContextState,
1186 ),
1187 SystemContextStageError,
1188 > {
1189 let mut guard = match self.inner.lock() {
1190 Ok(guard) => guard,
1191 Err(poisoned) => {
1192 tracing::warn!("system-context state lock poisoned while staging append");
1193 poisoned.into_inner()
1194 }
1195 };
1196 let snapshot = guard.clone();
1197 let status = guard.stage_append(req, accepted_at)?;
1198 let staged = guard.clone();
1199 Ok((status, snapshot, staged))
1200 }
1201
1202 pub fn stage_active_turn_appends_with_snapshot(
1203 &self,
1204 appends: Vec<(AppendSystemContextRequest, SystemTime)>,
1205 ) -> Result<(SessionSystemContextState, SessionSystemContextState), SystemContextStageError>
1206 {
1207 let mut guard = match self.inner.lock() {
1208 Ok(guard) => guard,
1209 Err(poisoned) => {
1210 tracing::warn!(
1211 "system-context state lock poisoned while staging active-turn appends"
1212 );
1213 poisoned.into_inner()
1214 }
1215 };
1216 let snapshot = guard.clone();
1217 let mut candidate = snapshot.clone();
1218 for (req, accepted_at) in appends {
1219 candidate.stage_active_turn_append(&req, accepted_at)?;
1220 }
1221 *guard = candidate.clone();
1222 let staged = candidate;
1223 Ok((snapshot, staged))
1224 }
1225
1226 pub fn discard_unapplied_active_turn_pending(&self) -> usize {
1227 let discarded = match self.inner.lock() {
1228 Ok(mut guard) => guard.discard_unapplied_active_turn_pending(),
1229 Err(poisoned) => {
1230 tracing::warn!(
1231 "system-context state lock poisoned while discarding active-turn context"
1232 );
1233 poisoned
1234 .into_inner()
1235 .discard_unapplied_active_turn_pending()
1236 }
1237 };
1238 discarded.len()
1239 }
1240
1241 pub fn discard_active_turn_pending_by_keys(
1242 &self,
1243 idempotency_keys: &[String],
1244 ) -> Vec<PendingSystemContextAppend> {
1245 match self.inner.lock() {
1246 Ok(mut guard) => guard.discard_active_turn_pending_by_keys(idempotency_keys),
1247 Err(poisoned) => {
1248 tracing::warn!(
1249 "system-context state lock poisoned while discarding active-turn pending appends"
1250 );
1251 poisoned
1252 .into_inner()
1253 .discard_active_turn_pending_by_keys(idempotency_keys)
1254 }
1255 }
1256 }
1257
1258 pub fn stage_active_turn_append(
1259 &self,
1260 req: &AppendSystemContextRequest,
1261 accepted_at: SystemTime,
1262 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
1263 match self.inner.lock() {
1264 Ok(mut guard) => guard.stage_active_turn_append(req, accepted_at),
1265 Err(poisoned) => {
1266 tracing::warn!(
1267 "system-context state lock poisoned while staging active-turn context"
1268 );
1269 poisoned
1270 .into_inner()
1271 .stage_active_turn_append(req, accepted_at)
1272 }
1273 }
1274 }
1275}
1276
1277#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
1281#[serde(rename_all = "snake_case")]
1282pub struct SessionSystemContextState {
1283 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1284 pub(crate) pending: Vec<PendingSystemContextAppend>,
1285 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1286 pub(crate) applied: Vec<PendingSystemContextAppend>,
1287 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1288 pub(crate) seen: std::collections::BTreeMap<String, SeenSystemContextKey>,
1289 #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
1290 pub(crate) active_turn_pending_keys: std::collections::BTreeSet<String>,
1291}
1292
1293#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1303#[serde(rename_all = "snake_case")]
1304pub enum SystemContextSource {
1305 #[default]
1307 Normal,
1308 RuntimeSteer,
1311}
1312
1313impl From<SystemContextSource> for session_document::SystemContextSource {
1314 fn from(value: SystemContextSource) -> Self {
1315 match value {
1316 SystemContextSource::Normal => Self::Normal,
1317 SystemContextSource::RuntimeSteer => Self::RuntimeSteer,
1318 }
1319 }
1320}
1321
1322impl SystemContextSource {
1323 #[must_use]
1326 pub fn is_normal(&self) -> bool {
1327 matches!(self, Self::Normal)
1328 }
1329
1330 #[must_use]
1332 pub fn is_runtime_steer(&self) -> bool {
1333 matches!(self, Self::RuntimeSteer)
1334 }
1335}
1336
1337#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1341#[serde(rename_all = "snake_case")]
1342pub struct PendingSystemContextAppend {
1343 pub content: crate::lifecycle::run_primitive::CoreRenderable,
1351 #[serde(default, skip_serializing_if = "Option::is_none")]
1352 pub source: Option<String>,
1353 #[serde(default, skip_serializing_if = "Option::is_none")]
1354 pub idempotency_key: Option<String>,
1355 #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
1357 pub source_kind: SystemContextSource,
1358 #[serde(default, skip_serializing_if = "Option::is_none")]
1366 pub peer_response_terminal: Option<crate::handles::PeerResponseTerminalFact>,
1367 pub accepted_at: SystemTime,
1368}
1369
1370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1385#[serde(rename_all = "snake_case")]
1386pub enum SessionLifecycleTerminal {
1387 Active,
1389 Archived,
1391}
1392
1393impl SessionLifecycleTerminal {
1394 #[must_use]
1396 pub fn is_archived(self) -> bool {
1397 matches!(self, Self::Archived)
1398 }
1399}
1400
1401impl From<SessionLifecycleTerminal> for session_document::SessionDocumentLifecycle {
1402 fn from(value: SessionLifecycleTerminal) -> Self {
1403 match value {
1404 SessionLifecycleTerminal::Active => Self::Active,
1405 SessionLifecycleTerminal::Archived => Self::Archived,
1406 }
1407 }
1408}
1409
1410impl From<session_document::SessionDocumentLifecycle> for SessionLifecycleTerminal {
1411 fn from(value: session_document::SessionDocumentLifecycle) -> Self {
1412 match value {
1413 session_document::SessionDocumentLifecycle::Active => Self::Active,
1414 session_document::SessionDocumentLifecycle::Archived => Self::Archived,
1415 }
1416 }
1417}
1418
1419#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
1421#[serde(rename_all = "snake_case")]
1422pub struct SessionDeferredTurnState {
1423 #[serde(default, skip_serializing_if = "DeferredFirstTurnPhase::is_inactive")]
1424 pub(crate) first_turn_phase: DeferredFirstTurnPhase,
1425 #[serde(default, skip_serializing_if = "Option::is_none")]
1426 pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
1427 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1428 pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
1429}
1430
1431#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
1433#[serde(rename_all = "snake_case")]
1434pub enum DeferredFirstTurnPhase {
1435 #[default]
1437 Inactive,
1438 Pending,
1440 Consumed,
1442}
1443
1444impl DeferredFirstTurnPhase {
1445 pub fn is_inactive(&self) -> bool {
1446 matches!(self, Self::Inactive)
1447 }
1448}
1449
1450impl From<DeferredFirstTurnPhase> for session_document::SessionFirstTurnPhase {
1451 fn from(value: DeferredFirstTurnPhase) -> Self {
1452 match value {
1453 DeferredFirstTurnPhase::Inactive => Self::Inactive,
1454 DeferredFirstTurnPhase::Pending => Self::Pending,
1455 DeferredFirstTurnPhase::Consumed => Self::Consumed,
1456 }
1457 }
1458}
1459
1460impl From<session_document::SessionFirstTurnPhase> for DeferredFirstTurnPhase {
1461 fn from(value: session_document::SessionFirstTurnPhase) -> Self {
1462 match value {
1463 session_document::SessionFirstTurnPhase::Inactive => Self::Inactive,
1464 session_document::SessionFirstTurnPhase::Pending => Self::Pending,
1465 session_document::SessionFirstTurnPhase::Consumed => Self::Consumed,
1466 }
1467 }
1468}
1469
1470fn is_default_hook_run_overrides(value: &crate::HookRunOverrides) -> bool {
1471 value == &crate::HookRunOverrides::default()
1472}
1473
1474fn is_default_call_timeout_override(value: &crate::CallTimeoutOverride) -> bool {
1475 value == &crate::CallTimeoutOverride::default()
1476}
1477
1478fn is_tool_filter_all(value: &ToolFilter) -> bool {
1479 matches!(value, ToolFilter::All)
1480}
1481
1482fn is_zero(value: &u64) -> bool {
1483 *value == 0
1484}
1485
1486pub fn capability_base_filter_for_image_tool_results(image_tool_results: bool) -> ToolFilter {
1488 if image_tool_results {
1489 ToolFilter::All
1490 } else {
1491 ToolFilter::Deny([VIEW_IMAGE_TOOL_NAME.to_string()].into_iter().collect())
1492 }
1493}
1494
1495#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1502#[serde(rename_all = "snake_case")]
1503pub struct ToolVisibilityWitness {
1504 #[serde(default, skip_serializing_if = "Option::is_none")]
1505 pub last_seen_provenance: Option<ToolProvenance>,
1506}
1507
1508impl ToolVisibilityWitness {
1509 pub fn has_identity_witness(&self) -> bool {
1510 self.last_seen_provenance.is_some()
1511 }
1512}
1513
1514#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1520#[serde(rename_all = "snake_case")]
1521pub struct DeferredToolLoadAuthority {
1522 pub name: ToolName,
1523 pub witness: ToolVisibilityWitness,
1524}
1525
1526impl DeferredToolLoadAuthority {
1527 pub fn new(name: impl Into<ToolName>, witness: ToolVisibilityWitness) -> Self {
1528 Self {
1529 name: name.into(),
1530 witness,
1531 }
1532 }
1533
1534 pub fn into_parts(self) -> (ToolName, ToolVisibilityWitness) {
1535 (self.name, self.witness)
1536 }
1537}
1538
1539#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1542#[serde(rename_all = "snake_case")]
1543pub struct WitnessedToolFilter {
1544 pub filter: ToolFilter,
1545 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1546 pub witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1547}
1548
1549impl WitnessedToolFilter {
1550 pub fn new(filter: ToolFilter, witnesses: BTreeMap<ToolName, ToolVisibilityWitness>) -> Self {
1551 Self { filter, witnesses }
1552 }
1553
1554 pub fn into_parts(self) -> (ToolFilter, BTreeMap<ToolName, ToolVisibilityWitness>) {
1555 (self.filter, self.witnesses)
1556 }
1557}
1558
1559#[derive(Debug, Clone, PartialEq, Eq)]
1566pub struct InheritedToolVisibilityAuthority {
1567 filter: ToolFilter,
1568 witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1569}
1570
1571impl InheritedToolVisibilityAuthority {
1572 pub(crate) fn from_generated_composition_authority(
1573 filter: ToolFilter,
1574 witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1575 ) -> Self {
1576 Self { filter, witnesses }
1577 }
1578
1579 pub fn filter(&self) -> &ToolFilter {
1580 &self.filter
1581 }
1582
1583 pub fn witnesses(&self) -> &BTreeMap<ToolName, ToolVisibilityWitness> {
1584 &self.witnesses
1585 }
1586
1587 pub(crate) fn into_initial_visibility_state(self) -> SessionToolVisibilityState {
1588 SessionToolVisibilityState {
1589 inherited_base_filter: self.filter,
1590 filter_witnesses: self.witnesses,
1591 ..Default::default()
1592 }
1593 }
1594}
1595
1596#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
1598#[serde(rename_all = "snake_case")]
1599pub struct SessionToolVisibilityState {
1600 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
1601 pub capability_base_filter: ToolFilter,
1602 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
1603 pub inherited_base_filter: ToolFilter,
1604 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
1605 pub active_filter: ToolFilter,
1606 #[serde(default, skip_serializing_if = "is_tool_filter_all")]
1607 pub staged_filter: ToolFilter,
1608 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1609 pub active_requested_deferred_names: BTreeSet<ToolName>,
1610 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1611 pub staged_requested_deferred_names: BTreeSet<ToolName>,
1612 #[serde(default, skip_serializing_if = "is_zero")]
1613 pub active_revision: u64,
1614 #[serde(default, skip_serializing_if = "is_zero")]
1615 pub staged_revision: u64,
1616 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1617 pub requested_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1618 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1619 pub filter_witnesses: BTreeMap<ToolName, ToolVisibilityWitness>,
1620}
1621
1622#[derive(Debug, Clone, PartialEq, Eq)]
1628pub struct AuthorizedSessionToolVisibilityState {
1629 state: SessionToolVisibilityState,
1630}
1631
1632impl AuthorizedSessionToolVisibilityState {
1633 pub(crate) fn from_generated_authority(state: SessionToolVisibilityState) -> Self {
1634 Self { state }
1635 }
1636
1637 pub fn as_state(&self) -> &SessionToolVisibilityState {
1638 &self.state
1639 }
1640
1641 pub fn into_state(self) -> SessionToolVisibilityState {
1642 self.state
1643 }
1644}
1645
1646#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1649#[serde(rename_all = "snake_case")]
1650pub struct SessionBuildState {
1651 #[serde(
1652 default,
1653 skip_serializing_if = "crate::config::SystemPromptOverride::is_inherit"
1654 )]
1655 pub system_prompt: crate::config::SystemPromptOverride,
1656 #[serde(default, skip_serializing_if = "Option::is_none")]
1657 pub output_schema: Option<crate::OutputSchema>,
1658 #[serde(default, skip_serializing_if = "is_default_hook_run_overrides")]
1659 pub hooks_override: crate::HookRunOverrides,
1660 #[serde(default, skip_serializing_if = "Option::is_none")]
1661 pub budget_limits: Option<crate::BudgetLimits>,
1662 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1663 pub recoverable_tool_defs: Vec<ToolDef>,
1664 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1665 pub silent_comms_intents: Vec<String>,
1666 #[serde(default, skip_serializing_if = "Option::is_none")]
1667 pub max_inline_peer_notifications: Option<i32>,
1668 #[serde(default, skip_serializing_if = "Option::is_none")]
1669 pub app_context: Option<serde_json::Value>,
1670 #[serde(default, skip_serializing_if = "Option::is_none")]
1671 pub additional_instructions: Option<Vec<String>>,
1672 #[serde(default, skip_serializing_if = "Option::is_none")]
1673 pub shell_env: Option<HashMap<String, String>>,
1674 #[serde(default, skip_serializing_if = "Option::is_none")]
1680 pub mob_tool_authority_context: Option<MobToolAuthorityContext>,
1681 #[serde(default, skip_serializing_if = "is_default_call_timeout_override")]
1682 pub call_timeout_override: crate::CallTimeoutOverride,
1683}
1684
1685#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1687#[serde(rename_all = "snake_case")]
1688pub struct PendingDeferredPrompt {
1689 pub prompt: ContentInput,
1690 pub accepted_at: SystemTime,
1691}
1692
1693#[derive(Debug, Clone, Serialize, Deserialize)]
1695#[serde(rename_all = "snake_case")]
1696pub struct PendingToolResultsMessage {
1697 pub results: Vec<ToolResult>,
1698 pub accepted_at: SystemTime,
1699}
1700
1701impl PartialEq for PendingToolResultsMessage {
1702 fn eq(&self, other: &Self) -> bool {
1703 self.accepted_at == other.accepted_at
1704 && serde_json::to_value(&self.results).ok() == serde_json::to_value(&other.results).ok()
1705 }
1706}
1707
1708#[derive(Debug, Clone, Default, PartialEq)]
1710pub struct ConsumedDeferredTurnInputs {
1711 pub(crate) restore_first_turn_pending: bool,
1712 pub(crate) pending_initial_prompt: Option<PendingDeferredPrompt>,
1713 pub(crate) pending_tool_results: Vec<PendingToolResultsMessage>,
1714}
1715
1716impl ConsumedDeferredTurnInputs {
1717 pub fn is_empty(&self) -> bool {
1718 !self.restore_first_turn_pending
1719 && self.pending_initial_prompt.is_none()
1720 && self.pending_tool_results.is_empty()
1721 }
1722
1723 pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
1724 self.pending_initial_prompt.as_ref()
1725 }
1726
1727 pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
1728 &self.pending_tool_results
1729 }
1730}
1731
1732#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1734#[serde(rename_all = "snake_case")]
1735pub struct SeenSystemContextKey {
1736 pub content: crate::lifecycle::run_primitive::CoreRenderable,
1738 #[serde(default, skip_serializing_if = "Option::is_none")]
1739 pub source: Option<String>,
1740 #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
1743 pub source_kind: SystemContextSource,
1744 pub state: SeenSystemContextState,
1745}
1746
1747#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1749#[serde(rename_all = "snake_case")]
1750pub enum SeenSystemContextState {
1751 Pending,
1752 Applied,
1753}
1754
1755impl SessionSystemContextState {
1756 pub fn pending(&self) -> &[PendingSystemContextAppend] {
1757 &self.pending
1758 }
1759
1760 pub fn applied(&self) -> &[PendingSystemContextAppend] {
1761 &self.applied
1762 }
1763
1764 pub fn seen(&self) -> &BTreeMap<String, SeenSystemContextKey> {
1765 &self.seen
1766 }
1767
1768 pub fn active_turn_pending_keys(&self) -> &BTreeSet<String> {
1769 &self.active_turn_pending_keys
1770 }
1771
1772 pub fn pending_len(&self) -> usize {
1773 self.pending.len()
1774 }
1775
1776 pub fn applied_len(&self) -> usize {
1777 self.applied.len()
1778 }
1779
1780 pub fn active_turn_pending_len(&self) -> usize {
1781 self.active_turn_pending_keys.len()
1782 }
1783
1784 pub fn realtime_projection_appends(&self) -> Vec<PendingSystemContextAppend> {
1785 self.applied
1786 .iter()
1787 .chain(self.pending.iter())
1788 .cloned()
1789 .collect()
1790 }
1791
1792 pub fn stage_append(
1794 &mut self,
1795 req: &AppendSystemContextRequest,
1796 accepted_at: SystemTime,
1797 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
1798 system_context_authority::stage_append(self, req, accepted_at, false)
1799 }
1800
1801 fn stage_append_with_generated_authority(
1802 &mut self,
1803 req: &AppendSystemContextRequest,
1804 accepted_at: SystemTime,
1805 active_turn_scoped: bool,
1806 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
1807 system_context_authority::stage_append(self, req, accepted_at, active_turn_scoped)
1808 }
1809
1810 pub fn stage_active_turn_append(
1817 &mut self,
1818 req: &AppendSystemContextRequest,
1819 accepted_at: SystemTime,
1820 ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
1821 self.stage_append_with_generated_authority(req, accepted_at, true)
1822 }
1823
1824 pub fn mark_pending_applied(&mut self) {
1826 system_context_authority::mark_pending_applied(self);
1827 }
1828
1829 pub fn discard_unapplied_active_turn_pending(&mut self) -> Vec<PendingSystemContextAppend> {
1832 system_context_authority::discard_unapplied_active_turn_pending(self)
1833 }
1834
1835 pub fn discard_active_turn_pending_by_keys(
1842 &mut self,
1843 idempotency_keys: &[String],
1844 ) -> Vec<PendingSystemContextAppend> {
1845 system_context_authority::discard_active_turn_pending_by_keys(self, idempotency_keys)
1846 }
1847
1848 pub fn restore_from_snapshot(self) -> Result<Self, SystemContextStageError> {
1852 system_context_authority::restore_system_context_state(self)
1853 }
1854
1855 pub fn record_applied_blocks(
1859 &mut self,
1860 appends: &[PendingSystemContextAppend],
1861 current_system_prompt: &str,
1862 ) -> Vec<PendingSystemContextAppend> {
1863 system_context_authority::record_applied_system_context_blocks(
1864 self,
1865 appends,
1866 current_system_prompt,
1867 )
1868 }
1869}
1870
1871const SESSION_DOCUMENT_FIRST_TURN_KEY: &str = "first_turn";
1876
1877fn usize_to_u64(value: usize) -> u64 {
1878 u64::try_from(value).unwrap_or(u64::MAX)
1879}
1880
1881fn validate_deferred_turn_snapshot(
1889 state: SessionDeferredTurnState,
1890) -> Result<SessionDeferredTurnState, session_document::SessionDocumentError> {
1891 let mut authority = session_document::SessionDocumentMachineAuthority::new();
1892 let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
1893 authority.recover_session_first_turn_phase(
1897 key,
1898 state.first_turn_phase.into(),
1899 state.pending_initial_prompt.is_some(),
1900 usize_to_u64(state.pending_tool_results.len()),
1901 )?;
1902 Ok(state)
1903}
1904
1905impl SessionDeferredTurnState {
1906 pub fn first_turn_phase(&self) -> DeferredFirstTurnPhase {
1907 self.first_turn_phase
1908 }
1909
1910 pub fn pending_initial_prompt(&self) -> Option<&PendingDeferredPrompt> {
1911 self.pending_initial_prompt.as_ref()
1912 }
1913
1914 pub fn pending_tool_results(&self) -> &[PendingToolResultsMessage] {
1915 &self.pending_tool_results
1916 }
1917
1918 pub fn pending_tool_results_len(&self) -> usize {
1919 self.pending_tool_results.len()
1920 }
1921
1922 pub(crate) fn pending_initial_prompt_mut_for_blob_rewrite(
1923 &mut self,
1924 ) -> Option<&mut PendingDeferredPrompt> {
1925 self.pending_initial_prompt.as_mut()
1926 }
1927
1928 pub(crate) fn pending_tool_results_mut_for_blob_rewrite(
1929 &mut self,
1930 ) -> &mut [PendingToolResultsMessage] {
1931 &mut self.pending_tool_results
1932 }
1933
1934 fn document_authority(
1944 &self,
1945 ) -> (
1946 session_document::SessionDocumentMachineAuthority,
1947 session_document::SessionDocumentKey,
1948 ) {
1949 let mut authority = session_document::SessionDocumentMachineAuthority::new();
1950 let key = session_document::SessionDocumentKey::new(SESSION_DOCUMENT_FIRST_TURN_KEY);
1951 if let Err(err) = authority.recover_session_first_turn_phase(
1952 key.clone(),
1953 self.first_turn_phase.into(),
1954 self.pending_initial_prompt.is_some(),
1955 usize_to_u64(self.pending_tool_results.len()),
1956 ) {
1957 tracing::warn!(
1958 error = %err,
1959 "generated session document authority rejected first-turn recovery"
1960 );
1961 }
1962 (authority, key)
1963 }
1964
1965 fn mirror_first_turn_phase(
1968 &mut self,
1969 effects: &[session_document::SessionDocumentEffect],
1970 ) -> Option<bool> {
1971 for effect in effects {
1972 if let session_document::SessionDocumentEffect::SessionFirstTurnPhaseResolved {
1973 phase,
1974 was_pending,
1975 } = effect
1976 {
1977 self.first_turn_phase = (*phase).into();
1978 return Some(*was_pending);
1979 }
1980 }
1981 None
1982 }
1983
1984 pub fn mark_initial_turn_pending(&mut self) {
1986 let (mut authority, key) = self.document_authority();
1987 match authority.mark_session_initial_turn_pending(key) {
1988 Ok(effects) => {
1989 self.mirror_first_turn_phase(&effects);
1990 }
1991 Err(err) => tracing::warn!(
1992 error = %err,
1993 "generated session document authority rejected pending mark"
1994 ),
1995 }
1996 }
1997
1998 pub fn mark_initial_turn_started(&mut self) -> bool {
2002 let (mut authority, key) = self.document_authority();
2003 match authority.start_session_initial_turn(key) {
2004 Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
2005 Err(err) => {
2006 tracing::warn!(
2007 error = %err,
2008 "generated session document authority rejected first-turn start"
2009 );
2010 false
2011 }
2012 }
2013 }
2014
2015 pub fn restore_initial_turn_pending(&mut self) {
2017 let (mut authority, key) = self.document_authority();
2022 match authority.restore_session_consumed_inputs(
2023 key.clone(),
2024 true,
2025 self.pending_initial_prompt.is_some(),
2026 usize_to_u64(self.pending_tool_results.len()),
2027 ) {
2028 Ok(_) => {
2029 if let Some(phase) = authority.session_first_turn_phase_for(&key) {
2032 self.first_turn_phase = phase.into();
2033 }
2034 }
2035 Err(err) => tracing::warn!(
2036 error = %err,
2037 "generated session document authority rejected pending restore"
2038 ),
2039 }
2040 }
2041
2042 pub fn allows_initial_turn_overrides(&self) -> bool {
2044 let (mut authority, key) = self.document_authority();
2045 match authority.resolve_session_first_turn_overrides_allowed(key) {
2046 Ok(effects) => effects
2047 .iter()
2048 .find_map(|effect| {
2049 match effect {
2050 session_document::SessionDocumentEffect::SessionFirstTurnOverridesResolved {
2051 allowed,
2052 } => Some(*allowed),
2053 _ => None,
2054 }
2055 })
2056 .unwrap_or(false),
2057 Err(err) => {
2058 tracing::warn!(
2059 error = %err,
2060 "generated session document authority rejected override resolution"
2061 );
2062 false
2063 }
2064 }
2065 }
2066
2067 pub fn stage_initial_prompt(&mut self, prompt: ContentInput, accepted_at: SystemTime) {
2069 let prompt_has_content = prompt.has_images() || !prompt.text_content().trim().is_empty();
2070 let (mut authority, key) = self.document_authority();
2071 match authority.stage_session_initial_prompt(key, prompt_has_content) {
2072 Ok(effects) => {
2073 let decision = effects.iter().find_map(|effect| {
2074 match effect {
2075 session_document::SessionDocumentEffect::SessionInitialPromptStageResolved {
2076 decision,
2077 } => Some(*decision),
2078 _ => None,
2079 }
2080 });
2081 match decision {
2082 Some(session_document::SessionInitialPromptStageDecision::Store) => {
2083 self.pending_initial_prompt = Some(PendingDeferredPrompt {
2084 prompt,
2085 accepted_at,
2086 });
2087 }
2088 Some(session_document::SessionInitialPromptStageDecision::Clear) => {
2089 self.pending_initial_prompt = None;
2090 }
2091 None => tracing::warn!(
2092 "generated session document authority returned no prompt-stage decision"
2093 ),
2094 }
2095 }
2096 Err(err) => tracing::warn!(
2097 error = %err,
2098 "generated session document authority rejected initial prompt stage"
2099 ),
2100 }
2101 }
2102
2103 pub fn stage_tool_results(
2105 &mut self,
2106 results: Vec<ToolResult>,
2107 accepted_at: SystemTime,
2108 ) -> usize {
2109 let (mut authority, key) = self.document_authority();
2110 let accepted = match authority.stage_session_tool_results(key, usize_to_u64(results.len()))
2111 {
2112 Ok(effects) => effects.iter().find_map(|effect| match effect {
2113 session_document::SessionDocumentEffect::SessionToolResultsStageResolved {
2114 accepted_count,
2115 } => Some(*accepted_count),
2116 _ => None,
2117 }),
2118 Err(err) => {
2119 tracing::warn!(
2120 error = %err,
2121 "generated session document authority rejected tool-results stage"
2122 );
2123 return 0;
2124 }
2125 };
2126 let Some(accepted) = accepted else {
2127 tracing::warn!(
2128 "generated session document authority returned no tool-results decision"
2129 );
2130 return 0;
2131 };
2132 if accepted == 0 {
2133 return 0;
2134 }
2135 let accepted = usize::try_from(accepted).unwrap_or(usize::MAX);
2136 self.pending_tool_results.push(PendingToolResultsMessage {
2137 results,
2138 accepted_at,
2139 });
2140 accepted
2141 }
2142
2143 pub fn has_pending_tool_results(&self) -> bool {
2145 !self.pending_tool_results.is_empty()
2146 }
2147
2148 pub fn consume_for_started_turn(&mut self) -> ConsumedDeferredTurnInputs {
2150 let (mut authority, key) = self.document_authority();
2151 let was_pending = match authority.consume_session_deferred_inputs(key) {
2152 Ok(effects) => self.mirror_first_turn_phase(&effects).unwrap_or(false),
2153 Err(err) => {
2154 tracing::warn!(
2155 error = %err,
2156 "generated session document authority rejected started-turn consumption"
2157 );
2158 return ConsumedDeferredTurnInputs::default();
2159 }
2160 };
2161 ConsumedDeferredTurnInputs {
2162 restore_first_turn_pending: was_pending,
2163 pending_initial_prompt: self.pending_initial_prompt.take(),
2164 pending_tool_results: std::mem::take(&mut self.pending_tool_results),
2165 }
2166 }
2167
2168 pub fn restore_consumed_turn_inputs(&mut self, consumed: ConsumedDeferredTurnInputs) {
2170 if consumed.is_empty() {
2171 return;
2172 }
2173 let (mut authority, key) = self.document_authority();
2174 let effects = match authority.restore_session_consumed_inputs(
2175 key,
2176 consumed.restore_first_turn_pending,
2177 consumed.pending_initial_prompt.is_some(),
2178 usize_to_u64(consumed.pending_tool_results.len()),
2179 ) {
2180 Ok(effects) => effects,
2181 Err(err) => {
2182 tracing::warn!(
2183 error = %err,
2184 "generated session document authority rejected consumed input restore"
2185 );
2186 return;
2187 }
2188 };
2189 let Some((restore_first_turn_pending, restore_initial_prompt, restore_tool_results)) =
2190 effects.iter().find_map(|effect| match effect {
2191 session_document::SessionDocumentEffect::SessionConsumedInputsRestoreResolved {
2192 restore_first_turn_pending,
2193 restore_initial_prompt,
2194 restore_tool_results,
2195 } => Some((
2196 *restore_first_turn_pending,
2197 *restore_initial_prompt,
2198 *restore_tool_results,
2199 )),
2200 _ => None,
2201 })
2202 else {
2203 tracing::warn!(
2204 "generated session document authority returned no consumed-input restore decision"
2205 );
2206 return;
2207 };
2208 if restore_first_turn_pending {
2209 self.restore_initial_turn_pending();
2210 }
2211 if restore_initial_prompt && self.pending_initial_prompt.is_none() {
2212 self.pending_initial_prompt = consumed.pending_initial_prompt;
2213 }
2214 if restore_tool_results {
2215 let mut restored = consumed.pending_tool_results;
2216 restored.extend(std::mem::take(&mut self.pending_tool_results));
2217 self.pending_tool_results = restored;
2218 }
2219 }
2220}
2221
2222#[derive(Debug, Clone, PartialEq, Eq)]
2224pub enum SystemContextStageError {
2225 InvalidRequest(String),
2226 Conflict {
2227 key: String,
2228 existing_text: String,
2229 existing_source: Option<String>,
2230 },
2231}
2232
2233impl std::fmt::Display for SystemContextStageError {
2234 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2235 match self {
2236 Self::InvalidRequest(message) => {
2237 write!(f, "invalid system-context append request: {message}")
2238 }
2239 Self::Conflict { key, .. } => {
2240 write!(
2241 f,
2242 "system-context append conflict for idempotency key `{key}`"
2243 )
2244 }
2245 }
2246 }
2247}
2248
2249impl std::error::Error for SystemContextStageError {}
2250
2251fn render_system_context_block(append: &PendingSystemContextAppend) -> String {
2259 let mut rendered = String::from(SYSTEM_CONTEXT_RENDER_LABEL);
2260 if let Some(source) = &append.source {
2261 rendered.push_str("\nsource: ");
2262 rendered.push_str(source);
2263 }
2264 rendered.push_str("\n\n");
2265 rendered.push_str(append.content.render_text().trim());
2268 rendered
2269}
2270
2271const SYSTEM_CONTEXT_RENDER_LABEL: &str = "[Runtime System Context]";
2276
2277mod system_context_authority {
2288 use super::{
2289 AppendSystemContextRequest, BTreeSet, PendingSystemContextAppend, SeenSystemContextKey,
2290 SeenSystemContextState, SessionSystemContextState, SystemContextSource,
2291 SystemContextStageError, SystemTime, render_system_context_block, session_document,
2292 usize_to_u64,
2293 };
2294 use crate::service::AppendSystemContextStatus;
2295
2296 fn document_authority() -> session_document::SessionDocumentMachineAuthority {
2297 session_document::SessionDocumentMachineAuthority::new()
2298 }
2299
2300 fn resolve_append_decision(
2302 trimmed_text_byte_count: u64,
2303 idempotency_key_present: bool,
2304 existing_key_matches: bool,
2305 existing_key_conflicts: bool,
2306 active_turn_scoped: bool,
2307 ) -> Result<session_document::SystemContextAppendDecision, SystemContextStageError> {
2308 let mut authority = document_authority();
2309 let effects = authority
2310 .resolve_system_context_append(
2311 trimmed_text_byte_count,
2312 idempotency_key_present,
2313 existing_key_matches,
2314 existing_key_conflicts,
2315 active_turn_scoped,
2316 )
2317 .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
2318 effects
2319 .into_iter()
2320 .find_map(|effect| match effect {
2321 session_document::SessionDocumentEffect::SystemContextAppendResolved {
2322 decision,
2323 ..
2324 } => Some(decision),
2325 _ => None,
2326 })
2327 .ok_or_else(|| {
2328 SystemContextStageError::InvalidRequest(
2329 "generated session document authority returned no append decision".to_string(),
2330 )
2331 })
2332 }
2333
2334 fn pending_apply_item(source_kind: SystemContextSource) -> Option<(bool, bool, bool)> {
2337 let mut authority = document_authority();
2338 match authority.resolve_system_context_pending_apply_item(source_kind.into()) {
2339 Ok(effects) => effects.into_iter().find_map(|effect| {
2340 match effect {
2341 session_document::SessionDocumentEffect::SystemContextPendingApplyItemResolved {
2342 promote_to_applied,
2343 mark_seen_applied,
2344 remove_seen,
2345 } => Some((promote_to_applied, mark_seen_applied, remove_seen)),
2346 _ => None,
2347 }
2348 }),
2349 Err(err) => {
2350 tracing::warn!(
2351 error = %err,
2352 "generated session document authority rejected system-context apply item"
2353 );
2354 None
2355 }
2356 }
2357 }
2358
2359 fn steer_cleanup_discards(source_kind: SystemContextSource) -> bool {
2362 let mut authority = document_authority();
2363 match authority.resolve_system_context_steer_cleanup_item(source_kind.into()) {
2364 Ok(effects) => effects
2365 .into_iter()
2366 .find_map(|effect| {
2367 match effect {
2368 session_document::SessionDocumentEffect::SystemContextSteerCleanupItemResolved {
2369 discard,
2370 } => Some(discard),
2371 _ => None,
2372 }
2373 })
2374 .unwrap_or(false),
2375 Err(err) => {
2376 tracing::warn!(
2377 error = %err,
2378 "generated session document authority rejected system-context steer cleanup item"
2379 );
2380 false
2381 }
2382 }
2383 }
2384
2385 pub(super) fn restore_system_context_state(
2386 state: SessionSystemContextState,
2387 ) -> Result<SessionSystemContextState, SystemContextStageError> {
2388 let active_keys_have_known_pending_or_seen =
2389 state.active_turn_pending_keys.iter().all(|key| {
2390 state.seen.contains_key(key)
2391 || state
2392 .pending
2393 .iter()
2394 .any(|append| append.idempotency_key.as_ref() == Some(key))
2395 });
2396 let seen_keys_match_known_appends = state.seen.iter().all(|(key, seen)| {
2397 state
2398 .pending
2399 .iter()
2400 .chain(state.applied.iter())
2401 .any(|append| {
2402 append.idempotency_key.as_ref() == Some(key)
2403 && seen.content == append.content
2404 && seen.source.as_deref() == append.source.as_deref()
2405 })
2406 });
2407 let mut authority = document_authority();
2408 authority
2409 .restore_system_context_snapshot(
2410 active_keys_have_known_pending_or_seen,
2411 seen_keys_match_known_appends,
2412 )
2413 .map_err(|err| SystemContextStageError::InvalidRequest(err.to_string()))?;
2414 Ok(state)
2415 }
2416
2417 pub(super) fn stage_append(
2418 state: &mut SessionSystemContextState,
2419 req: &AppendSystemContextRequest,
2420 accepted_at: SystemTime,
2421 active_turn_scoped: bool,
2422 ) -> Result<AppendSystemContextStatus, SystemContextStageError> {
2423 let rendered_text = req.content.render_text();
2427 let rendered_len = rendered_text.trim().len();
2428 let existing = req
2429 .idempotency_key
2430 .as_ref()
2431 .and_then(|key| state.seen.get(key));
2432 let existing_key_matches = existing.is_some_and(|existing| {
2433 existing.content == req.content && existing.source.as_deref() == req.source.as_deref()
2434 });
2435 let existing_key_conflicts = existing.is_some() && !existing_key_matches;
2436 let decision = resolve_append_decision(
2437 usize_to_u64(rendered_len),
2438 req.idempotency_key.is_some(),
2439 existing_key_matches,
2440 existing_key_conflicts,
2441 active_turn_scoped,
2442 )?;
2443
2444 match decision {
2445 session_document::SystemContextAppendDecision::RejectEmpty => {
2446 return Err(SystemContextStageError::InvalidRequest(
2447 "system context text must not be empty".to_string(),
2448 ));
2449 }
2450 session_document::SystemContextAppendDecision::RejectConflict => {
2451 let Some(key) = req.idempotency_key.as_ref() else {
2452 return Err(SystemContextStageError::InvalidRequest(
2453 "generated system-context authority rejected append without a key"
2454 .to_string(),
2455 ));
2456 };
2457 let Some(existing) = existing else {
2458 return Err(SystemContextStageError::InvalidRequest(
2459 "generated system-context authority rejected append without a conflict"
2460 .to_string(),
2461 ));
2462 };
2463 return Err(SystemContextStageError::Conflict {
2464 key: key.clone(),
2465 existing_text: existing.content.render_text(),
2466 existing_source: existing.source.clone(),
2467 });
2468 }
2469 session_document::SystemContextAppendDecision::Duplicate => {
2470 return Ok(AppendSystemContextStatus::Duplicate);
2471 }
2472 session_document::SystemContextAppendDecision::Staged => {}
2473 }
2474
2475 let append = PendingSystemContextAppend {
2476 content: req.content.clone(),
2477 source: req.source.clone(),
2478 idempotency_key: req.idempotency_key.clone(),
2479 source_kind: req.source_kind,
2480 peer_response_terminal: req.peer_response_terminal.clone(),
2484 accepted_at,
2485 };
2486 if let Some(key) = req.idempotency_key.as_ref() {
2487 state.seen.insert(
2488 key.clone(),
2489 SeenSystemContextKey {
2490 content: append.content.clone(),
2491 source: append.source.clone(),
2492 source_kind: append.source_kind,
2493 state: SeenSystemContextState::Pending,
2494 },
2495 );
2496 }
2497 if active_turn_scoped && let Some(key) = req.idempotency_key.as_ref() {
2498 state.active_turn_pending_keys.insert(key.clone());
2499 }
2500 state.pending.push(append);
2501 Ok(AppendSystemContextStatus::Staged)
2502 }
2503
2504 pub(super) fn mark_pending_applied(state: &mut SessionSystemContextState) {
2505 let pending = std::mem::take(&mut state.pending);
2508 let mut seen_to_remove = Vec::new();
2509 for append in &pending {
2510 let Some((promote_to_applied, mark_seen_applied, remove_seen)) =
2511 pending_apply_item(append.source_kind)
2512 else {
2513 continue;
2514 };
2515 if promote_to_applied && !state.applied.contains(append) {
2516 state.applied.push(append.clone());
2517 }
2518 if let Some(key) = append.idempotency_key.as_ref() {
2519 if remove_seen {
2520 seen_to_remove.push(key.clone());
2521 } else if mark_seen_applied && let Some(seen) = state.seen.get_mut(key) {
2522 seen.state = SeenSystemContextState::Applied;
2523 }
2524 }
2525 }
2526 for key in seen_to_remove {
2527 state.seen.remove(&key);
2528 }
2529 state.active_turn_pending_keys.clear();
2530 }
2531
2532 pub(super) fn discard_unapplied_active_turn_pending(
2533 state: &mut SessionSystemContextState,
2534 ) -> Vec<PendingSystemContextAppend> {
2535 if state.active_turn_pending_keys.is_empty() {
2536 return Vec::new();
2537 }
2538 let active_keys = std::mem::take(&mut state.active_turn_pending_keys);
2539 let mut discarded = Vec::new();
2540 state.pending.retain(|append| {
2541 let should_discard = append
2542 .idempotency_key
2543 .as_ref()
2544 .is_some_and(|key| active_keys.contains(key));
2545 if should_discard {
2546 discarded.push(append.clone());
2547 }
2548 !should_discard
2549 });
2550
2551 for append in &discarded {
2552 if let Some(key) = append.idempotency_key.as_ref()
2553 && state
2554 .seen
2555 .get(key)
2556 .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
2557 {
2558 state.seen.remove(key);
2559 }
2560 }
2561
2562 discarded
2563 }
2564
2565 pub(super) fn discard_active_turn_pending_by_keys(
2566 state: &mut SessionSystemContextState,
2567 idempotency_keys: &[String],
2568 ) -> Vec<PendingSystemContextAppend> {
2569 if idempotency_keys.is_empty() || state.active_turn_pending_keys.is_empty() {
2570 return Vec::new();
2571 }
2572 let requested_keys: BTreeSet<&str> = idempotency_keys.iter().map(String::as_str).collect();
2573 let mut discarded = Vec::new();
2574 let mut discarded_keys = Vec::new();
2575 state.pending.retain(|append| {
2576 let should_discard = append.idempotency_key.as_ref().is_some_and(|key| {
2577 requested_keys.contains(key.as_str())
2578 && state.active_turn_pending_keys.contains(key)
2579 });
2580 if should_discard {
2581 if let Some(key) = append.idempotency_key.as_ref() {
2582 discarded_keys.push(key.clone());
2583 }
2584 discarded.push(append.clone());
2585 }
2586 !should_discard
2587 });
2588
2589 for key in discarded_keys {
2590 state.active_turn_pending_keys.remove(&key);
2591 if state
2592 .seen
2593 .get(&key)
2594 .is_some_and(|seen| seen.state == SeenSystemContextState::Pending)
2595 {
2596 state.seen.remove(&key);
2597 }
2598 }
2599
2600 discarded
2601 }
2602
2603 pub(super) fn discard_transient_runtime_steer_state(
2604 state: &mut SessionSystemContextState,
2605 ) -> usize {
2606 let mut removed = 0usize;
2607
2608 let before_pending = state.pending.len();
2609 state
2610 .pending
2611 .retain(|append| !steer_cleanup_discards(append.source_kind));
2612 removed += before_pending.saturating_sub(state.pending.len());
2613
2614 let before_applied = state.applied.len();
2615 state
2616 .applied
2617 .retain(|append| !steer_cleanup_discards(append.source_kind));
2618 removed += before_applied.saturating_sub(state.applied.len());
2619
2620 let before_seen = state.seen.len();
2621 state
2622 .seen
2623 .retain(|_key, seen| !steer_cleanup_discards(seen.source_kind));
2624 removed += before_seen.saturating_sub(state.seen.len());
2625
2626 let before_active = state.active_turn_pending_keys.len();
2630 let steer_keys: BTreeSet<String> = state
2631 .seen
2632 .iter()
2633 .filter(|(_key, seen)| steer_cleanup_discards(seen.source_kind))
2634 .map(|(key, _seen)| key.clone())
2635 .collect();
2636 state
2640 .active_turn_pending_keys
2641 .retain(|key| state.seen.contains_key(key) && !steer_keys.contains(key));
2642 removed += before_active.saturating_sub(state.active_turn_pending_keys.len());
2643
2644 removed
2645 }
2646
2647 pub(super) fn remove_runtime_steer_blocks_for_rendered(
2648 system_prompt: &str,
2649 runtime_steer_appends: &[PendingSystemContextAppend],
2650 ) -> (String, usize) {
2651 if runtime_steer_appends.is_empty() {
2652 return (system_prompt.to_string(), 0);
2653 }
2654 let steer_blocks: BTreeSet<String> = runtime_steer_appends
2658 .iter()
2659 .map(render_system_context_block)
2660 .collect();
2661 let parts = system_prompt
2662 .split(super::SYSTEM_CONTEXT_SEPARATOR)
2663 .map(str::to_string)
2664 .collect::<Vec<_>>();
2665 let original_len = parts.len();
2666 let retained = parts
2667 .into_iter()
2668 .filter(|part| !steer_blocks.contains(part))
2669 .collect::<Vec<_>>();
2670 let removed = original_len.saturating_sub(retained.len());
2671 (retained.join(super::SYSTEM_CONTEXT_SEPARATOR), removed)
2672 }
2673
2674 pub(super) fn record_applied_system_context_blocks(
2675 state: &mut SessionSystemContextState,
2676 appends: &[PendingSystemContextAppend],
2677 current_system_prompt: &str,
2678 ) -> Vec<PendingSystemContextAppend> {
2679 let mut new_appends: Vec<PendingSystemContextAppend> = Vec::new();
2680 for append in appends {
2681 if append.content.render_text().trim().is_empty() {
2682 continue;
2683 }
2684 let rendered = render_system_context_block(append);
2685 if let Some(key) = append.idempotency_key.as_ref() {
2686 if let Some(existing) = state.seen.get(key)
2687 && !seen_system_context_matches(existing, append)
2688 {
2689 tracing::warn!(
2690 idempotency_key = %key,
2691 "skipping conflicting runtime system-context append"
2692 );
2693 continue;
2694 }
2695 if let Some(existing) = state
2696 .applied
2697 .iter()
2698 .find(|applied| applied.idempotency_key.as_ref() == Some(key))
2699 && !pending_system_context_matches(existing, append)
2700 {
2701 tracing::warn!(
2702 idempotency_key = %key,
2703 "skipping conflicting runtime system-context append"
2704 );
2705 continue;
2706 }
2707 if let Some(existing) = new_appends
2708 .iter()
2709 .find(|pending| pending.idempotency_key.as_ref() == Some(key))
2710 {
2711 if !pending_system_context_matches(existing, append) {
2712 tracing::warn!(
2713 idempotency_key = %key,
2714 "skipping conflicting runtime system-context append"
2715 );
2716 }
2717 continue;
2718 }
2719 if current_system_prompt.contains(&rendered) {
2720 record_applied_append(state, append);
2721 continue;
2722 }
2723 } else if new_appends.contains(append) || current_system_prompt.contains(&rendered) {
2724 continue;
2725 }
2726 record_applied_append(state, append);
2727 new_appends.push(append.clone());
2728 }
2729 new_appends
2730 }
2731
2732 fn record_applied_append(
2733 state: &mut SessionSystemContextState,
2734 append: &PendingSystemContextAppend,
2735 ) {
2736 if let Some(key) = append.idempotency_key.as_ref() {
2737 state.seen.insert(
2738 key.clone(),
2739 SeenSystemContextKey {
2740 content: append.content.clone(),
2741 source: append.source.clone(),
2742 source_kind: append.source_kind,
2743 state: SeenSystemContextState::Applied,
2744 },
2745 );
2746 if state
2747 .applied
2748 .iter()
2749 .any(|applied| applied.idempotency_key.as_ref() == Some(key))
2750 {
2751 return;
2752 }
2753 } else if state.applied.contains(append) {
2754 return;
2755 }
2756 state.applied.push(append.clone());
2757 }
2758
2759 fn seen_system_context_matches(
2760 seen: &SeenSystemContextKey,
2761 append: &PendingSystemContextAppend,
2762 ) -> bool {
2763 seen.content == append.content && seen.source.as_deref() == append.source.as_deref()
2764 }
2765
2766 fn pending_system_context_matches(
2767 existing: &PendingSystemContextAppend,
2768 append: &PendingSystemContextAppend,
2769 ) -> bool {
2770 existing.content == append.content && existing.source.as_deref() == append.source.as_deref()
2771 }
2772}
2773
2774impl Session {
2775 pub fn new() -> Self {
2777 let now = SystemTime::now();
2778 Self {
2779 version: session_version(),
2780 id: SessionId::new(),
2781 messages: Arc::new(Vec::new()),
2782 created_at: now,
2783 updated_at: now,
2784 metadata: serde_json::Map::new(),
2785 usage: Usage::default(),
2786 }
2787 }
2788
2789 pub fn with_id(id: SessionId) -> Self {
2791 let mut session = Self::new();
2792 session.id = id;
2793 session
2794 }
2795
2796 pub fn id(&self) -> &SessionId {
2798 &self.id
2799 }
2800
2801 pub fn version(&self) -> u32 {
2803 self.version
2804 }
2805
2806 pub fn messages(&self) -> &[Message] {
2808 &self.messages
2809 }
2810
2811 pub(crate) fn replace_messages_internal(
2817 &mut self,
2818 messages: Vec<Message>,
2819 reason: TranscriptRewriteReason,
2820 ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError> {
2821 if transcript_messages_digest(self.messages()).ok()
2822 == transcript_messages_digest(&messages).ok()
2823 {
2824 return Ok(None);
2825 }
2826 let commit = self.commit_transcript_rewrite(
2827 TranscriptRewriteSelection::MessageRange {
2828 start: 0,
2829 end: self.messages.len(),
2830 },
2831 messages,
2832 reason,
2833 Some("meerkat-core".to_string()),
2834 None,
2835 )?;
2836 Ok(Some(commit))
2837 }
2838
2839 pub(crate) fn retain_messages_internal<F>(
2841 &mut self,
2842 mut retain: F,
2843 reason: TranscriptRewriteReason,
2844 ) -> Result<Option<TranscriptRewriteCommit>, TranscriptEditError>
2845 where
2846 F: FnMut(&Message) -> bool,
2847 {
2848 let retained = self
2849 .messages
2850 .iter()
2851 .filter(|message| retain(message))
2852 .cloned()
2853 .collect::<Vec<_>>();
2854 if retained.len() == self.messages.len()
2855 && transcript_messages_digest(self.messages()).ok()
2856 == transcript_messages_digest(&retained).ok()
2857 {
2858 return Ok(None);
2859 }
2860 self.replace_messages_internal(retained, reason)
2861 }
2862
2863 pub fn replace_synthetic_notices(
2873 &mut self,
2874 kind: crate::types::SystemNoticeKind,
2875 replacements: Vec<Message>,
2876 ) -> Result<(), TranscriptEditError> {
2877 for (index, message) in replacements.iter().enumerate() {
2878 let matches_kind =
2879 matches!(message, Message::SystemNotice(notice) if notice.kind == kind);
2880 if !matches_kind {
2881 return Err(TranscriptEditError::InvalidTranscriptShape(format!(
2882 "replacement {index} for synthetic notice kind {kind:?} is not a system notice of that kind"
2883 )));
2884 }
2885 }
2886 self.retain_messages_internal(
2887 |message| !matches!(message, Message::SystemNotice(notice) if notice.kind == kind),
2888 TranscriptRewriteReason::new("synthetic_notice_cleanup"),
2889 )?;
2890 for message in replacements {
2891 self.push(message);
2892 }
2893 Ok(())
2894 }
2895
2896 pub fn created_at(&self) -> SystemTime {
2898 self.created_at
2899 }
2900
2901 pub fn updated_at(&self) -> SystemTime {
2903 self.updated_at
2904 }
2905
2906 pub fn push(&mut self, message: Message) {
2910 Arc::make_mut(&mut self.messages).push(message);
2911 self.updated_at = SystemTime::now();
2912 self.refresh_transcript_head_after_message_mutation();
2913 }
2914
2915 pub fn push_batch(&mut self, messages: Vec<Message>) {
2919 if messages.is_empty() {
2920 return;
2921 }
2922 let inner = Arc::make_mut(&mut self.messages);
2923 inner.extend(messages);
2924 self.updated_at = SystemTime::now();
2925 self.refresh_transcript_head_after_message_mutation();
2926 }
2927
2928 pub async fn externalize_media(
2939 &mut self,
2940 blob_store: &dyn crate::BlobStore,
2941 start: usize,
2942 ) -> Result<(), crate::blob::BlobStoreError> {
2943 let previous_digest = if self
2944 .metadata
2945 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
2946 {
2947 transcript_messages_digest(self.messages()).ok()
2948 } else {
2949 None
2950 };
2951 let messages = Arc::make_mut(&mut self.messages);
2952 crate::image_content::externalize_messages_from(blob_store, messages, start).await?;
2953 if let Some(previous_digest) = previous_digest
2954 && transcript_messages_digest(self.messages()).ok().as_ref() != Some(&previous_digest)
2955 {
2956 self.refresh_transcript_head_after_message_mutation();
2957 }
2958 Ok(())
2959 }
2960
2961 pub fn touch(&mut self) {
2965 self.updated_at = SystemTime::now();
2966 }
2967
2968 pub fn last_n(&self, n: usize) -> &[Message] {
2970 let start = self.messages.len().saturating_sub(n);
2971 &self.messages[start..]
2972 }
2973
2974 pub fn total_tokens(&self) -> u64 {
2976 self.usage.total_tokens()
2977 }
2978
2979 pub fn total_usage(&self) -> Usage {
2981 self.usage.clone()
2982 }
2983
2984 pub fn record_usage(&mut self, turn_usage: Usage) {
2986 self.usage.add(&turn_usage);
2987 self.updated_at = SystemTime::now();
2988 }
2989
2990 pub fn append_external_user_content(&mut self, content: ContentInput) {
2992 self.push(Message::User(UserMessage::with_blocks(
2993 content.into_blocks(),
2994 )));
2995 }
2996
2997 pub fn append_external_assistant_blocks(
2999 &mut self,
3000 blocks: Vec<AssistantBlock>,
3001 stop_reason: StopReason,
3002 usage: Usage,
3003 ) {
3004 if !blocks.is_empty() {
3005 self.push(Message::BlockAssistant(BlockAssistantMessage::new(
3006 blocks,
3007 stop_reason,
3008 )));
3009 }
3010 if usage != Usage::default() {
3011 self.record_usage(usage);
3012 }
3013 }
3014
3015 pub fn append_realtime_transcript_event(
3023 &mut self,
3024 event: RealtimeTranscriptEvent,
3025 ) -> RealtimeTranscriptApplyOutcome {
3026 let mut state = self.realtime_transcript_state();
3027 let commit =
3028 realtime_transcript_revision::apply_realtime_transcript_event(&mut state, event)
3029 .unwrap_or_else(|err| {
3030 fail_closed_generated_restore(
3031 "realtime-transcript",
3032 <serde_json::Error as serde::de::Error>::custom(err),
3033 )
3034 });
3035 self.store_realtime_transcript_state(&state);
3036 self.push_batch(commit.messages);
3037 if commit.usage != Usage::default() {
3038 self.record_usage(commit.usage);
3039 }
3040 commit.outcome
3041 }
3042
3043 #[must_use]
3062 pub fn in_flight_realtime_assistant_response_ids(&self) -> Vec<String> {
3063 let state = self.realtime_transcript_state();
3064 realtime_transcript_revision::in_flight_realtime_assistant_response_ids(&state)
3065 }
3066
3067 fn realtime_transcript_state(&self) -> SessionRealtimeTranscriptState {
3068 match self.try_realtime_transcript_state() {
3069 Ok(Some(state)) => state,
3070 Ok(None) => SessionRealtimeTranscriptState::default(),
3071 Err(err) => fail_closed_generated_restore("realtime-transcript", err),
3072 }
3073 }
3074
3075 fn try_realtime_transcript_state(
3076 &self,
3077 ) -> Result<Option<SessionRealtimeTranscriptState>, serde_json::Error> {
3078 self.metadata
3079 .get(SESSION_REALTIME_TRANSCRIPT_STATE_KEY)
3080 .map(|value| {
3081 let state = serde_json::from_value(value.clone())?;
3082 realtime_transcript_revision::restore_realtime_transcript_state(state)
3083 .map_err(<serde_json::Error as serde::de::Error>::custom)
3084 })
3085 .transpose()
3086 }
3087
3088 fn store_realtime_transcript_state(&mut self, state: &SessionRealtimeTranscriptState) {
3089 match serde_json::to_value(state) {
3090 Ok(value) => self.set_metadata_unchecked(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, value),
3091 Err(error) => {
3092 tracing::warn!(error = %error, "failed to serialize realtime transcript state");
3093 }
3094 }
3095 }
3096
3097 fn apply_authorized_system_prompt(
3098 &mut self,
3099 prompt: session_durable_config_authority::AuthorizedSystemPrompt,
3100 ) {
3101 use crate::types::SystemMessage;
3102
3103 let mutation_kind = prompt.mutation_kind();
3108 let (prompt, _replacing_existing) = prompt.into_parts();
3109 let message = SystemMessage::with_mutation_kind(prompt, mutation_kind);
3110 let inner = Arc::make_mut(&mut self.messages);
3111 if let Some(Message::System(_)) = inner.first() {
3113 inner[0] = Message::System(message);
3114 } else {
3115 inner.insert(0, Message::System(message));
3116 }
3117 self.updated_at = SystemTime::now();
3118 self.refresh_transcript_head_after_message_mutation();
3119 }
3120
3121 pub fn set_system_prompt_with_source(
3123 &mut self,
3124 prompt: String,
3125 source: session_durable_config_authority::SessionSystemPromptSource,
3126 ) -> Result<(), session_durable_config_authority::SessionDurableConfigAuthorityError> {
3127 let replacing_existing = matches!(self.messages.first(), Some(Message::System(_)));
3128 let prompt = session_durable_config_authority::authorize_system_prompt_mutation(
3129 prompt,
3130 source,
3131 replacing_existing,
3132 )?;
3133 self.apply_authorized_system_prompt(prompt);
3134 Ok(())
3135 }
3136
3137 pub fn set_system_prompt(&mut self, prompt: String) {
3139 if let Err(err) = self.set_system_prompt_with_source(
3140 prompt,
3141 session_durable_config_authority::SessionSystemPromptSource::DirectMutation,
3142 ) {
3143 tracing::warn!(error = %err, "generated session durable-config authority rejected system prompt mutation");
3144 }
3145 }
3146
3147 pub fn discard_transient_runtime_steer_context(&mut self) -> usize {
3153 let mut removed = 0usize;
3154
3155 let mut state = match self.try_system_context_state() {
3156 Ok(state) => state.unwrap_or_default(),
3157 Err(err) => {
3158 tracing::warn!(
3159 error = %err,
3160 "generated system-context authority rejected runtime steer cleanup state"
3161 );
3162 return removed;
3163 }
3164 };
3165
3166 let runtime_steer_appends = state
3171 .pending
3172 .iter()
3173 .chain(state.applied.iter())
3174 .filter(|append| append.source_kind.is_runtime_steer())
3175 .cloned()
3176 .collect::<Vec<_>>();
3177 if let Some(Message::System(system)) = self.messages.first() {
3178 let (retained_prompt, removed_blocks) =
3179 system_context_authority::remove_runtime_steer_blocks_for_rendered(
3180 &system.content,
3181 &runtime_steer_appends,
3182 );
3183 if removed_blocks > 0 {
3184 removed += removed_blocks;
3185 if let Err(err) = self.set_system_prompt_with_source(
3186 retained_prompt,
3187 session_durable_config_authority::SessionSystemPromptSource::RuntimeSteerCleanup,
3188 ) {
3189 tracing::warn!(
3190 error = %err,
3191 "generated session durable-config authority rejected runtime steer prompt cleanup"
3192 );
3193 }
3194 }
3195 }
3196
3197 removed += system_context_authority::discard_transient_runtime_steer_state(&mut state);
3198
3199 if removed > 0
3200 && let Err(err) = self.set_system_context_state(state)
3201 {
3202 tracing::warn!(
3203 error = %err,
3204 "failed to persist runtime steer context cleanup"
3205 );
3206 }
3207
3208 removed
3209 }
3210
3211 pub fn append_system_context_blocks(&mut self, appends: &[PendingSystemContextAppend]) {
3213 if appends.is_empty() {
3214 return;
3215 }
3216
3217 let current_system_prompt = self
3218 .messages
3219 .first()
3220 .and_then(|message| match message {
3221 Message::System(system) => Some(system.content.as_str()),
3222 _ => None,
3223 })
3224 .unwrap_or_default();
3225 let mut state = match self.try_system_context_state() {
3226 Ok(state) => state.unwrap_or_default(),
3227 Err(err) => {
3228 tracing::warn!(
3229 error = %err,
3230 "generated system-context authority rejected applied context state"
3231 );
3232 return;
3233 }
3234 };
3235 let new_appends = system_context_authority::record_applied_system_context_blocks(
3236 &mut state,
3237 appends,
3238 current_system_prompt,
3239 );
3240 if new_appends.is_empty() {
3241 if let Err(err) = self.set_system_context_state(state) {
3242 tracing::warn!(error = %err, "failed to persist applied system-context state");
3243 }
3244 return;
3245 }
3246
3247 let rendered = new_appends
3248 .iter()
3249 .map(render_system_context_block)
3250 .collect::<Vec<_>>()
3251 .join(SYSTEM_CONTEXT_SEPARATOR);
3252
3253 let next = match self.messages.first() {
3254 Some(Message::System(sys)) if !sys.content.is_empty() => {
3255 format!("{}{}{}", sys.content, SYSTEM_CONTEXT_SEPARATOR, rendered)
3256 }
3257 _ => rendered,
3258 };
3259 if let Err(err) = self.set_system_prompt_with_source(
3260 next,
3261 session_durable_config_authority::SessionSystemPromptSource::RuntimeContextAppend,
3262 ) {
3263 tracing::warn!(
3264 error = %err,
3265 "generated session durable-config authority rejected system-context prompt append"
3266 );
3267 return;
3268 }
3269 if let Err(err) = self.set_system_context_state(state) {
3270 tracing::warn!(error = %err, "failed to persist applied system-context state");
3271 }
3272 }
3273
3274 pub fn last_assistant_text(&self) -> Option<String> {
3281 self.messages.iter().rev().find_map(|m| match m {
3282 Message::BlockAssistant(a) => {
3283 let mut buf = String::new();
3284 for block in &a.blocks {
3285 match block {
3286 crate::types::AssistantBlock::Text { text, .. }
3287 | crate::types::AssistantBlock::Transcript { text, .. } => {
3288 buf.push_str(text);
3289 }
3290 _ => {}
3291 }
3292 }
3293 if buf.is_empty() { None } else { Some(buf) }
3294 }
3295 _ => None,
3296 })
3297 }
3298
3299 pub fn tool_call_count(&self) -> usize {
3301 self.messages
3302 .iter()
3303 .filter_map(|m| match m {
3304 Message::BlockAssistant(a) => Some(
3305 a.blocks
3306 .iter()
3307 .filter(|b| matches!(b, crate::types::AssistantBlock::ToolUse { .. }))
3308 .count(),
3309 ),
3310 _ => None,
3311 })
3312 .sum()
3313 }
3314
3315 pub fn metadata(&self) -> &serde_json::Map<String, serde_json::Value> {
3317 &self.metadata
3318 }
3319
3320 fn set_metadata_unchecked(&mut self, key: &str, value: serde_json::Value) {
3321 self.metadata.insert(key.to_string(), value);
3322 self.updated_at = SystemTime::now();
3323 }
3324
3325 #[cfg(test)]
3326 pub(crate) fn set_metadata_unchecked_for_test(&mut self, key: &str, value: serde_json::Value) {
3327 self.set_metadata_unchecked(key, value);
3328 }
3329
3330 fn fork_metadata_projection(&self) -> serde_json::Map<String, serde_json::Value> {
3331 let mut metadata = self.metadata.clone();
3332 metadata.retain(|key, _| !is_session_authority_metadata_key(key));
3333 metadata
3334 }
3335
3336 fn remove_metadata_unchecked(&mut self, key: &str) {
3337 self.metadata.remove(key);
3338 self.updated_at = SystemTime::now();
3339 }
3340
3341 pub fn try_set_metadata(
3343 &mut self,
3344 key: &str,
3345 value: serde_json::Value,
3346 ) -> Result<(), ReservedSessionMetadataKey> {
3347 if is_session_authority_metadata_key(key) {
3348 return Err(ReservedSessionMetadataKey::new(key));
3349 }
3350 self.set_metadata_unchecked(key, value);
3351 Ok(())
3352 }
3353
3354 pub fn set_metadata(&mut self, key: &str, value: serde_json::Value) {
3359 if let Err(err) = self.try_set_metadata(key, value) {
3360 tracing::warn!(error = %err, "rejected raw session metadata mutation");
3361 }
3362 }
3363
3364 pub fn backfill_metadata_if_absent(&mut self, key: &str, value: serde_json::Value) -> bool {
3370 if is_session_authority_metadata_key(key) {
3371 tracing::warn!(
3372 metadata_key = key,
3373 "rejected raw session metadata backfill for authority key"
3374 );
3375 return false;
3376 }
3377 if self.metadata.contains_key(key) {
3378 false
3379 } else {
3380 self.metadata.insert(key.to_string(), value);
3381 true
3382 }
3383 }
3384
3385 pub fn remove_metadata(&mut self, key: &str) {
3387 if is_session_authority_metadata_key(key) {
3388 tracing::warn!(
3389 metadata_key = key,
3390 "rejected raw session metadata removal for authority key"
3391 );
3392 return;
3393 }
3394 self.metadata.remove(key);
3395 self.updated_at = SystemTime::now();
3396 }
3397
3398 pub fn set_session_metadata(
3400 &mut self,
3401 metadata: SessionMetadata,
3402 ) -> Result<(), serde_json::Error> {
3403 let metadata =
3404 session_durable_config_authority::authorize_session_metadata_persist(metadata)
3405 .map_err(<serde_json::Error as serde::ser::Error>::custom)?
3406 .into_metadata();
3407 let value = serde_json::to_value(metadata)?;
3408 self.set_metadata_unchecked(SESSION_METADATA_KEY, value);
3409 Ok(())
3410 }
3411
3412 pub fn session_metadata(&self) -> Option<SessionMetadata> {
3417 match self.try_session_metadata() {
3418 Ok(metadata) => metadata,
3419 Err(err) => fail_closed_generated_restore("session-metadata", err),
3420 }
3421 }
3422
3423 pub fn try_session_metadata(&self) -> Result<Option<SessionMetadata>, serde_json::Error> {
3425 let Some(value) = self.metadata.get(SESSION_METADATA_KEY) else {
3426 return Ok(None);
3427 };
3428 let mut metadata = serde_json::from_value::<SessionMetadata>(value.clone())?;
3429 metadata.schema_version =
3430 session_persistence_version_authority::restore_session_metadata_schema_version(
3431 metadata.schema_version,
3432 )
3433 .map_err(<serde_json::Error as serde::de::Error>::custom)?;
3434 session_durable_config_authority::restore_session_metadata(metadata)
3435 .map(Some)
3436 .map_err(<serde_json::Error as serde::de::Error>::custom)
3437 }
3438
3439 pub fn set_system_context_state(
3441 &mut self,
3442 state: SessionSystemContextState,
3443 ) -> Result<(), serde_json::Error> {
3444 let state = system_context_authority::restore_system_context_state(state)
3445 .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
3446 let value = serde_json::to_value(state)?;
3447 self.set_metadata_unchecked(SESSION_SYSTEM_CONTEXT_STATE_KEY, value);
3448 Ok(())
3449 }
3450
3451 pub fn try_system_context_state(
3453 &self,
3454 ) -> Result<Option<SessionSystemContextState>, serde_json::Error> {
3455 self.metadata
3456 .get(SESSION_SYSTEM_CONTEXT_STATE_KEY)
3457 .map(|value| {
3458 let state = serde_json::from_value(value.clone())?;
3459 system_context_authority::restore_system_context_state(state)
3460 .map_err(<serde_json::Error as serde::de::Error>::custom)
3461 })
3462 .transpose()
3463 }
3464
3465 pub fn system_context_state(&self) -> Option<SessionSystemContextState> {
3471 match self.try_system_context_state() {
3472 Ok(state) => state,
3473 Err(err) => fail_closed_generated_restore("system-context", err),
3474 }
3475 }
3476
3477 pub fn set_deferred_turn_state(
3479 &mut self,
3480 state: SessionDeferredTurnState,
3481 ) -> Result<(), serde_json::Error> {
3482 let state = validate_deferred_turn_snapshot(state)
3483 .map_err(<serde_json::Error as serde::ser::Error>::custom)?;
3484 let value = serde_json::to_value(state)?;
3485 self.set_metadata_unchecked(SESSION_DEFERRED_TURN_STATE_KEY, value);
3486 Ok(())
3487 }
3488
3489 pub fn try_deferred_turn_state(
3491 &self,
3492 ) -> Result<Option<SessionDeferredTurnState>, serde_json::Error> {
3493 self.metadata
3494 .get(SESSION_DEFERRED_TURN_STATE_KEY)
3495 .map(|value| {
3496 let state = serde_json::from_value(value.clone())?;
3497 validate_deferred_turn_snapshot(state)
3498 .map_err(<serde_json::Error as serde::de::Error>::custom)
3499 })
3500 .transpose()
3501 }
3502
3503 pub fn deferred_turn_state(&self) -> Option<SessionDeferredTurnState> {
3509 match self.try_deferred_turn_state() {
3510 Ok(state) => state,
3511 Err(err) => fail_closed_generated_restore("deferred-turn", err),
3512 }
3513 }
3514
3515 pub fn set_lifecycle_terminal(
3524 &mut self,
3525 terminal: SessionLifecycleTerminal,
3526 ) -> Result<(), serde_json::Error> {
3527 let value = serde_json::to_value(terminal)?;
3528 self.set_metadata_unchecked(SESSION_LIFECYCLE_TERMINAL_KEY, value);
3529 Ok(())
3530 }
3531
3532 pub fn try_lifecycle_terminal(
3537 &self,
3538 ) -> Result<Option<SessionLifecycleTerminal>, serde_json::Error> {
3539 match self.metadata.get(SESSION_LIFECYCLE_TERMINAL_KEY) {
3540 Some(value) => serde_json::from_value(value.clone()).map(Some),
3541 None => Ok(None),
3542 }
3543 }
3544
3545 pub fn lifecycle_terminal(&self) -> Option<SessionLifecycleTerminal> {
3551 match self.try_lifecycle_terminal() {
3552 Ok(state) => state,
3553 Err(err) => fail_closed_generated_restore("session-lifecycle-terminal", err),
3554 }
3555 }
3556
3557 pub fn set_build_state(&mut self, state: SessionBuildState) -> Result<(), serde_json::Error> {
3559 let state = session_durable_config_authority::authorize_session_build_state_persist(state)
3560 .map_err(<serde_json::Error as serde::ser::Error>::custom)?
3561 .into_state();
3562 let value = serde_json::to_value(state)?;
3563 self.set_metadata_unchecked(SESSION_BUILD_STATE_KEY, value);
3564 Ok(())
3565 }
3566
3567 pub fn build_state(&self) -> Option<SessionBuildState> {
3572 match self.try_build_state() {
3573 Ok(state) => state,
3574 Err(err) => fail_closed_generated_restore("session-build-state", err),
3575 }
3576 }
3577
3578 pub fn try_build_state(&self) -> Result<Option<SessionBuildState>, serde_json::Error> {
3580 let Some(value) = self.metadata.get(SESSION_BUILD_STATE_KEY) else {
3581 return Ok(None);
3582 };
3583 let state = serde_json::from_value::<SessionBuildState>(value.clone())?;
3584 session_durable_config_authority::restore_session_build_state(state)
3585 .map(Some)
3586 .map_err(<serde_json::Error as serde::de::Error>::custom)
3587 }
3588
3589 pub fn set_tool_visibility_state(
3591 &mut self,
3592 state: AuthorizedSessionToolVisibilityState,
3593 ) -> Result<(), serde_json::Error> {
3594 let value = serde_json::to_value(state.into_state())?;
3595 self.set_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY, value);
3596 Ok(())
3597 }
3598
3599 #[cfg(test)]
3604 pub(crate) fn clear_tool_visibility_state(&mut self) {
3605 self.remove_metadata_unchecked(SESSION_TOOL_VISIBILITY_STATE_KEY);
3606 }
3607
3608 pub fn tool_visibility_state(
3610 &self,
3611 ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
3612 self.try_tool_visibility_state()
3613 }
3614
3615 pub fn try_tool_visibility_state(
3618 &self,
3619 ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
3620 self.metadata
3621 .get(SESSION_TOOL_VISIBILITY_STATE_KEY)
3622 .map(|value| serde_json::from_value(value.clone()))
3623 .transpose()
3624 }
3625
3626 pub fn transcript_history_state(
3628 &self,
3629 ) -> Result<Option<TranscriptHistoryState>, serde_json::Error> {
3630 self.metadata
3631 .get(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
3632 .map(|value| serde_json::from_value(value.clone()))
3633 .transpose()
3634 }
3635
3636 pub fn validate_transcript_history_state(&self) -> Result<(), TranscriptEditError> {
3638 let Some(state) = self
3639 .transcript_history_state()
3640 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
3641 else {
3642 return Ok(());
3643 };
3644 validate_transcript_history_state(&state)
3645 }
3646
3647 pub fn clear_transcript_history_state(&mut self) {
3650 self.remove_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY);
3651 }
3652
3653 pub fn set_runtime_checkpoint_provenance(&mut self) {
3659 self.set_metadata_unchecked(
3660 SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY,
3661 serde_json::Value::Bool(true),
3662 );
3663 }
3664
3665 pub fn clear_runtime_checkpoint_provenance(&mut self) {
3669 self.remove_metadata_unchecked(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY);
3670 }
3671
3672 pub fn has_runtime_checkpoint_provenance(&self) -> bool {
3676 self.metadata
3677 .get(SESSION_RUNTIME_CHECKPOINT_PROVENANCE_KEY)
3678 .and_then(serde_json::Value::as_bool)
3679 .unwrap_or(false)
3680 }
3681
3682 pub fn transcript_revision_body(
3684 &self,
3685 revision: &str,
3686 ) -> Result<Option<TranscriptRevisionBody>, serde_json::Error> {
3687 Ok(self.transcript_history_state()?.and_then(|state| {
3688 state
3689 .revisions
3690 .into_iter()
3691 .find(|body| body.revision == revision)
3692 }))
3693 }
3694
3695 pub fn transcript_revision_messages(
3697 &self,
3698 revision: &str,
3699 ) -> Result<Option<Vec<Message>>, serde_json::Error> {
3700 Ok(self
3701 .transcript_revision_body(revision)?
3702 .map(|body| body.messages))
3703 }
3704
3705 pub fn apply_transcript_history_state(
3707 &mut self,
3708 state: TranscriptHistoryState,
3709 ) -> Result<(), TranscriptEditError> {
3710 validate_transcript_history_state(&state)?;
3711 let head_body = state
3712 .revisions
3713 .iter()
3714 .find(|body| body.revision == state.head)
3715 .ok_or_else(|| {
3716 TranscriptEditError::HistoryStateMalformed(format!(
3717 "missing transcript head body {}",
3718 state.head
3719 ))
3720 })?
3721 .clone();
3722 let value = serde_json::to_value(&state)
3723 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
3724 self.set_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, value);
3725 let mut updated_at = head_body.created_at;
3726 for commit in &state.commits {
3727 if commit.committed_at > updated_at {
3728 updated_at = commit.committed_at;
3729 }
3730 }
3731 self.messages = Arc::new(head_body.messages);
3732 self.updated_at = updated_at;
3733 Ok(())
3734 }
3735
3736 pub fn transcript_revision(&self) -> Result<String, serde_json::Error> {
3739 if let Some(state) = self.transcript_history_state()? {
3740 Ok(state.head)
3741 } else {
3742 transcript_messages_digest(self.messages())
3743 }
3744 }
3745
3746 pub fn commit_transcript_rewrite(
3748 &mut self,
3749 selection: TranscriptRewriteSelection,
3750 replacement: Vec<Message>,
3751 reason: TranscriptRewriteReason,
3752 actor: Option<String>,
3753 expected_parent_revision: Option<String>,
3754 ) -> Result<TranscriptRewriteCommit, TranscriptEditError> {
3755 let parent_revision = self
3756 .transcript_revision()
3757 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
3758 if let Some(expected) = expected_parent_revision
3759 && expected != parent_revision
3760 {
3761 return Err(TranscriptEditError::RevisionConflict {
3762 expected,
3763 actual: parent_revision,
3764 });
3765 }
3766
3767 let (start, end) = selection.bounds();
3768 let message_count = self.messages.len();
3769 if start > end || end > message_count {
3770 return Err(TranscriptEditError::InvalidRewriteRange {
3771 start,
3772 end,
3773 message_count,
3774 });
3775 }
3776
3777 let replacement_len = replacement.len();
3778 let mut rewritten = Vec::with_capacity(
3779 start
3780 .saturating_add(replacement_len)
3781 .saturating_add(message_count.saturating_sub(end)),
3782 );
3783 rewritten.extend_from_slice(&self.messages[..start]);
3784 rewritten.extend(replacement);
3785 rewritten.extend_from_slice(&self.messages[end..]);
3786 validate_transcript_tool_result_shape(&rewritten)?;
3787
3788 let original_span_digest = transcript_messages_digest(&self.messages[start..end])
3789 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
3790 let replacement_digest =
3791 transcript_messages_digest(&rewritten[start..start + replacement_len])
3792 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
3793 let revision = transcript_messages_digest(&rewritten)
3794 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
3795 if revision == parent_revision {
3796 return Err(TranscriptEditError::NoOpRewrite { revision });
3797 }
3798
3799 let commit = TranscriptRewriteCommit {
3800 parent_revision,
3801 revision: revision.clone(),
3802 selection,
3803 original_span_digest,
3804 replacement_digest,
3805 messages_before: message_count,
3806 messages_after: rewritten.len(),
3807 reason,
3808 actor,
3809 committed_at: SystemTime::now(),
3810 };
3811
3812 let mut state = self
3813 .transcript_history_state()
3814 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?
3815 .unwrap_or_else(|| TranscriptHistoryState {
3816 head: commit.parent_revision.clone(),
3817 commits: Vec::new(),
3818 revisions: Vec::new(),
3819 });
3820 if !state
3821 .revisions
3822 .iter()
3823 .any(|body| body.revision == commit.parent_revision)
3824 {
3825 state.revisions.push(TranscriptRevisionBody {
3826 revision: commit.parent_revision.clone(),
3827 parent_revision: None,
3828 messages: self.messages().to_vec(),
3829 created_at: self.updated_at,
3830 });
3831 }
3832 if !state
3833 .revisions
3834 .iter()
3835 .any(|body| body.revision == commit.revision)
3836 {
3837 state.revisions.push(TranscriptRevisionBody {
3838 revision: commit.revision.clone(),
3839 parent_revision: Some(commit.parent_revision.clone()),
3840 messages: rewritten.clone(),
3841 created_at: commit.committed_at,
3842 });
3843 }
3844 state.head = revision;
3845 state.commits.push(commit.clone());
3846 let value = serde_json::to_value(state)
3847 .map_err(|err| TranscriptEditError::HistoryStateMalformed(err.to_string()))?;
3848 self.set_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, value);
3849
3850 self.messages = Arc::new(rewritten);
3851 self.updated_at = SystemTime::now();
3852 Ok(commit)
3853 }
3854
3855 fn refresh_transcript_head_after_message_mutation(&mut self) {
3856 if !self
3857 .metadata
3858 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
3859 {
3860 return;
3861 }
3862 let Ok(Some(mut state)) = self.transcript_history_state() else {
3863 tracing::warn!(
3864 session_id = %self.id,
3865 "transcript history state is malformed; leaving head unchanged after message mutation"
3866 );
3867 return;
3868 };
3869 let Ok(head) = transcript_messages_digest(self.messages()) else {
3870 tracing::warn!(
3871 session_id = %self.id,
3872 "failed to digest transcript after message mutation; leaving head unchanged"
3873 );
3874 return;
3875 };
3876 let previous_head = state.head.clone();
3877 if !state.revisions.iter().any(|body| body.revision == head) {
3878 state.revisions.push(TranscriptRevisionBody {
3879 revision: head.clone(),
3880 parent_revision: Some(previous_head),
3881 messages: self.messages().to_vec(),
3882 created_at: SystemTime::now(),
3883 });
3884 }
3885 state.head = head;
3886 match serde_json::to_value(state) {
3887 Ok(value) => self.set_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY, value),
3888 Err(error) => {
3889 tracing::warn!(
3890 session_id = %self.id,
3891 error = %error,
3892 "failed to serialize transcript history state after message mutation"
3893 );
3894 }
3895 }
3896 }
3897
3898 pub fn set_mob_tool_authority_context(
3906 &mut self,
3907 authority_context: Option<MobToolAuthorityContext>,
3908 ) -> Result<(), serde_json::Error> {
3909 if let Some(authority_context) = authority_context.as_ref()
3910 && !authority_context.is_generated_authority_context()
3911 {
3912 return Err(<serde_json::Error as serde::de::Error>::custom(
3913 "mob authority context was not minted by generated authority",
3914 ));
3915 }
3916 let mut build_state = self.build_state().ok_or_else(|| {
3917 <serde_json::Error as serde::de::Error>::custom(format!(
3918 "session {} is missing session build state",
3919 self.id
3920 ))
3921 })?;
3922 build_state.mob_tool_authority_context = authority_context;
3923 self.set_build_state(build_state)
3924 }
3925
3926 pub fn mob_tool_authority_context(&self) -> Option<MobToolAuthorityContext> {
3931 self.build_state()
3932 .and_then(|state| state.mob_tool_authority_context)
3933 .filter(MobToolAuthorityContext::is_generated_authority_context)
3934 }
3935
3936 pub fn fork_at(&self, index: usize) -> Self {
3941 let now = SystemTime::now();
3942 let truncated = self.messages[..index.min(self.messages.len())].to_vec();
3943 Self {
3944 version: session_version(),
3945 id: SessionId::new(),
3946 messages: Arc::new(truncated),
3947 created_at: now,
3948 updated_at: now,
3949 metadata: self.fork_metadata_projection(),
3950 usage: self.usage.clone(),
3951 }
3952 }
3953
3954 pub fn fork_replacing(
3961 &self,
3962 message_index: usize,
3963 replacement: TranscriptReplacement,
3964 ) -> Result<Self, TranscriptEditError> {
3965 let Some(original) = self.messages.get(message_index) else {
3966 return Err(TranscriptEditError::MessageIndexOutOfBounds {
3967 message_index,
3968 message_count: self.messages.len(),
3969 });
3970 };
3971
3972 let replacement_message = match replacement {
3973 TranscriptReplacement::Message { message } => message,
3974 TranscriptReplacement::UserContentBlock { block_index, block } => {
3975 let Message::User(user) = original else {
3976 return Err(TranscriptEditError::MessageRoleMismatch {
3977 message_index,
3978 expected: "user",
3979 actual: message_role_name(original),
3980 });
3981 };
3982 if block_index >= user.content.len() {
3983 return Err(TranscriptEditError::BlockIndexOutOfBounds {
3984 block_kind: "user content block",
3985 block_index,
3986 block_count: user.content.len(),
3987 });
3988 }
3989 let mut edited = user.clone();
3990 edited.content[block_index] = block;
3991 Message::User(edited)
3992 }
3993 TranscriptReplacement::AssistantBlock { block_index, block } => {
3994 let Message::BlockAssistant(assistant) = original else {
3995 return Err(TranscriptEditError::MessageRoleMismatch {
3996 message_index,
3997 expected: "block_assistant",
3998 actual: message_role_name(original),
3999 });
4000 };
4001 if block_index >= assistant.blocks.len() {
4002 return Err(TranscriptEditError::BlockIndexOutOfBounds {
4003 block_kind: "assistant block",
4004 block_index,
4005 block_count: assistant.blocks.len(),
4006 });
4007 }
4008 let mut edited = assistant.clone();
4009 edited.blocks[block_index] = block;
4010 Message::BlockAssistant(edited)
4011 }
4012 TranscriptReplacement::ToolResultContentBlock {
4013 result_index,
4014 block_index,
4015 block,
4016 } => {
4017 let Message::ToolResults {
4018 results,
4019 created_at,
4020 } = original
4021 else {
4022 return Err(TranscriptEditError::MessageRoleMismatch {
4023 message_index,
4024 expected: "tool_results",
4025 actual: message_role_name(original),
4026 });
4027 };
4028 let Some(result) = results.get(result_index) else {
4029 return Err(TranscriptEditError::BlockIndexOutOfBounds {
4030 block_kind: "tool result",
4031 block_index: result_index,
4032 block_count: results.len(),
4033 });
4034 };
4035 if block_index >= result.content.len() {
4036 return Err(TranscriptEditError::BlockIndexOutOfBounds {
4037 block_kind: "tool result content block",
4038 block_index,
4039 block_count: result.content.len(),
4040 });
4041 }
4042 let mut edited_results = results.clone();
4043 edited_results[result_index].content[block_index] = block;
4044 Message::ToolResults {
4045 results: edited_results,
4046 created_at: *created_at,
4047 }
4048 }
4049 };
4050
4051 let mut forked = self.fork_at(message_index);
4052 forked.push(replacement_message);
4053 Ok(forked)
4054 }
4055
4056 pub fn fork(&self) -> Self {
4061 let now = SystemTime::now();
4062 Self {
4063 version: session_version(),
4064 id: SessionId::new(),
4065 messages: Arc::clone(&self.messages),
4066 created_at: now,
4067 updated_at: now,
4068 metadata: self.fork_metadata_projection(),
4069 usage: self.usage.clone(),
4070 }
4071 }
4072}
4073
4074impl Default for Session {
4075 fn default() -> Self {
4076 Self::new()
4077 }
4078}
4079
4080#[derive(Debug, Clone, Serialize, Deserialize)]
4082#[serde(rename_all = "snake_case")]
4083pub struct SessionMeta {
4084 pub id: SessionId,
4085 pub created_at: SystemTime,
4086 pub updated_at: SystemTime,
4087 pub message_count: usize,
4088 pub total_tokens: u64,
4089 #[serde(default)]
4090 pub metadata: serde_json::Map<String, serde_json::Value>,
4091}
4092
4093#[derive(Debug, Clone, Serialize, Deserialize)]
4095#[serde(rename_all = "snake_case")]
4096pub struct SessionMetadata {
4097 pub schema_version: u32,
4104 pub model: String,
4105 pub max_tokens: u32,
4106 #[serde(default = "crate::config::default_structured_output_retries")]
4107 pub structured_output_retries: u32,
4108 pub provider: Provider,
4109 #[serde(default, skip_serializing_if = "Option::is_none")]
4110 pub self_hosted_server_id: Option<String>,
4111 #[serde(default, skip_serializing_if = "Option::is_none")]
4114 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
4115 pub tooling: SessionTooling,
4116 #[serde(default)]
4117 pub keep_alive: bool,
4118 pub comms_name: Option<String>,
4119 #[serde(default, skip_serializing_if = "Option::is_none")]
4121 pub peer_meta: Option<PeerMeta>,
4122 #[serde(default, skip_serializing_if = "Option::is_none")]
4128 pub realm_id: Option<crate::RealmId>,
4129 #[serde(default, skip_serializing_if = "Option::is_none")]
4131 pub instance_id: Option<String>,
4132 #[serde(default, skip_serializing_if = "Option::is_none")]
4134 pub backend: Option<String>,
4135 #[serde(default, skip_serializing_if = "Option::is_none")]
4137 pub config_generation: Option<u64>,
4138 #[serde(default, skip_serializing_if = "Option::is_none")]
4148 pub auth_binding: Option<crate::AuthBindingRef>,
4149 #[serde(default, skip_serializing_if = "Option::is_none")]
4162 pub mob_member_binding: Option<crate::MobMemberBinding>,
4163}
4164
4165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
4167#[serde(rename_all = "snake_case")]
4168pub struct SessionLlmIdentity {
4169 pub model: String,
4170 pub provider: Provider,
4171 #[serde(default, skip_serializing_if = "Option::is_none")]
4172 pub self_hosted_server_id: Option<String>,
4173 #[serde(default, skip_serializing_if = "Option::is_none")]
4175 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
4176 #[serde(default, skip_serializing_if = "Option::is_none")]
4189 pub auth_binding: Option<crate::AuthBindingRef>,
4190}
4191
4192pub struct SessionLlmIdentityOverride<'a> {
4200 pub model: Option<&'a str>,
4201 pub provider: Option<Provider>,
4202 pub provider_params:
4203 Option<TurnMetadataOverride<&'a crate::lifecycle::run_primitive::ProviderParamsOverride>>,
4204 pub auth_binding: Option<TurnMetadataOverride<&'a crate::AuthBindingRef>>,
4205}
4206
4207#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
4208pub enum SessionLlmIdentityOverrideError {
4209 #[error("provider override requires model on an existing session")]
4210 ProviderRequiresModel,
4211 #[error("{0}")]
4212 ProviderModelMismatch(String),
4213 #[error("self-hosted provider requires a registered model alias; '{model}' is not configured")]
4214 MissingSelfHostedAlias { model: String },
4215}
4216
4217pub fn resolve_session_llm_identity_override(
4225 current: &SessionLlmIdentity,
4226 registry: &crate::ModelRegistry,
4227 overrides: SessionLlmIdentityOverride<'_>,
4228) -> Result<SessionLlmIdentity, SessionLlmIdentityOverrideError> {
4229 if overrides.provider.is_some() && overrides.model.is_none() {
4230 return Err(SessionLlmIdentityOverrideError::ProviderRequiresModel);
4231 }
4232
4233 let model = overrides
4234 .model
4235 .map(str::to_string)
4236 .unwrap_or_else(|| current.model.clone());
4237 let provider = if let Some(provider) = overrides.provider {
4238 provider
4239 } else if overrides.model.is_some() {
4240 registry
4241 .entry(&model)
4242 .map_or(current.provider, |entry| entry.provider)
4243 } else {
4244 current.provider
4245 };
4246
4247 if (overrides.model.is_some() || overrides.provider.is_some())
4248 && let Some(reason) = registry.provider_override_mismatch_reason(provider, &model)
4249 {
4250 return Err(SessionLlmIdentityOverrideError::ProviderModelMismatch(
4251 reason,
4252 ));
4253 }
4254
4255 let provider_params = match overrides.provider_params {
4256 Some(TurnMetadataOverride::Clear) => None,
4257 Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
4258 None => current.provider_params.clone(),
4259 };
4260 let self_hosted_server_id = if provider == Provider::SelfHosted {
4261 if overrides.model.is_none() {
4262 current.self_hosted_server_id.clone().or_else(|| {
4263 registry
4264 .entry_for_provider(Provider::SelfHosted, &model)
4265 .and_then(|entry| entry.self_hosted.as_ref())
4266 .map(|server| server.server_id.clone())
4267 })
4268 } else {
4269 let entry = registry
4270 .entry_for_provider(Provider::SelfHosted, &model)
4271 .ok_or_else(|| SessionLlmIdentityOverrideError::MissingSelfHostedAlias {
4272 model: model.clone(),
4273 })?;
4274 entry
4275 .self_hosted
4276 .as_ref()
4277 .map(|server| server.server_id.clone())
4278 }
4279 } else {
4280 None
4281 };
4282
4283 let auth_binding = match overrides.auth_binding {
4284 Some(TurnMetadataOverride::Clear) => None,
4285 Some(TurnMetadataOverride::Set(value)) => Some(value.clone()),
4286 None if provider != current.provider => None,
4289 None => current.auth_binding.clone(),
4290 };
4291
4292 Ok(SessionLlmIdentity {
4293 model,
4294 provider,
4295 self_hosted_server_id,
4296 provider_params,
4297 auth_binding,
4298 })
4299}
4300
4301#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
4308#[serde(rename_all = "snake_case")]
4309pub struct SessionLlmRequestPolicy {
4310 pub model: String,
4311 #[serde(default, skip_serializing_if = "Option::is_none")]
4313 pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
4314 #[serde(default, skip_serializing_if = "Option::is_none")]
4316 pub provider_tool_defaults: Option<crate::lifecycle::run_primitive::ProviderTag>,
4317}
4318
4319impl SessionMetadata {
4320 pub fn llm_identity(&self) -> SessionLlmIdentity {
4322 SessionLlmIdentity {
4323 model: self.model.clone(),
4324 provider: self.provider,
4325 self_hosted_server_id: self.self_hosted_server_id.clone(),
4326 provider_params: self.provider_params.clone(),
4327 auth_binding: self.auth_binding.clone(),
4328 }
4329 }
4330
4331 pub fn apply_llm_identity(&mut self, identity: &SessionLlmIdentity) {
4333 self.model = identity.model.clone();
4334 self.provider = identity.provider;
4335 self.self_hosted_server_id = identity.self_hosted_server_id.clone();
4336 self.provider_params = identity.provider_params.clone();
4337 self.auth_binding = identity.auth_binding.clone();
4338 }
4339}
4340
4341pub const SESSION_METADATA_KEY: &str = "session_metadata";
4343
4344#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
4352#[serde(rename_all = "snake_case")]
4353pub enum ToolCategoryOverride {
4354 #[default]
4356 Inherit,
4357 Enable,
4359 Disable,
4361}
4362
4363impl ToolCategoryOverride {
4364 #[must_use]
4370 pub fn resolve(self, runtime_default: bool) -> bool {
4371 match self {
4372 Self::Enable => true,
4373 Self::Disable => false,
4374 Self::Inherit => runtime_default,
4375 }
4376 }
4377
4378 #[must_use]
4384 pub fn to_override(self) -> Option<bool> {
4385 match self {
4386 Self::Enable => Some(true),
4387 Self::Disable => Some(false),
4388 Self::Inherit => None,
4389 }
4390 }
4391
4392 #[must_use]
4400 pub fn from_effective(enabled: bool) -> Self {
4401 if enabled { Self::Enable } else { Self::Disable }
4402 }
4403
4404 #[must_use]
4414 pub fn from_override(value: Option<bool>) -> Self {
4415 match value {
4416 Some(true) => Self::Enable,
4417 Some(false) => Self::Disable,
4418 None => Self::Inherit,
4419 }
4420 }
4421}
4422
4423#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
4430#[serde(rename_all = "snake_case")]
4431pub struct SessionTooling {
4432 #[serde(default)]
4433 pub builtins: ToolCategoryOverride,
4434 #[serde(default)]
4435 pub shell: ToolCategoryOverride,
4436 #[serde(default)]
4437 pub comms: ToolCategoryOverride,
4438 #[serde(default)]
4440 pub mob: ToolCategoryOverride,
4441 #[serde(default)]
4443 pub memory: ToolCategoryOverride,
4444 #[serde(default)]
4446 pub schedule: ToolCategoryOverride,
4447 #[serde(default)]
4449 pub workgraph: ToolCategoryOverride,
4450 #[serde(default)]
4452 pub image_generation: ToolCategoryOverride,
4453 #[serde(default)]
4455 pub web_search: ToolCategoryOverride,
4456 #[serde(default, skip_serializing_if = "Option::is_none")]
4466 pub tool_access_policy: Option<crate::ops::ToolAccessPolicy>,
4467 #[serde(default, skip_serializing_if = "Option::is_none")]
4469 pub active_skills: Option<Vec<crate::skills::SkillKey>>,
4470}
4471
4472impl From<&Session> for SessionMeta {
4473 fn from(session: &Session) -> Self {
4474 Self {
4475 id: session.id.clone(),
4476 created_at: session.created_at,
4477 updated_at: session.updated_at,
4478 message_count: session.messages.len(),
4479 total_tokens: session.total_tokens(),
4480 metadata: session.metadata.clone(),
4481 }
4482 }
4483}
4484
4485#[cfg(test)]
4486#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
4487mod tests {
4488 use super::*;
4489 use crate::realtime_transcript::RealtimeTranscriptRole;
4490 use crate::types::{
4491 AssistantBlock, BlockAssistantMessage, ContentBlock, StopReason, SystemMessage, Usage,
4492 UserMessage,
4493 };
4494 use std::sync::Arc;
4495
4496 fn block_assistant_text(message: &BlockAssistantMessage) -> String {
4497 message
4498 .blocks
4499 .iter()
4500 .filter_map(|block| match block {
4501 AssistantBlock::Text { text, .. } => Some(text.as_str()),
4502 _ => None,
4503 })
4504 .collect()
4505 }
4506
4507 #[test]
4508 fn transcript_digest_is_content_addressed() {
4509 let base_time = crate::types::message_timestamp_now();
4510 let stamped = vec![
4511 Message::User(UserMessage::text("turn one".to_string())),
4512 Message::BlockAssistant(BlockAssistantMessage {
4513 blocks: vec![AssistantBlock::Text {
4514 text: "answer one".to_string(),
4515 meta: None,
4516 }],
4517 stop_reason: StopReason::EndTurn,
4518 identity: crate::types::TranscriptMessageIdentity {
4519 interaction_id: None,
4520 run_id: Some(crate::lifecycle::RunId::new()),
4521 },
4522 created_at: base_time,
4523 }),
4524 ];
4525 let mut restamped = stamped.clone();
4526 for message in &mut restamped {
4527 match message {
4528 Message::User(user) => {
4529 user.created_at = base_time + chrono::Duration::hours(2);
4530 }
4531 Message::BlockAssistant(assistant) => {
4532 assistant.identity = crate::types::TranscriptMessageIdentity {
4533 interaction_id: None,
4534 run_id: Some(crate::lifecycle::RunId::new()),
4535 };
4536 assistant.created_at = base_time + chrono::Duration::hours(2);
4537 }
4538 _ => {}
4539 }
4540 }
4541 assert_eq!(
4542 transcript_messages_digest(&stamped).expect("digest"),
4543 transcript_messages_digest(&restamped).expect("digest"),
4544 "bookkeeping variance must not fork the transcript revision"
4545 );
4546
4547 let mut content_changed = stamped.clone();
4548 if let Message::User(user) = &mut content_changed[0] {
4549 user.content = vec![ContentBlock::Text {
4550 text: "a different turn".to_string(),
4551 }];
4552 }
4553 assert_ne!(
4554 transcript_messages_digest(&stamped).expect("digest"),
4555 transcript_messages_digest(&content_changed).expect("digest"),
4556 "content changes must fork the transcript revision"
4557 );
4558 }
4559
4560 fn legacy_rewrite_fixture() -> (TranscriptRewriteCommit, Vec<Message>, Vec<Message>) {
4561 let parent_messages = vec![
4562 Message::User(UserMessage::text("before rewrite".to_string())),
4563 Message::User(UserMessage::text("retained tail".to_string())),
4564 ];
4565 let revision_messages = vec![
4566 Message::User(UserMessage::text("after rewrite".to_string())),
4567 Message::User(UserMessage::text("retained tail".to_string())),
4568 ];
4569 let parent_revision =
4572 legacy_transcript_messages_digest(&parent_messages).expect("legacy parent digest");
4573 let revision =
4574 legacy_transcript_messages_digest(&revision_messages).expect("legacy revision digest");
4575 let commit = TranscriptRewriteCommit {
4576 parent_revision,
4577 revision,
4578 selection: TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
4579 original_span_digest: legacy_transcript_messages_digest(&parent_messages[0..1])
4580 .expect("legacy span digest"),
4581 replacement_digest: legacy_transcript_messages_digest(&revision_messages[0..1])
4582 .expect("legacy replacement digest"),
4583 messages_before: 2,
4584 messages_after: 2,
4585 reason: TranscriptRewriteReason::new("compaction"),
4586 actor: Some("legacy-test".to_string()),
4587 committed_at: SystemTime::now(),
4588 };
4589 (commit, parent_messages, revision_messages)
4590 }
4591
4592 #[test]
4593 fn legacy_transcript_history_state_heals_to_content_addressed_on_parse() {
4594 let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
4595 let state = TranscriptHistoryState {
4596 head: commit.revision.clone(),
4597 commits: vec![commit.clone()],
4598 revisions: vec![
4599 TranscriptRevisionBody {
4600 revision: commit.parent_revision.clone(),
4601 parent_revision: None,
4602 messages: parent_messages.clone(),
4603 created_at: SystemTime::now(),
4604 },
4605 TranscriptRevisionBody {
4606 revision: commit.revision.clone(),
4607 parent_revision: Some(commit.parent_revision),
4608 messages: revision_messages.clone(),
4609 created_at: SystemTime::now(),
4610 },
4611 ],
4612 };
4613 let value = serde_json::to_value(&state).expect("serialize legacy state");
4614 let healed: TranscriptHistoryState =
4615 serde_json::from_value(value).expect("parse legacy state");
4616
4617 let content_parent =
4618 transcript_messages_digest(&parent_messages).expect("content parent digest");
4619 let content_revision =
4620 transcript_messages_digest(&revision_messages).expect("content revision digest");
4621 assert_eq!(healed.head, content_revision, "head must re-derive");
4622 assert_eq!(healed.commits[0].parent_revision, content_parent);
4623 assert_eq!(healed.commits[0].revision, content_revision);
4624 assert_eq!(healed.revisions[0].revision, content_parent);
4625 assert_eq!(healed.revisions[1].revision, content_revision);
4626 assert_eq!(
4627 healed.revisions[1].parent_revision.as_deref(),
4628 Some(content_parent.as_str())
4629 );
4630 validate_transcript_history_state(&healed).expect("healed graph must validate");
4631
4632 let mut session = Session::new();
4635 session
4636 .apply_transcript_history_state(healed)
4637 .expect("apply healed graph");
4638 session
4639 .commit_transcript_rewrite(
4640 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
4641 vec![Message::User(UserMessage::text(
4642 "rewritten again".to_string(),
4643 ))],
4644 TranscriptRewriteReason::new("unit-test"),
4645 None,
4646 None,
4647 )
4648 .expect("extend healed graph with a new rewrite");
4649 session
4650 .validate_transcript_history_state()
4651 .expect("extended graph must validate");
4652 }
4653
4654 #[test]
4655 fn legacy_transcript_rewrite_record_heals_on_parse() {
4656 let (commit, parent_messages, revision_messages) = legacy_rewrite_fixture();
4657 let record_value = serde_json::json!({
4658 "commit": commit,
4659 "parent_body": TranscriptRevisionBody {
4660 revision: commit.parent_revision.clone(),
4661 parent_revision: None,
4662 messages: parent_messages,
4663 created_at: SystemTime::now(),
4664 },
4665 "revision_body": TranscriptRevisionBody {
4666 revision: commit.revision.clone(),
4667 parent_revision: Some(commit.parent_revision),
4668 messages: revision_messages.clone(),
4669 created_at: SystemTime::now(),
4670 },
4671 });
4672 let healed: TranscriptRewriteRecord =
4673 serde_json::from_value(record_value).expect("parse legacy record");
4674 assert_eq!(
4675 healed.commit.revision,
4676 transcript_messages_digest(&revision_messages).expect("content digest")
4677 );
4678 TranscriptRewriteRecord::new(healed.commit, healed.parent_body, healed.revision_body)
4680 .expect("healed record must validate");
4681 }
4682
4683 #[test]
4684 fn corrupt_transcript_history_strings_stay_untouched_and_fail_validation() {
4685 let (commit, parent_messages, _revision_messages) = legacy_rewrite_fixture();
4686 let bogus = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
4687 let state = TranscriptHistoryState {
4688 head: bogus.to_string(),
4689 commits: Vec::new(),
4690 revisions: vec![TranscriptRevisionBody {
4691 revision: bogus.to_string(),
4692 parent_revision: None,
4693 messages: parent_messages,
4694 created_at: SystemTime::now(),
4695 }],
4696 };
4697 let _ = commit;
4698 let value = serde_json::to_value(&state).expect("serialize corrupt state");
4699 let parsed: TranscriptHistoryState =
4700 serde_json::from_value(value).expect("corrupt strings still parse");
4701 assert_eq!(
4702 parsed.head, bogus,
4703 "unverifiable strings must not be rewritten"
4704 );
4705 assert!(
4706 validate_transcript_history_state(&parsed).is_err(),
4707 "corrupt graph must keep failing validation"
4708 );
4709 }
4710
4711 #[test]
4715 fn replace_synthetic_notices_leaves_only_replacements_of_kind() {
4716 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
4717
4718 let mut session = Session::new();
4719 session.push(Message::User(UserMessage::text("hello".to_string())));
4720 session.push(Message::SystemNotice(SystemNoticeMessage::new(
4721 SystemNoticeKind::McpPending,
4722 "stale one",
4723 )));
4724 session.push(Message::SystemNotice(SystemNoticeMessage::new(
4725 SystemNoticeKind::McpPending,
4726 "stale two",
4727 )));
4728 session.push(Message::SystemNotice(SystemNoticeMessage::new(
4730 SystemNoticeKind::BackgroundJob,
4731 "other-kind",
4732 )));
4733
4734 session
4735 .replace_synthetic_notices(
4736 SystemNoticeKind::McpPending,
4737 vec![Message::SystemNotice(SystemNoticeMessage::new(
4738 SystemNoticeKind::McpPending,
4739 "fresh",
4740 ))],
4741 )
4742 .expect("notice refresh succeeds");
4743
4744 let mcp_pending: Vec<&SystemNoticeMessage> = session
4745 .messages()
4746 .iter()
4747 .filter_map(|message| match message {
4748 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending => {
4749 Some(notice)
4750 }
4751 _ => None,
4752 })
4753 .collect();
4754 assert_eq!(mcp_pending.len(), 1, "exactly one notice of the kind");
4755 assert_eq!(mcp_pending[0].body.as_deref(), Some("fresh"));
4756 assert!(
4757 session.messages().iter().any(|message| matches!(
4758 message,
4759 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::BackgroundJob
4760 )),
4761 "other-kind notices are untouched"
4762 );
4763
4764 session
4766 .replace_synthetic_notices(SystemNoticeKind::McpPending, Vec::new())
4767 .expect("pure strip succeeds");
4768 assert!(
4769 !session.messages().iter().any(|message| matches!(
4770 message,
4771 Message::SystemNotice(notice) if notice.kind == SystemNoticeKind::McpPending
4772 )),
4773 "empty replacement clears the kind"
4774 );
4775 }
4776
4777 #[test]
4781 fn replace_synthetic_notices_rejects_mismatched_kind_without_mutation() {
4782 use crate::types::{SystemNoticeKind, SystemNoticeMessage};
4783
4784 let mut session = Session::new();
4785 session.push(Message::SystemNotice(SystemNoticeMessage::new(
4786 SystemNoticeKind::McpPending,
4787 "stale",
4788 )));
4789 let before = session.messages().to_vec();
4790
4791 let err = session
4792 .replace_synthetic_notices(
4793 SystemNoticeKind::McpPending,
4794 vec![Message::User(UserMessage::text("not a notice".to_string()))],
4795 )
4796 .expect_err("mismatched replacement must fail typed");
4797 assert!(
4798 matches!(err, TranscriptEditError::InvalidTranscriptShape(_)),
4799 "expected InvalidTranscriptShape, got {err:?}"
4800 );
4801 assert_eq!(
4802 session.messages(),
4803 before.as_slice(),
4804 "fault must leave the transcript unchanged (no partial strip)"
4805 );
4806 }
4807
4808 #[test]
4809 fn transcript_rewrite_preserves_full_assistant_block_trace() {
4810 let mut session = Session::new();
4811 session.push(Message::User(UserMessage::text(
4812 "run the trace".to_string(),
4813 )));
4814 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
4815 vec![AssistantBlock::Text {
4816 text: "original assistant trace".to_string(),
4817 meta: None,
4818 }],
4819 StopReason::EndTurn,
4820 )));
4821
4822 let parent_revision = session.transcript_revision().expect("parent revision");
4823 let replacement = vec![
4824 Message::BlockAssistant(BlockAssistantMessage::new(
4825 vec![
4826 AssistantBlock::Text {
4827 text: "compacted assistant trace".to_string(),
4828 meta: None,
4829 },
4830 AssistantBlock::ToolUse {
4831 id: "toolu_trace".to_string(),
4832 name: "trace_probe".to_string(),
4833 args: serde_json::value::RawValue::from_string(
4834 r#"{"path":"N-3"}"#.to_string(),
4835 )
4836 .expect("valid tool args"),
4837 meta: None,
4838 },
4839 ],
4840 StopReason::ToolUse,
4841 )),
4842 Message::tool_results(vec![ToolResult::new(
4843 "toolu_trace".to_string(),
4844 "trace complete".to_string(),
4845 false,
4846 )]),
4847 ];
4848
4849 let commit = session
4850 .commit_transcript_rewrite(
4851 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
4852 replacement,
4853 TranscriptRewriteReason::new("compaction"),
4854 Some("unit-test".to_string()),
4855 Some(parent_revision.clone()),
4856 )
4857 .expect("rewrite should commit");
4858
4859 assert_eq!(commit.parent_revision, parent_revision);
4860 let current = session
4861 .transcript_revision_messages(&commit.revision)
4862 .expect("history state should decode")
4863 .expect("current revision should be retained");
4864 let Message::BlockAssistant(assistant) = ¤t[1] else {
4865 panic!("replacement should remain a block assistant message");
4866 };
4867 assert!(assistant.blocks.iter().any(|block| matches!(
4868 block,
4869 AssistantBlock::ToolUse { name, args, .. }
4870 if name == "trace_probe" && args.get().contains("\"N-3\"")
4871 )));
4872
4873 let parent = session
4874 .transcript_revision_messages(&parent_revision)
4875 .expect("history state should decode")
4876 .expect("parent revision should remain retained");
4877 assert!(matches!(
4878 &parent[1],
4879 Message::BlockAssistant(assistant)
4880 if block_assistant_text(assistant).contains("original assistant trace")
4881 ));
4882 }
4883
4884 #[test]
4885 fn transcript_rewrite_rejects_trailing_block_assistant_tool_call() {
4886 let mut session = Session::new();
4887 session.push(Message::User(UserMessage::text("question".to_string())));
4888 session.push(Message::BlockAssistant(BlockAssistantMessage {
4889 blocks: vec![AssistantBlock::Text {
4890 text: "plain answer".to_string(),
4891 meta: None,
4892 }],
4893 stop_reason: StopReason::EndTurn,
4894 identity: crate::types::TranscriptMessageIdentity::default(),
4895 created_at: crate::types::message_timestamp_now(),
4896 }));
4897 let parent_revision = session.transcript_revision().expect("parent revision");
4898
4899 let err = session
4900 .commit_transcript_rewrite(
4901 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
4902 vec![Message::BlockAssistant(BlockAssistantMessage::new(
4903 vec![AssistantBlock::ToolUse {
4904 id: "toolu_1".to_string(),
4905 name: "lookup".to_string(),
4906 args: serde_json::value::RawValue::from_string("{}".to_string())
4907 .expect("valid args"),
4908 meta: None,
4909 }],
4910 StopReason::ToolUse,
4911 ))],
4912 TranscriptRewriteReason::new("compaction"),
4913 Some("unit-test".to_string()),
4914 Some(parent_revision),
4915 )
4916 .expect_err("rewrite should reject trailing unresolved block-assistant tool call");
4917 assert!(matches!(
4918 err,
4919 TranscriptEditError::InvalidTranscriptShape(_)
4920 ));
4921 }
4922
4923 #[test]
4924 fn transcript_rewrite_rejects_no_op_self_edge() {
4925 let mut session = Session::new();
4926 session.push(Message::User(UserMessage::text(
4927 "keep this exact transcript".to_string(),
4928 )));
4929 session.push(Message::BlockAssistant(BlockAssistantMessage {
4930 blocks: vec![AssistantBlock::Text {
4931 text: "unchanged".to_string(),
4932 meta: None,
4933 }],
4934 stop_reason: StopReason::EndTurn,
4935 identity: crate::types::TranscriptMessageIdentity::default(),
4936 created_at: crate::types::message_timestamp_now(),
4937 }));
4938
4939 let parent_revision = session.transcript_revision().expect("parent revision");
4940 let err = session
4941 .commit_transcript_rewrite(
4942 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
4943 vec![session.messages()[1].clone()],
4944 TranscriptRewriteReason::new("retry"),
4945 Some("unit-test".to_string()),
4946 Some(parent_revision.clone()),
4947 )
4948 .expect_err("same-content rewrite should not emit a self-edge commit");
4949
4950 assert!(matches!(
4951 err,
4952 TranscriptEditError::NoOpRewrite { revision } if revision == parent_revision
4953 ));
4954 assert!(
4955 session
4956 .transcript_history_state()
4957 .expect("history state should decode")
4958 .is_none()
4959 );
4960 }
4961
4962 #[test]
4963 fn transcript_rewrite_run_boundary_guard_accepts_rewrite_then_append() {
4964 let mut original = Session::new();
4965 original.push(Message::User(UserMessage::text("question".to_string())));
4966 original.push(Message::BlockAssistant(BlockAssistantMessage {
4967 blocks: vec![AssistantBlock::Text {
4968 text: "verbose answer".to_string(),
4969 meta: None,
4970 }],
4971 stop_reason: StopReason::EndTurn,
4972 identity: crate::types::TranscriptMessageIdentity::default(),
4973 created_at: crate::types::message_timestamp_now(),
4974 }));
4975
4976 let parent_revision = original.transcript_revision().expect("parent revision");
4977 let mut incoming = original.clone();
4978 incoming
4979 .commit_transcript_rewrite(
4980 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
4981 vec![Message::BlockAssistant(BlockAssistantMessage {
4982 blocks: vec![AssistantBlock::Text {
4983 text: "compact answer".to_string(),
4984 meta: None,
4985 }],
4986 stop_reason: StopReason::EndTurn,
4987 identity: crate::types::TranscriptMessageIdentity::default(),
4988 created_at: crate::types::message_timestamp_now(),
4989 })],
4990 TranscriptRewriteReason::new("compaction"),
4991 Some("unit-test".to_string()),
4992 Some(parent_revision),
4993 )
4994 .expect("rewrite should commit");
4995 incoming.push(Message::User(UserMessage::text("follow-up".to_string())));
4996 incoming.push(Message::BlockAssistant(BlockAssistantMessage {
4997 blocks: vec![AssistantBlock::Text {
4998 text: "follow-up answer".to_string(),
4999 meta: None,
5000 }],
5001 stop_reason: StopReason::EndTurn,
5002 identity: crate::types::TranscriptMessageIdentity::default(),
5003 created_at: crate::types::message_timestamp_now(),
5004 }));
5005
5006 crate::session_store::run_boundary_snapshot_save_guard(&incoming, Some(&original))
5007 .expect("rewrite plus appended turn should be a valid run-boundary commit");
5008 }
5009
5010 #[test]
5011 fn transcript_rewrite_rejects_orphaned_tool_results() {
5012 let mut session = Session::new();
5013 session.push(Message::User(UserMessage::text("use a tool".to_string())));
5014 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
5015 vec![AssistantBlock::ToolUse {
5016 id: "toolu_1".to_string(),
5017 name: "lookup".to_string(),
5018 args: serde_json::value::RawValue::from_string("{}".to_string())
5019 .expect("valid args"),
5020 meta: None,
5021 }],
5022 StopReason::ToolUse,
5023 )));
5024 session.push(Message::tool_results(vec![ToolResult::new(
5025 "toolu_1".to_string(),
5026 "done".to_string(),
5027 false,
5028 )]));
5029 let parent_revision = session.transcript_revision().expect("parent revision");
5030
5031 let err = session
5032 .commit_transcript_rewrite(
5033 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5034 vec![Message::BlockAssistant(BlockAssistantMessage {
5035 blocks: vec![AssistantBlock::Text {
5036 text: "no tool after all".to_string(),
5037 meta: None,
5038 }],
5039 stop_reason: StopReason::EndTurn,
5040 identity: crate::types::TranscriptMessageIdentity::default(),
5041 created_at: crate::types::message_timestamp_now(),
5042 })],
5043 TranscriptRewriteReason::new("compaction"),
5044 Some("unit-test".to_string()),
5045 Some(parent_revision),
5046 )
5047 .expect_err("rewrite should reject stranded tool results");
5048 assert!(matches!(
5049 err,
5050 TranscriptEditError::InvalidTranscriptShape(_)
5051 ));
5052 }
5053
5054 #[test]
5055 fn transcript_rewrite_rejects_trailing_assistant_tool_call() {
5056 let mut session = Session::new();
5057 session.push(Message::User(UserMessage::text("question".to_string())));
5058 session.push(Message::BlockAssistant(BlockAssistantMessage {
5059 blocks: vec![AssistantBlock::Text {
5060 text: "plain answer".to_string(),
5061 meta: None,
5062 }],
5063 stop_reason: StopReason::EndTurn,
5064 identity: crate::types::TranscriptMessageIdentity::default(),
5065 created_at: crate::types::message_timestamp_now(),
5066 }));
5067 let parent_revision = session.transcript_revision().expect("parent revision");
5068
5069 let err = session
5070 .commit_transcript_rewrite(
5071 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5072 vec![Message::BlockAssistant(BlockAssistantMessage {
5073 blocks: vec![AssistantBlock::ToolUse {
5074 id: "toolu_1".to_string(),
5075 name: "lookup".to_string(),
5076 args: serde_json::value::RawValue::from_string("{}".to_string())
5077 .expect("valid args"),
5078 meta: None,
5079 }],
5080 stop_reason: StopReason::ToolUse,
5081 identity: crate::types::TranscriptMessageIdentity::default(),
5082 created_at: crate::types::message_timestamp_now(),
5083 })],
5084 TranscriptRewriteReason::new("compaction"),
5085 Some("unit-test".to_string()),
5086 Some(parent_revision),
5087 )
5088 .expect_err("rewrite should reject trailing unresolved tool call");
5089 assert!(matches!(
5090 err,
5091 TranscriptEditError::InvalidTranscriptShape(_)
5092 ));
5093 }
5094
5095 #[test]
5096 fn transcript_rewrite_rejects_duplicate_tool_results() {
5097 let mut session = Session::new();
5098 session.push(Message::User(UserMessage::text("use a tool".to_string())));
5099 session.push(Message::BlockAssistant(BlockAssistantMessage {
5100 blocks: vec![AssistantBlock::Text {
5101 text: "plain answer".to_string(),
5102 meta: None,
5103 }],
5104 stop_reason: StopReason::EndTurn,
5105 identity: crate::types::TranscriptMessageIdentity::default(),
5106 created_at: crate::types::message_timestamp_now(),
5107 }));
5108 let parent_revision = session.transcript_revision().expect("parent revision");
5109
5110 let err = session
5111 .commit_transcript_rewrite(
5112 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5113 vec![
5114 Message::BlockAssistant(BlockAssistantMessage::new(
5115 vec![AssistantBlock::ToolUse {
5116 id: "toolu_1".to_string(),
5117 name: "lookup".to_string(),
5118 args: serde_json::value::RawValue::from_string("{}".to_string())
5119 .expect("valid args"),
5120 meta: None,
5121 }],
5122 StopReason::ToolUse,
5123 )),
5124 Message::tool_results(vec![
5125 ToolResult::new("toolu_1".to_string(), "one".to_string(), false),
5126 ToolResult::new("toolu_1".to_string(), "two".to_string(), false),
5127 ]),
5128 ],
5129 TranscriptRewriteReason::new("compaction"),
5130 Some("unit-test".to_string()),
5131 Some(parent_revision),
5132 )
5133 .expect_err("rewrite should reject duplicate tool results");
5134 assert!(matches!(
5135 err,
5136 TranscriptEditError::InvalidTranscriptShape(_)
5137 ));
5138 }
5139
5140 #[test]
5141 fn transcript_rewrite_record_rejects_prefix_or_suffix_tampering() {
5142 let mut session = Session::new();
5143 session.push(Message::System(SystemMessage::new("keep prefix")));
5144 session.push(Message::BlockAssistant(BlockAssistantMessage {
5145 blocks: vec![AssistantBlock::Text {
5146 text: "verbose answer".to_string(),
5147 meta: None,
5148 }],
5149 stop_reason: StopReason::EndTurn,
5150 identity: crate::types::TranscriptMessageIdentity::default(),
5151 created_at: crate::types::message_timestamp_now(),
5152 }));
5153 session.push(Message::User(UserMessage::text("keep suffix".to_string())));
5154
5155 let parent_revision = session.transcript_revision().expect("parent revision");
5156 let commit = session
5157 .commit_transcript_rewrite(
5158 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5159 vec![Message::BlockAssistant(BlockAssistantMessage {
5160 blocks: vec![AssistantBlock::Text {
5161 text: "compact answer".to_string(),
5162 meta: None,
5163 }],
5164 stop_reason: StopReason::EndTurn,
5165 identity: crate::types::TranscriptMessageIdentity::default(),
5166 created_at: crate::types::message_timestamp_now(),
5167 })],
5168 TranscriptRewriteReason::new("compaction"),
5169 Some("unit-test".to_string()),
5170 Some(parent_revision),
5171 )
5172 .expect("rewrite should commit");
5173 let state = session
5174 .transcript_history_state()
5175 .expect("history state should decode")
5176 .expect("history state should exist");
5177 let parent_body = state
5178 .revisions
5179 .iter()
5180 .find(|body| body.revision == commit.parent_revision)
5181 .expect("parent body retained")
5182 .clone();
5183 let revision_body = state
5184 .revisions
5185 .iter()
5186 .find(|body| body.revision == commit.revision)
5187 .expect("revision body retained")
5188 .clone();
5189
5190 let mut forged_body = revision_body;
5191 forged_body.messages[0] = Message::System(SystemMessage::new("tampered prefix"));
5192 forged_body.revision =
5193 transcript_messages_digest(&forged_body.messages).expect("forged digest");
5194 let mut forged_commit = commit;
5195 forged_commit.revision = forged_body.revision.clone();
5196 let err = TranscriptRewriteRecord::new(forged_commit, parent_body, forged_body)
5197 .expect_err("record validation must reject changes outside selected span");
5198 assert!(
5199 err.to_string().contains("before the selected span"),
5200 "unexpected error: {err}"
5201 );
5202 }
5203
5204 #[test]
5205 fn transcript_rewrite_replay_allows_normal_turn_revisions_between_rewrites() {
5206 let mut session = Session::new();
5207 session.push(Message::User(UserMessage::text("first".to_string())));
5208 session.push(Message::BlockAssistant(BlockAssistantMessage {
5209 blocks: vec![AssistantBlock::Text {
5210 text: "verbose first answer".to_string(),
5211 meta: None,
5212 }],
5213 stop_reason: StopReason::EndTurn,
5214 identity: crate::types::TranscriptMessageIdentity::default(),
5215 created_at: crate::types::message_timestamp_now(),
5216 }));
5217
5218 let first_parent = session.transcript_revision().expect("first parent");
5219 let first_commit = session
5220 .commit_transcript_rewrite(
5221 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5222 vec![Message::BlockAssistant(BlockAssistantMessage {
5223 blocks: vec![AssistantBlock::Text {
5224 text: "compact first answer".to_string(),
5225 meta: None,
5226 }],
5227 stop_reason: StopReason::EndTurn,
5228 identity: crate::types::TranscriptMessageIdentity::default(),
5229 created_at: crate::types::message_timestamp_now(),
5230 })],
5231 TranscriptRewriteReason::new("compaction"),
5232 Some("unit-test".to_string()),
5233 Some(first_parent),
5234 )
5235 .expect("first rewrite");
5236
5237 session.push(Message::User(UserMessage::text("normal turn".to_string())));
5238 session.push(Message::BlockAssistant(BlockAssistantMessage {
5239 blocks: vec![AssistantBlock::Text {
5240 text: "verbose second answer".to_string(),
5241 meta: None,
5242 }],
5243 stop_reason: StopReason::EndTurn,
5244 identity: crate::types::TranscriptMessageIdentity::default(),
5245 created_at: crate::types::message_timestamp_now(),
5246 }));
5247 let bridge_parent = session
5248 .transcript_revision()
5249 .expect("normal turn should advance transcript head");
5250 assert_ne!(bridge_parent, first_commit.revision);
5251 validate_transcript_history_state(
5252 &session
5253 .transcript_history_state()
5254 .expect("history state should decode")
5255 .expect("history state should exist"),
5256 )
5257 .expect("normal turn head may legitimately differ from last rewrite commit");
5258
5259 let second_commit = session
5260 .commit_transcript_rewrite(
5261 TranscriptRewriteSelection::MessageRange { start: 3, end: 4 },
5262 vec![Message::BlockAssistant(BlockAssistantMessage {
5263 blocks: vec![AssistantBlock::Text {
5264 text: "compact second answer".to_string(),
5265 meta: None,
5266 }],
5267 stop_reason: StopReason::EndTurn,
5268 identity: crate::types::TranscriptMessageIdentity::default(),
5269 created_at: crate::types::message_timestamp_now(),
5270 })],
5271 TranscriptRewriteReason::new("compaction"),
5272 Some("unit-test".to_string()),
5273 Some(bridge_parent.clone()),
5274 )
5275 .expect("second rewrite");
5276
5277 let state = session
5278 .transcript_history_state()
5279 .expect("history state should decode")
5280 .expect("history state should exist");
5281 let records = state.commits.iter().map(|commit| {
5282 let parent_body = state
5283 .revisions
5284 .iter()
5285 .find(|body| body.revision == commit.parent_revision)
5286 .expect("parent body retained")
5287 .clone();
5288 let revision_body = state
5289 .revisions
5290 .iter()
5291 .find(|body| body.revision == commit.revision)
5292 .expect("revision body retained")
5293 .clone();
5294 TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
5295 .expect("record should validate")
5296 });
5297
5298 let replayed = TranscriptHistoryState::from_rewrite_records(records)
5299 .expect("rewrite replay should accept normal-turn bridge revisions")
5300 .expect("rewrite records should exist");
5301 assert_eq!(replayed.head, second_commit.revision);
5302 assert!(
5303 replayed
5304 .revisions
5305 .iter()
5306 .any(|body| body.revision == bridge_parent)
5307 );
5308 }
5309
5310 #[test]
5311 fn transcript_rewrite_replay_rejects_branched_rewrite_records() {
5312 let mut base = Session::new();
5313 base.push(Message::User(UserMessage::text("question".to_string())));
5314 base.push(Message::BlockAssistant(BlockAssistantMessage {
5315 blocks: vec![AssistantBlock::Text {
5316 text: "verbose answer".to_string(),
5317 meta: None,
5318 }],
5319 stop_reason: StopReason::EndTurn,
5320 identity: crate::types::TranscriptMessageIdentity::default(),
5321 created_at: crate::types::message_timestamp_now(),
5322 }));
5323 let parent = base.transcript_revision().expect("parent revision");
5324
5325 let mut first = base.clone();
5326 let first_commit = first
5327 .commit_transcript_rewrite(
5328 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5329 vec![Message::BlockAssistant(BlockAssistantMessage {
5330 blocks: vec![AssistantBlock::Text {
5331 text: "first compact answer".to_string(),
5332 meta: None,
5333 }],
5334 stop_reason: StopReason::EndTurn,
5335 identity: crate::types::TranscriptMessageIdentity::default(),
5336 created_at: crate::types::message_timestamp_now(),
5337 })],
5338 TranscriptRewriteReason::new("compaction"),
5339 Some("unit-test".to_string()),
5340 Some(parent.clone()),
5341 )
5342 .expect("first rewrite");
5343 let first_state = first
5344 .transcript_history_state()
5345 .expect("first state decodes")
5346 .expect("first state exists");
5347
5348 let mut second = base;
5349 let second_commit = second
5350 .commit_transcript_rewrite(
5351 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5352 vec![Message::BlockAssistant(BlockAssistantMessage {
5353 blocks: vec![AssistantBlock::Text {
5354 text: "second compact answer".to_string(),
5355 meta: None,
5356 }],
5357 stop_reason: StopReason::EndTurn,
5358 identity: crate::types::TranscriptMessageIdentity::default(),
5359 created_at: crate::types::message_timestamp_now(),
5360 })],
5361 TranscriptRewriteReason::new("compaction"),
5362 Some("unit-test".to_string()),
5363 Some(parent),
5364 )
5365 .expect("second rewrite");
5366 let second_state = second
5367 .transcript_history_state()
5368 .expect("second state decodes")
5369 .expect("second state exists");
5370
5371 let record = |state: &TranscriptHistoryState, commit: &TranscriptRewriteCommit| {
5372 let parent_body = state
5373 .revisions
5374 .iter()
5375 .find(|body| body.revision == commit.parent_revision)
5376 .expect("parent body retained")
5377 .clone();
5378 let revision_body = state
5379 .revisions
5380 .iter()
5381 .find(|body| body.revision == commit.revision)
5382 .expect("revision body retained")
5383 .clone();
5384 TranscriptRewriteRecord::new(commit.clone(), parent_body, revision_body)
5385 .expect("record should validate")
5386 };
5387
5388 let err = TranscriptHistoryState::from_rewrite_records(vec![
5389 record(&first_state, &first_commit),
5390 record(&second_state, &second_commit),
5391 ])
5392 .expect_err("branched rewrite records must not replay as a linear source history");
5393 assert!(
5394 err.to_string().contains("does not extend transcript head"),
5395 "unexpected error: {err}"
5396 );
5397 }
5398
5399 #[test]
5400 fn internal_message_rewrites_refresh_transcript_history_head() {
5401 let mut session = Session::new();
5402 session.push(Message::User(UserMessage::text("question".to_string())));
5403 session.push(Message::BlockAssistant(BlockAssistantMessage {
5404 blocks: vec![AssistantBlock::Text {
5405 text: "verbose answer".to_string(),
5406 meta: None,
5407 }],
5408 stop_reason: StopReason::EndTurn,
5409 identity: crate::types::TranscriptMessageIdentity::default(),
5410 created_at: crate::types::message_timestamp_now(),
5411 }));
5412
5413 let parent = session.transcript_revision().expect("parent revision");
5414 session
5415 .commit_transcript_rewrite(
5416 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5417 vec![Message::BlockAssistant(BlockAssistantMessage {
5418 blocks: vec![AssistantBlock::Text {
5419 text: "compact answer".to_string(),
5420 meta: None,
5421 }],
5422 stop_reason: StopReason::EndTurn,
5423 identity: crate::types::TranscriptMessageIdentity::default(),
5424 created_at: crate::types::message_timestamp_now(),
5425 })],
5426 TranscriptRewriteReason::new("compaction"),
5427 Some("unit-test".to_string()),
5428 Some(parent),
5429 )
5430 .expect("rewrite should commit");
5431
5432 session.push(Message::User(UserMessage::text(
5433 "notice-bearing turn".to_string(),
5434 )));
5435 session
5436 .retain_messages_internal(
5437 |message| {
5438 !matches!(
5439 message,
5440 Message::User(user)
5441 if user.content.iter().any(|block| matches!(
5442 block,
5443 ContentBlock::Text { text } if text.contains("notice-bearing")
5444 ))
5445 )
5446 },
5447 TranscriptRewriteReason::new("synthetic_notice_cleanup"),
5448 )
5449 .expect("retain should commit internal rewrite");
5450 let retained_digest =
5451 transcript_messages_digest(session.messages()).expect("retained digest");
5452 assert_eq!(
5453 session.transcript_revision().expect("retained head"),
5454 retained_digest
5455 );
5456
5457 session
5458 .replace_messages_internal(
5459 vec![
5460 Message::User(UserMessage::text("compacted question".to_string())),
5461 Message::BlockAssistant(BlockAssistantMessage {
5462 blocks: vec![AssistantBlock::Text {
5463 text: "compacted answer".to_string(),
5464 meta: None,
5465 }],
5466 stop_reason: StopReason::EndTurn,
5467 identity: crate::types::TranscriptMessageIdentity::default(),
5468 created_at: crate::types::message_timestamp_now(),
5469 }),
5470 ],
5471 TranscriptRewriteReason::new("compaction"),
5472 )
5473 .expect("replace should commit internal rewrite");
5474 let replaced_digest =
5475 transcript_messages_digest(session.messages()).expect("replaced digest");
5476 assert_eq!(
5477 session.transcript_revision().expect("replaced head"),
5478 replaced_digest
5479 );
5480 let state = session
5481 .transcript_history_state()
5482 .expect("history state should decode")
5483 .expect("history state should exist");
5484 assert!(
5485 state
5486 .revisions
5487 .iter()
5488 .any(|body| body.revision == replaced_digest)
5489 );
5490 validate_transcript_history_state(&state).expect("history state remains valid");
5491 }
5492
5493 #[test]
5494 fn set_system_prompt_refreshes_transcript_history_head_after_rewrite() {
5495 let mut session = Session::new();
5496 session.push(Message::User(UserMessage::text("question".to_string())));
5497 session.push(Message::BlockAssistant(BlockAssistantMessage {
5498 blocks: vec![AssistantBlock::Text {
5499 text: "verbose answer".to_string(),
5500 meta: None,
5501 }],
5502 stop_reason: StopReason::EndTurn,
5503 identity: crate::types::TranscriptMessageIdentity::default(),
5504 created_at: crate::types::message_timestamp_now(),
5505 }));
5506
5507 let parent = session.transcript_revision().expect("parent revision");
5508 let rewrite = session
5509 .commit_transcript_rewrite(
5510 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5511 vec![Message::BlockAssistant(BlockAssistantMessage {
5512 blocks: vec![AssistantBlock::Text {
5513 text: "compact answer".to_string(),
5514 meta: None,
5515 }],
5516 stop_reason: StopReason::EndTurn,
5517 identity: crate::types::TranscriptMessageIdentity::default(),
5518 created_at: crate::types::message_timestamp_now(),
5519 })],
5520 TranscriptRewriteReason::new("compaction"),
5521 Some("unit-test".to_string()),
5522 Some(parent),
5523 )
5524 .expect("rewrite should commit");
5525
5526 session.set_system_prompt("durable system prompt".to_string());
5527
5528 let head = session
5529 .transcript_revision()
5530 .expect("system prompt should refresh transcript head");
5531 assert_ne!(head, rewrite.revision);
5532 assert_eq!(
5533 head,
5534 transcript_messages_digest(session.messages()).expect("current digest")
5535 );
5536 let head_messages = session
5537 .transcript_revision_messages(&head)
5538 .expect("history state should decode")
5539 .expect("refreshed head body should be retained");
5540 assert_eq!(
5541 serde_json::to_value(&head_messages).expect("head serializes"),
5542 serde_json::to_value(session.messages()).expect("session serializes")
5543 );
5544 validate_transcript_history_state(
5545 &session
5546 .transcript_history_state()
5547 .expect("history state should decode")
5548 .expect("history state should exist"),
5549 )
5550 .expect("history state remains valid after system prompt update");
5551 }
5552
5553 #[test]
5554 fn apply_transcript_history_state_uses_latest_commit_time_for_restored_head() {
5555 let mut session = Session::new();
5556 session.push(Message::User(UserMessage::text("question".to_string())));
5557 session.push(Message::BlockAssistant(BlockAssistantMessage {
5558 blocks: vec![AssistantBlock::Text {
5559 text: "verbose answer".to_string(),
5560 meta: None,
5561 }],
5562 stop_reason: StopReason::EndTurn,
5563 identity: crate::types::TranscriptMessageIdentity::default(),
5564 created_at: crate::types::message_timestamp_now(),
5565 }));
5566 let original_messages = session.messages().to_vec();
5567 let parent = session.transcript_revision().expect("parent revision");
5568 let compact = session
5569 .commit_transcript_rewrite(
5570 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
5571 vec![Message::BlockAssistant(BlockAssistantMessage {
5572 blocks: vec![AssistantBlock::Text {
5573 text: "compact answer".to_string(),
5574 meta: None,
5575 }],
5576 stop_reason: StopReason::EndTurn,
5577 identity: crate::types::TranscriptMessageIdentity::default(),
5578 created_at: crate::types::message_timestamp_now(),
5579 })],
5580 TranscriptRewriteReason::new("compaction"),
5581 Some("unit-test".to_string()),
5582 Some(parent.clone()),
5583 )
5584 .expect("rewrite should commit");
5585
5586 std::thread::sleep(std::time::Duration::from_millis(2));
5587 let restore = session
5588 .commit_transcript_rewrite(
5589 TranscriptRewriteSelection::MessageRange {
5590 start: 0,
5591 end: session.messages().len(),
5592 },
5593 original_messages.clone(),
5594 TranscriptRewriteReason::new("restore"),
5595 Some("unit-test".to_string()),
5596 Some(compact.revision),
5597 )
5598 .expect("restore should commit");
5599 assert_eq!(restore.revision, parent);
5600
5601 let state = session
5602 .transcript_history_state()
5603 .expect("history state should decode")
5604 .expect("history state should exist");
5605 let restored_body_created_at = state
5606 .revisions
5607 .iter()
5608 .find(|body| body.revision == restore.revision)
5609 .expect("restored body should be retained")
5610 .created_at;
5611 assert!(
5612 restored_body_created_at < restore.committed_at,
5613 "test requires restore commit to be newer than retained body"
5614 );
5615
5616 let mut replayed = Session::new();
5617 replayed
5618 .apply_transcript_history_state(state)
5619 .expect("replay should materialize restored head");
5620 assert_eq!(
5621 serde_json::to_value(replayed.messages()).expect("replayed serializes"),
5622 serde_json::to_value(&original_messages).expect("original serializes")
5623 );
5624 assert_eq!(replayed.updated_at(), restore.committed_at);
5625 }
5626
5627 #[test]
5628 fn test_session_new() {
5629 let session = Session::new();
5630 assert_eq!(session.version(), SESSION_VERSION);
5631 assert!(session.messages().is_empty());
5632 assert!(session.created_at() <= session.updated_at());
5633 }
5634
5635 #[test]
5636 fn llm_identity_model_override_switches_to_catalog_provider() {
5637 let registry = crate::ModelRegistry::from_config(
5638 &crate::Config::default(),
5639 *crate::model_profile::test_catalog::TEST_CATALOG,
5640 )
5641 .unwrap();
5642 let current = SessionLlmIdentity {
5643 model: "test-anthropic-default".to_string(),
5644 provider: Provider::Anthropic,
5645 self_hosted_server_id: None,
5646 provider_params: None,
5647 auth_binding: Some(crate::AuthBindingRef {
5648 realm: crate::RealmId::parse("tenant_a").unwrap(),
5649 binding: crate::BindingId::parse("anthropic_default").unwrap(),
5650 profile: None,
5651 origin: crate::BindingOrigin::Configured,
5652 }),
5653 };
5654
5655 let resolved = resolve_session_llm_identity_override(
5656 ¤t,
5657 ®istry,
5658 SessionLlmIdentityOverride {
5659 model: Some("test-openai-default"),
5660 provider: None,
5661 provider_params: None,
5662 auth_binding: None,
5663 },
5664 )
5665 .unwrap();
5666
5667 assert_eq!(resolved.model, "test-openai-default");
5668 assert_eq!(resolved.provider, Provider::OpenAI);
5669 assert!(
5670 resolved.auth_binding.is_none(),
5671 "provider switches must not inherit a binding from the previous provider"
5672 );
5673 }
5674
5675 #[test]
5676 fn llm_identity_model_override_keeps_uncatalogued_model_on_current_provider() {
5677 let registry = crate::ModelRegistry::from_config(
5678 &crate::Config::default(),
5679 *crate::model_profile::test_catalog::TEST_CATALOG,
5680 )
5681 .unwrap();
5682 let current = SessionLlmIdentity {
5683 model: "custom-model".to_string(),
5684 provider: Provider::Anthropic,
5685 self_hosted_server_id: None,
5686 provider_params: None,
5687 auth_binding: None,
5688 };
5689
5690 let resolved = resolve_session_llm_identity_override(
5691 ¤t,
5692 ®istry,
5693 SessionLlmIdentityOverride {
5694 model: Some("uncatalogued-custom-model"),
5695 provider: None,
5696 provider_params: None,
5697 auth_binding: None,
5698 },
5699 )
5700 .unwrap();
5701
5702 assert_eq!(resolved.model, "uncatalogued-custom-model");
5703 assert_eq!(resolved.provider, Provider::Anthropic);
5704 }
5705
5706 #[test]
5707 fn realtime_transcript_append_is_idempotent_by_provider_item_and_delta_id() {
5708 let mut session = Session::new();
5709
5710 let user = RealtimeTranscriptEvent::UserTranscriptFinal {
5711 item_id: "item_user".to_string(),
5712 previous_item_id: None,
5713 content_index: 0,
5714 text: "hello".to_string(),
5715 };
5716 assert!(
5717 !session
5718 .append_realtime_transcript_event(user.clone())
5719 .is_inert()
5720 );
5721 assert!(session.append_realtime_transcript_event(user).is_inert());
5722
5723 let delta = RealtimeTranscriptEvent::AssistantTextDelta {
5724 response_id: "resp_assistant".to_string(),
5725 delta_id: "evt_delta_1".to_string(),
5726 item_id: "item_assistant".to_string(),
5727 previous_item_id: Some("item_user".to_string()),
5728 content_index: 0,
5729 delta: "hi".to_string(),
5730 };
5731 assert!(
5732 session
5733 .append_realtime_transcript_event(delta.clone())
5734 .is_inert()
5735 );
5736 assert!(session.append_realtime_transcript_event(delta).is_inert());
5737
5738 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
5739 response_id: "resp_assistant".to_string(),
5740 stop_reason: StopReason::EndTurn,
5741 usage: Usage::default(),
5742 };
5743 assert!(
5744 !session
5745 .append_realtime_transcript_event(terminal.clone())
5746 .is_inert()
5747 );
5748 assert!(
5749 session
5750 .append_realtime_transcript_event(terminal)
5751 .is_inert()
5752 );
5753
5754 assert_eq!(session.messages().len(), 2);
5755 assert!(matches!(
5756 &session.messages()[0],
5757 Message::User(user) if user.text_content() == "hello"
5758 ));
5759 assert!(matches!(
5760 &session.messages()[1],
5761 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "hi"
5762 ));
5763 }
5764
5765 #[test]
5770 fn realtime_transcript_final_text_overrides_partial_delta_and_promotes_to_spoken_lane() {
5771 let mut session = Session::new();
5772
5773 assert!(
5776 session
5777 .append_realtime_transcript_event(
5778 RealtimeTranscriptEvent::AssistantTranscriptDelta {
5779 response_id: "resp_a".to_string(),
5780 delta_id: "evt_1".to_string(),
5781 item_id: "item_a".to_string(),
5782 previous_item_id: None,
5783 content_index: 0,
5784 delta: "incom".to_string(),
5785 }
5786 )
5787 .is_inert()
5788 );
5789
5790 assert!(
5792 session
5793 .append_realtime_transcript_event(
5794 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
5795 response_id: "resp_a".to_string(),
5796 item_id: "item_a".to_string(),
5797 content_index: 0,
5798 text: "complete answer".to_string(),
5799 }
5800 )
5801 .is_inert()
5802 );
5803
5804 let outcome = session.append_realtime_transcript_event(
5806 RealtimeTranscriptEvent::AssistantTurnCompleted {
5807 response_id: "resp_a".to_string(),
5808 stop_reason: StopReason::EndTurn,
5809 usage: Usage::default(),
5810 },
5811 );
5812 assert!(!outcome.is_inert());
5813
5814 assert_eq!(session.messages().len(), 1);
5817 match &session.messages()[0] {
5818 Message::BlockAssistant(assistant) => {
5819 let mut found_transcript = false;
5820 for block in &assistant.blocks {
5821 if let AssistantBlock::Transcript { text, .. } = block {
5822 assert_eq!(text, "complete answer");
5823 found_transcript = true;
5824 }
5825 }
5826 assert!(
5827 found_transcript,
5828 "AssistantTranscriptFinalText must promote to the Spoken lane and \
5829 materialize as AssistantBlock::Transcript"
5830 );
5831 }
5832 other => unreachable!("expected BlockAssistant, got {other:?}"),
5833 }
5834 }
5835
5836 #[test]
5839 fn realtime_transcript_final_text_creates_item_when_no_delta_staged() {
5840 let mut session = Session::new();
5841
5842 assert!(
5843 session
5844 .append_realtime_transcript_event(
5845 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
5846 response_id: "resp_a".to_string(),
5847 item_id: "item_a".to_string(),
5848 content_index: 0,
5849 text: "spoken-final-only".to_string(),
5850 }
5851 )
5852 .is_inert()
5853 );
5854
5855 let outcome = session.append_realtime_transcript_event(
5856 RealtimeTranscriptEvent::AssistantTurnCompleted {
5857 response_id: "resp_a".to_string(),
5858 stop_reason: StopReason::EndTurn,
5859 usage: Usage::default(),
5860 },
5861 );
5862 assert!(!outcome.is_inert());
5863
5864 assert_eq!(session.messages().len(), 1);
5865 match &session.messages()[0] {
5866 Message::BlockAssistant(assistant) => {
5867 let has_transcript = assistant.blocks.iter().any(|b| {
5868 matches!(b, AssistantBlock::Transcript { text, .. } if text == "spoken-final-only")
5869 });
5870 assert!(
5871 has_transcript,
5872 "final-only provider path must materialize as Transcript on the Spoken lane"
5873 );
5874 }
5875 other => unreachable!("expected BlockAssistant, got {other:?}"),
5876 }
5877 }
5878
5879 #[test]
5880 fn realtime_transcript_append_orders_causally_equivalent_out_of_order_items() {
5881 let mut session = Session::new();
5882
5883 assert!(
5884 session
5885 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
5886 response_id: "resp_assistant".to_string(),
5887 delta_id: "evt_delta_1".to_string(),
5888 item_id: "item_assistant".to_string(),
5889 previous_item_id: Some("item_user".to_string()),
5890 content_index: 0,
5891 delta: "answer".to_string(),
5892 })
5893 .is_inert()
5894 );
5895 assert!(
5896 session
5897 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
5898 response_id: "resp_assistant".to_string(),
5899 stop_reason: StopReason::EndTurn,
5900 usage: Usage::default(),
5901 })
5902 .is_inert()
5903 );
5904
5905 let outcome = session.append_realtime_transcript_event(
5906 RealtimeTranscriptEvent::UserTranscriptFinal {
5907 item_id: "item_user".to_string(),
5908 previous_item_id: None,
5909 content_index: 0,
5910 text: "question".to_string(),
5911 },
5912 );
5913
5914 assert_eq!(outcome.materialized_messages.len(), 2);
5915 assert_eq!(session.messages().len(), 2);
5916 assert!(matches!(
5917 &session.messages()[0],
5918 Message::User(user) if user.text_content() == "question"
5919 ));
5920 assert!(matches!(
5921 &session.messages()[1],
5922 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer"
5923 ));
5924 }
5925
5926 #[test]
5927 fn realtime_transcript_replay_of_seen_provider_items_is_inert() {
5928 let mut session = Session::new();
5929 let events = vec![
5930 RealtimeTranscriptEvent::UserTranscriptFinal {
5931 item_id: "item_user".to_string(),
5932 previous_item_id: None,
5933 content_index: 0,
5934 text: "hello".to_string(),
5935 },
5936 RealtimeTranscriptEvent::AssistantTextDelta {
5937 response_id: "resp_assistant".to_string(),
5938 delta_id: "evt_delta_1".to_string(),
5939 item_id: "item_assistant".to_string(),
5940 previous_item_id: Some("item_user".to_string()),
5941 content_index: 0,
5942 delta: "world".to_string(),
5943 },
5944 RealtimeTranscriptEvent::AssistantTurnCompleted {
5945 response_id: "resp_assistant".to_string(),
5946 stop_reason: StopReason::EndTurn,
5947 usage: Usage::default(),
5948 },
5949 ];
5950
5951 for event in events.iter().cloned() {
5952 let _ = session.append_realtime_transcript_event(event);
5953 }
5954 let first_messages = serde_json::to_value(session.messages()).unwrap();
5955
5956 for event in events {
5957 assert!(session.append_realtime_transcript_event(event).is_inert());
5958 }
5959
5960 assert_eq!(
5961 serde_json::to_value(session.messages()).unwrap(),
5962 first_messages
5963 );
5964 }
5965
5966 #[test]
5967 fn realtime_transcript_user_final_replay_cannot_erase_existing_segment() {
5968 let mut session = Session::new();
5969
5970 let user = RealtimeTranscriptEvent::UserTranscriptFinal {
5971 item_id: "item_user".to_string(),
5972 previous_item_id: None,
5973 content_index: 0,
5974 text: "remember amber lantern".to_string(),
5975 };
5976 assert!(
5977 !session
5978 .append_realtime_transcript_event(user.clone())
5979 .is_inert()
5980 );
5981 let first_messages = serde_json::to_value(session.messages()).unwrap();
5982
5983 assert!(
5984 session
5985 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
5986 item_id: "item_user".to_string(),
5987 previous_item_id: None,
5988 content_index: 0,
5989 text: String::new(),
5990 })
5991 .is_inert()
5992 );
5993 assert!(session.append_realtime_transcript_event(user).is_inert());
5994 assert_eq!(
5995 serde_json::to_value(session.messages()).unwrap(),
5996 first_messages
5997 );
5998 }
5999
6000 #[test]
6001 fn realtime_transcript_empty_user_final_can_be_filled_by_later_nonempty_replay() {
6002 let mut session = Session::new();
6003
6004 assert!(
6005 session
6006 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
6007 item_id: "item_user".to_string(),
6008 previous_item_id: None,
6009 content_index: 0,
6010 text: String::new(),
6011 })
6012 .is_inert()
6013 );
6014 assert!(session.messages().is_empty());
6015
6016 let outcome = session.append_realtime_transcript_event(
6017 RealtimeTranscriptEvent::UserTranscriptFinal {
6018 item_id: "item_user".to_string(),
6019 previous_item_id: None,
6020 content_index: 0,
6021 text: "remember amber lantern".to_string(),
6022 },
6023 );
6024 assert_eq!(outcome.materialized_messages.len(), 1);
6025 assert_eq!(session.messages().len(), 1);
6026 assert!(matches!(
6027 &session.messages()[0],
6028 Message::User(user) if user.text_content() == "remember amber lantern"
6029 ));
6030 }
6031
6032 #[test]
6033 fn realtime_transcript_skipped_provider_items_preserve_causal_order_without_content() {
6034 let mut session = Session::new();
6035
6036 let assistant_delta = RealtimeTranscriptEvent::AssistantTextDelta {
6037 response_id: "resp_assistant".to_string(),
6038 delta_id: "evt_delta_1".to_string(),
6039 item_id: "item_assistant".to_string(),
6040 previous_item_id: Some("item_tool".to_string()),
6041 content_index: 0,
6042 delta: "done".to_string(),
6043 };
6044 assert!(
6045 session
6046 .append_realtime_transcript_event(assistant_delta.clone())
6047 .is_inert()
6048 );
6049 let assistant_complete = RealtimeTranscriptEvent::AssistantTurnCompleted {
6050 response_id: "resp_assistant".to_string(),
6051 stop_reason: StopReason::EndTurn,
6052 usage: Usage::default(),
6053 };
6054 assert!(
6055 session
6056 .append_realtime_transcript_event(assistant_complete.clone())
6057 .is_inert()
6058 );
6059
6060 let skipped = RealtimeTranscriptEvent::ItemSkipped {
6061 item_id: "item_tool".to_string(),
6062 previous_item_id: Some("item_user".to_string()),
6063 };
6064 assert!(
6065 session
6066 .append_realtime_transcript_event(skipped.clone())
6067 .is_inert(),
6068 "a skipped provider item must not append transcript content"
6069 );
6070 assert!(session.messages().is_empty());
6071
6072 let outcome = session.append_realtime_transcript_event(
6073 RealtimeTranscriptEvent::UserTranscriptFinal {
6074 item_id: "item_user".to_string(),
6075 previous_item_id: None,
6076 content_index: 0,
6077 text: "please use the tool".to_string(),
6078 },
6079 );
6080 assert_eq!(outcome.materialized_messages.len(), 2);
6081 assert_eq!(session.messages().len(), 2);
6082 assert!(matches!(
6083 &session.messages()[0],
6084 Message::User(user) if user.text_content() == "please use the tool"
6085 ));
6086 assert!(matches!(
6087 &session.messages()[1],
6088 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "done"
6089 ));
6090
6091 let first_messages = serde_json::to_value(session.messages()).unwrap();
6092 assert!(session.append_realtime_transcript_event(skipped).is_inert());
6093 assert!(
6094 session
6095 .append_realtime_transcript_event(assistant_delta)
6096 .is_inert()
6097 );
6098 assert!(
6099 session
6100 .append_realtime_transcript_event(assistant_complete)
6101 .is_inert()
6102 );
6103 assert_eq!(
6104 serde_json::to_value(session.messages()).unwrap(),
6105 first_messages
6106 );
6107 }
6108
6109 #[test]
6110 fn realtime_transcript_interrupted_assistant_item_unblocks_later_provider_items() {
6111 let mut session = Session::new();
6118
6119 let _ = session.append_realtime_transcript_event(
6120 RealtimeTranscriptEvent::UserTranscriptFinal {
6121 item_id: "item_repeat".to_string(),
6122 previous_item_id: None,
6123 content_index: 0,
6124 text: "repeat until stop".to_string(),
6125 },
6126 );
6127 assert!(
6128 session
6129 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6130 response_id: "resp_loop".to_string(),
6131 delta_id: "evt_loop_1".to_string(),
6132 item_id: "item_loop".to_string(),
6133 previous_item_id: Some("item_repeat".to_string()),
6134 content_index: 0,
6135 delta: "Looping now".to_string(),
6136 })
6137 .is_inert()
6138 );
6139 assert!(
6140 session
6141 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
6142 item_id: "item_stop".to_string(),
6143 previous_item_id: Some("item_loop".to_string()),
6144 content_index: 0,
6145 text: "Stop.".to_string(),
6146 })
6147 .is_inert(),
6148 "the stop turn waits until the interrupted assistant provider item is resolved"
6149 );
6150
6151 let outcome = session.append_realtime_transcript_event(
6152 RealtimeTranscriptEvent::AssistantTurnInterrupted {
6153 response_id: "resp_loop".to_string(),
6154 },
6155 );
6156
6157 assert_eq!(outcome.materialized_messages.len(), 2);
6160 assert_eq!(session.messages().len(), 3);
6162 assert!(matches!(
6163 &session.messages()[0],
6164 Message::User(user) if user.text_content() == "repeat until stop"
6165 ));
6166 match &session.messages()[1] {
6167 Message::BlockAssistant(assistant) => {
6168 let text = block_assistant_text(assistant);
6169 assert_eq!(text, "Looping now");
6170 }
6171 other => unreachable!(
6172 "Display lane assistant item must be retained on Interrupted, got {other:?}"
6173 ),
6174 }
6175 assert!(matches!(
6176 &session.messages()[2],
6177 Message::User(user) if user.text_content() == "Stop."
6178 ));
6179 }
6180
6181 #[test]
6182 fn realtime_transcript_late_interrupted_assistant_delta_stays_noncanonical() {
6183 let mut session = Session::new();
6184
6185 let _ = session.append_realtime_transcript_event(
6186 RealtimeTranscriptEvent::UserTranscriptFinal {
6187 item_id: "item_repeat".to_string(),
6188 previous_item_id: None,
6189 content_index: 0,
6190 text: "repeat until stop".to_string(),
6191 },
6192 );
6193 assert!(
6194 session
6195 .append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
6196 item_id: "item_loop".to_string(),
6197 previous_item_id: Some("item_repeat".to_string()),
6198 role: RealtimeTranscriptRole::Assistant,
6199 response_id: None,
6200 })
6201 .is_inert(),
6202 "provider can observe an assistant item before the adapter learns its response id"
6203 );
6204 assert!(
6205 session
6206 .append_realtime_transcript_event(
6207 RealtimeTranscriptEvent::AssistantTurnInterrupted {
6208 response_id: "resp_loop".to_string(),
6209 }
6210 )
6211 .is_inert(),
6212 "an interruption can arrive before delayed transcript deltas for the response"
6213 );
6214 assert!(
6215 session
6216 .append_realtime_transcript_event(RealtimeTranscriptEvent::UserTranscriptFinal {
6217 item_id: "item_stop".to_string(),
6218 previous_item_id: Some("item_loop".to_string()),
6219 content_index: 0,
6220 text: "Stop.".to_string(),
6221 })
6222 .is_inert(),
6223 "the stop turn waits for the provider's interrupted assistant item anchor"
6224 );
6225
6226 let late_delta_outcome =
6227 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6228 response_id: "resp_loop".to_string(),
6229 delta_id: "evt_loop_late".to_string(),
6230 item_id: "item_loop".to_string(),
6231 previous_item_id: Some("item_repeat".to_string()),
6232 content_index: 0,
6233 delta: "Looping now".to_string(),
6234 });
6235 assert_eq!(late_delta_outcome.materialized_messages.len(), 1);
6236 assert!(matches!(
6237 &session.messages()[1],
6238 Message::User(user) if user.text_content() == "Stop."
6239 ));
6240 assert!(
6241 session
6242 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
6243 response_id: "resp_loop".to_string(),
6244 stop_reason: StopReason::EndTurn,
6245 usage: Usage::default(),
6246 })
6247 .is_inert(),
6248 "late completion for an interrupted response must not resurrect its deltas"
6249 );
6250 assert!(
6251 session
6252 .messages()
6253 .iter()
6254 .filter_map(|message| match message {
6255 Message::BlockAssistant(assistant) => Some(block_assistant_text(assistant)),
6256 _ => None,
6257 })
6258 .all(|text| !text.contains("Looping now")),
6259 "late interrupted assistant text must remain non-canonical"
6260 );
6261 }
6262
6263 #[test]
6264 fn realtime_transcript_completion_only_finalizes_matching_response() {
6265 let mut session = Session::new();
6266
6267 let _ = session.append_realtime_transcript_event(
6268 RealtimeTranscriptEvent::UserTranscriptFinal {
6269 item_id: "item_user".to_string(),
6270 previous_item_id: None,
6271 content_index: 0,
6272 text: "question".to_string(),
6273 },
6274 );
6275 assert!(
6276 session
6277 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6278 response_id: "resp_a".to_string(),
6279 delta_id: "evt_a".to_string(),
6280 item_id: "item_a".to_string(),
6281 previous_item_id: Some("item_user".to_string()),
6282 content_index: 0,
6283 delta: "answer a".to_string(),
6284 })
6285 .is_inert()
6286 );
6287
6288 assert!(
6289 session
6290 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
6291 response_id: "resp_b".to_string(),
6292 stop_reason: StopReason::EndTurn,
6293 usage: Usage::default(),
6294 })
6295 .is_inert(),
6296 "a completion for another response must not finalize buffered assistant text"
6297 );
6298 assert_eq!(session.messages().len(), 1);
6299
6300 let outcome = session.append_realtime_transcript_event(
6301 RealtimeTranscriptEvent::AssistantTurnCompleted {
6302 response_id: "resp_a".to_string(),
6303 stop_reason: StopReason::EndTurn,
6304 usage: Usage::default(),
6305 },
6306 );
6307 assert_eq!(outcome.materialized_messages.len(), 1);
6308 assert_eq!(session.messages().len(), 2);
6309 assert!(matches!(
6310 &session.messages()[1],
6311 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "answer a"
6312 ));
6313 }
6314
6315 #[test]
6316 fn realtime_transcript_completion_before_later_delta_is_response_scoped() {
6317 let mut session = Session::new();
6318
6319 let _ = session.append_realtime_transcript_event(
6320 RealtimeTranscriptEvent::UserTranscriptFinal {
6321 item_id: "item_user".to_string(),
6322 previous_item_id: None,
6323 content_index: 0,
6324 text: "question".to_string(),
6325 },
6326 );
6327 assert!(
6328 session
6329 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
6330 response_id: "resp_a".to_string(),
6331 stop_reason: StopReason::EndTurn,
6332 usage: Usage::default(),
6333 })
6334 .is_inert()
6335 );
6336 assert!(
6337 session
6338 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6339 response_id: "resp_b".to_string(),
6340 delta_id: "evt_b".to_string(),
6341 item_id: "item_b".to_string(),
6342 previous_item_id: Some("item_user".to_string()),
6343 content_index: 0,
6344 delta: "wrong response".to_string(),
6345 })
6346 .is_inert(),
6347 "a later delta for another response must not be finalized by resp_a's pending completion"
6348 );
6349
6350 let outcome =
6351 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6352 response_id: "resp_a".to_string(),
6353 delta_id: "evt_a".to_string(),
6354 item_id: "item_a".to_string(),
6355 previous_item_id: Some("item_user".to_string()),
6356 content_index: 0,
6357 delta: "right response".to_string(),
6358 });
6359
6360 assert_eq!(outcome.materialized_messages.len(), 1);
6361 assert_eq!(session.messages().len(), 2);
6362 assert!(matches!(
6363 &session.messages()[1],
6364 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "right response"
6365 ));
6366 }
6367
6368 #[test]
6369 fn realtime_transcript_late_duplicate_completion_cannot_finalize_unrelated_response() {
6370 let mut session = Session::new();
6371
6372 let _ = session.append_realtime_transcript_event(
6373 RealtimeTranscriptEvent::UserTranscriptFinal {
6374 item_id: "item_user".to_string(),
6375 previous_item_id: None,
6376 content_index: 0,
6377 text: "question".to_string(),
6378 },
6379 );
6380 let _ =
6381 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6382 response_id: "resp_a".to_string(),
6383 delta_id: "evt_a".to_string(),
6384 item_id: "item_a".to_string(),
6385 previous_item_id: Some("item_user".to_string()),
6386 content_index: 0,
6387 delta: "first".to_string(),
6388 });
6389 let _ = session.append_realtime_transcript_event(
6390 RealtimeTranscriptEvent::AssistantTurnCompleted {
6391 response_id: "resp_a".to_string(),
6392 stop_reason: StopReason::EndTurn,
6393 usage: Usage::default(),
6394 },
6395 );
6396 assert_eq!(session.messages().len(), 2);
6397
6398 assert!(
6399 session
6400 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6401 response_id: "resp_b".to_string(),
6402 delta_id: "evt_b".to_string(),
6403 item_id: "item_b".to_string(),
6404 previous_item_id: Some("item_a".to_string()),
6405 content_index: 0,
6406 delta: "second".to_string(),
6407 })
6408 .is_inert()
6409 );
6410 assert!(
6411 session
6412 .append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTurnCompleted {
6413 response_id: "resp_a".to_string(),
6414 stop_reason: StopReason::EndTurn,
6415 usage: Usage::default(),
6416 })
6417 .is_inert(),
6418 "a duplicate late terminal for resp_a must not finalize resp_b"
6419 );
6420 assert_eq!(session.messages().len(), 2);
6421
6422 let outcome = session.append_realtime_transcript_event(
6423 RealtimeTranscriptEvent::AssistantTurnCompleted {
6424 response_id: "resp_b".to_string(),
6425 stop_reason: StopReason::EndTurn,
6426 usage: Usage::default(),
6427 },
6428 );
6429 assert_eq!(outcome.materialized_messages.len(), 1);
6430 assert_eq!(session.messages().len(), 3);
6431 }
6432
6433 #[test]
6434 fn realtime_transcript_interruption_discards_only_matching_response() {
6435 let mut session = Session::new();
6441
6442 let _ = session.append_realtime_transcript_event(
6443 RealtimeTranscriptEvent::UserTranscriptFinal {
6444 item_id: "item_user".to_string(),
6445 previous_item_id: None,
6446 content_index: 0,
6447 text: "question".to_string(),
6448 },
6449 );
6450 let _ =
6451 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6452 response_id: "resp_a".to_string(),
6453 delta_id: "evt_a".to_string(),
6454 item_id: "item_a".to_string(),
6455 previous_item_id: Some("item_user".to_string()),
6456 content_index: 0,
6457 delta: "interrupted display".to_string(),
6458 });
6459 let _ =
6460 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
6461 response_id: "resp_b".to_string(),
6462 delta_id: "evt_b".to_string(),
6463 item_id: "item_b".to_string(),
6464 previous_item_id: Some("item_user".to_string()),
6465 content_index: 0,
6466 delta: "keep me".to_string(),
6467 });
6468
6469 let interrupt_outcome = session.append_realtime_transcript_event(
6472 RealtimeTranscriptEvent::AssistantTurnInterrupted {
6473 response_id: "resp_a".to_string(),
6474 },
6475 );
6476 assert_eq!(
6477 interrupt_outcome.materialized_messages.len(),
6478 1,
6479 "resp_a's Display item commits on Interrupted"
6480 );
6481
6482 let outcome = session.append_realtime_transcript_event(
6483 RealtimeTranscriptEvent::AssistantTurnCompleted {
6484 response_id: "resp_b".to_string(),
6485 stop_reason: StopReason::EndTurn,
6486 usage: Usage::default(),
6487 },
6488 );
6489 assert_eq!(
6490 outcome.materialized_messages.len(),
6491 1,
6492 "resp_b commits on its TurnCompleted, untouched by resp_a's Interrupted"
6493 );
6494
6495 assert_eq!(session.messages().len(), 3);
6497 assert!(matches!(
6498 &session.messages()[1],
6499 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "interrupted display"
6500 ));
6501 assert!(matches!(
6502 &session.messages()[2],
6503 Message::BlockAssistant(assistant) if block_assistant_text(assistant) == "keep me"
6504 ));
6505 }
6506
6507 #[test]
6510 fn test_fork_shares_arc_no_clone() {
6511 let mut session = Session::new();
6512 for i in 0..100 {
6513 session.push(Message::User(UserMessage::text(format!("Message {i}"))));
6514 }
6515
6516 let forked = session.fork();
6518
6519 assert!(Arc::ptr_eq(&session.messages, &forked.messages));
6521 assert_eq!(forked.messages().len(), 100);
6522 }
6523
6524 #[test]
6525 fn test_fork_at_shares_arc_prefix() {
6526 let mut session = Session::new();
6527 for i in 0..100 {
6528 session.push(Message::User(UserMessage::text(format!("Message {i}"))));
6529 }
6530
6531 let forked = session.fork_at(50);
6533 assert_eq!(forked.messages().len(), 50);
6534
6535 assert_eq!(session.messages().len(), 100);
6537 }
6538
6539 #[test]
6540 fn test_fork_at_resets_transcript_history_state_for_branch_identity() {
6541 let mut session = Session::new();
6542 session.push(Message::User(UserMessage::text(
6543 "summarize this".to_string(),
6544 )));
6545 session.push(Message::BlockAssistant(BlockAssistantMessage::new(
6546 vec![AssistantBlock::Text {
6547 text: "long assistant trace".to_string(),
6548 meta: None,
6549 }],
6550 StopReason::EndTurn,
6551 )));
6552 let parent_revision = session.transcript_revision().expect("parent revision");
6553 session
6554 .commit_transcript_rewrite(
6555 TranscriptRewriteSelection::MessageRange { start: 1, end: 2 },
6556 vec![Message::BlockAssistant(BlockAssistantMessage::new(
6557 vec![AssistantBlock::Text {
6558 text: "compact trace".to_string(),
6559 meta: None,
6560 }],
6561 StopReason::EndTurn,
6562 ))],
6563 TranscriptRewriteReason::new("compaction"),
6564 Some("test".to_string()),
6565 Some(parent_revision),
6566 )
6567 .expect("rewrite should commit");
6568
6569 let source_head = session.transcript_revision().expect("source head");
6570 let mut forked = session.fork_at(1);
6571 assert_ne!(forked.id(), session.id());
6572 assert!(
6573 !forked
6574 .metadata()
6575 .contains_key(SESSION_TRANSCRIPT_HISTORY_STATE_KEY)
6576 );
6577 assert_eq!(
6578 forked.transcript_revision().expect("fork head"),
6579 transcript_messages_digest(forked.messages()).expect("fork digest")
6580 );
6581 assert!(
6582 forked
6583 .transcript_revision_messages(&source_head)
6584 .expect("fork history lookup")
6585 .is_none()
6586 );
6587
6588 let fork_parent = forked.transcript_revision().expect("fork parent");
6589 let commit = forked
6590 .commit_transcript_rewrite(
6591 TranscriptRewriteSelection::MessageRange { start: 0, end: 1 },
6592 vec![Message::User(UserMessage::text(
6593 "branch prompt".to_string(),
6594 ))],
6595 TranscriptRewriteReason::new("branch_edit"),
6596 Some("test".to_string()),
6597 Some(fork_parent.clone()),
6598 )
6599 .expect("fork rewrite should use fork-local parent");
6600 assert_eq!(commit.parent_revision, fork_parent);
6601 }
6602
6603 #[test]
6604 fn test_push_cow_behavior() {
6605 let mut session = Session::new();
6606 session.push(Message::User(UserMessage::text("First".to_string())));
6607
6608 let forked = session.fork();
6610 assert!(Arc::ptr_eq(&session.messages, &forked.messages));
6611
6612 session.push(Message::User(UserMessage::text("Second".to_string())));
6614
6615 assert!(!Arc::ptr_eq(&session.messages, &forked.messages));
6617 assert_eq!(session.messages().len(), 2);
6618 assert_eq!(forked.messages().len(), 1);
6619 }
6620
6621 #[test]
6624 fn test_push_batch_single_timestamp() {
6625 let mut session = Session::new();
6626 let initial_updated = session.updated_at();
6627
6628 session.push_batch(vec![
6630 Message::User(UserMessage::text("First".to_string())),
6631 Message::User(UserMessage::text("Second".to_string())),
6632 Message::User(UserMessage::text("Third".to_string())),
6633 ]);
6634
6635 assert_eq!(session.messages().len(), 3);
6636 assert!(session.updated_at() >= initial_updated);
6638 }
6639
6640 #[test]
6641 fn test_touch_updates_timestamp() {
6642 let mut session = Session::new();
6643 let initial = session.updated_at();
6644
6645 std::thread::sleep(std::time::Duration::from_millis(10));
6646
6647 session.touch();
6649
6650 assert!(session.updated_at() > initial);
6651 }
6652
6653 #[test]
6654 fn test_session_push() {
6655 let mut session = Session::new();
6656 let initial_updated = session.updated_at();
6657
6658 std::thread::sleep(std::time::Duration::from_millis(10));
6660
6661 session.push(Message::User(UserMessage::text("Hello".to_string())));
6662
6663 assert_eq!(session.messages().len(), 1);
6664 assert!(session.updated_at() > initial_updated);
6665 }
6666
6667 #[test]
6668 fn test_session_fork() {
6669 let mut session = Session::new();
6670 session.push(Message::System(SystemMessage::new("System prompt")));
6671 session.push(Message::User(UserMessage::text("Hello".to_string())));
6672 session.push(Message::BlockAssistant(BlockAssistantMessage {
6673 blocks: vec![AssistantBlock::Text {
6674 text: "Hi!".to_string(),
6675 meta: None,
6676 }],
6677 stop_reason: StopReason::EndTurn,
6678 identity: crate::types::TranscriptMessageIdentity::default(),
6679 created_at: crate::types::message_timestamp_now(),
6680 }));
6681
6682 let forked = session.fork_at(2);
6684 assert_eq!(forked.messages().len(), 2);
6685 assert_ne!(forked.id(), session.id());
6686
6687 let full_fork = session.fork();
6689 assert_eq!(full_fork.messages().len(), 3);
6690 }
6691
6692 #[test]
6693 fn test_session_forks_drop_generated_authority_metadata() {
6694 let mut session = Session::new();
6695 session.push(Message::User(UserMessage::text("original")));
6696 session.set_metadata("ordinary", serde_json::json!("keep"));
6697 session
6698 .set_build_state(SessionBuildState::default())
6699 .expect("build state should serialize");
6700 session
6701 .set_system_context_state(SessionSystemContextState::default())
6702 .expect("system-context state should serialize");
6703 session
6704 .set_deferred_turn_state(SessionDeferredTurnState::default())
6705 .expect("deferred-turn state should serialize");
6706 session
6707 .set_tool_visibility_state(
6708 AuthorizedSessionToolVisibilityState::from_generated_authority(
6709 SessionToolVisibilityState::default(),
6710 ),
6711 )
6712 .expect("visibility state should serialize");
6713 let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
6714 item_id: "rt-item".to_string(),
6715 previous_item_id: None,
6716 role: RealtimeTranscriptRole::User,
6717 response_id: None,
6718 });
6719 assert!(
6720 session
6721 .metadata()
6722 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
6723 "test setup should install realtime transcript authority state"
6724 );
6725
6726 let forked_at = session.fork_at(1);
6727 let full_fork = session.fork();
6728 let replaced = session
6729 .fork_replacing(
6730 0,
6731 TranscriptReplacement::Message {
6732 message: Message::User(UserMessage::text("replacement")),
6733 },
6734 )
6735 .expect("replacement fork should succeed");
6736
6737 for forked in [&forked_at, &full_fork, &replaced] {
6738 assert_eq!(forked.metadata().get("ordinary").unwrap(), "keep");
6739 assert!(
6740 !forked.metadata().contains_key(SESSION_BUILD_STATE_KEY),
6741 "forked sessions must not raw-copy durable build-state authority"
6742 );
6743 assert!(
6744 !forked
6745 .metadata()
6746 .contains_key(SESSION_SYSTEM_CONTEXT_STATE_KEY),
6747 "forked sessions must not raw-copy system-context authority state"
6748 );
6749 assert!(
6750 !forked
6751 .metadata()
6752 .contains_key(SESSION_DEFERRED_TURN_STATE_KEY),
6753 "forked sessions must not raw-copy deferred-turn authority state"
6754 );
6755 assert!(
6756 !forked
6757 .metadata()
6758 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY),
6759 "forked sessions must not raw-copy tool-visibility authority state"
6760 );
6761 assert!(
6762 !forked
6763 .metadata()
6764 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
6765 "forked sessions must not raw-copy realtime transcript authority state"
6766 );
6767 }
6768 }
6769
6770 #[test]
6771 fn test_session_metadata() {
6772 let mut session = Session::new();
6773 session.set_metadata("key", serde_json::json!("value"));
6774
6775 assert_eq!(session.metadata().get("key").unwrap(), "value");
6776 }
6777
6778 #[test]
6779 fn session_metadata_realm_id_is_back_read_compatible_string() {
6780 let metadata = SessionMetadata {
6783 schema_version: SESSION_METADATA_SCHEMA_VERSION,
6784 model: "test-model".to_string(),
6785 max_tokens: 1024,
6786 structured_output_retries: 2,
6787 provider: Provider::Other,
6788 self_hosted_server_id: None,
6789 provider_params: None,
6790 tooling: SessionTooling::default(),
6791 keep_alive: false,
6792 comms_name: None,
6793 peer_meta: None,
6794 realm_id: Some(crate::RealmId::parse("env_default").unwrap()),
6795 instance_id: None,
6796 backend: None,
6797 config_generation: None,
6798 auth_binding: None,
6799 mob_member_binding: None,
6800 };
6801 let value = serde_json::to_value(&metadata).unwrap();
6802 assert_eq!(
6803 value.get("realm_id"),
6804 Some(&serde_json::json!("env_default")),
6805 "typed realm_id must serialize as a bare slug string"
6806 );
6807
6808 let legacy = serde_json::json!({
6811 "schema_version": SESSION_METADATA_SCHEMA_VERSION,
6812 "model": "test-model",
6813 "max_tokens": 1024,
6814 "structured_output_retries": 2,
6815 "provider": "other",
6816 "tooling": SessionTooling::default(),
6817 "keep_alive": false,
6818 "comms_name": null,
6819 "realm_id": "legacy_realm",
6820 });
6821 let restored: SessionMetadata = serde_json::from_value(legacy).unwrap();
6822 assert_eq!(
6823 restored.realm_id.as_ref().map(crate::RealmId::as_str),
6824 Some("legacy_realm")
6825 );
6826 }
6827
6828 #[test]
6833 fn session_tooling_tool_access_policy_round_trip_and_absent_default() {
6834 let legacy = serde_json::json!({});
6836 let restored: SessionTooling = serde_json::from_value(legacy).unwrap();
6837 assert_eq!(restored.tool_access_policy, None);
6838
6839 let value = serde_json::to_value(SessionTooling::default()).unwrap();
6841 assert!(
6842 value.get("tool_access_policy").is_none(),
6843 "None policy must not serialize"
6844 );
6845
6846 let tooling = SessionTooling {
6848 tool_access_policy: Some(crate::ops::ToolAccessPolicy::AllowList(
6849 ["read_file", "send_message"].into_iter().collect(),
6850 )),
6851 ..SessionTooling::default()
6852 };
6853 let value = serde_json::to_value(&tooling).unwrap();
6854 let restored: SessionTooling = serde_json::from_value(value).unwrap();
6855 assert_eq!(restored.tool_access_policy, tooling.tool_access_policy);
6856 }
6857
6858 #[test]
6859 fn lifecycle_terminal_typed_round_trip() {
6860 let mut session = Session::new();
6861 assert_eq!(session.lifecycle_terminal(), None);
6862
6863 session
6864 .set_lifecycle_terminal(SessionLifecycleTerminal::Archived)
6865 .expect("typed terminal write should serialize");
6866 assert_eq!(
6867 session.lifecycle_terminal(),
6868 Some(SessionLifecycleTerminal::Archived)
6869 );
6870 assert!(
6871 session
6872 .lifecycle_terminal()
6873 .is_some_and(SessionLifecycleTerminal::is_archived)
6874 );
6875 assert_eq!(
6877 session
6878 .metadata()
6879 .get(SESSION_LIFECYCLE_TERMINAL_KEY)
6880 .unwrap(),
6881 &serde_json::json!("archived")
6882 );
6883 }
6884
6885 #[test]
6886 fn lifecycle_terminal_key_rejects_raw_mutation() {
6887 let mut session = Session::new();
6888 assert!(
6889 session
6890 .try_set_metadata(
6891 SESSION_LIFECYCLE_TERMINAL_KEY,
6892 serde_json::json!("archived")
6893 )
6894 .is_err(),
6895 "the typed lifecycle-terminal key is reserved for session authority"
6896 );
6897 }
6898
6899 #[test]
6900 fn test_session_metadata_backfill_preserves_timestamp() {
6901 let mut session = Session::new();
6902 let initial_updated = session.updated_at();
6903
6904 std::thread::sleep(std::time::Duration::from_millis(10));
6905
6906 assert!(session.backfill_metadata_if_absent("key", serde_json::json!("value")));
6907 assert_eq!(session.metadata().get("key").unwrap(), "value");
6908 assert_eq!(session.updated_at(), initial_updated);
6909 assert!(!session.backfill_metadata_if_absent("key", serde_json::json!("other")));
6910 assert_eq!(session.metadata().get("key").unwrap(), "value");
6911 assert_eq!(session.updated_at(), initial_updated);
6912 }
6913
6914 #[test]
6915 fn test_reserved_generated_authority_metadata_rejects_raw_mutation() {
6916 let mut session = Session::new();
6917
6918 assert!(
6919 session
6920 .try_set_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY, serde_json::json!({}))
6921 .is_err()
6922 );
6923 assert!(
6924 session
6925 .try_set_metadata(SESSION_METADATA_KEY, serde_json::json!({}))
6926 .is_err()
6927 );
6928 assert!(
6929 session
6930 .try_set_metadata(SESSION_BUILD_STATE_KEY, serde_json::json!({}))
6931 .is_err()
6932 );
6933 session
6934 .set_session_metadata(SessionMetadata {
6935 schema_version: SESSION_METADATA_SCHEMA_VERSION,
6936 model: "test-model".to_string(),
6937 max_tokens: 1024,
6938 structured_output_retries: 2,
6939 provider: Provider::Other,
6940 self_hosted_server_id: None,
6941 provider_params: None,
6942 tooling: SessionTooling::default(),
6943 keep_alive: false,
6944 comms_name: None,
6945 peer_meta: None,
6946 realm_id: None,
6947 instance_id: None,
6948 backend: None,
6949 config_generation: None,
6950 auth_binding: None,
6951 mob_member_binding: None,
6952 })
6953 .expect("typed metadata setter should route through generated authority");
6954 session
6955 .set_build_state(SessionBuildState::default())
6956 .expect("typed build-state setter should route through generated authority");
6957 session.remove_metadata(SESSION_METADATA_KEY);
6958 session.remove_metadata(SESSION_BUILD_STATE_KEY);
6959 assert!(
6960 session.metadata().contains_key(SESSION_METADATA_KEY),
6961 "raw removal must not delete generated-authority session metadata"
6962 );
6963 assert!(
6964 session.metadata().contains_key(SESSION_BUILD_STATE_KEY),
6965 "raw removal must not delete generated-authority build state"
6966 );
6967 session.set_metadata(SESSION_DEFERRED_TURN_STATE_KEY, serde_json::json!({}));
6968 assert!(
6969 !session
6970 .metadata()
6971 .contains_key(SESSION_DEFERRED_TURN_STATE_KEY)
6972 );
6973 assert!(
6974 !session.backfill_metadata_if_absent(
6975 SESSION_SYSTEM_CONTEXT_STATE_KEY,
6976 serde_json::json!({})
6977 )
6978 );
6979
6980 let state = SessionSystemContextState::default();
6981 session
6982 .set_system_context_state(state.clone())
6983 .expect("typed setter should route through generated authority");
6984 session.remove_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY);
6985 assert_eq!(
6986 session
6987 .try_system_context_state()
6988 .expect("typed state should restore"),
6989 Some(state)
6990 );
6991
6992 session.metadata.insert(
6993 SESSION_SYSTEM_CONTEXT_STATE_KEY.to_string(),
6994 serde_json::json!("not-a-state"),
6995 );
6996 assert!(
6997 session.try_system_context_state().is_err(),
6998 "malformed generated authority state must not decode as absent/default"
6999 );
7000
7001 session.metadata.insert(
7002 SESSION_METADATA_KEY.to_string(),
7003 serde_json::json!("not-metadata"),
7004 );
7005 assert!(
7006 session.try_session_metadata().is_err(),
7007 "malformed session metadata must not decode as absent/default"
7008 );
7009
7010 session.metadata.insert(
7011 SESSION_BUILD_STATE_KEY.to_string(),
7012 serde_json::json!("not-build-state"),
7013 );
7014 assert!(
7015 session.try_build_state().is_err(),
7016 "malformed build state must not decode as absent/default"
7017 );
7018
7019 assert!(
7020 session
7021 .try_set_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY, serde_json::json!({}))
7022 .is_err()
7023 );
7024 session
7025 .set_tool_visibility_state(
7026 AuthorizedSessionToolVisibilityState::from_generated_authority(
7027 SessionToolVisibilityState::default(),
7028 ),
7029 )
7030 .expect("typed visibility setter should route through typed authority handoff");
7031 session.remove_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY);
7032 assert!(
7033 session
7034 .metadata()
7035 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
7036 );
7037 session.clear_tool_visibility_state();
7038 assert!(
7039 !session
7040 .metadata()
7041 .contains_key(SESSION_TOOL_VISIBILITY_STATE_KEY)
7042 );
7043 assert!(
7044 session
7045 .try_set_metadata(SESSION_REALTIME_TRANSCRIPT_STATE_KEY, serde_json::json!({}))
7046 .is_err()
7047 );
7048 let _ = session.append_realtime_transcript_event(RealtimeTranscriptEvent::ItemObserved {
7049 item_id: "rt-item".to_string(),
7050 previous_item_id: None,
7051 role: RealtimeTranscriptRole::User,
7052 response_id: None,
7053 });
7054 assert!(
7055 session
7056 .metadata()
7057 .contains_key(SESSION_REALTIME_TRANSCRIPT_STATE_KEY),
7058 "typed realtime transcript append should retain authority to persist its state"
7059 );
7060 session.metadata.insert(
7061 SESSION_REALTIME_TRANSCRIPT_STATE_KEY.to_string(),
7062 serde_json::json!("not-a-state"),
7063 );
7064 assert!(
7065 session.try_realtime_transcript_state().is_err(),
7066 "malformed realtime generated authority state must not decode as absent/default"
7067 );
7068 }
7069
7070 #[test]
7071 fn test_session_mob_tool_authority_context_persists_projection_without_authority_seal() {
7072 let mut session = Session::new();
7073 session
7074 .set_build_state(SessionBuildState::default())
7075 .expect("session build state should serialize");
7076 let authority = MobToolAuthorityContext::generated_for_test(
7077 crate::service::OpaquePrincipalToken::new("opaque-principal"),
7078 false,
7079 false,
7080 false,
7081 std::collections::BTreeSet::from(["mob-a".to_string()]),
7082 std::collections::BTreeMap::new(),
7083 None,
7084 Some("audit-1".to_string()),
7085 );
7086
7087 session
7088 .set_mob_tool_authority_context(Some(authority))
7089 .expect("authority should serialize");
7090 assert!(session.mob_tool_authority_context().is_none());
7091 let stored = session
7092 .build_state()
7093 .and_then(|state| state.mob_tool_authority_context)
7094 .expect("stored projection should deserialize");
7095 assert!(!stored.is_generated_authority_context());
7096 assert!(!stored.can_manage_mob("mob-a"));
7097
7098 session
7099 .set_mob_tool_authority_context(None)
7100 .expect("authority should clear");
7101 assert!(session.mob_tool_authority_context().is_none());
7102 }
7103
7104 #[test]
7105 fn test_session_build_state_rejects_forged_mob_authority_projection() {
7106 let mut session = Session::new();
7107 let authority = MobToolAuthorityContext::generated_for_test(
7108 crate::service::OpaquePrincipalToken::new("opaque-principal"),
7109 false,
7110 false,
7111 false,
7112 std::collections::BTreeSet::from(["mob-a".to_string()]),
7113 std::collections::BTreeMap::new(),
7114 None,
7115 Some("audit-1".to_string()),
7116 );
7117 let forged_projection: MobToolAuthorityContext =
7118 serde_json::from_value(serde_json::to_value(authority).expect("serialize authority"))
7119 .expect("deserialize projection");
7120 assert!(!forged_projection.is_generated_authority_context());
7121
7122 let err = session
7123 .set_build_state(SessionBuildState {
7124 mob_tool_authority_context: Some(forged_projection),
7125 ..Default::default()
7126 })
7127 .expect_err("forged build state must be rejected by generated authority");
7128 assert!(
7132 err.to_string()
7133 .contains("generated session document authority rejected"),
7134 "unexpected error: {err}"
7135 );
7136 }
7137
7138 #[test]
7139 fn test_session_tool_visibility_state_roundtrip() {
7140 let mut session = Session::new();
7141 let state = SessionToolVisibilityState {
7142 inherited_base_filter: ToolFilter::Allow(["visible".to_string()].into_iter().collect()),
7143 active_filter: ToolFilter::Allow(
7144 ["visible".to_string(), "missing".to_string()]
7145 .into_iter()
7146 .collect(),
7147 ),
7148 staged_filter: ToolFilter::Allow(
7149 ["visible".to_string(), "missing".to_string()]
7150 .into_iter()
7151 .collect(),
7152 ),
7153 active_revision: 1,
7154 staged_revision: 2,
7155 ..Default::default()
7156 };
7157
7158 session
7159 .set_tool_visibility_state(
7160 AuthorizedSessionToolVisibilityState::from_generated_authority(state.clone()),
7161 )
7162 .expect("tool visibility state should serialize");
7163 assert_eq!(session.tool_visibility_state().unwrap(), Some(state));
7164 }
7165
7166 #[test]
7167 fn test_session_tool_visibility_state_malformed_returns_error() {
7168 let mut session = Session::new();
7169 session.metadata.insert(
7170 SESSION_TOOL_VISIBILITY_STATE_KEY.to_string(),
7171 serde_json::json!({
7172 "active_filter": {
7173 "unexpected_filter_kind": ["secret"]
7174 }
7175 }),
7176 );
7177
7178 assert!(
7179 session.tool_visibility_state().is_err(),
7180 "malformed canonical visibility metadata must not decode as absent/default"
7181 );
7182 }
7183
7184 #[test]
7185 fn test_session_serialization() {
7186 let mut session = Session::new();
7187 session.push(Message::User(UserMessage::text("Test".to_string())));
7188
7189 let json = serde_json::to_string(&session).unwrap();
7190 let parsed: Session = serde_json::from_str(&json).unwrap();
7191
7192 assert_eq!(parsed.id(), session.id());
7193 assert_eq!(parsed.messages().len(), 1);
7194 assert_eq!(parsed.version(), SESSION_VERSION);
7195 }
7196
7197 #[test]
7198 fn test_session_meta_from_session() {
7199 let mut session = Session::new();
7200 session.push(Message::User(UserMessage::text("Hello".to_string())));
7201 session.push(Message::BlockAssistant(BlockAssistantMessage {
7202 blocks: vec![AssistantBlock::Text {
7203 text: "Hi!".to_string(),
7204 meta: None,
7205 }],
7206 stop_reason: StopReason::EndTurn,
7207 identity: crate::types::TranscriptMessageIdentity::default(),
7208 created_at: crate::types::message_timestamp_now(),
7209 }));
7210 session.record_usage(Usage {
7211 input_tokens: 10,
7212 output_tokens: 5,
7213 cache_creation_tokens: None,
7214 cache_read_tokens: None,
7215 });
7216
7217 let meta = SessionMeta::from(&session);
7218 assert_eq!(meta.id, *session.id());
7219 assert_eq!(meta.message_count, 2);
7220 assert_eq!(meta.total_tokens, 15);
7221 }
7222
7223 #[test]
7224 fn system_context_state_preserves_applied_runtime_context() {
7225 let accepted_at = SystemTime::UNIX_EPOCH;
7226 let mut state = SessionSystemContextState::default();
7227 state
7228 .stage_append(
7229 &AppendSystemContextRequest {
7230 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7231 "Authoritative peer token is birch seventeen.".to_string(),
7232 ),
7233 source: Some(
7234 "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1"
7235 .to_string(),
7236 ),
7237 idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
7238 source_kind: SystemContextSource::Normal,
7239 peer_response_terminal: None,
7240 },
7241 accepted_at,
7242 )
7243 .expect("append should stage");
7244
7245 state.mark_pending_applied();
7246
7247 assert!(state.pending.is_empty());
7248 assert_eq!(state.applied.len(), 1);
7249 assert_eq!(
7250 state.applied[0].content.render_text(),
7251 "Authoritative peer token is birch seventeen."
7252 );
7253 assert_eq!(
7254 state.applied[0].source.as_deref(),
7255 Some("peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1")
7256 );
7257
7258 let round_tripped: SessionSystemContextState =
7259 serde_json::from_value(serde_json::to_value(&state).expect("serialize state"))
7260 .expect("deserialize state");
7261 assert_eq!(round_tripped.applied, state.applied);
7262 }
7263
7264 #[test]
7265 fn active_turn_system_context_is_discarded_when_not_applied() {
7266 let mut state = SessionSystemContextState::default();
7267 state
7268 .stage_active_turn_append(
7269 &AppendSystemContextRequest {
7270 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7271 "only for the active run".to_string(),
7272 ),
7273 source: Some("runtime:steer:input-1".to_string()),
7274 idempotency_key: Some("runtime:steer:input-1".to_string()),
7275 source_kind: SystemContextSource::RuntimeSteer,
7276 peer_response_terminal: None,
7277 },
7278 SystemTime::UNIX_EPOCH,
7279 )
7280 .expect("active context should stage");
7281
7282 let discarded = state.discard_unapplied_active_turn_pending();
7283
7284 assert_eq!(discarded.len(), 1);
7285 assert!(state.pending.is_empty());
7286 assert!(state.applied.is_empty());
7287 assert!(state.active_turn_pending_keys.is_empty());
7288 assert!(
7289 state.seen.is_empty(),
7290 "discarded active-turn context should not block later idempotency keys"
7291 );
7292 }
7293
7294 #[test]
7295 fn active_turn_system_context_can_roll_back_targeted_keys() {
7296 let mut state = SessionSystemContextState::default();
7297 for key in ["runtime:steer:input-1", "runtime:steer:input-2"] {
7298 state
7299 .stage_active_turn_append(
7300 &AppendSystemContextRequest {
7301 content: crate::lifecycle::run_primitive::CoreRenderable::text(format!(
7302 "context for {key}"
7303 )),
7304 source: Some(key.to_string()),
7305 idempotency_key: Some(key.to_string()),
7306 source_kind: SystemContextSource::RuntimeSteer,
7307 peer_response_terminal: None,
7308 },
7309 SystemTime::UNIX_EPOCH,
7310 )
7311 .expect("active context should stage");
7312 }
7313
7314 let discarded =
7315 state.discard_active_turn_pending_by_keys(&["runtime:steer:input-1".to_string()]);
7316
7317 assert_eq!(discarded.len(), 1);
7318 assert_eq!(
7319 discarded[0].idempotency_key.as_deref(),
7320 Some("runtime:steer:input-1")
7321 );
7322 assert_eq!(state.pending.len(), 1);
7323 assert_eq!(
7324 state.pending[0].idempotency_key.as_deref(),
7325 Some("runtime:steer:input-2")
7326 );
7327 assert!(!state.seen.contains_key("runtime:steer:input-1"));
7328 assert!(state.seen.contains_key("runtime:steer:input-2"));
7329 assert!(
7330 !state
7331 .active_turn_pending_keys
7332 .contains("runtime:steer:input-1")
7333 );
7334 assert!(
7335 state
7336 .active_turn_pending_keys
7337 .contains("runtime:steer:input-2")
7338 );
7339 }
7340
7341 #[test]
7342 fn active_turn_system_context_is_transient_when_boundary_consumes_it() {
7343 let mut state = SessionSystemContextState::default();
7344 state
7345 .stage_active_turn_append(
7346 &AppendSystemContextRequest {
7347 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7348 "visible to this run".to_string(),
7349 ),
7350 source: Some("runtime:steer:input-2".to_string()),
7351 idempotency_key: Some("runtime:steer:input-2".to_string()),
7352 source_kind: SystemContextSource::RuntimeSteer,
7353 peer_response_terminal: None,
7354 },
7355 SystemTime::UNIX_EPOCH,
7356 )
7357 .expect("active context should stage");
7358
7359 state.mark_pending_applied();
7360 let discarded = state.discard_unapplied_active_turn_pending();
7361
7362 assert!(discarded.is_empty());
7363 assert!(state.pending.is_empty());
7364 assert!(state.applied.is_empty());
7365 assert!(state.active_turn_pending_keys.is_empty());
7366 assert_eq!(
7367 state.seen.get("runtime:steer:input-2"),
7368 None,
7369 "consumed active-turn steer context must not become durable state"
7370 );
7371 }
7372
7373 #[test]
7374 fn discard_transient_runtime_steer_context_removes_steer_via_typed_marker() {
7375 let mut session = Session::new();
7376 session.set_system_prompt(format!(
7380 "base{}{}{}{}",
7381 SYSTEM_CONTEXT_SEPARATOR,
7382 render_system_context_block(&PendingSystemContextAppend {
7383 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7384 "old steer".to_string()
7385 ),
7386 source: Some("steer-source-old".to_string()),
7387 idempotency_key: Some("steer-key-old".to_string()),
7388 source_kind: SystemContextSource::RuntimeSteer,
7389 peer_response_terminal: None,
7390 accepted_at: SystemTime::UNIX_EPOCH,
7391 }),
7392 SYSTEM_CONTEXT_SEPARATOR,
7393 render_system_context_block(&PendingSystemContextAppend {
7394 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7395 "durable peer fact".to_string()
7396 ),
7397 source: Some("peer_response_terminal:analyst:req".to_string()),
7398 idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
7399 source_kind: SystemContextSource::Normal,
7400 peer_response_terminal: None,
7401 accepted_at: SystemTime::UNIX_EPOCH,
7402 })
7403 ));
7404 session
7405 .set_system_context_state(SessionSystemContextState {
7406 pending: vec![PendingSystemContextAppend {
7407 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7408 "pending steer".to_string(),
7409 ),
7410 source: Some("steer-source-pending".to_string()),
7411 idempotency_key: Some("steer-key-pending".to_string()),
7412 source_kind: SystemContextSource::RuntimeSteer,
7413 peer_response_terminal: None,
7414 accepted_at: SystemTime::UNIX_EPOCH,
7415 }],
7416 applied: vec![
7417 PendingSystemContextAppend {
7418 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7419 "old steer".to_string(),
7420 ),
7421 source: Some("steer-source-old".to_string()),
7422 idempotency_key: Some("steer-key-old".to_string()),
7423 source_kind: SystemContextSource::RuntimeSteer,
7424 peer_response_terminal: None,
7425 accepted_at: SystemTime::UNIX_EPOCH,
7426 },
7427 PendingSystemContextAppend {
7428 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7429 "durable peer fact".to_string(),
7430 ),
7431 source: Some("peer_response_terminal:analyst:req".to_string()),
7432 idempotency_key: Some("peer_response_terminal:analyst:req".to_string()),
7433 source_kind: SystemContextSource::Normal,
7434 peer_response_terminal: None,
7435 accepted_at: SystemTime::UNIX_EPOCH,
7436 },
7437 ],
7438 seen: BTreeMap::from([(
7439 "steer-key-old".to_string(),
7440 SeenSystemContextKey {
7441 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7442 "old steer".to_string(),
7443 ),
7444 source: Some("steer-source-old".to_string()),
7445 source_kind: SystemContextSource::RuntimeSteer,
7446 state: SeenSystemContextState::Applied,
7447 },
7448 )]),
7449 active_turn_pending_keys: BTreeSet::from(["steer-key-pending".to_string()]),
7450 })
7451 .expect("system context state should serialize");
7452
7453 let removed = session.discard_transient_runtime_steer_context();
7454
7455 assert!(removed >= 4);
7456 let system_prompt = match session.messages().first() {
7457 Some(Message::System(system)) => system.content.as_str(),
7458 other => panic!("expected system prompt, got {other:?}"),
7459 };
7460 assert!(!system_prompt.contains("old steer"));
7461 assert!(system_prompt.contains("durable peer fact"));
7462 let state = session.system_context_state().unwrap_or_default();
7463 assert!(state.pending.is_empty());
7464 assert_eq!(state.applied.len(), 1);
7465 assert_eq!(state.applied[0].content.render_text(), "durable peer fact");
7466 assert!(state.seen.is_empty());
7467 assert!(state.active_turn_pending_keys.is_empty());
7468 }
7469
7470 #[test]
7471 fn append_system_context_blocks_records_typed_applied_context() {
7472 let append = PendingSystemContextAppend {
7473 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7474 "Authoritative peer token is birch seventeen.".to_string(),
7475 ),
7476 source: Some(
7477 "peer_response_terminal:analyst:018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string(),
7478 ),
7479 idempotency_key: Some("018f6f79-7a82-7c4e-a552-a3b86f9630f1".to_string()),
7480 source_kind: SystemContextSource::Normal,
7481 peer_response_terminal: None,
7482 accepted_at: SystemTime::UNIX_EPOCH,
7483 };
7484 let mut session = Session::new();
7485
7486 session.append_system_context_blocks(std::slice::from_ref(&append));
7487
7488 let state = session
7489 .system_context_state()
7490 .expect("append should persist typed context state");
7491 assert_eq!(state.applied, vec![append]);
7492 }
7493
7494 #[test]
7495 fn append_system_context_blocks_renders_pre_marked_pending_context() {
7496 let accepted_at = SystemTime::UNIX_EPOCH;
7497 let mut state = SessionSystemContextState::default();
7498 state
7499 .stage_append(
7500 &AppendSystemContextRequest {
7501 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7502 "Apply this staged context at the request boundary.".to_string(),
7503 ),
7504 source: Some("rpc/session_inject_context".to_string()),
7505 idempotency_key: Some("ctx-boundary".to_string()),
7506 source_kind: SystemContextSource::Normal,
7507 peer_response_terminal: None,
7508 },
7509 accepted_at,
7510 )
7511 .expect("append should stage");
7512 let pending = state.pending.clone();
7513 state.mark_pending_applied();
7514 let mut session = Session::new();
7515 session
7516 .set_system_context_state(state)
7517 .expect("state should serialize");
7518
7519 session.append_system_context_blocks(&pending);
7520
7521 let system_prompt = session
7522 .messages()
7523 .first()
7524 .and_then(|message| match message {
7525 Message::System(system) => Some(system.content.as_str()),
7526 _ => None,
7527 })
7528 .unwrap_or_default();
7529 assert!(system_prompt.contains("Apply this staged context at the request boundary."));
7530 let state = session
7531 .system_context_state()
7532 .expect("append should persist typed context state");
7533 assert_eq!(state.applied.len(), 1);
7534 assert_eq!(
7535 state.seen["ctx-boundary"].state,
7536 SeenSystemContextState::Applied
7537 );
7538 }
7539
7540 #[test]
7541 fn append_system_context_blocks_renders_pre_marked_context_without_idempotency_key() {
7542 let accepted_at = SystemTime::UNIX_EPOCH;
7543 let mut state = SessionSystemContextState::default();
7544 state
7545 .stage_append(
7546 &AppendSystemContextRequest {
7547 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7548 "Apply this unkeyed staged context at the request boundary.".to_string(),
7549 ),
7550 source: Some("rpc/session_inject_context".to_string()),
7551 idempotency_key: None,
7552 source_kind: SystemContextSource::Normal,
7553 peer_response_terminal: None,
7554 },
7555 accepted_at,
7556 )
7557 .expect("append should stage");
7558 let pending = state.pending.clone();
7559 state.mark_pending_applied();
7560 let mut session = Session::new();
7561 session
7562 .set_system_context_state(state)
7563 .expect("state should serialize");
7564
7565 session.append_system_context_blocks(&pending);
7566
7567 let system_prompt = session
7568 .messages()
7569 .first()
7570 .and_then(|message| match message {
7571 Message::System(system) => Some(system.content.as_str()),
7572 _ => None,
7573 })
7574 .unwrap_or_default();
7575 assert!(
7576 system_prompt.contains("Apply this unkeyed staged context at the request boundary.")
7577 );
7578 }
7579
7580 #[test]
7584 fn staged_system_context_carries_typed_renderable_to_render_seam() {
7585 use crate::lifecycle::run_primitive::CoreRenderable;
7586
7587 let accepted_at = SystemTime::UNIX_EPOCH;
7588 let mut state = SessionSystemContextState::default();
7589 let renderable = CoreRenderable::Json {
7590 value: serde_json::json!({"alert": "disk-full", "severity": 2}),
7591 };
7592 state
7593 .stage_append(
7594 &AppendSystemContextRequest {
7595 content: renderable.clone(),
7596 source: Some("ops/monitor".to_string()),
7597 idempotency_key: Some("alert-1".to_string()),
7598 source_kind: SystemContextSource::Normal,
7599 peer_response_terminal: None,
7600 },
7601 accepted_at,
7602 )
7603 .expect("typed renderable append should stage");
7604
7605 assert_eq!(state.pending.len(), 1);
7608 assert_eq!(state.pending[0].content, renderable);
7609
7610 let rendered = render_system_context_block(&state.pending[0]);
7613 assert!(rendered.starts_with(SYSTEM_CONTEXT_RENDER_LABEL));
7614 assert!(
7615 rendered.contains(renderable.render_text().trim()),
7616 "render seam must lower via CoreRenderable::render_text: {rendered}"
7617 );
7618 }
7619
7620 #[test]
7621 fn append_system_context_blocks_skips_duplicate_idempotency_key() {
7622 let first = PendingSystemContextAppend {
7623 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7624 "Authoritative peer token is birch seventeen.".to_string(),
7625 ),
7626 source: Some("peer_response_terminal:analyst:req-1".to_string()),
7627 idempotency_key: Some("req-1".to_string()),
7628 source_kind: SystemContextSource::Normal,
7629 peer_response_terminal: None,
7630 accepted_at: SystemTime::UNIX_EPOCH,
7631 };
7632 let duplicate = PendingSystemContextAppend {
7633 accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
7634 ..first.clone()
7635 };
7636 let mut session = Session::new();
7637
7638 session.append_system_context_blocks(std::slice::from_ref(&first));
7639 session.append_system_context_blocks(std::slice::from_ref(&duplicate));
7640
7641 let state = session
7642 .system_context_state()
7643 .expect("append should persist typed context state");
7644 assert_eq!(state.applied, vec![first]);
7645 let system_prompt = session
7646 .messages()
7647 .first()
7648 .and_then(|message| match message {
7649 Message::System(system) => Some(system.content.as_str()),
7650 _ => None,
7651 })
7652 .unwrap_or_default();
7653 assert_eq!(
7654 system_prompt
7655 .matches("Authoritative peer token is birch seventeen.")
7656 .count(),
7657 1
7658 );
7659 }
7660
7661 #[test]
7662 fn append_system_context_blocks_skips_conflicting_duplicate_idempotency_key() {
7663 let first = PendingSystemContextAppend {
7664 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7665 "Authoritative peer token is birch seventeen.".to_string(),
7666 ),
7667 source: Some("peer_response_terminal:analyst:req-1".to_string()),
7668 idempotency_key: Some("req-1".to_string()),
7669 source_kind: SystemContextSource::Normal,
7670 peer_response_terminal: None,
7671 accepted_at: SystemTime::UNIX_EPOCH,
7672 };
7673 let conflicting = PendingSystemContextAppend {
7674 content: crate::lifecycle::run_primitive::CoreRenderable::text(
7675 "Conflicting peer token should not reach the prompt.".to_string(),
7676 ),
7677 accepted_at: SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1),
7678 ..first.clone()
7679 };
7680 let mut session = Session::new();
7681
7682 session.append_system_context_blocks(std::slice::from_ref(&first));
7683 session.append_system_context_blocks(std::slice::from_ref(&conflicting));
7684
7685 let state = session
7686 .system_context_state()
7687 .expect("append should persist typed context state");
7688 assert_eq!(state.applied, vec![first]);
7689 let system_prompt = session
7690 .messages()
7691 .first()
7692 .and_then(|message| match message {
7693 Message::System(system) => Some(system.content.as_str()),
7694 _ => None,
7695 })
7696 .unwrap_or_default();
7697 assert!(system_prompt.contains("Authoritative peer token is birch seventeen."));
7698 assert!(!system_prompt.contains("Conflicting peer token should not reach the prompt."));
7699 }
7700
7701 #[test]
7713 fn realtime_transcript_assistant_transcript_delta_materializes_transcript_block() {
7714 let mut session = Session::new();
7715
7716 let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
7717 response_id: "resp_spoken".to_string(),
7718 delta_id: "evt_delta_spoken_1".to_string(),
7719 item_id: "item_spoken".to_string(),
7720 previous_item_id: None,
7721 content_index: 0,
7722 delta: "I said hi".to_string(),
7723 };
7724 assert!(
7725 session.append_realtime_transcript_event(delta).is_inert(),
7726 "delta alone is inert until turn-completed flushes"
7727 );
7728
7729 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
7730 response_id: "resp_spoken".to_string(),
7731 stop_reason: StopReason::EndTurn,
7732 usage: Usage::default(),
7733 };
7734 let outcome = session.append_realtime_transcript_event(terminal);
7735 assert_eq!(outcome.materialized_messages.len(), 1);
7736
7737 let messages = session.messages();
7739 assert_eq!(messages.len(), 1);
7740 match &messages[0] {
7741 Message::BlockAssistant(assistant) => {
7742 assert_eq!(assistant.blocks.len(), 1);
7743 match &assistant.blocks[0] {
7744 AssistantBlock::Transcript { text, source, .. } => {
7745 assert_eq!(text, "I said hi");
7746 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
7747 }
7748 other => unreachable!(
7749 "AssistantTranscriptDelta must materialize as AssistantBlock::Transcript, got {other:?}"
7750 ),
7751 }
7752 }
7753 other => unreachable!("expected BlockAssistant message, got {other:?}"),
7754 }
7755 }
7756
7757 #[test]
7758 fn round4_cc4_in_flight_response_ids_lists_distinct_unmaterialized_responses() {
7759 let mut session = Session::new();
7765
7766 for (i, response_id) in [
7770 ("resp_a", "resp_a"),
7771 ("resp_a_extra", "resp_a"),
7772 ("resp_b", "resp_b"),
7773 ("resp_c", "resp_c"),
7774 ]
7775 .iter()
7776 .enumerate()
7777 {
7778 let event = RealtimeTranscriptEvent::AssistantTranscriptDelta {
7779 response_id: response_id.1.to_string(),
7780 delta_id: format!("delta_{i}"),
7781 item_id: response_id.0.to_string(),
7782 previous_item_id: None,
7783 content_index: 0,
7784 delta: "x".to_string(),
7785 };
7786 let _ = session.append_realtime_transcript_event(event);
7787 }
7788
7789 let _ = session.append_realtime_transcript_event(
7791 RealtimeTranscriptEvent::AssistantTurnInterrupted {
7792 response_id: "resp_c".to_string(),
7793 },
7794 );
7795
7796 let _ = session.append_realtime_transcript_event(
7799 RealtimeTranscriptEvent::UserTranscriptFinal {
7800 item_id: "u_item".to_string(),
7801 previous_item_id: None,
7802 content_index: 0,
7803 text: "hi".to_string(),
7804 },
7805 );
7806
7807 let in_flight = session.in_flight_realtime_assistant_response_ids();
7808 assert!(in_flight.contains(&"resp_a".to_string()), "{in_flight:?}");
7809 assert!(in_flight.contains(&"resp_b".to_string()), "{in_flight:?}");
7810 assert!(
7811 !in_flight.contains(&"resp_c".to_string()),
7812 "discarded response must not appear in in_flight: {in_flight:?}"
7813 );
7814 assert_eq!(
7816 in_flight.iter().filter(|r| *r == "resp_a").count(),
7817 1,
7818 "distinct response_ids only: {in_flight:?}"
7819 );
7820 }
7821
7822 #[test]
7823 fn round4_cc2_assistant_turn_completed_after_transcript_deltas_materializes_transcript() {
7824 let mut session = Session::new();
7831
7832 let delta = RealtimeTranscriptEvent::AssistantTranscriptDelta {
7833 response_id: "resp_cc2".to_string(),
7834 delta_id: "delta_cc2_1".to_string(),
7835 item_id: "item_cc2".to_string(),
7836 previous_item_id: None,
7837 content_index: 0,
7838 delta: "hello world".to_string(),
7839 };
7840 assert!(session.append_realtime_transcript_event(delta).is_inert());
7841
7842 assert_eq!(
7844 session.in_flight_realtime_assistant_response_ids(),
7845 vec!["resp_cc2".to_string()]
7846 );
7847
7848 let outcome = session.append_realtime_transcript_event(
7849 RealtimeTranscriptEvent::AssistantTurnCompleted {
7850 response_id: "resp_cc2".to_string(),
7851 stop_reason: StopReason::EndTurn,
7852 usage: Usage::default(),
7853 },
7854 );
7855 assert_eq!(outcome.materialized_messages.len(), 1);
7856
7857 assert!(
7859 session
7860 .in_flight_realtime_assistant_response_ids()
7861 .is_empty(),
7862 "materialized items must not appear in in_flight_realtime_assistant_response_ids"
7863 );
7864
7865 let messages = session.messages();
7866 let assistant = messages.iter().find_map(|m| match m {
7867 Message::BlockAssistant(a) => Some(a),
7868 _ => None,
7869 });
7870 let assistant = assistant.expect("assistant block message expected");
7871 assert_eq!(assistant.blocks.len(), 1);
7872 assert!(matches!(
7873 &assistant.blocks[0],
7874 AssistantBlock::Transcript {
7875 source: crate::types::TranscriptSource::Spoken,
7876 ..
7877 }
7878 ));
7879 }
7880
7881 #[test]
7882 fn realtime_transcript_assistant_text_delta_still_materializes_text_block() {
7883 let mut session = Session::new();
7887
7888 let delta = RealtimeTranscriptEvent::AssistantTextDelta {
7889 response_id: "resp_display".to_string(),
7890 delta_id: "evt_delta_display_1".to_string(),
7891 item_id: "item_display".to_string(),
7892 previous_item_id: None,
7893 content_index: 0,
7894 delta: "I wrote".to_string(),
7895 };
7896 let _ = session.append_realtime_transcript_event(delta);
7897
7898 let terminal = RealtimeTranscriptEvent::AssistantTurnCompleted {
7899 response_id: "resp_display".to_string(),
7900 stop_reason: StopReason::EndTurn,
7901 usage: Usage::default(),
7902 };
7903 let outcome = session.append_realtime_transcript_event(terminal);
7904 assert_eq!(outcome.materialized_messages.len(), 1);
7905
7906 let messages = session.messages();
7907 match &messages[0] {
7908 Message::BlockAssistant(assistant) => match &assistant.blocks[0] {
7909 AssistantBlock::Text { text, .. } => assert_eq!(text, "I wrote"),
7910 other => unreachable!(
7911 "AssistantTextDelta must keep materializing AssistantBlock::Text, got {other:?}"
7912 ),
7913 },
7914 other => unreachable!("expected BlockAssistant message, got {other:?}"),
7915 }
7916 }
7917
7918 #[test]
7919 fn round4_cc7_mixed_response_persists_text_and_transcript_in_order() {
7920 let mut session = Session::new();
7938
7939 let display_a = RealtimeTranscriptEvent::AssistantTextDelta {
7941 response_id: "resp_mixed_1".to_string(),
7942 delta_id: "delta_disp_1".to_string(),
7943 item_id: "item_display".to_string(),
7944 previous_item_id: None,
7945 content_index: 0,
7946 delta: "Here's the report:".to_string(),
7947 };
7948 assert!(
7949 session
7950 .append_realtime_transcript_event(display_a)
7951 .is_inert()
7952 );
7953
7954 let display_b = RealtimeTranscriptEvent::AssistantTextDelta {
7955 response_id: "resp_mixed_1".to_string(),
7956 delta_id: "delta_disp_2".to_string(),
7957 item_id: "item_display".to_string(),
7958 previous_item_id: None,
7959 content_index: 0,
7960 delta: " (still writing)".to_string(),
7961 };
7962 assert!(
7963 session
7964 .append_realtime_transcript_event(display_b)
7965 .is_inert()
7966 );
7967
7968 let spoken_a = RealtimeTranscriptEvent::AssistantTranscriptDelta {
7973 response_id: "resp_mixed_1".to_string(),
7974 delta_id: "delta_spoken_1".to_string(),
7975 item_id: "item_spoken".to_string(),
7976 previous_item_id: Some("item_display".to_string()),
7977 content_index: 0,
7978 delta: "I'm reading the report aloud:".to_string(),
7979 };
7980 assert!(
7981 session
7982 .append_realtime_transcript_event(spoken_a)
7983 .is_inert()
7984 );
7985
7986 let spoken_b = RealtimeTranscriptEvent::AssistantTranscriptDelta {
7987 response_id: "resp_mixed_1".to_string(),
7988 delta_id: "delta_spoken_2".to_string(),
7989 item_id: "item_spoken".to_string(),
7990 previous_item_id: Some("item_display".to_string()),
7991 content_index: 0,
7992 delta: " sentence two.".to_string(),
7993 };
7994 assert!(
7995 session
7996 .append_realtime_transcript_event(spoken_b)
7997 .is_inert()
7998 );
7999
8000 let outcome = session.append_realtime_transcript_event(
8003 RealtimeTranscriptEvent::AssistantTurnCompleted {
8004 response_id: "resp_mixed_1".to_string(),
8005 stop_reason: StopReason::EndTurn,
8006 usage: Usage {
8007 input_tokens: 11,
8008 output_tokens: 22,
8009 cache_creation_tokens: None,
8010 cache_read_tokens: None,
8011 },
8012 },
8013 );
8014 assert_eq!(outcome.materialized_messages.len(), 2);
8016
8017 let messages = session.messages();
8020 let assistants: Vec<&BlockAssistantMessage> = messages
8021 .iter()
8022 .filter_map(|m| match m {
8023 Message::BlockAssistant(a) => Some(a),
8024 _ => None,
8025 })
8026 .collect();
8027 assert_eq!(
8028 assistants.len(),
8029 1,
8030 "mixed display+spoken response under one response_id must produce exactly ONE BlockAssistant message, got: {assistants:?}"
8031 );
8032 let assistant = assistants[0];
8033 assert_eq!(
8034 assistant.blocks.len(),
8035 2,
8036 "mixed response message must carry both blocks: {:?}",
8037 assistant.blocks
8038 );
8039
8040 match &assistant.blocks[0] {
8042 AssistantBlock::Text { text, .. } => {
8043 assert_eq!(text, "Here's the report: (still writing)");
8044 }
8045 other => unreachable!(
8046 "first block must be AssistantBlock::Text (display lane), got {other:?}"
8047 ),
8048 }
8049 match &assistant.blocks[1] {
8051 AssistantBlock::Transcript { text, source, .. } => {
8052 assert_eq!(text, "I'm reading the report aloud: sentence two.");
8053 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
8054 }
8055 other => unreachable!(
8056 "second block must be AssistantBlock::Transcript {{ source: Spoken }}, got {other:?}"
8057 ),
8058 }
8059
8060 assert_eq!(session.usage.input_tokens, 11);
8062 assert_eq!(session.usage.output_tokens, 22);
8063 }
8064
8065 #[test]
8066 fn round5_r55_mixed_response_barge_in_preserves_display_drops_spoken() {
8067 let mut session = Session::new();
8083
8084 let display = RealtimeTranscriptEvent::AssistantTextDelta {
8085 response_id: "resp_mixed_2".to_string(),
8086 delta_id: "delta_disp_1".to_string(),
8087 item_id: "item_display_2".to_string(),
8088 previous_item_id: None,
8089 content_index: 0,
8090 delta: "Working on the report...".to_string(),
8091 };
8092 let _ = session.append_realtime_transcript_event(display);
8093
8094 let spoken = RealtimeTranscriptEvent::AssistantTranscriptDelta {
8095 response_id: "resp_mixed_2".to_string(),
8096 delta_id: "delta_spoken_1".to_string(),
8097 item_id: "item_spoken_2".to_string(),
8098 previous_item_id: Some("item_display_2".to_string()),
8099 content_index: 0,
8100 delta: "I'm reading the report".to_string(),
8101 };
8102 let _ = session.append_realtime_transcript_event(spoken);
8103
8104 let outcome = session.append_realtime_transcript_event(
8108 RealtimeTranscriptEvent::AssistantTurnInterrupted {
8109 response_id: "resp_mixed_2".to_string(),
8110 },
8111 );
8112 assert_eq!(
8113 outcome.materialized_messages.len(),
8114 1,
8115 "Display lane item must materialize on Interrupted: {outcome:?}"
8116 );
8117
8118 let late_completion = session.append_realtime_transcript_event(
8122 RealtimeTranscriptEvent::AssistantTurnCompleted {
8123 response_id: "resp_mixed_2".to_string(),
8124 stop_reason: StopReason::Cancelled,
8125 usage: Usage::default(),
8126 },
8127 );
8128 assert_eq!(
8129 late_completion.materialized_messages.len(),
8130 0,
8131 "post-barge-in TurnCompleted must not resurrect anything"
8132 );
8133
8134 let messages = session.messages();
8137 let assistants: Vec<&BlockAssistantMessage> = messages
8138 .iter()
8139 .filter_map(|m| match m {
8140 Message::BlockAssistant(a) => Some(a),
8141 _ => None,
8142 })
8143 .collect();
8144 assert_eq!(
8145 assistants.len(),
8146 1,
8147 "barge-in must commit exactly one BlockAssistant containing the Display lane: {assistants:?}"
8148 );
8149 let assistant = assistants[0];
8150 assert_eq!(assistant.blocks.len(), 1, "blocks: {:?}", assistant.blocks);
8151 match &assistant.blocks[0] {
8152 AssistantBlock::Text { text, .. } => {
8153 assert_eq!(text, "Working on the report...");
8154 }
8155 other => {
8156 unreachable!("Display lane must materialize as AssistantBlock::Text, got {other:?}")
8157 }
8158 }
8159 assert!(
8161 !assistant
8162 .blocks
8163 .iter()
8164 .any(|b| matches!(b, AssistantBlock::Transcript { .. })),
8165 "Spoken lane must be dropped on barge-in"
8166 );
8167
8168 assert!(
8171 !session
8172 .in_flight_realtime_assistant_response_ids()
8173 .contains(&"resp_mixed_2".to_string()),
8174 "barged-in response must not appear in in_flight_realtime_assistant_response_ids"
8175 );
8176 }
8177
8178 #[test]
8179 fn round5_r55_barge_in_preserves_display_lane_drops_spoken() {
8180 let mut session = Session::new();
8184
8185 let _ =
8186 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
8187 response_id: "resp_a".to_string(),
8188 delta_id: "delta_d_1".to_string(),
8189 item_id: "item_display".to_string(),
8190 previous_item_id: None,
8191 content_index: 0,
8192 delta: "display-text".to_string(),
8193 });
8194 let _ = session.append_realtime_transcript_event(
8195 RealtimeTranscriptEvent::AssistantTranscriptDelta {
8196 response_id: "resp_a".to_string(),
8197 delta_id: "delta_s_1".to_string(),
8198 item_id: "item_spoken".to_string(),
8199 previous_item_id: None,
8200 content_index: 0,
8201 delta: "spoken-transcript".to_string(),
8202 },
8203 );
8204
8205 let outcome = session.append_realtime_transcript_event(
8206 RealtimeTranscriptEvent::AssistantTurnInterrupted {
8207 response_id: "resp_a".to_string(),
8208 },
8209 );
8210 assert_eq!(outcome.materialized_messages.len(), 1);
8212
8213 let messages = session.messages();
8214 let assistants: Vec<&BlockAssistantMessage> = messages
8215 .iter()
8216 .filter_map(|m| match m {
8217 Message::BlockAssistant(a) => Some(a),
8218 _ => None,
8219 })
8220 .collect();
8221 assert_eq!(assistants.len(), 1);
8222 assert_eq!(assistants[0].blocks.len(), 1);
8224 match &assistants[0].blocks[0] {
8225 AssistantBlock::Text { text, .. } => assert_eq!(text, "display-text"),
8226 other => unreachable!("expected Text, got {other:?}"),
8227 }
8228 }
8229
8230 #[test]
8231 fn round5_r55_barge_in_finalizes_retained_display_into_committed_block() {
8232 let mut session = Session::new();
8237
8238 let _ =
8239 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
8240 response_id: "resp_a".to_string(),
8241 delta_id: "delta_d_1".to_string(),
8242 item_id: "item_display".to_string(),
8243 previous_item_id: None,
8244 content_index: 0,
8245 delta: "committed-display-text".to_string(),
8246 });
8247
8248 assert!(session.messages().is_empty());
8250
8251 let outcome = session.append_realtime_transcript_event(
8252 RealtimeTranscriptEvent::AssistantTurnInterrupted {
8253 response_id: "resp_a".to_string(),
8254 },
8255 );
8256 assert_eq!(
8257 outcome.materialized_messages.len(),
8258 1,
8259 "Interrupted must finalize retained Display lane immediately"
8260 );
8261
8262 let messages = session.messages();
8264 assert_eq!(messages.len(), 1);
8265 match &messages[0] {
8266 Message::BlockAssistant(assistant) => {
8267 assert_eq!(assistant.blocks.len(), 1);
8268 match &assistant.blocks[0] {
8269 AssistantBlock::Text { text, .. } => {
8270 assert_eq!(text, "committed-display-text");
8271 }
8272 other => unreachable!("expected Text, got {other:?}"),
8273 }
8274 }
8275 other => unreachable!("expected BlockAssistant, got {other:?}"),
8276 }
8277 }
8278
8279 #[test]
8280 fn round5_r56_truncation_promotes_default_lane_item_to_spoken() {
8281 let mut session = Session::new();
8288
8289 let _ = session.append_realtime_transcript_event(
8290 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
8291 response_id: "resp_a".to_string(),
8292 item_id: "item_a".to_string(),
8293 content_index: 0,
8294 text: "what was actually heard".to_string(),
8295 },
8296 );
8297
8298 let outcome = session.append_realtime_transcript_event(
8299 RealtimeTranscriptEvent::AssistantTurnCompleted {
8300 response_id: "resp_a".to_string(),
8301 stop_reason: StopReason::EndTurn,
8302 usage: Usage::default(),
8303 },
8304 );
8305 assert_eq!(outcome.materialized_messages.len(), 1);
8306
8307 assert_eq!(session.messages().len(), 1);
8308 match &session.messages()[0] {
8309 Message::BlockAssistant(assistant) => {
8310 assert_eq!(assistant.blocks.len(), 1);
8311 match &assistant.blocks[0] {
8312 AssistantBlock::Transcript { text, source, .. } => {
8313 assert_eq!(text, "what was actually heard");
8314 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
8315 }
8316 other => unreachable!(
8317 "truncation-only path must materialize as AssistantBlock::Transcript, got {other:?}"
8318 ),
8319 }
8320 }
8321 other => unreachable!("expected BlockAssistant, got {other:?}"),
8322 }
8323 }
8324
8325 #[test]
8326 fn round5_r56_truncation_after_display_delta_is_no_op_keeping_display_content() {
8327 let mut session = Session::new();
8335
8336 let _ =
8337 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
8338 response_id: "resp_a".to_string(),
8339 delta_id: "delta_d_1".to_string(),
8340 item_id: "item_a".to_string(),
8341 previous_item_id: None,
8342 content_index: 0,
8343 delta: "display-text-from-delta".to_string(),
8344 });
8345
8346 let _ = session.append_realtime_transcript_event(
8347 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
8348 response_id: "resp_a".to_string(),
8349 item_id: "item_a".to_string(),
8350 content_index: 0,
8351 text: "spoken-truncation-text".to_string(),
8352 },
8353 );
8354
8355 let _ = session.append_realtime_transcript_event(
8356 RealtimeTranscriptEvent::AssistantTurnCompleted {
8357 response_id: "resp_a".to_string(),
8358 stop_reason: StopReason::EndTurn,
8359 usage: Usage::default(),
8360 },
8361 );
8362
8363 assert_eq!(session.messages().len(), 1);
8366 match &session.messages()[0] {
8367 Message::BlockAssistant(assistant) => {
8368 assert_eq!(assistant.blocks.len(), 1);
8369 match &assistant.blocks[0] {
8370 AssistantBlock::Text { text, .. } => {
8371 assert_eq!(text, "display-text-from-delta");
8372 }
8373 other => unreachable!(
8374 "Display content must survive misrouted truncation, got {other:?}"
8375 ),
8376 }
8377 }
8378 other => unreachable!("expected BlockAssistant, got {other:?}"),
8379 }
8380 }
8381
8382 #[test]
8390 fn round5_r56_sibling_display_delta_skipped_on_spoken_item() {
8391 let mut session = Session::new();
8392
8393 let _ = session.append_realtime_transcript_event(
8395 RealtimeTranscriptEvent::AssistantTranscriptTruncated {
8396 response_id: "resp_a".to_string(),
8397 item_id: "item_a".to_string(),
8398 content_index: 0,
8399 text: "what was actually heard".to_string(),
8400 },
8401 );
8402
8403 let _ =
8406 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
8407 response_id: "resp_a".to_string(),
8408 delta_id: "delta_d_1".to_string(),
8409 item_id: "item_a".to_string(),
8410 previous_item_id: None,
8411 content_index: 0,
8412 delta: "should-not-appear".to_string(),
8413 });
8414
8415 let _ = session.append_realtime_transcript_event(
8416 RealtimeTranscriptEvent::AssistantTurnCompleted {
8417 response_id: "resp_a".to_string(),
8418 stop_reason: StopReason::EndTurn,
8419 usage: Usage::default(),
8420 },
8421 );
8422
8423 assert_eq!(session.messages().len(), 1);
8426 match &session.messages()[0] {
8427 Message::BlockAssistant(assistant) => {
8428 assert_eq!(assistant.blocks.len(), 1);
8429 match &assistant.blocks[0] {
8430 AssistantBlock::Transcript { text, source, .. } => {
8431 assert_eq!(text, "what was actually heard");
8432 assert_eq!(*source, crate::types::TranscriptSource::Spoken);
8433 }
8434 other => unreachable!(
8435 "Spoken-locked item must materialize as Transcript, got {other:?}"
8436 ),
8437 }
8438 }
8439 other => unreachable!("expected BlockAssistant, got {other:?}"),
8440 }
8441 }
8442
8443 #[test]
8450 fn round5_r56_sibling_spoken_delta_skipped_on_display_item() {
8451 let mut session = Session::new();
8452
8453 let _ =
8455 session.append_realtime_transcript_event(RealtimeTranscriptEvent::AssistantTextDelta {
8456 response_id: "resp_a".to_string(),
8457 delta_id: "delta_d_1".to_string(),
8458 item_id: "item_a".to_string(),
8459 previous_item_id: None,
8460 content_index: 0,
8461 delta: "display-locked-text".to_string(),
8462 });
8463
8464 let _ = session.append_realtime_transcript_event(
8467 RealtimeTranscriptEvent::AssistantTranscriptDelta {
8468 response_id: "resp_a".to_string(),
8469 delta_id: "delta_s_1".to_string(),
8470 item_id: "item_a".to_string(),
8471 previous_item_id: None,
8472 content_index: 0,
8473 delta: "should-not-appear".to_string(),
8474 },
8475 );
8476
8477 let _ = session.append_realtime_transcript_event(
8478 RealtimeTranscriptEvent::AssistantTurnCompleted {
8479 response_id: "resp_a".to_string(),
8480 stop_reason: StopReason::EndTurn,
8481 usage: Usage::default(),
8482 },
8483 );
8484
8485 assert_eq!(session.messages().len(), 1);
8487 match &session.messages()[0] {
8488 Message::BlockAssistant(assistant) => {
8489 assert_eq!(assistant.blocks.len(), 1);
8490 match &assistant.blocks[0] {
8491 AssistantBlock::Text { text, .. } => {
8492 assert_eq!(text, "display-locked-text");
8493 }
8494 other => {
8495 unreachable!("Display-locked item must materialize as Text, got {other:?}")
8496 }
8497 }
8498 }
8499 other => unreachable!("expected BlockAssistant, got {other:?}"),
8500 }
8501 }
8502
8503 #[test]
8511 fn round5_r57_late_final_text_after_turn_completed_warns_and_skips() {
8512 let mut session = Session::new();
8513
8514 let _ = session.append_realtime_transcript_event(
8516 RealtimeTranscriptEvent::AssistantTranscriptDelta {
8517 response_id: "resp_a".to_string(),
8518 delta_id: "delta_s_1".to_string(),
8519 item_id: "item_a".to_string(),
8520 previous_item_id: None,
8521 content_index: 0,
8522 delta: "delta-accumulated".to_string(),
8523 },
8524 );
8525
8526 let commit_outcome = session.append_realtime_transcript_event(
8528 RealtimeTranscriptEvent::AssistantTurnCompleted {
8529 response_id: "resp_a".to_string(),
8530 stop_reason: StopReason::EndTurn,
8531 usage: Usage::default(),
8532 },
8533 );
8534 assert_eq!(commit_outcome.materialized_messages.len(), 1);
8535
8536 let late_outcome = session.append_realtime_transcript_event(
8540 RealtimeTranscriptEvent::AssistantTranscriptFinalText {
8541 response_id: "resp_a".to_string(),
8542 item_id: "item_a".to_string(),
8543 content_index: 0,
8544 text: "authoritative-final-that-must-not-land".to_string(),
8545 },
8546 );
8547 assert!(
8548 late_outcome.is_inert(),
8549 "late FinalText after materialization must produce inert outcome"
8550 );
8551
8552 assert_eq!(session.messages().len(), 1);
8555 match &session.messages()[0] {
8556 Message::BlockAssistant(assistant) => {
8557 assert_eq!(assistant.blocks.len(), 1);
8558 match &assistant.blocks[0] {
8559 AssistantBlock::Transcript { text, .. } => {
8560 assert_eq!(
8561 text, "delta-accumulated",
8562 "canonical message must preserve delta-accumulated text; \
8563 append-only history forbids late FinalText repair"
8564 );
8565 }
8566 other => unreachable!("expected Transcript, got {other:?}"),
8567 }
8568 }
8569 other => unreachable!("expected BlockAssistant, got {other:?}"),
8570 }
8571 }
8572}