Skip to main content

meerkat_core/
session.rs

1//! Session management for Meerkat
2//!
3//! A session represents a conversation history that can be persisted and resumed.
4//!
5//! # Performance
6//!
7//! Sessions use Arc-based copy-on-write for message storage:
8//! - `fork()` shares the message buffer (O(1), no clone)
9//! - Mutation (push) triggers CoW only when refcount > 1
10//! - `push_batch()` adds multiple messages with a single timestamp update
11
12use 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
33/// Current session format version.
34///
35/// The persisted `version` byte is mandatory and fail-closed: a stored row
36/// with a missing or non-current version (including pre-typed-owner v0/v1
37/// rows) is rejected at the serde boundary by the generated persistence
38/// version authority — it never silently defaults or upgrades on read.
39pub use crate::generated::session_persistence_version_authority::SESSION_VERSION;
40
41/// Current `SessionMetadata` schema version. Distinct from `SESSION_VERSION`
42/// so `SessionMetadata` can evolve independently of the Session envelope.
43///
44/// Mandatory and fail-closed on read, same contract as `SESSION_VERSION`.
45pub use crate::generated::session_persistence_version_authority::SESSION_METADATA_SCHEMA_VERSION;
46
47/// Current session format version accepted by generated persistence authority.
48pub fn session_version() -> u32 {
49    session_persistence_version_authority::session_envelope_version()
50}
51
52/// Current `SessionMetadata` schema version accepted by generated persistence authority.
53pub fn session_metadata_schema_version() -> u32 {
54    session_persistence_version_authority::session_metadata_schema_version()
55}
56
57/// Typed transcript replacement used to create an edited fork.
58///
59/// Replacements never mutate the source session in place. The owning service
60/// applies this to a forked prefix, producing a new `SessionId`.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(tag = "type", rename_all = "snake_case")]
63pub enum TranscriptReplacement {
64    /// Replace the addressed message with a full canonical message.
65    Message { message: Message },
66    /// Replace one user-message content block.
67    UserContentBlock {
68        block_index: usize,
69        block: ContentBlock,
70    },
71    /// Replace one block in a block-assistant message.
72    AssistantBlock {
73        block_index: usize,
74        block: AssistantBlock,
75    },
76    /// Replace one content block inside one tool-result payload.
77    ToolResultContentBlock {
78        result_index: usize,
79        block_index: usize,
80        block: ContentBlock,
81    },
82}
83
84/// Session metadata key for the typed transcript revision graph head.
85pub const SESSION_TRANSCRIPT_HISTORY_STATE_KEY: &str = "session_transcript_history_state_v1";
86
87/// A concrete transcript span selected for same-session rewrite.
88#[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    /// Replace messages in `[start, end)`.
93    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/// Audit annotation carried with a transcript rewrite commit.
105///
106/// The free-form kind is for review, debugging, and provenance. It is not a
107/// second policy authority; rewrite admission is enforced by the typed
108/// selection, digest, parent-revision, and store-guard contracts.
109#[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    /// Human-facing projection consumed by revision-list reads. The typed
129    /// `{kind, note}` pair stays the owner; this rendering is derived only.
130    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/// Immutable rewrite commit that advances a session transcript head.
139#[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/// Immutable transcript revision body retained by the session-local graph.
158#[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/// Self-contained append-only transcript rewrite record.
181#[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/// Typed session-local transcript revision graph state.
206#[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    /// Rebuild transcript revision graph state from append-only rewrite records.
219    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/// Invalid typed transcript edit request.
267#[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            // An inline image hydrates from its blob's own bytes, so its
394            // content-addressed identity equals the blob id the store minted.
395            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
404/// Canonicalize image payloads to their content-addressed blob identity so the
405/// transcript digest is invariant to inline-vs-blob representation.
406///
407/// The same image hydrated inline for model execution and externalized to a
408/// blob for persistence must share one transcript revision; otherwise a live
409/// session and its durable snapshot would appear "diverged" purely because of
410/// image storage form, and a runtime-backed live session would be discarded as
411/// stale mid-turn.
412fn 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/// A conversation session with full history
688///
689/// Uses Arc<Vec<Message>> internally for efficient forking (copy-on-write).
690#[derive(Debug, Clone)]
691pub struct Session {
692    /// Persisted envelope format version, validated fail-closed on read by
693    /// the generated persistence version authority.
694    version: u32,
695    /// Unique identifier
696    id: SessionId,
697    /// All messages in order (Arc for CoW on fork)
698    pub(crate) messages: Arc<Vec<Message>>,
699    /// When the session was created
700    created_at: SystemTime,
701    /// When the session was last updated
702    updated_at: SystemTime,
703    /// Arbitrary metadata
704    metadata: serde_json::Map<String, serde_json::Value>,
705    /// Cumulative token usage across all LLM calls in this session
706    usage: Usage,
707}
708
709/// Serde helper for Session serialization (flattens Arc)
710#[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
764/// Metadata key used to store durable system-context control state.
765pub const SESSION_SYSTEM_CONTEXT_STATE_KEY: &str = "session_system_context_state";
766
767/// Metadata key used to store deferred-turn control state.
768pub const SESSION_DEFERRED_TURN_STATE_KEY: &str = "session_deferred_turn_state";
769
770/// Metadata key used to store recoverable build-only session state.
771pub const SESSION_BUILD_STATE_KEY: &str = "session_build_state";
772
773/// Metadata key used to store durable session-local tool visibility intent.
774pub const SESSION_TOOL_VISIBILITY_STATE_KEY: &str = "session_tool_visibility_state_v1";
775
776/// Metadata key used to store the typed session lifecycle-terminal fact.
777pub const SESSION_LIFECYCLE_TERMINAL_KEY: &str = "session_lifecycle_terminal";
778
779/// Canonical tool name gated by `image_tool_results` capability.
780pub const VIEW_IMAGE_TOOL_NAME: &str = "view_image";
781
782/// Canonical separator between appended runtime system-context blocks.
783pub 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    // Single reserved-key authority: the typed classifier owns the
801    // session-authority key set (the `session_*` state constants).
802    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/// Shared runtime system-context authority handle.
816///
817/// This handle is intentionally narrower than `Arc<Mutex<SessionSystemContextState>>`:
818/// callers can read snapshots or request generated-authority transitions, but
819/// cannot replace the machine-owned state by taking a mutable guard.
820#[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/// Durable control state for runtime system-context append requests.
1022// Cannot derive `Eq`: `PendingSystemContextAppend` carries a typed
1023// `peer_response_terminal` fact whose render payload is a `serde_json::Value`.
1024#[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/// Typed provenance class for a runtime system-context append.
1038///
1039/// Canonical replacement for the retired `runtime:steer:` string-prefix
1040/// folklore. The PRODUCER of a runtime-steer append (the runtime input
1041/// projection in `meerkat-runtime`) constructs it with
1042/// [`SystemContextSource::RuntimeSteer`]; everything else is
1043/// [`SystemContextSource::Normal`]. No code reclassifies a `source` string
1044/// into this fact — it is set once at construction and the machine guards the
1045/// typed field.
1046#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1047#[serde(rename_all = "snake_case")]
1048pub enum SystemContextSource {
1049    /// A durable, non-transient runtime context append (peer responses, etc.).
1050    #[default]
1051    Normal,
1052    /// A transient operator/peer steer append that must not survive past the
1053    /// turn it steers and must not be promoted to the durable applied set.
1054    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    /// Whether this is the default (`Normal`) provenance. Used by
1068    /// `skip_serializing_if` so durable appends serialize without the field.
1069    #[must_use]
1070    pub fn is_normal(&self) -> bool {
1071        matches!(self, Self::Normal)
1072    }
1073
1074    /// Whether this append is a transient runtime steer.
1075    #[must_use]
1076    pub fn is_runtime_steer(&self) -> bool {
1077        matches!(self, Self::RuntimeSteer)
1078    }
1079}
1080
1081/// Pending append request accepted by the control plane but not yet applied at an LLM boundary.
1082// Cannot derive `Eq`: the typed `peer_response_terminal` fact carries a
1083// `serde_json::Value` render payload, which is `PartialEq` but not `Eq`.
1084#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1085#[serde(rename_all = "snake_case")]
1086pub struct PendingSystemContextAppend {
1087    /// Typed renderable append content, carried end-to-end from the surface
1088    /// request ([`AppendSystemContextRequest.content`]). The ONE lowering to
1089    /// model-facing prompt text happens where the transcript consumes the
1090    /// append ([`CoreRenderable::render_text`] inside the render seam) —
1091    /// surfaces never pre-flatten this into a string.
1092    ///
1093    /// [`CoreRenderable::render_text`]: crate::lifecycle::run_primitive::CoreRenderable::render_text
1094    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    /// Typed provenance: whether this append is a transient runtime steer.
1100    #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
1101    pub source_kind: SystemContextSource,
1102    /// Typed terminal-peer-response fact this append carries, when the append
1103    /// projects a `PeerResponseTerminalFact`. The producer stamps the typed
1104    /// fact here at construction; realtime/live consumers read the typed fact
1105    /// directly instead of re-parsing the flattened prompt `text`/`source`
1106    /// string (the `peer_response_terminal:` prefix + `Payload:` split). This
1107    /// mirrors the `source_kind` precedent that retired the `runtime:steer:`
1108    /// string-prefix re-derivation.
1109    #[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/// Typed terminal-lifecycle projection of the canonical
1115/// [`session_document::SessionDocumentMachine`] `session_lifecycle_terminal`
1116/// fact.
1117///
1118/// The machine owns archive lifecycle truth for ALL profiles (LUC-524 R004
1119/// fold): both the runtime-backed and the store-only archive paths drive the
1120/// machine's `ArchiveSessionDocument` input, and this reserved-key field is
1121/// the machine-realized durable projection of the emitted verdict — the shell
1122/// realizes it, it never decides it. `RuntimeState::Retired` is the runtime
1123/// realization of the SAME verdict; the fail-closed realization order (durable
1124/// document commit first, runtime retire second) keeps the two projections
1125/// convergent. A two-variant enum (rather than a bare bool) keeps future
1126/// terminal classes — e.g. `Destroyed` — extending the type rather than the
1127/// call sites.
1128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1129#[serde(rename_all = "snake_case")]
1130pub enum SessionLifecycleTerminal {
1131    /// The session is live / resumable.
1132    Active,
1133    /// The session has been archived and is terminal.
1134    Archived,
1135}
1136
1137impl SessionLifecycleTerminal {
1138    /// Whether this terminal fact marks the session as archived.
1139    #[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/// Durable control state for deferred first-turn prompt and staged callback tool results.
1164#[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/// Canonical lifecycle phase for the session's deferred first turn.
1176#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
1177#[serde(rename_all = "snake_case")]
1178pub enum DeferredFirstTurnPhase {
1179    /// The session was not created in deferred-first-turn mode.
1180    #[default]
1181    Inactive,
1182    /// The session exists durably but the first turn has not started yet.
1183    Pending,
1184    /// The first turn has started; build-only overrides are no longer legal.
1185    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
1230/// Derive the machine-owned capability base filter from the current image-tool-results support.
1231pub 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/// Persisted witness for a durable tool-visibility name.
1240///
1241/// `last_seen_provenance` is the single typed identity owner. The formatted
1242/// `stable_owner_key` string is a read-only projection derived on demand via
1243/// [`crate::tool_catalog::stable_owner_key_from_provenance`], never stored
1244/// beside the owner.
1245#[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/// Typed authority value for a deferred-tool load request.
1259///
1260/// The public/effect seam carries the requested route name and provenance
1261/// witness as one value. Canonical owners may project this into name-indexed
1262/// maps internally, but callers do not get to make a map key the authority.
1263#[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/// Durable tool-filter intent paired with the witnesses that made the names
1284/// authoritative at capture time.
1285#[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/// Opaque parent/composition-authorized inherited tool visibility handoff.
1304///
1305/// The filter and witnesses are intentionally not public fields. Callers that
1306/// need to hand inherited visibility to a child build must obtain this from an
1307/// AgentFactory-minted parent composition authority; they cannot write
1308/// canonical session visibility state directly.
1309#[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/// Canonical durable session-local tool visibility intent.
1341#[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/// Generated-authority-approved durable tool visibility projection.
1367///
1368/// Session metadata stores this as a projection of the generated visibility
1369/// owner. Code that only has raw `SessionToolVisibilityState` must first route
1370/// it through a `ToolVisibilityOwner`/`ToolScope` restore path.
1371#[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/// Durable build-only session state required to faithfully recover and rebuild
1391/// a persisted session without surface-local shadow config.
1392#[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    /// Compatibility projection of mob operator authority.
1419    ///
1420    /// `MobToolAuthorityContext` deliberately loses its generated authority
1421    /// seal when serialized; restored behavior must be approved by the
1422    /// generated runtime bridge before this projection can affect tools.
1423    #[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/// Deferred create-time prompt staged for the next turn.
1430#[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/// Staged callback tool results waiting to be admitted on the next turn seam.
1438#[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/// Deferred first-turn inputs consumed at the generated start-turn authority seam.
1453#[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/// Seen idempotency-key entry for system-context append requests.
1477#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1478#[serde(rename_all = "snake_case")]
1479pub struct SeenSystemContextKey {
1480    /// Typed renderable content of the accepted append for this key.
1481    pub content: crate::lifecycle::run_primitive::CoreRenderable,
1482    #[serde(default, skip_serializing_if = "Option::is_none")]
1483    pub source: Option<String>,
1484    /// Typed provenance carried from the append, so runtime-steer cleanup can
1485    /// match seen entries by the typed marker rather than a `source` prefix.
1486    #[serde(default, skip_serializing_if = "SystemContextSource::is_normal")]
1487    pub source_kind: SystemContextSource,
1488    pub state: SeenSystemContextState,
1489}
1490
1491/// Lifecycle state for an accepted idempotency key.
1492#[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    /// Stage an append request, enforcing per-session idempotency.
1537    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    /// Stage an append that is scoped to the currently-active turn only.
1555    ///
1556    /// If the active turn reaches another model boundary, normal pending
1557    /// consumption moves it to `applied`. If the turn completes first, callers
1558    /// should discard the still-pending active-turn keys so the context cannot
1559    /// leak into an unrelated later run.
1560    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    /// Mark all currently-pending appends as applied and clear the pending queue.
1569    pub fn mark_pending_applied(&mut self) {
1570        system_context_authority::mark_pending_applied(self);
1571    }
1572
1573    /// Discard active-turn-only appends that were not consumed by the turn's
1574    /// next LLM boundary.
1575    pub fn discard_unapplied_active_turn_pending(&mut self) -> Vec<PendingSystemContextAppend> {
1576        system_context_authority::discard_unapplied_active_turn_pending(self)
1577    }
1578
1579    /// Discard specific active-turn-only appends that are still pending.
1580    ///
1581    /// This is the rollback companion for live-boundary staging. The runtime
1582    /// owns the accepted input, so if that commit fails after the session has
1583    /// staged context, the session-side projection must be removed by the same
1584    /// idempotency keys before the caller reports failure.
1585    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    /// Authorize this snapshot through the canonical
1593    /// [`session_document::SessionDocumentMachine`] system-context restore
1594    /// transition, returning the state unchanged on success.
1595    pub fn restore_from_snapshot(self) -> Result<Self, SystemContextStageError> {
1596        system_context_authority::restore_system_context_state(self)
1597    }
1598
1599    /// Record the machine-authorized applied system-context blocks, returning
1600    /// the appends that are newly applied (and thus need rendering into the
1601    /// system prompt by the caller).
1602    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
1615/// Per-session registry key for the first-turn region of the
1616/// [`session_document::SessionDocumentMachine`]. Each
1617/// [`SessionDeferredTurnState`] is a single session's projection, so its
1618/// machine instance carries exactly one registry entry under this key.
1619const 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
1625/// Authorize a durable deferred-turn snapshot through the canonical
1626/// [`session_document::SessionDocumentMachine`] recovery transition.
1627///
1628/// The machine validates that the persisted first-turn phase is a legal
1629/// recovery target and adopts it into its per-session registry, emitting
1630/// `SessionFirstTurnPhaseRecovered`. The snapshot is returned unchanged on
1631/// success; the machine — not this shell — owns the recovery legality.
1632fn 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    // The recovery transition fails closed for any illegal first-turn phase
1638    // (its guard admits only the three known phases); a rejection surfaces as
1639    // `Err` here. On success the machine has adopted the snapshot.
1640    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    /// Build a [`SessionDocumentMachineAuthority`] seeded with this session's
1679    /// current durable first-turn projection.
1680    ///
1681    /// The machine owns the canonical first-turn phase + presence/count in its
1682    /// own per-session `Map`; the durable [`SessionDeferredTurnState`] is its
1683    /// projection. We recover the machine-owned registry from that projection
1684    /// before driving an operation so every subsequent decision reads the
1685    /// machine's own state — the shell never passes a phase conclusion as an
1686    /// operation input.
1687    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    /// Mirror the machine-resolved first-turn phase from one effect batch onto
1710    /// the durable projection, returning `was_pending` when present.
1711    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    /// Mark that this session has a deferred first turn waiting to start.
1729    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    /// Mark the deferred first turn as started.
1743    ///
1744    /// Returns true when the phase transitioned from `Pending`.
1745    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    /// Restore the deferred first-turn pending phase after a failed pre-run setup.
1760    pub fn restore_initial_turn_pending(&mut self) {
1761        // The restore-to-pending decision is the machine's
1762        // `RestoreSessionConsumedInputs` transition with phase rollback
1763        // requested; presence/count mirrors are left untouched here because the
1764        // bulky payloads are restored separately by the caller.
1765        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                // Mirror the machine-owned phase the restore transition wrote
1774                // into its per-session registry rather than re-deriving it.
1775                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    /// Whether build-only first-turn overrides are still legal for this session.
1787    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    /// Stage the create-time prompt for a later first turn.
1812    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    /// Stage one callback tool-results message for the next turn.
1848    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    /// Whether any callback tool results are currently staged.
1888    pub fn has_pending_tool_results(&self) -> bool {
1889        !self.pending_tool_results.is_empty()
1890    }
1891
1892    /// Start a turn and consume all inputs generated-authorized for that seam.
1893    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    /// Restore inputs previously consumed by `consume_for_started_turn`.
1913    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/// Failure when staging a system-context append request.
1967#[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
1995/// Mechanical PRESENTATION helper: render a system-context append into the
1996/// display block string that is concatenated into the model-facing system
1997/// prompt. This is NOT a decision — it builds the `[Runtime System Context]`
1998/// label text for OUTPUT only. The authority for which appends to render and
1999/// whether one is a runtime steer lives in the
2000/// [`session_document::SessionDocumentMachine`]; this function never inspects
2001/// the `source` string to classify anything.
2002fn 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    // The single CoreRenderable -> prompt-text lowering for system-context
2010    // appends. Surfaces carry the typed renderable through untouched.
2011    rendered.push_str(append.content.render_text().trim());
2012    rendered
2013}
2014
2015/// Display label prefix for a rendered runtime system-context block.
2016///
2017/// PRESENTATION only — this is the human/model-facing heading, not a
2018/// classification key. Nothing reads this back to make a semantic decision.
2019const SYSTEM_CONTEXT_RENDER_LABEL: &str = "[Runtime System Context]";
2020
2021/// Shell adapter that drives the canonical
2022/// [`session_document::SessionDocumentMachine`] system-context region and
2023/// mirrors its emitted decisions onto the bulky `SessionSystemContextState`.
2024///
2025/// The machine owns every SEMANTIC decision (append disposition, per-append
2026/// apply/discard from the typed [`SystemContextSource`] marker, snapshot
2027/// restore legality). This module performs only the mechanical collection
2028/// work — iterating the shell's pending/applied/seen collections and applying
2029/// the machine's per-item verdict. It never decides; in particular it never
2030/// inspects a `source` string to classify a runtime steer.
2031mod 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    /// Resolve the four-way append disposition through the machine.
2045    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    /// Per-pending-append apply verdict, decided by the machine from the typed
2079    /// `source_kind` marker (NOT a `source` string prefix).
2080    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    /// Per-item transient-steer discard verdict, decided by the machine from
2104    /// the typed `source_kind` marker.
2105    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        // Emptiness is judged on the canonical text projection; the typed
2168        // renderable itself is what gets stored (lowering happens once, at
2169        // the transcript render seam).
2170        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            // Carry the typed `PeerResponseTerminalFact` so realtime/live
2225            // consumers read it directly instead of re-parsing the flattened
2226            // prompt text. Mirrors the `source_kind` typed-provenance precedent.
2227            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        // Promote pending appends to applied per the machine's per-item
2250        // verdict (keyed on the typed `source_kind`).
2251        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        // Active-turn keys are tracked only by idempotency key, so an active
2371        // key is a runtime steer iff its seen entry (or pending append) was.
2372        // Recompute the surviving steer keys from the typed seen markers.
2373        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        // Any active key whose seen entry was already removed above (because it
2381        // was a steer) is no longer present in `seen`; drop those, plus any
2382        // still-present steer keys.
2383        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        // Build the set of rendered blocks for the typed runtime-steer appends,
2399        // then remove those exact rendered blocks from the prompt. The typed
2400        // marker is the authority; rendering is mechanical presentation.
2401        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    /// Create a new empty session
2520    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    /// Create a session with a specific ID (for loading)
2534    pub fn with_id(id: SessionId) -> Self {
2535        let mut session = Self::new();
2536        session.id = id;
2537        session
2538    }
2539
2540    /// Get the session ID
2541    pub fn id(&self) -> &SessionId {
2542        &self.id
2543    }
2544
2545    /// Get the session version
2546    pub fn version(&self) -> u32 {
2547        self.version
2548    }
2549
2550    /// Get all messages.
2551    pub fn messages(&self) -> &[Message] {
2552        &self.messages
2553    }
2554
2555    /// Replace the message buffer for core-owned internal transcript rewrites.
2556    ///
2557    /// Intentionally `pub(crate)`: cross-crate consumers must route same-session
2558    /// rewrites through transcript-edit APIs so the revision graph remains the
2559    /// semantic owner of message history.
2560    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    /// Retain messages for core-owned synthetic-notice projection cleanup.
2584    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    /// Atomically refresh the synthetic runtime notices of one kind.
2608    ///
2609    /// This is the ONE transcript authority operation for synthetic-notice
2610    /// refresh: it strips every existing `SystemNotice` message of `kind` and
2611    /// appends `replacements` (possibly empty, meaning "no current notice")
2612    /// as a single edit. On a strip fault nothing is pushed and the typed
2613    /// [`TranscriptEditError`] propagates — callers must not re-implement
2614    /// the strip-then-push pair (the swallowed-strip variant leaves a stale
2615    /// notice beside a fresh one: a divergence window).
2616    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    /// Get creation time
2641    pub fn created_at(&self) -> SystemTime {
2642        self.created_at
2643    }
2644
2645    /// Get last update time
2646    pub fn updated_at(&self) -> SystemTime {
2647        self.updated_at
2648    }
2649
2650    /// Add a message to the session
2651    ///
2652    /// Updates the timestamp. For adding multiple messages, prefer `push_batch`.
2653    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    /// Add multiple messages in one operation (single timestamp update)
2660    ///
2661    /// More efficient than multiple `push` calls when adding many messages.
2662    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    /// Rewrite inline media payloads in-place as `BlobRef` pointers.
2673    ///
2674    /// Message count is invariant across this operation — `externalize`
2675    /// only swaps inline image/media bytes for opaque blob references.
2676    /// This is the cross-crate-legitimate rewrite operation that used
2677    /// to require public `messages_mut()`; post-C-H1 callers in
2678    /// `meerkat-session` go through this typed method.
2679    ///
2680    /// Does not touch `updated_at` — externalization is bookkeeping, not
2681    /// a semantic session mutation.
2682    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    /// Explicitly update the timestamp
2706    ///
2707    /// Call this after bulk operations that don't update timestamps automatically.
2708    pub fn touch(&mut self) {
2709        self.updated_at = SystemTime::now();
2710    }
2711
2712    /// Get the last N messages
2713    pub fn last_n(&self, n: usize) -> &[Message] {
2714        let start = self.messages.len().saturating_sub(n);
2715        &self.messages[start..]
2716    }
2717
2718    /// Count total tokens used.
2719    pub fn total_tokens(&self) -> u64 {
2720        self.usage.total_tokens()
2721    }
2722
2723    /// Get total usage statistics for the session.
2724    pub fn total_usage(&self) -> Usage {
2725        self.usage.clone()
2726    }
2727
2728    /// Update cumulative usage after an LLM call.
2729    pub fn record_usage(&mut self, turn_usage: Usage) {
2730        self.usage.add(&turn_usage);
2731        self.updated_at = SystemTime::now();
2732    }
2733
2734    /// Append externally-produced user content to the canonical transcript.
2735    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    /// Append externally-produced assistant output to the canonical transcript.
2742    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    /// Apply an identity-bearing provider realtime transcript event.
2760    ///
2761    /// This is the canonical append authority for provider-managed realtime
2762    /// turns: provider item ids, predecessor links, and content segment ids are
2763    /// persisted in session metadata so duplicate websocket delivery,
2764    /// reconnect replay, and causally equivalent event ordering cannot create
2765    /// duplicate or misordered canonical messages.
2766    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    /// Return every distinct provider `response_id` currently staged in the
2788    /// realtime-transcript metadata that has at least one **unmaterialized**
2789    /// assistant item and is **not already discarded**.
2790    ///
2791    /// CC4 (Round-4 architectural reconciliation): when the live boundary
2792    /// signals a barge-in (`TurnInterrupted`), the projection sink does not
2793    /// know which provider response_ids have streaming deltas staged in
2794    /// session metadata. This accessor lets the sink fan
2795    /// [`RealtimeTranscriptEvent::AssistantTurnInterrupted`] events out to
2796    /// each in-flight response so staged-but-not-yet-materialized transcript
2797    /// fragments are discarded — preventing them from silently committing
2798    /// when the *next* turn's `AssistantTurnCompleted` (synthesized by the
2799    /// CC2 fix in `signal_turn_completed`) sweeps the materializer.
2800    ///
2801    /// Order is the [`SessionRealtimeTranscriptState::first_seen_order`]
2802    /// projection so callers see deterministic iteration. Items already
2803    /// materialized or skipped are excluded — only response_ids with at
2804    /// least one live unmaterialized assistant item are returned.
2805    #[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        // The typed mutation provenance is carried onto the applied system
2848        // message so the transcript-continuity save-guard recognizes a
2849        // runtime context-append shape from a typed field instead of the
2850        // rendered `[Runtime System Context]` label.
2851        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        // Check if first message is system
2856        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    /// Set a system prompt through generated durable-config authority.
2866    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    /// Set a system prompt (adds or replaces System message at start).
2882    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    /// Remove transient active-turn steer context from persisted session state.
2892    ///
2893    /// Operator steers accepted into an already-running turn are request-local:
2894    /// they should be visible to that turn's next model boundary, then vanish
2895    /// instead of replaying into later turns after persistence or resume.
2896    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        // The typed `source_kind` marker on persisted appends is the authority
2911        // for which rendered prompt blocks are transient runtime steers. Gather
2912        // the runtime-steer appends, then remove their exact rendered blocks
2913        // from the system prompt — no `runtime:steer:` string classification.
2914        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    /// Append one or more runtime system-context blocks to the canonical system prompt.
2956    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    /// Get the last assistant message text content.
3019    ///
3020    /// Concatenates both `Text` (display) and `Transcript` (spoken) blocks
3021    /// in document order, since both lanes project to the same human-readable
3022    /// stream. Lane provenance is preserved on the underlying `AssistantBlock`
3023    /// for callers that need it.
3024    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    /// Count tool calls made
3044    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    /// Get metadata
3060    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    /// Set a metadata value when the key is not reserved for generated authority.
3086    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    /// Set a metadata value.
3099    ///
3100    /// Reserved generated-authority metadata keys fail closed and are left
3101    /// untouched. Use the typed setters for those keys.
3102    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    /// Backfill a missing metadata value without changing `updated_at`.
3109    ///
3110    /// This is only for compatibility reads that need to hydrate metadata from
3111    /// an older projection. Semantic metadata mutations must use
3112    /// [`Session::set_metadata`] so the session timestamp advances.
3113    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    /// Remove a metadata value.
3130    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    /// Store SessionMetadata in the session metadata map.
3143    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    /// Load SessionMetadata from the session metadata map.
3157    ///
3158    /// If the reserved key exists but cannot pass typed generated restore,
3159    /// fail closed instead of treating corrupted machine facts as absent.
3160    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    /// Try to load SessionMetadata through generated restore authority.
3168    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    /// Store durable system-context control state in the session metadata map.
3184    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    /// Try to load durable system-context control state through generated restore authority.
3196    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    /// Load durable system-context control state from the session metadata map.
3210    ///
3211    /// Rejected durable facts fail closed through the generated restore
3212    /// authority. Callers that need the typed rejection must use
3213    /// [`Self::try_system_context_state`].
3214    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    /// Store durable deferred-turn control state in the session metadata map.
3222    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    /// Try to load durable deferred-turn control state through generated restore authority.
3234    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    /// Load durable deferred-turn control state from the session metadata map.
3248    ///
3249    /// Rejected durable facts fail closed through the generated restore
3250    /// authority. Callers that need the typed rejection must use
3251    /// [`Self::try_deferred_turn_state`].
3252    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    /// Realize the typed session lifecycle-terminal projection in the session
3260    /// metadata map.
3261    ///
3262    /// The lifecycle-terminal fact is owned by the canonical
3263    /// [`session_document::SessionDocumentMachine`]; production archive paths
3264    /// call this only to realize a machine-emitted `SessionArchiveResolved`
3265    /// verdict (the value written mirrors the machine's decision — the shell
3266    /// decides nothing here).
3267    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    /// Try to load the typed session lifecycle-terminal fact.
3277    ///
3278    /// Reads the typed [`SESSION_LIFECYCLE_TERMINAL_KEY`]; an absent key means
3279    /// no terminal fact.
3280    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    /// Load the typed session lifecycle-terminal fact, failing closed on a
3290    /// corrupt typed value.
3291    ///
3292    /// Callers that need the typed rejection must use
3293    /// [`Self::try_lifecycle_terminal`].
3294    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    /// Store recoverable build-only session state in the session metadata map.
3302    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    /// Load recoverable build-only session state from the session metadata map.
3312    ///
3313    /// If the reserved key exists but cannot pass typed generated restore,
3314    /// fail closed instead of treating corrupted machine facts as absent.
3315    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    /// Try to load recoverable build-only session state through generated restore authority.
3323    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    /// Store durable tool-visibility control state in the session metadata map.
3334    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    /// Test-only metadata clear for compatibility assertions.
3344    ///
3345    /// Production paths persist an explicit generated-authority projection
3346    /// rather than making durable absence carry semantic default truth.
3347    #[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    /// Load durable tool-visibility control state from the session metadata map.
3353    pub fn tool_visibility_state(
3354        &self,
3355    ) -> Result<Option<SessionToolVisibilityState>, serde_json::Error> {
3356        self.try_tool_visibility_state()
3357    }
3358
3359    /// Load durable tool-visibility control state while distinguishing absent
3360    /// metadata from malformed canonical metadata.
3361    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    /// Load typed transcript revision state from metadata.
3371    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    /// Validate the retained transcript revision graph, when present.
3381    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    /// Clear retained transcript revision metadata after a caller has
3392    /// materialized the desired message projection.
3393    pub fn clear_transcript_history_state(&mut self) {
3394        self.remove_metadata_unchecked(SESSION_TRANSCRIPT_HISTORY_STATE_KEY);
3395    }
3396
3397    /// Return the retained immutable body for a transcript revision.
3398    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    /// Return the ordered messages for a retained transcript revision.
3411    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    /// Materialize this session projection from a typed transcript history graph.
3421    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    /// Current transcript head revision. Rows written before transcript
3452    /// revisions derive their implicit head from the current message snapshot.
3453    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    /// Commit a same-session transcript rewrite and advance the transcript head.
3462    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    /// Store typed mob operator authority inside canonical build-state metadata.
3614    ///
3615    /// Store the mob operator authority projection inside build-state metadata.
3616    ///
3617    /// The projection is durable compatibility data only: serialization drops
3618    /// the generated authority seal, so behavior must re-enter generated
3619    /// authority before using restored facts.
3620    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    /// Load the in-memory generated mob operator authority, if still present.
3642    ///
3643    /// Stored/deserialized contexts deliberately fail this check and are not
3644    /// returned as behavior authority.
3645    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    /// Fork the session at a specific message index
3652    ///
3653    /// Creates a new session with a subset of messages. The messages are copied
3654    /// (not shared) since the new session has a different prefix.
3655    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    /// Fork the session and replace the message at `message_index`.
3670    ///
3671    /// The returned session contains the original prefix before
3672    /// `message_index`, followed by the typed replacement. Later source
3673    /// messages are intentionally omitted so follow-up work continues from the
3674    /// edited branch rather than replaying stale descendants.
3675    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    /// Fork the entire session (full history)
3772    ///
3773    /// This is O(1) - the new session shares the message buffer via Arc.
3774    /// Copy-on-write occurs when either session mutates its messages.
3775    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/// Summary metadata for listing sessions
3796#[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/// Metadata required to reliably resume a session across interfaces.
3809#[derive(Debug, Clone, Serialize, Deserialize)]
3810#[serde(rename_all = "snake_case")]
3811pub struct SessionMetadata {
3812    /// Per-entity schema version byte.
3813    ///
3814    /// Mandatory on read: a persisted row missing the byte (or carrying a
3815    /// non-current value) fails closed through the generated persistence
3816    /// version authority instead of silently defaulting. Stamped with the
3817    /// current `SESSION_METADATA_SCHEMA_VERSION` on every persist.
3818    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    /// Typed provider parameter overrides persisted with the session.
3827    /// Parsed fail-closed at the serde boundary — no JSON bag survives here.
3828    #[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    /// Friendly metadata for peer discovery (populated when comms is enabled).
3835    #[serde(default, skip_serializing_if = "Option::is_none")]
3836    pub peer_meta: Option<PeerMeta>,
3837    /// Realm identity for cross-surface storage sharing/isolation.
3838    ///
3839    /// Typed [`crate::RealmId`]; the realm slug is validated at the serde
3840    /// boundary. `RealmId` serializes transparently as its slug string, so the
3841    /// durable JSON shape is identical to the prior `Option<String>` form.
3842    #[serde(default, skip_serializing_if = "Option::is_none")]
3843    pub realm_id: Option<crate::RealmId>,
3844    /// Optional process/agent instance identifier within a realm.
3845    #[serde(default, skip_serializing_if = "Option::is_none")]
3846    pub instance_id: Option<String>,
3847    /// Backend pinned by the realm manifest (e.g. "sqlite", "jsonl", "memory").
3848    #[serde(default, skip_serializing_if = "Option::is_none")]
3849    pub backend: Option<String>,
3850    /// Config generation used when this session was created/resumed.
3851    #[serde(default, skip_serializing_if = "Option::is_none")]
3852    pub config_generation: Option<u64>,
3853    /// Realm-scoped auth binding (Phase 3 provider-auth redesign).
3854    ///
3855    /// Persisted intent for the auth/backend binding this session resolved
3856    /// through. On resume, `apply_resumed_session_metadata` writes this
3857    /// back into `AgentBuildConfig.auth_binding` so the same realm
3858    /// binding is re-resolved. Never carries secret material — leases
3859    /// are rebuilt from the active realm connection set at resume time.
3860    /// Older persisted sessions without the field deserialize as `None`
3861    /// (backward compatible via `#[serde(default)]`).
3862    #[serde(default, skip_serializing_if = "Option::is_none")]
3863    pub auth_binding: Option<crate::AuthBindingRef>,
3864    /// Typed durable identity of a mob member, when this session was created by
3865    /// the mob runtime.
3866    ///
3867    /// This is the canonical owner of the `(mob_id, role, member)` identity
3868    /// fact used by mob ownership routing on resume/restart. It replaces the
3869    /// prior recovery-by-string-split of `comms_name` plus a realm
3870    /// format-string check. `comms_name`/`realm_id`/`peer_meta` remain as the
3871    /// transport routing name and discovery metadata.
3872    ///
3873    /// Older persisted sessions without the field deserialize as `None`
3874    /// (backward compatible via `#[serde(default)]`), so old rows read as
3875    /// "no typed binding" rather than failing.
3876    #[serde(default, skip_serializing_if = "Option::is_none")]
3877    pub mob_member_binding: Option<crate::MobMemberBinding>,
3878}
3879
3880/// Canonical durable LLM identity for a session.
3881#[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    /// Typed provider parameter overrides carried on the durable identity.
3889    #[serde(default, skip_serializing_if = "Option::is_none")]
3890    pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
3891    /// Realm-scoped auth binding this session resolves credentials
3892    /// through. Carried on the identity so mid-session hot-swaps
3893    /// (`apply_live_session_llm_identity`) re-resolve against the
3894    /// same realm the session was created with — preventing
3895    /// cross-realm credential bleed in multi-tenant setups. Dogma
3896    /// §12 (dynamic policy follows dynamic identity): on swap the
3897    /// factory re-enters `ProviderRuntimeRegistry::resolve` against
3898    /// this binding, not a new synthesized env-default realm.
3899    ///
3900    /// Projection (dogma §1/§13): canonical owner is
3901    /// `SessionMetadata.auth_binding`; this field is the
3902    /// read/write projection used by hot-swap.
3903    #[serde(default, skip_serializing_if = "Option::is_none")]
3904    pub auth_binding: Option<crate::AuthBindingRef>,
3905}
3906
3907/// Typed per-turn override request for a session LLM identity.
3908///
3909/// `provider_params` and `auth_binding` carry the canonical Inherit/Set/Clear
3910/// tri-state via [`TurnMetadataOverride`]: `None` preserves the durable value,
3911/// `Some(Set)` overrides it for this turn, and `Some(Clear)` removes it. The
3912/// illegal "set and clear" fourth state is structurally unrepresentable, so the
3913/// resolver needs no reject branch for it.
3914pub 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
3932/// Resolve a turn-time model/provider/auth override against the current
3933/// durable session identity.
3934///
3935/// The model registry is the authority for catalog ownership. A model-only
3936/// override follows catalog ownership when the target model is registered;
3937/// uncatalogued models keep the current provider so custom aliases remain
3938/// possible.
3939pub 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        // Inherit: a provider change without an explicit binding drops the
4002        // stale binding; otherwise the durable binding is retained.
4003        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/// Live request policy paired with a session LLM identity hot-swap.
4017///
4018/// `SessionLlmIdentity` is the durable semantic identity. This projection is
4019/// the per-turn request policy the live agent must use for the next LLM call,
4020/// including provider params and provider-native tool defaults resolved for
4021/// the same target model/provider.
4022#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
4023#[serde(rename_all = "snake_case")]
4024pub struct SessionLlmRequestPolicy {
4025    pub model: String,
4026    /// Typed explicit provider parameter overrides for the next LLM call.
4027    #[serde(default, skip_serializing_if = "Option::is_none")]
4028    pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
4029    /// Typed provider-native tool defaults resolved for the swapped target.
4030    #[serde(default, skip_serializing_if = "Option::is_none")]
4031    pub provider_tool_defaults: Option<crate::lifecycle::run_primitive::ProviderTag>,
4032}
4033
4034impl SessionMetadata {
4035    /// Return the current durable LLM identity for this session.
4036    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    /// Overwrite the durable LLM identity while preserving unrelated session metadata.
4047    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
4056/// Key used to store SessionMetadata in Session metadata map.
4057pub const SESSION_METADATA_KEY: &str = "session_metadata";
4058
4059/// Caller intent for a tool category.
4060///
4061/// Distinguishes "no opinion / didn't exist" (`Inherit`) from explicit
4062/// `Enable` / `Disable` so that resumed sessions don't freeze tool
4063/// availability at the capabilities of the Meerkat version that created them.
4064///
4065/// **Dogma §10:** Inherit, disable, and set are different facts.
4066#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
4067#[serde(rename_all = "snake_case")]
4068pub enum ToolCategoryOverride {
4069    /// No explicit intent — inherit runtime/factory default.
4070    #[default]
4071    Inherit,
4072    /// Explicitly enabled by caller.
4073    Enable,
4074    /// Explicitly disabled by caller.
4075    Disable,
4076}
4077
4078impl ToolCategoryOverride {
4079    /// Resolve this override against a runtime default.
4080    ///
4081    /// - `Enable` → `true`
4082    /// - `Disable` → `false`
4083    /// - `Inherit` → `runtime_default`
4084    #[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    /// Convert to `Option<bool>` for feeding `AgentBuildConfig` override fields.
4094    ///
4095    /// - `Enable` → `Some(true)`
4096    /// - `Disable` → `Some(false)`
4097    /// - `Inherit` → `None` (factory default wins)
4098    #[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    /// Construct from a resolved effective bool.
4108    ///
4109    /// **Warning:** this collapses `Inherit` into `Enable`/`Disable`. Prefer
4110    /// [`from_override`] when persisting session metadata so that `Inherit`
4111    /// survives across save/resume cycles. Only use `from_effective` in test
4112    /// helpers or when constructing metadata from external sources that only
4113    /// provide a resolved bool.
4114    #[must_use]
4115    pub fn from_effective(enabled: bool) -> Self {
4116        if enabled { Self::Enable } else { Self::Disable }
4117    }
4118
4119    /// Construct from an `Option<bool>` override field, preserving `Inherit`.
4120    ///
4121    /// - `Some(true)` → `Enable`
4122    /// - `Some(false)` → `Disable`
4123    /// - `None` → `Inherit` (factory default was used, no explicit intent)
4124    ///
4125    /// This is the inverse of [`to_override`] and should be used when persisting
4126    /// session tooling metadata so that `Inherit` survives across save/resume
4127    /// cycles.
4128    #[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/// Tooling intent captured at session creation time.
4139///
4140/// Fields use [`ToolCategoryOverride`] to distinguish "no opinion" from
4141/// explicit enable/disable (Dogma §10). On resume, `Inherit` falls through
4142/// to the factory's current runtime default, allowing new tool categories
4143/// to become available without re-creating the session.
4144#[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    /// Mob (multi-agent orchestration) tools.
4154    #[serde(default)]
4155    pub mob: ToolCategoryOverride,
4156    /// Semantic memory.
4157    #[serde(default)]
4158    pub memory: ToolCategoryOverride,
4159    /// Scheduler tools.
4160    #[serde(default)]
4161    pub schedule: ToolCategoryOverride,
4162    /// WorkGraph durable work tools.
4163    #[serde(default)]
4164    pub workgraph: ToolCategoryOverride,
4165    /// Assistant image generation.
4166    #[serde(default)]
4167    pub image_generation: ToolCategoryOverride,
4168    /// Meerkat-owned fallback web search.
4169    #[serde(default)]
4170    pub web_search: ToolCategoryOverride,
4171    /// Effective call-level tool execution policy for this session's builds.
4172    ///
4173    /// Persisted RESOLVED (never `Inherit`): the factory fails the build
4174    /// closed on an unresolved `Inherit` before metadata is written, so this
4175    /// field only ever holds `AllowList`/`DenyList`. Absent means
4176    /// unrestricted. Spawn/fork resolution reads this field as the parent's
4177    /// effective policy when a child requests `Inherit` (transitive
4178    /// containment — a restricted parent cannot mint an unrestricted child
4179    /// by spawning).
4180    #[serde(default, skip_serializing_if = "Option::is_none")]
4181    pub tool_access_policy: Option<crate::ops::ToolAccessPolicy>,
4182    /// Active skills at session creation time (for deterministic resume).
4183    #[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    /// K4 invariant: synthetic-notice refresh is ONE atomic transcript edit —
4223    /// after a refresh, at most the replacement notices of that kind exist
4224    /// (no stale notice survives beside a fresh one).
4225    #[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        // A notice of another kind must be untouched.
4240        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        // Empty replacements = pure strip.
4276        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    /// K4 invariant (fail-closed): an invalid replacement is rejected with a
4289    /// typed fault BEFORE any strip happens — the transcript is unchanged, so
4290    /// a fault can never strand a half-refreshed notice state.
4291    #[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) = &current[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            &current,
5168            &registry,
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            &current,
5203            &registry,
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    /// R5-7: `AssistantTranscriptFinalText` injects authoritative final text
5277    /// into the staged item. Verifies the override semantics: a partial
5278    /// delta is replaced, not concatenated, and the item promotes to the
5279    /// Spoken lane so flush emits `AssistantBlock::Transcript`.
5280    #[test]
5281    fn realtime_transcript_final_text_overrides_partial_delta_and_promotes_to_spoken_lane() {
5282        let mut session = Session::new();
5283
5284        // Partial delta accumulates "incom" — simulating delta loss before
5285        // the final arrives.
5286        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        // Authoritative final text overrides the staged content.
5302        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        // Turn completion drives the flush.
5316        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        // Verify the materialized block has the final's authoritative text
5326        // (not the partial "incom") and the Spoken lane.
5327        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    /// R5-7: `AssistantTranscriptFinalText` works for final-only providers
5348    /// where no prior delta has staged an item.
5349    #[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        // R5-5 (Round-5): the staged assistant content is a Display-lane item
5623        // (`AssistantTextDelta`). Under the new lane-aware barge-in contract,
5624        // the Display lane survives interruption and materializes. The User
5625        // "Stop." item, gated on the chained Display item being materialized,
5626        // also unblocks. Round-4's "must stay non-canonical" assertion was
5627        // wrong — that contract was lane-blind.
5628        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        // R5-5: materializer commits 2 messages (the retained Display item +
5669        // the unblocked "Stop." User message).
5670        assert_eq!(outcome.materialized_messages.len(), 2);
5671        // Canonical history: User-repeat, BlockAssistant(Display "Looping now"), User-Stop.
5672        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        // R5-5: cross-response isolation invariant — Interrupted on resp_a
5947        // does NOT touch resp_b's staged content. Both responses use
5948        // `AssistantTextDelta` (Display lane); under R5-5 resp_a's Display
5949        // item is RETAINED at Interrupted time and resp_b's continues
5950        // unaffected, materializing on its later TurnCompleted.
5951        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        // R5-5: Interrupted commits the resp_a Display item; resp_b
5981        // remains untouched.
5982        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        // 1 user + 2 assistant messages.
6007        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    // Performance tests for Arc-based CoW
6019
6020    #[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        // Fork should share the same Arc, not clone messages
6028        let forked = session.fork();
6029
6030        // Both should point to the same underlying data (Arc refcount > 1)
6031        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        // Fork at 50 should create new Arc with copied prefix
6043        let forked = session.fork_at(50);
6044        assert_eq!(forked.messages().len(), 50);
6045
6046        // Original should be unchanged
6047        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        // Fork shares the Arc
6120        let forked = session.fork();
6121        assert!(Arc::ptr_eq(&session.messages, &forked.messages));
6122
6123        // Push on original triggers CoW - original gets new Arc
6124        session.push(Message::User(UserMessage::text("Second".to_string())));
6125
6126        // Now they should have different Arcs
6127        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    // Performance tests for lazy timestamp updates
6133
6134    #[test]
6135    fn test_push_batch_single_timestamp() {
6136        let mut session = Session::new();
6137        let initial_updated = session.updated_at();
6138
6139        // Use push_batch to add multiple messages without repeated syscalls
6140        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        // Timestamp should have been updated once
6148        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        // Explicit touch to update timestamp
6159        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        // Small delay to ensure time changes
6170        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        // Fork at index 2 (system + user)
6194        let forked = session.fork_at(2);
6195        assert_eq!(forked.messages().len(), 2);
6196        assert_ne!(forked.id(), session.id());
6197
6198        // Full fork
6199        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        // A typed realm_id serializes as a bare JSON string (byte-identical to
6292        // the prior Option<String> durable shape).
6293        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        // A legacy persisted row stored realm_id as a JSON string; it must
6320        // deserialize into the typed RealmId (durable back-read).
6321        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    /// Ask 6: `SessionTooling.tool_access_policy` is additive — a persisted
6340    /// row without the field back-reads as `None` (unrestricted), `None` is
6341    /// omitted on write (durable shape unchanged for ungated sessions), and a
6342    /// resolved policy round-trips intact.
6343    #[test]
6344    fn session_tooling_tool_access_policy_round_trip_and_absent_default() {
6345        // Absent field back-reads as None.
6346        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        // None is omitted on write — ungated sessions keep their prior shape.
6351        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        // A resolved policy round-trips intact.
6358        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        // Persisted JSON for the typed key is the snake_case variant string.
6387        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        // The build-state-persist admission decision now lives in the canonical
6640        // SessionDocumentMachine durable-config region (LUC-524); the rejection
6641        // surfaces with that machine's authority wording.
6642        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        // The runtime-steer fact is carried by the typed `source_kind`, not by
6888        // the `source` string. The durable peer fact uses the same `source`
6889        // string scheme but is marked `Normal`, so only the steers are removed.
6890        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    /// K5 invariant: the typed `CoreRenderable` travels end-to-end through
7092    /// staging — the pending append stores the renderable itself, and the
7093    /// ONE lowering to prompt text happens at the transcript render seam.
7094    #[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        // The pending append owns the typed renderable — no pre-flattened
7117        // text shadow exists anywhere on the staging path.
7118        assert_eq!(state.pending.len(), 1);
7119        assert_eq!(state.pending[0].content, renderable);
7120
7121        // Lowering happens exactly once, at the render seam, via the single
7122        // canonical projection.
7123        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    // ------------------------------------------------------------------
7213    // T9/T10: realtime transcript lane materialization.
7214    //
7215    // The display-text lane (`AssistantTextDelta`) materializes as
7216    // `AssistantBlock::Text`; the spoken-transcript lane
7217    // (`AssistantTranscriptDelta`) materializes as
7218    // `AssistantBlock::Transcript { source: TranscriptSource::Spoken }`.
7219    // These regressions pin both flushes and prove the materializer
7220    // dispatches on the per-item `TranscriptLane`.
7221    // ------------------------------------------------------------------
7222
7223    #[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        // T9/T10: must be a Transcript block, NOT Text.
7249        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        // CC4 (Round-4 architectural reconciliation): the helper that
7271        // powers `signal_turn_interrupt`'s cross-layer fan-out must
7272        // return every distinct provider response_id that has at least
7273        // one unmaterialized assistant item, EXCLUDING already-discarded
7274        // responses and EXCLUDING the user role.
7275        let mut session = Session::new();
7276
7277        // Two transcript-delta items on resp_a (different content_index
7278        // ranges), one on resp_b. resp_c gets a delta and is then
7279        // discarded explicitly via AssistantTurnInterrupted.
7280        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        // Discard resp_c — it should not appear in the in-flight list.
7301        let _ = session.append_realtime_transcript_event(
7302            RealtimeTranscriptEvent::AssistantTurnInterrupted {
7303                response_id: "resp_c".to_string(),
7304            },
7305        );
7306
7307        // User-role item should never appear (CC4 only fans interrupts
7308        // to assistant responses).
7309        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        // resp_a appears exactly once even though two items reference it.
7326        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        // CC2 (Round-4 architectural reconciliation): once
7336        // `signal_turn_completed` synthesizes
7337        // `RealtimeTranscriptEvent::AssistantTurnCompleted`, the staging
7338        // materializer commits every staged transcript-delta item for
7339        // that response_id as `AssistantBlock::Transcript { Spoken }`.
7340        // This pins the production end-to-end shape the sink relies on.
7341        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        // Pre-completion: in-flight list reports resp_cc2.
7354        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        // Post-completion: in-flight list is empty (item is materialized).
7369        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        // Counter-regression: the display-text lane must continue to
7395        // produce `AssistantBlock::Text` after T9/T10. Prevents an
7396        // accidental cross-lane flip.
7397        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        // CC7 (Round-4 adversarial-verifier follow-up): a single mixed-modality
7432        // realtime response that emits BOTH display-text deltas
7433        // (`AssistantTextDelta`) AND spoken-transcript deltas
7434        // (`AssistantTranscriptDelta`) under the same response_id must
7435        // materialize as ONE `Message::BlockAssistant` whose `blocks` field
7436        // contains exactly two ordered entries:
7437        //   1. AssistantBlock::Text       (display-text lane)
7438        //   2. AssistantBlock::Transcript { source: Spoken } (spoken lane)
7439        // Pre-fix the materializer emitted one Message::BlockAssistant per
7440        // staged item, splitting the mixed response into two messages.
7441        //
7442        // This test drives the production materializer end-to-end: deltas
7443        // stage in `SessionRealtimeTranscriptState`; `AssistantTurnCompleted`
7444        // triggers the materializer; canonical history is the assertion
7445        // surface — exactly the same code path that
7446        // `SessionServiceProjectionSink::signal_turn_completed` invokes via
7447        // `runtime.append_realtime_transcript_event` in production.
7448        let mut session = Session::new();
7449
7450        // Provider-arrival order: display first, then spoken.
7451        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        // Spoken items chain after the display item to mirror provider
7480        // arrival semantics — `previous_item_id` carries arrival ordering
7481        // that the materializer must preserve as block ordering inside the
7482        // single emitted message.
7483        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        // TurnCompleted triggers the materializer to flush all staged items
7512        // for this response_id into ONE BlockAssistant message.
7513        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        // Materializer reports two staged items got materialized.
7526        assert_eq!(outcome.materialized_messages.len(), 2);
7527
7528        // Canonical history MUST contain exactly ONE BlockAssistant message
7529        // (the CC7 fix: mixed lanes interleave into one message, not two).
7530        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        // Block 0: display-text (concatenated deltas).
7552        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        // Block 1: spoken transcript (concatenated deltas), tagged Spoken.
7561        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        // Usage was recorded once for the turn.
7572        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        // R5-5 (Round-5 contract update): barge-in MUST filter staged items
7579        // by lane — `Spoken` is invalidated (the user spoke over the audio
7580        // they were hearing) but `Display` survives as committed history
7581        // (sideband display text from the same response is not "spoken
7582        // over"). Round-4's `round4_cc7_mixed_response_barge_in_discards_*`
7583        // pinned the wrong invariant; this test replaces it.
7584        //
7585        // Architectural decision: `AssistantTurnInterrupted` is terminal for
7586        // the response on the realtime-staging path — any later
7587        // `AssistantTurnCompleted { stop_reason: Cancelled }` short-circuits
7588        // via the `discarded_assistant_response_ids` guard. So the
7589        // Interrupted handler must seed a synthetic
7590        // `assistant_completions` entry (`StopReason::Cancelled`,
7591        // `Usage::default()`) so retained Display items materialize
7592        // immediately rather than stranding forever.
7593        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        // Barge-in arrives BEFORE TurnCompleted. The Display item with
7616        // staged content materializes immediately under the synthetic
7617        // Cancelled completion.
7618        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        // A late `AssistantTurnCompleted` (the provider's response.done
7630        // emitted after cancel) must be a no-op: the Display item is
7631        // already materialized; the Spoken item was dropped at Interrupted.
7632        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        // Canonical history: exactly one BlockAssistant carrying the
7646        // Display text (no Transcript block — Spoken was dropped).
7647        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        // No Transcript block — Spoken lane was dropped.
7671        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        // The in-flight tracker reports the response as no longer in flight
7680        // (the Display item is materialized; the Spoken item is skipped).
7681        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        // R5-5 unit test: pin the lane-filter behavior at the staged-item
7692        // level (no chained predecessor). One Display item, one Spoken item,
7693        // both unchained, both staged before Interrupted.
7694        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        // Display materializes, Spoken does not.
7722        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        // Single Text block (the Display lane) — no Transcript.
7734        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        // R5-5: the architectural decision — Interrupted is terminal for the
7744        // response. Display lane must commit at Interrupted time, not wait
7745        // on a hypothetical AssistantTurnCompleted that may never arrive
7746        // (or arrives Cancelled and short-circuits).
7747        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        // Pre-condition: nothing committed yet.
7760        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        // Post-condition: BlockAssistant in canonical history, no Transcript.
7774        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        // R5-6: when truncation is the first content-bearing event for an
7793        // item (no prior delta), the staged item's lane MUST be promoted to
7794        // Spoken so the materializer commits as `AssistantBlock::Transcript`.
7795        // Without the explicit promotion, the lane stays `Display` (the
7796        // default) and the heard audio transcript persists as
7797        // `AssistantBlock::Text`.
7798        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        // R5-6 edge case: a Display delta arrived first and staged Display
7839        // content; a truncation event arrives for the SAME item id
7840        // (provider bug — truncation only applies to spoken/audio output).
7841        // Contract: the staged Display content must NOT be clobbered by
7842        // the truncation text. `promote_item_lane` keeps the existing
7843        // Display lane and emits a `tracing::warn!`; the truncation arm
7844        // sees the lane stayed Display and skips the segment-write.
7845        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        // Display content survives unchanged — the truncation text was
7875        // refused. Materializes as `AssistantBlock::Text` (Display lane).
7876        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    /// R5-6 sibling: a Spoken-classified item (transcript-truncation
7894    /// arrived first and locked the lane to Spoken) must reject a later
7895    /// `AssistantTextDelta` rather than silently appending the Display
7896    /// text into the Spoken-locked content_segment. Pre-fix the delta
7897    /// arm called `promote_item_lane` and unconditionally pushed the
7898    /// delta — clobbering the lane invariant. Post-fix the delta is
7899    /// dropped (warn fires) and the Spoken-truncation text survives.
7900    #[test]
7901    fn round5_r56_sibling_display_delta_skipped_on_spoken_item() {
7902        let mut session = Session::new();
7903
7904        // Truncation arrives first and locks the item to the Spoken lane.
7905        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        // A Display delta arrives later for the SAME item id (provider
7915        // lane-classification bug). It MUST be dropped.
7916        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        // The Spoken-truncation text survives intact; no Display text
7935        // leaked into the Spoken lane content.
7936        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    /// R5-6 sibling: a Display-classified item (a Display delta arrived
7955    /// first and locked the lane to Display) must reject a later
7956    /// `AssistantTranscriptDelta` rather than appending the Spoken text
7957    /// into the Display-locked content_segment. Pre-fix the transcript
7958    /// delta arm called `promote_item_lane` and unconditionally pushed —
7959    /// silently mixing a Spoken stream into a Display block.
7960    #[test]
7961    fn round5_r56_sibling_spoken_delta_skipped_on_display_item() {
7962        let mut session = Session::new();
7963
7964        // Display delta arrives first and locks the item to the Display lane.
7965        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        // A spoken-transcript delta arrives later for the SAME item id
7976        // (provider lane-classification bug). It MUST be dropped.
7977        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        // The Display text survives intact; no Spoken text leaked in.
7997        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    /// R5-7: a late `AssistantTranscriptFinalText` arriving AFTER
8015    /// `AssistantTurnCompleted` already materialized the item must NOT
8016    /// mutate `content_segments` and must NOT rewrite the canonical
8017    /// `Message::BlockAssistant` (append-only history is a stronger
8018    /// invariant than typed text repair). The committed message keeps
8019    /// the delta-accumulated text; the late final is dropped with a
8020    /// warn; the materializer outcome is inert (no new messages).
8021    #[test]
8022    fn round5_r57_late_final_text_after_turn_completed_warns_and_skips() {
8023        let mut session = Session::new();
8024
8025        // Delta accumulates partial text on the Spoken lane.
8026        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        // TurnCompleted materializes the item with the delta-accumulated text.
8038        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        // Late FinalText arrives — provider-side ordering bug. It MUST
8048        // be dropped: no canonical message rewrite, no segment mutation,
8049        // outcome is inert.
8050        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        // Canonical history: still one message with the original
8064        // delta-accumulated text — NOT the authoritative final.
8065        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}