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