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