Skip to main content

meerkat_core/
session.rs

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